Skip to content

Commit c716d7f

Browse files
authored
[menu] Add <Menu.LinkItem> part (#3400)
1 parent fc7ac60 commit c716d7f

File tree

21 files changed

+556
-144
lines changed

21 files changed

+556
-144
lines changed
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"name": "MenuLinkItem",
3+
"description": "A link in the menu that can be used to navigate to a different page or section.\nRenders an `<a>` element.",
4+
"props": {
5+
"label": {
6+
"type": "string",
7+
"description": "Overrides the text label to use when the item is matched during keyboard text navigation.",
8+
"detailedType": "string | undefined"
9+
},
10+
"closeOnClick": {
11+
"type": "boolean",
12+
"default": "false",
13+
"description": "Whether to close the menu when the item is clicked.",
14+
"detailedType": "boolean | undefined"
15+
},
16+
"className": {
17+
"type": "string | ((state: Menu.LinkItem.State) => string | undefined)",
18+
"description": "CSS class applied to the element, or a function that\nreturns a class based on the component’s state.",
19+
"detailedType": "| string\n| ((state: Menu.LinkItem.State) => string | undefined)"
20+
},
21+
"style": {
22+
"type": "CSSProperties | ((state: Menu.LinkItem.State) => CSSProperties | undefined)",
23+
"detailedType": "| React.CSSProperties\n| ((\n state: Menu.LinkItem.State,\n ) => CSSProperties | undefined)\n| undefined"
24+
},
25+
"render": {
26+
"type": "ReactElement | ((props: HTMLProps, state: Menu.LinkItem.State) => ReactElement)",
27+
"description": "Allows you to replace the component’s HTML element\nwith a different tag, or compose it with another component.\n\nAccepts a `ReactElement` or a function that returns the element to render.",
28+
"detailedType": "| ReactElement\n| ((\n props: HTMLProps,\n state: Menu.LinkItem.State,\n ) => ReactElement)"
29+
}
30+
},
31+
"dataAttributes": {
32+
"data-highlighted": {
33+
"description": "Present when the link is highlighted."
34+
}
35+
},
36+
"cssVariables": {}
37+
}

docs/src/app/(docs)/react/components/context-menu/page.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { ContextMenu } from '@base-ui/react/context-menu';
2525
<ContextMenu.Popup>
2626
<ContextMenu.Arrow />
2727
<ContextMenu.Item />
28+
<ContextMenu.LinkItem />
2829
<ContextMenu.Separator />
2930
<ContextMenu.Group>
3031
<ContextMenu.GroupLabel />
@@ -59,7 +60,7 @@ import { DemoContextMenuSubmenu } from './demos/submenu';
5960
<Reference component="ContextMenu" parts="Root, Trigger" />
6061
<Reference
6162
component="Menu"
62-
parts="Portal, Positioner, Popup, Arrow, Item, SubmenuRoot, SubmenuTrigger, Group, GroupLabel, RadioGroup, RadioItem, RadioItemIndicator, CheckboxItem, CheckboxItemIndicator, Separator"
63+
parts="Portal, Positioner, Popup, Arrow, Item, LinkItem, SubmenuRoot, SubmenuTrigger, Group, GroupLabel, RadioGroup, RadioItem, RadioItemIndicator, CheckboxItem, CheckboxItemIndicator, Separator"
6364
/>
6465

6566
export const metadata = {

docs/src/app/(docs)/react/components/menu/page.mdx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { Menu } from '@base-ui/react/menu';
2525
<Menu.Popup>
2626
<Menu.Arrow />
2727
<Menu.Item />
28+
<Menu.LinkItem />
2829
<Menu.Separator />
2930
<Menu.Group>
3031
<Menu.GroupLabel />
@@ -125,10 +126,10 @@ import { DemoMenuSubmenu } from './demos/submenu';
125126

126127
### Navigate to another page
127128

128-
Use the `render` prop to compose a menu item with an anchor element.
129+
Use the `<Menu.LinkItem>` part to create a link.
129130

130131
```jsx title="A menu item that opens a link"
131-
<Menu.Item render={<a href="/projects">Go to Projects</a>} />
132+
<Menu.LinkItem href="/projects">Go to Projects</<Menu.LinkItem>
132133
```
133134

134135
### Open a dialog
@@ -277,7 +278,7 @@ import { DemoMenuDetachedTriggersControlled } from './demos/detached-triggers-co
277278
278279
<Reference
279280
component="Menu"
280-
parts="Root, Trigger, Portal, Backdrop, Positioner, Popup, Arrow, Item, SubmenuRoot, SubmenuTrigger, Group, GroupLabel, RadioGroup, RadioItem, RadioItemIndicator, CheckboxItem, CheckboxItemIndicator, Separator"
281+
parts="Root, Trigger, Portal, Backdrop, Positioner, Popup, Arrow, Item, LinkItem, SubmenuRoot, SubmenuTrigger, Group, GroupLabel, RadioGroup, RadioItem, RadioItemIndicator, CheckboxItem, CheckboxItemIndicator, Separator"
281282
/>
282283
283284
export const metadata = {

docs/src/app/(docs)/react/components/page.mdx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -603,13 +603,16 @@ A list of actions in a dropdown, enhanced with keyboard navigation.
603603
- Menu - CheckboxItemIndicator
604604
- Props: className, keepMounted, render, style
605605
- Data Attributes: data-checked, data-disabled, data-ending-style, data-starting-style, data-unchecked
606-
- Menu - Group
607-
- Props: children, className, render, style
608606
- Menu - GroupLabel
609607
- Props: className, render, style
608+
- Menu - Group
609+
- Props: children, className, render, style
610610
- Menu - Item
611611
- Props: className, closeOnClick, disabled, label, nativeButton, onClick, render, style
612612
- Data Attributes: data-disabled, data-highlighted
613+
- Menu - LinkItem
614+
- Props: className, closeOnClick, label, render, style
615+
- Data Attributes: data-highlighted
613616
- Menu - Popup
614617
- Props: children, className, finalFocus, render, style
615618
- Data Attributes: data-align, data-closed, data-ending-style, data-instant, data-open, data-side, data-starting-style

docs/src/app/(private)/experiments/menu/menu-fully-featured.tsx

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
'use client';
22
import * as React from 'react';
33
import { Menu } from '@base-ui/react/menu';
4+
import clsx from 'clsx';
5+
import NextLink from 'next/link';
46
import {
57
SettingsMetadata,
68
useExperimentSettings,
@@ -62,6 +64,23 @@ export default function MenuFullyFeatured() {
6264
<Menu.Item className={classes.Item} onClick={handleItemClick}>
6365
Item 2
6466
</Menu.Item>
67+
<Menu.LinkItem
68+
href="https://base-ui.com"
69+
className={clsx(classes.Item, 'hover:cursor-pointer!')}
70+
>
71+
Link 1 (base-ui.com)
72+
</Menu.LinkItem>
73+
<Menu.LinkItem
74+
render={<a href="https://github.com">Link 2 (github.com)</a>}
75+
className={clsx(classes.Item, 'hover:cursor-pointer!')}
76+
/>
77+
<Menu.LinkItem
78+
render={<NextLink href="/experiments">Link 3 (/experiments)</NextLink>}
79+
className={clsx(classes.Item, 'hover:cursor-pointer!')}
80+
/>
81+
<Menu.Item className={classes.Item} onClick={handleItemClick}>
82+
Item 3
83+
</Menu.Item>
6584
<Menu.Separator className={classes.Separator} />
6685
<Menu.Item className={classes.Item} closeOnClick={false} onClick={handleItemClick}>
6786
Item (close on click disabled)
@@ -80,7 +99,9 @@ export default function MenuFullyFeatured() {
8099
<Menu.Portal>
81100
<Menu.Positioner className={classes.Positioner} sideOffset={8}>
82101
<Menu.Popup className={classes.Popup}>
83-
<div>Non-focusable text</div>
102+
<div className="flex items-center py-2 pl-7.5 text-xs">
103+
Non-focusable text
104+
</div>
84105
<Menu.Group>
85106
<Menu.GroupLabel className={classes.GroupLabel}>
86107
Radio items

packages/react/src/context-menu/index.parts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export { MenuGroupLabel as GroupLabel } from '../menu/group-label/MenuGroupLabel
1111
export { MenuItem as Item } from '../menu/item/MenuItem';
1212
export { MenuCheckboxItem as CheckboxItem } from '../menu/checkbox-item/MenuCheckboxItem';
1313
export { MenuCheckboxItemIndicator as CheckboxItemIndicator } from '../menu/checkbox-item-indicator/MenuCheckboxItemIndicator';
14+
export { MenuLinkItem as LinkItem } from '../menu/link-item/MenuLinkItem';
1415
export { MenuRadioGroup as RadioGroup } from '../menu/radio-group/MenuRadioGroup';
1516
export { MenuRadioItem as RadioItem } from '../menu/radio-item/MenuRadioItem';
1617
export { MenuRadioItemIndicator as RadioItemIndicator } from '../menu/radio-item-indicator/MenuRadioItemIndicator';

packages/react/src/context-menu/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ export type {
3232
MenuItemProps as ContextMenuItemProps,
3333
MenuItemState as ContextMenuItemState,
3434
} from '../menu/item/MenuItem';
35+
export type {
36+
MenuLinkItemProps as ContextMenuLinkItemProps,
37+
MenuLinkItemState as ContextMenuLinkItemState,
38+
} from '../menu/link-item/MenuLinkItem';
3539
export type {
3640
MenuCheckboxItemProps as ContextMenuCheckboxItemProps,
3741
MenuCheckboxItemState as ContextMenuCheckboxItemState,

packages/react/src/menu/index.parts.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export { MenuCheckboxItemIndicator as CheckboxItemIndicator } from './checkbox-i
55
export { MenuGroup as Group } from './group/MenuGroup';
66
export { MenuGroupLabel as GroupLabel } from './group-label/MenuGroupLabel';
77
export { MenuItem as Item } from './item/MenuItem';
8+
export { MenuLinkItem as LinkItem } from './link-item/MenuLinkItem';
89
export { MenuPopup as Popup } from './popup/MenuPopup';
910
export { MenuPortal as Portal } from './portal/MenuPortal';
1011
export { MenuPositioner as Positioner } from './positioner/MenuPositioner';

packages/react/src/menu/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@ export type * from './arrow/MenuArrow';
55
export type * from './backdrop/MenuBackdrop';
66
export type * from './checkbox-item/MenuCheckboxItem';
77
export type * from './checkbox-item-indicator/MenuCheckboxItemIndicator';
8-
export type * from './group/MenuGroup';
98
export type * from './group-label/MenuGroupLabel';
9+
export type * from './group/MenuGroup';
1010
export type * from './item/MenuItem';
11+
export type * from './link-item/MenuLinkItem';
1112
export type * from './popup/MenuPopup';
1213
export type * from './portal/MenuPortal';
1314
export type * from './positioner/MenuPositioner';

packages/react/src/menu/item/MenuItem.test.tsx

Lines changed: 0 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import * as React from 'react';
22
import { expect } from 'chai';
33
import { spy } from 'sinon';
4-
import { MemoryRouter, Route, Routes, Link, useLocation } from 'react-router';
54
import { act, fireEvent, screen, waitFor } from '@mui/internal-test-utils';
65
import { Menu } from '@base-ui/react/menu';
76
import { describeConformance, createRenderer, isJSDOM } from '#test-utils';
@@ -170,75 +169,6 @@ describe('<Menu.Item />', () => {
170169
});
171170
});
172171

173-
describe('rendering links', () => {
174-
function One() {
175-
return <div>page one</div>;
176-
}
177-
function Two() {
178-
return <div>page two</div>;
179-
}
180-
function LocationDisplay() {
181-
const location = useLocation();
182-
return <div data-testid="location">{location.pathname}</div>;
183-
}
184-
185-
it('react-router <Link>', async () => {
186-
const { user } = await render(
187-
<MemoryRouter initialEntries={['/']}>
188-
<Routes>
189-
<Route path="/" element={<One />} />
190-
<Route path="/two" element={<Two />} />
191-
</Routes>
192-
193-
<LocationDisplay />
194-
195-
<Menu.Root open>
196-
<Menu.Portal>
197-
<Menu.Positioner>
198-
<Menu.Popup>
199-
<Menu.Item render={<Link to="/" />}>link 1</Menu.Item>
200-
<Menu.Item render={<Link to="/two" />}>link 2</Menu.Item>
201-
</Menu.Popup>
202-
</Menu.Positioner>
203-
</Menu.Portal>
204-
</Menu.Root>
205-
</MemoryRouter>,
206-
);
207-
208-
const [link1, link2] = screen.getAllByRole('menuitem');
209-
210-
const locationDisplay = screen.getByTestId('location');
211-
212-
expect(screen.getByText(/page one/i)).not.to.equal(null);
213-
214-
expect(locationDisplay).to.have.text('/');
215-
216-
await act(async () => {
217-
link2.focus();
218-
});
219-
220-
await waitFor(() => {
221-
expect(link2).toHaveFocus();
222-
});
223-
224-
await user.keyboard('[Enter]');
225-
226-
expect(locationDisplay).to.have.text('/two');
227-
228-
expect(screen.getByText(/page two/i)).not.to.equal(null);
229-
230-
await act(async () => {
231-
link1.focus();
232-
});
233-
234-
await user.keyboard('[Enter]');
235-
236-
expect(screen.getByText(/page one/i)).not.to.equal(null);
237-
238-
expect(locationDisplay).to.have.text('/');
239-
});
240-
});
241-
242172
describe('disabled state', () => {
243173
it('can be focused but not interacted with when disabled', async () => {
244174
const handleClick = spy();

0 commit comments

Comments
 (0)