Merge pull request #30908 from backstage/bui-menu

Move Menu to React Aria
This commit is contained in:
Charles de Dreuille
2025-08-17 09:47:01 +01:00
committed by GitHub
24 changed files with 2046 additions and 1470 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/ui': minor
---
**Breaking change** We are updating the Menu component to use React Aria under the hood. The structure and all props are changing to follow React Aria's guidance.
File diff suppressed because one or more lines are too long
+184 -42
View File
@@ -3,15 +3,26 @@ import { Snippet } from '@/components/Snippet';
import { CodeBlock } from '@/components/CodeBlock';
import { MenuSnippet } from '@/snippets/stories-snippets';
import {
menuRootPropDefs,
usage,
preview,
submenu,
icons,
sections,
separators,
links,
autocomplete,
autocompleteListbox,
autocompleteListboxMultiple,
menuTriggerPropDefs,
menuPositionerPropDefs,
submenuTriggerPropDefs,
menuPropDefs,
menuListBoxPropDefs,
menuAutocompletePropDefs,
menuAutocompleteListboxPropDefs,
menuItemPropDefs,
menuUsageSnippet,
menuDefaultSnippet,
menuTriggerSnippet,
menuHoverSnippet,
menuSubmenuSnippet,
menuListBoxItemPropDefs,
menuSectionPropDefs,
menuSeparatorPropDefs,
} from './menu.props';
import { PageTitle } from '@/components/PageTitle';
import { Theming } from '@/components/Theming';
@@ -25,68 +36,199 @@ import { ChangelogComponent } from '@/components/ChangelogComponent';
<Snippet
align="center"
py={4}
preview={<MenuSnippet story="Default" />}
code={menuDefaultSnippet}
preview={<MenuSnippet story="Preview" />}
code={preview}
/>
## Usage
<CodeBlock code={menuUsageSnippet} />
<CodeBlock code={usage} />
### Triggers
- `MenuTrigger` is a wrapper component that combines a button or other trigger element with a menu displayed in a popover.
- `SubmenuTrigger` is a wrapper component that combines a `MenuItem` with a menu displayed in a popover.
### Containers
- `Menu` is a container component that contains a list of menu items or sections.
- `MenuListBox` is a container component that contains a list of menu items or sections.
- `MenuAutocomplete` is a container component that contains a list of menu items or sections.
- `MenuAutocompleteListbox` is a container component that contains a list of menu items or sections.
### Items
- `MenuItem` is an individual interactive item in the menu.
- `MenuListBoxItem` is an individual interactive item in the menu list box.
### Separators
- `MenuSeparator` is a component that renders a horizontal line to separate menu items.
- `MenuSection` is a component that renders a section in the menu.
## API reference
### Menu.Root
### MenuTrigger
Groups all parts of the menu. Doesn't render its own HTML element.
<PropsTable data={menuRootPropDefs} />
### Menu.Trigger
The trigger by default render a simple unstyled button. Because menus can be rendered in different ways, we recommend
using the `render` prop to render a custom trigger.
<CodeBlock code={menuTriggerSnippet} />
`MenuTrigger` accepts exactly two children: the first child should be the trigger element, and second child should be
one of the menu containers containing the menu.
<PropsTable data={menuTriggerPropDefs} />
### Menu.Positioner
### SubmenuTrigger
Positions the menu popup against the trigger. Renders a `<div>` element.
The `SubmenuTrigger` accepts exactly two children: the first child should be the `MenuItem` which triggers opening
of the submenu, and second child should be one of the menu containers containing the submenu.
<PropsTable data={menuPositionerPropDefs} />
<PropsTable data={submenuTriggerPropDefs} />
### Menu.Item
### Menu
An individual interactive item in the menu. Renders a `<div>` element.
`Menu` is a container component that contains a list of menu items or sections.
<PropsTable data={menuPropDefs} />
### MenuListBox
`MenuListBox` is a container component that contains a list of menu items or sections.
<PropsTable data={menuListBoxPropDefs} />
### MenuAutocomplete
`MenuAutocomplete` is a container component that contains a list of menu items or sections.
<PropsTable data={menuAutocompletePropDefs} />
### MenuAutocompleteListbox
`MenuAutocompleteListbox` is a container component that contains a list of menu items or sections.
<PropsTable data={menuAutocompleteListboxPropDefs} />
### MenuItem
`MenuItem` is an individual interactive item in the menu.
<PropsTable data={menuItemPropDefs} />
### MenuListBoxItem
`MenuListBoxItem` is an individual interactive item in the menu list box.
<PropsTable data={menuListBoxItemPropDefs} />
### MenuSection
`MenuSection` is a component that renders a section in the menu.
<PropsTable data={menuSectionPropDefs} />
### MenuSeparator
`MenuSeparator` is a component that renders a horizontal line to separate menu items.
<PropsTable data={menuSeparatorPropDefs} />
## Examples
### Open on hover
To create a menu that opens on hover, add the `openOnHover` prop to the Root part.
You can additionally configure how quickly the menu opens on hover using the `delay` prop.
<Snippet
align="center"
py={4}
open
preview={<MenuSnippet story="OpenOnHover" />}
code={menuHoverSnippet}
/>
### Nested navigation
You can nest menus to create a more complex navigation structure.
You can nest menus to create a more complex navigation structure. It is important to use the `placement` prop to ensure
the submenu is displayed in the correct position. The best practice is to use the `right top` placement for the submenu.
<Snippet
align="center"
py={4}
open
preview={<MenuSnippet story="Submenu" />}
code={menuSubmenuSnippet}
preview={<MenuSnippet story="PreviewSubmenu" />}
code={submenu}
/>
### With icons
You can use the `iconStart` prop to add an icon to the menu item.
<Snippet
align="center"
py={4}
open
preview={<MenuSnippet story="PreviewIcons" />}
code={icons}
/>
### With links
You can use the `href` prop to add a link to the menu item. This is using our router provider under the hood
to work for both internal and external links.
<Snippet
align="center"
py={4}
open
preview={<MenuSnippet story="PreviewLinks" />}
code={links}
/>
### With sections
You can use the `MenuSection` component to add a section to the menu.
<Snippet
align="center"
py={4}
open
preview={<MenuSnippet story="PreviewSections" />}
code={sections}
/>
### With separators
You can use the `MenuSeparator` component to add a separator to the menu.
<Snippet
align="center"
py={4}
open
preview={<MenuSnippet story="PreviewSeparators" />}
code={separators}
/>
### With autocomplete
You can use the `MenuAutocomplete` component to add a autocomplete to the menu.
<Snippet
align="center"
py={4}
open
preview={<MenuSnippet story="PreviewAutocompleteMenu" />}
code={autocomplete}
/>
### With list box
You can use the `MenuListBox` component to add a list box to the menu.
<Snippet
align="center"
py={4}
open
preview={<MenuSnippet story="PreviewAutocompleteListbox" />}
code={autocompleteListbox}
/>
### With list box with multiple selection
You can use the `MenuListBox` component to add a list box to the menu. You can also use the `selectionMode` prop to
allow multiple selection.
<Snippet
align="center"
py={4}
open
preview={<MenuSnippet story="PreviewAutocompleteListboxMultiple" />}
code={autocompleteListboxMultiple}
/>
<Theming component="Menu" />
+282 -192
View File
@@ -1,242 +1,332 @@
import {
classNamePropDefs,
stylePropDefs,
renderPropDefs,
type PropDef,
} from '@/utils/propDefs';
export const menuRootPropDefs: Record<string, PropDef> = {
defaultOpen: {
const placementValues = [
'bottom',
'bottom left',
'bottom right',
'bottom start',
'bottom end',
'top',
'top left',
'top right',
'top start',
'top end',
'left',
'left top',
'left bottom',
'start',
'start top',
'start bottom',
'right',
'right top',
'right bottom',
'end',
'end top',
'end bottom',
];
export const menuTriggerPropDefs: Record<string, PropDef> = {
isOpen: {
type: 'boolean',
default: 'false',
},
open: {
defaultOpen: {
type: 'boolean',
},
onOpenChange: {
type: 'enum',
values: ['(open, event) => void'],
},
closeParentOnEsc: {
type: 'boolean',
default: 'true',
},
modal: {
type: 'boolean',
default: 'true',
},
onOpenChangeComplete: {
type: 'enum',
values: ['(open) => void'],
},
disabled: {
type: 'boolean',
default: 'false',
},
openOnHover: {
type: 'boolean',
values: ['(isOpen: boolean) => void'],
},
};
export const submenuTriggerPropDefs: Record<string, PropDef> = {
delay: {
type: 'number',
default: '100',
default: '200',
},
loop: {
type: 'boolean',
default: 'true',
},
orientation: {
};
export const menuPropDefs: Record<string, PropDef> = {
disabledKeys: {
type: 'enum',
values: ['horizontal', 'vertical'],
default: 'vertical',
values: ['Iterable<Key>'],
},
selectionMode: {
type: 'enum',
values: ['none', 'single', 'multiple'],
},
selectedKeys: {
type: 'enum',
values: ['all', 'Iterable<Key>'],
},
defaultSelectedKeys: {
type: 'enum',
values: ['all', 'Iterable<Key>'],
},
placement: {
type: 'enum',
values: placementValues,
},
...classNamePropDefs,
...stylePropDefs,
};
export const menuTriggerPropDefs: Record<string, PropDef> = {
...renderPropDefs,
export const menuListBoxPropDefs: Record<string, PropDef> = {
disabledKeys: {
type: 'enum',
values: ['Iterable<Key>'],
},
selectionMode: {
type: 'enum',
values: ['none', 'single', 'multiple'],
},
selectedKeys: {
type: 'enum',
values: ['all', 'Iterable<Key>'],
},
defaultSelectedKeys: {
type: 'enum',
values: ['all', 'Iterable<Key>'],
},
placement: {
type: 'enum',
values: placementValues,
},
...classNamePropDefs,
...stylePropDefs,
};
export const menuPositionerPropDefs: Record<string, PropDef> = {
align: {
export const menuAutocompletePropDefs: Record<string, PropDef> = {
placement: {
type: 'enum',
values: ['start', 'center', 'end'],
default: 'center',
values: placementValues,
},
alignOffset: {
...classNamePropDefs,
...stylePropDefs,
};
export const menuAutocompleteListboxPropDefs: Record<string, PropDef> = {
placement: {
type: 'enum',
values: ['number', '(data) => number'],
default: '0',
},
side: {
type: 'enum',
values: ['bottom', 'inline-end', 'inline-start', 'left', 'right', 'top'],
default: 'bottom',
},
sideOffset: {
type: 'enum',
values: ['number', '(data) => number'],
default: '0',
},
arrowPadding: {
type: 'number',
default: '5',
},
anchor: {
type: 'enum',
values: [
'React.Ref',
'Element',
'VirtualElement',
'(() => Element | VirtualElement | null)',
'null',
],
},
collisionBoundary: {
type: 'enum',
values: ['clipping-ancestors', 'Element', 'Element[]', 'Rect'],
default: 'clipping-ancestors',
},
collisionPadding: {
type: 'enum',
values: ['number', 'Rect'],
default: '5',
},
sticky: {
type: 'boolean',
default: 'false',
},
positionMethod: {
type: 'enum',
values: ['absolute', 'fixed'],
default: 'absolute',
},
trackAnchor: {
type: 'boolean',
default: 'true',
values: placementValues,
},
...classNamePropDefs,
...stylePropDefs,
};
export const menuItemPropDefs: Record<string, PropDef> = {
label: {
id: {
type: 'enum',
values: ['Key'],
},
value: {
type: 'string',
},
onClick: {
textValue: {
type: 'string',
},
isDisabled: {
type: 'boolean',
},
href: {
type: 'string',
},
onAction: {
type: 'enum',
values: ['(event) => void'],
},
closeOnClick: {
type: 'boolean',
default: 'true',
},
disabled: {
type: 'boolean',
default: 'false',
},
...classNamePropDefs,
...stylePropDefs,
};
export const menuUsageSnippet = `import { Menu } from '@backstage/ui';
export const menuListBoxItemPropDefs: Record<string, PropDef> = {
id: {
type: 'enum',
values: ['Key'],
},
value: {
type: 'string',
},
textValue: {
type: 'string',
},
isDisabled: {
type: 'boolean',
},
...classNamePropDefs,
...stylePropDefs,
};
<Menu.Root>
<Menu.Trigger />
<Menu.Portal>
<Menu.Positioner>
<Menu.Popup>
<Menu.Item />
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>`;
export const menuSectionPropDefs: Record<string, PropDef> = {
title: {
type: 'string',
},
...classNamePropDefs,
...stylePropDefs,
};
export const menuDefaultSnippet = `<Menu.Root>
<Menu.Trigger
render={props => (
<Button
{...props}
size="medium"
variant="secondary"
iconEnd="chevron-down"
>
Menu
</Button>
)}
/>
<Menu.Portal>
<Menu.Positioner sideOffset={8} align="start">
<Menu.Popup>
<Menu.Item>Settings</Menu.Item>
<Menu.Item>Invite new members</Menu.Item>
<Menu.Item>Download app</Menu.Item>
<Menu.Item>Log out</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>`;
export const menuSeparatorPropDefs: Record<string, PropDef> = {
...classNamePropDefs,
...stylePropDefs,
};
export const menuTriggerSnippet = `<Menu.Trigger render={props => <Button {...props} />} />`;
export const usage = `import { MenuTrigger, Menu, MenuItem } from '@backstage/ui';
export const menuHoverSnippet = `<Menu.Root openOnHover delay={100}>
<Menu.Trigger
render={props => (
<Button
{...props}
size="medium"
variant="secondary"
iconEnd="chevron-down"
>
Menu
</Button>
)}
/>
<Menu.Portal>
<Menu.Positioner sideOffset={8} align="start">
<Menu.Popup>
<Menu.Item>Settings</Menu.Item>
<Menu.Item>Invite new members</Menu.Item>
<Menu.Item>Download app</Menu.Item>
<Menu.Item>Log out</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>`;
<MenuTrigger>
<Button>Menu</Button>
<Menu>
<MenuItem id="apple">Apple</MenuItem>
<MenuItem id="banana">Banana</MenuItem>
<MenuItem id="blueberry">Blueberry</MenuItem>
<MenuSeparator />
<SubmenuTrigger>
<MenuItem>Vegetables</MenuItem>
<Menu>
<MenuItem id="carrot">Carrot</MenuItem>
<MenuItem id="tomato">Tomato</MenuItem>
<MenuItem id="potato">Potato</MenuItem>
</Menu>
</SubmenuTrigger>
</Menu>
</MenuTrigger>`;
export const menuSubmenuSnippet = `<Menu.Trigger
render={props => (
<Button
{...props}
size="small"
variant="secondary"
iconEnd={<Icon name="chevron-down" />}
export const preview = `<MenuTrigger>
<Button>Menu</Button>
<Menu>
{options.map(option => (
<MenuItem key={option.value}>{option.label}</MenuItem>
))}
</Menu>
</MenuTrigger>`;
export const submenu = `<MenuTrigger>
<Button aria-label="Menu">Menu</Button>
<Menu>
<MenuItem>Edit</MenuItem>
<MenuItem>Duplicate</MenuItem>
<SubmenuTrigger>
<MenuItem>Submenu</MenuItem>
<Menu placement="right top">
<MenuItem>Edit</MenuItem>
<MenuItem>Duplicate</MenuItem>
<MenuItem>Rename</MenuItem>
<MenuSeparator />
<MenuItem>Share</MenuItem>
<MenuItem>Move</MenuItem>
<MenuSeparator />
<MenuItem iconStart={<RiChat1Line />}>Feedback</MenuItem>
</Menu>
</SubmenuTrigger>
</Menu>
</MenuTrigger>`;
export const icons = `<MenuTrigger>
<Button aria-label="Menu">Menu</Button>
<Menu>
<MenuItem iconStart={<RiFileCopyLine />}>Copy</MenuItem>
<MenuItem iconStart={<RiEdit2Line />}>Rename</MenuItem>
<MenuItem iconStart={<RiChat1Line />}>Send feedback</MenuItem>
</Menu>
</MenuTrigger>`;
export const sections = `<MenuTrigger>
<Button aria-label="Menu">Menu</Button>
<Menu>
<MenuSection title="My Account">
<MenuItem iconStart={<RiUserLine />}>Profile</MenuItem>
<MenuItem iconStart={<RiSettingsLine />}>Settings</MenuItem>
</MenuSection>
<MenuSection title="Support">
<MenuItem iconStart={<RiQuestionLine />}>Help Center</MenuItem>
<MenuItem iconStart={<RiCustomerService2Line />}>
Contact Support
</MenuItem>
<MenuItem iconStart={<RiChat1Line />}>Feedback</MenuItem>
</MenuSection>
</Menu>
</MenuTrigger>`;
export const separators = `<MenuTrigger>
<Button aria-label="Menu">Menu</Button>
<Menu>
<MenuItem>Edit</MenuItem>
<MenuItem>Duplicate</MenuItem>
<MenuItem>Rename</MenuItem>
<MenuSeparator />
<MenuItem>Share</MenuItem>
<MenuItem>Move</MenuItem>
<MenuSeparator />
<MenuItem iconStart={<RiChat1Line />}>Feedback</MenuItem>
</Menu>
</MenuTrigger>`;
export const links = `<MenuTrigger>
<Button aria-label="Menu">Menu</Button>
<Menu>
<MenuItem href="/home">Internal link</MenuItem>
<MenuItem href="https://www.google.com" target="_blank">
External link
</MenuItem>
<MenuItem href="mailto:test@test.com">Email link</MenuItem>
</Menu>
</MenuTrigger>`;
export const autocomplete = `<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<MenuAutocomplete placeholder="Filter">
<MenuItem>Create new file...</MenuItem>
<MenuItem>Create new folder...</MenuItem>
<MenuItem>Assign to...</MenuItem>
<MenuItem>Assign to me</MenuItem>
<MenuItem>Change status...</MenuItem>
<MenuItem>Change priority...</MenuItem>
<MenuItem>Add label...</MenuItem>
<MenuItem>Remove label...</MenuItem>
</MenuAutocomplete>
</MenuTrigger>`;
export const autocompleteListbox = `const [selected, setSelected] = useState<Selection>(
new Set(['blueberry']),
);
<Flex direction="column" gap="2" align="start">
<Text>Selected: {Array.from(selected).join(', ')}</Text>
<MenuTrigger>
<Button aria-label="Menu">Menu</Button>
<MenuAutocompleteListbox
selectedKeys={selected}
onSelectionChange={setSelected}
>
Menu
</Button>
)}
/>
<Menu.Portal>
<Menu.Positioner sideOffset={8} align="start">
<Menu.Popup>
<Menu.Item>Settings</Menu.Item>
<Menu.Item>Invite new members</Menu.Item>
<Menu.Item>Download app</Menu.Item>
<Menu.Item>Log out</Menu.Item>
<Menu.Root>
<Menu.SubmenuTrigger>Submenu</Menu.SubmenuTrigger>
<Menu.Portal>
<Menu.Positioner>
<Menu.Popup>
<Menu.Item>Submenu Item 1</Menu.Item>
<Menu.Item>Submenu Item 2</Menu.Item>
<Menu.Item>Submenu Item 3</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>`;
{options.map(option => (
<MenuListBoxItem key={option.value} id={option.value}>
{option.label}
</MenuListBoxItem>
))}
</MenuAutocompleteListbox>
</MenuTrigger>
</Flex>`;
export const autocompleteListboxMultiple = `const [selected, setSelected] = useState<Selection>(
new Set(['blueberry', 'cherry']),
);
<Flex direction="column" gap="2" align="start">
<Text>Selected: {Array.from(selected).join(', ')}</Text>
<MenuTrigger>
<Button aria-label="Menu">Menu</Button>
<MenuAutocompleteListbox
selectionMode="multiple"
selectedKeys={selected}
onSelectionChange={setSelected}
>
{options.map(option => (
<MenuListBoxItem key={option.value} id={option.value}>
{option.label}
</MenuListBoxItem>
))}
</MenuAutocompleteListbox>
</MenuTrigger>
</Flex>`;
+190 -129
View File
@@ -9688,184 +9688,245 @@
}
}
.bui-MenuPositioner {
z-index: 100;
outline: 0;
}
.bui-MenuPopup {
background-color: var(--bui-bg-surface-1);
.bui-MenuPopover {
border: 1px solid var(--bui-border);
border-radius: var(--bui-radius-2);
background: var(--bui-bg-surface-1);
color: var(--bui-fg-primary);
transform-origin: var(--transform-origin);
max-width: min(var(--available-width), 340px);
max-height: min(var(--available-height), 500px);
padding-bottom: var(--bui-space-1);
border-radius: .375rem;
outline: none;
flex-direction: column;
transition: transform .15s, opacity .15s;
min-height: 0;
transition: transform .2s, opacity .2s;
display: flex;
position: relative;
overflow: auto;
overflow: hidden;
&[data-starting-style], &[data-ending-style] {
&[data-entering], &[data-exiting] {
transform: var(--origin);
opacity: 0;
transform: scale(.9);
}
&[data-placement="top"] {
--origin: translateY(8px);
}
&[data-placement="bottom"] {
--origin: translateY(-8px);
}
&[data-placement="right"] {
--origin: translateX(-8px);
}
&[data-placement="left"] {
--origin: translateX(8px);
}
}
.bui-MenuContent {
max-height: inherit;
box-sizing: border-box;
padding: var(--bui-space-1);
outline: none;
min-width: 150px;
overflow: auto;
}
.bui-MenuPopover .bui-ScrollAreaRoot {
flex-direction: column;
flex: 1;
height: 100%;
min-height: 0;
display: flex;
}
.bui-MenuPopover .bui-ScrollAreaScrollbar {
margin-inline: var(--bui-space-1_5);
}
.bui-MenuItem {
user-select: none;
align-items: center;
gap: var(--bui-space-2);
height: 32px;
color: var(--bui-fg-primary);
border-radius: var(--bui-radius-2);
margin-inline: var(--bui-space-1);
height: 2rem;
padding-inline: var(--bui-space-2);
border-radius: var(--bui-radius-2);
cursor: default;
color: var(--bui-fg-primary);
font-size: var(--bui-font-size-3);
cursor: pointer;
outline: 0;
flex-shrink: 0;
text-decoration: none;
justify-content: space-between;
align-items: center;
gap: var(--bui-space-6);
outline: none;
display: flex;
&:first-child {
margin-top: var(--bui-space-1);
&[data-focused], &[data-open] {
background: var(--bui-bg-surface-2);
color: var(--bui-fg-primary);
}
&[data-highlighted] {
background-color: var(--bui-gray-3);
&[data-color="danger"] {
color: var(--bui-fg-danger);
}
&[data-color="danger"][data-focused] {
background: var(--bui-bg-danger);
color: var(--bui-fg-danger);
}
&[data-has-submenu] {
& > .bui-MenuItemArrow {
display: block;
}
}
}
.bui-MenuSubmenuTrigger {
user-select: none;
.bui-MenuItemListBox {
height: 2rem;
padding-inline: var(--bui-space-2);
border-radius: var(--bui-radius-2);
cursor: default;
color: var(--bui-fg-primary);
font-size: var(--bui-font-size-3);
justify-content: space-between;
align-items: center;
gap: var(--bui-space-2);
height: 32px;
color: var(--bui-fg-primary);
border-radius: var(--bui-radius-2);
margin-inline: var(--bui-space-1);
padding-inline: var(--bui-space-2);
font-size: var(--bui-font-size-3);
cursor: pointer;
outline: 0;
flex-shrink: 0;
text-decoration: none;
gap: var(--bui-space-6);
outline: none;
display: flex;
& .bui-Icon {
color: var(--bui-fg-secondary);
&:hover {
background: var(--bui-bg-surface-2);
color: var(--bui-fg-primary);
}
&:first-child {
margin-top: var(--bui-space-1);
}
&[data-popup-open], &[data-highlighted] {
background-color: var(--bui-gray-3);
& .bui-Icon {
&[data-selected] .bui-MenuItemListBoxCheck {
& > svg {
opacity: 1;
color: var(--bui-fg-primary);
}
}
}
.bui-MenuSeparator {
background-color: var(--color-gray-200);
height: 1px;
margin: .375rem 1rem;
.bui-MenuItemListBoxCheck {
justify-content: center;
align-items: center;
width: 1rem;
height: 1rem;
display: flex;
& > svg {
opacity: 0;
width: 1rem;
height: 1rem;
}
}
.bui-SubmenuComboboxSearch {
padding-inline: var(--bui-space-3);
.bui-MenuItemContent {
align-items: center;
gap: var(--bui-space-2);
display: flex;
& > svg {
width: 1rem;
height: 1rem;
}
}
.bui-MenuItemArrow {
width: 1rem;
height: 1rem;
display: none;
& > svg {
width: 1rem;
height: 1rem;
}
}
.bui-MenuSection {
&:first-child .bui-MenuSectionHeader {
padding-top: 0;
}
}
.bui-MenuSectionHeader {
height: 2rem;
padding-top: var(--bui-space-3);
padding-left: var(--bui-space-2);
color: var(--bui-fg-primary);
font-size: var(--bui-font-size-1);
letter-spacing: .05rem;
text-transform: uppercase;
align-items: center;
font-weight: bold;
display: flex;
}
.bui-MenuSeparator {
background: var(--bui-border);
height: 1px;
margin-inline: var(--bui-space-1_5);
margin-block: var(--bui-space-1);
}
.bui-MenuSearchField {
font-family: var(--bui-font-regular);
flex-shrink: 0;
width: 100%;
position: relative;
&[data-empty] {
& .bui-MenuSearchFieldClear {
display: none;
}
}
}
.bui-MenuSearchFieldInput {
padding: 0 var(--bui-space-3);
border: none;
border-bottom: 1px solid var(--bui-border);
background-color: var(--bui-bg-surface-1);
font-size: var(--bui-font-size-3);
font-family: var(--bui-font-regular);
font-weight: var(--bui-font-weight-regular);
color: var(--bui-fg-primary);
width: 100%;
height: 32px;
color: var(--bui-fg-primary);
line-height: 140%;
font-size: var(--bui-font-size-3);
z-index: 1;
height: 2rem;
cursor: inherit;
outline: none;
position: sticky;
top: 0;
&::placeholder {
color: var(--bui-fg-secondary);
}
&:disabled {
opacity: .6;
cursor: not-allowed;
}
}
.bui-SubmenuComboboxItems {
padding-top: var(--bui-space-2);
outline: none;
flex-direction: column;
display: flex;
overflow-y: auto;
}
.bui-SubmenuComboboxNoResults {
padding-inline: var(--bui-space-3);
padding-top: var(--bui-space-2);
padding-bottom: var(--bui-space-4);
color: var(--bui-fg-secondary);
font-size: var(--bui-font-size-3);
}
.bui-SubmenuComboboxItem {
user-select: none;
justify-content: space-between;
align-items: center;
gap: var(--bui-space-2);
height: 32px;
color: var(--bui-fg-primary);
border-radius: var(--bui-radius-2);
margin-inline: var(--bui-space-1);
padding-inline: var(--bui-space-2);
font-size: var(--bui-font-size-3);
cursor: pointer;
outline: 0;
flex-shrink: 0;
text-decoration: none;
display: flex;
&[data-highlighted] {
background-color: var(--bui-gray-3);
}
&[data-disabled] {
opacity: .5;
cursor: not-allowed;
&::-webkit-search-cancel-button, &::-webkit-search-decoration {
-webkit-appearance: none;
}
}
.bui-SubmenuComboboxItemCheckbox {
width: 16px;
height: 16px;
color: var(--bui-fg-primary);
border-radius: var(--bui-radius-2);
border: 1px solid var(--bui-border);
background: var(--bui-bg-surface-1);
flex-shrink: 0;
.bui-MenuSearchFieldClear {
right: var(--bui-space-2);
cursor: pointer;
color: var(--bui-fg-secondary);
background-color: #0000;
border: none;
justify-content: center;
align-items: center;
margin: 0;
padding: 0;
transition: color .2s ease-in-out;
display: flex;
position: absolute;
top: 0;
bottom: 0;
& > svg {
width: 1rem;
height: 1rem;
}
}
.bui-SubmenuComboboxItemLabel {
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
overflow: hidden;
.bui-MenuEmptyState {
padding: var(--bui-space-1);
color: var(--bui-fg-secondary);
font-size: var(--bui-font-size-3);
font-family: var(--bui-font-regular);
font-weight: var(--bui-font-weight-regular);
}
.bui-Popover {
+120 -65
View File
@@ -15,7 +15,13 @@ import type { ElementType } from 'react';
import { ForwardRefExoticComponent } from 'react';
import { JSX as JSX_2 } from 'react/jsx-runtime';
import { LinkProps as LinkProps_2 } from 'react-aria-components';
import { Menu as Menu_2 } from '@base-ui-components/react/menu';
import type { ListBoxItemProps } from 'react-aria-components';
import type { ListBoxProps } from 'react-aria-components';
import type { MenuItemProps as MenuItemProps_2 } from 'react-aria-components';
import type { MenuProps as MenuProps_2 } from 'react-aria-components';
import type { MenuSectionProps as MenuSectionProps_2 } from 'react-aria-components';
import type { MenuTriggerProps as MenuTriggerProps_2 } from 'react-aria-components';
import type { PopoverProps } from 'react-aria-components';
import type { RadioGroupProps as RadioGroupProps_2 } from 'react-aria-components';
import type { RadioProps as RadioProps_2 } from 'react-aria-components';
import { ReactElement } from 'react';
@@ -26,6 +32,8 @@ import { RowProps } from 'react-aria-components';
import { ScrollArea as ScrollArea_2 } from '@base-ui-components/react/scroll-area';
import type { SearchFieldProps as SearchFieldProps_2 } from 'react-aria-components';
import type { SelectProps as SelectProps_2 } from 'react-aria-components';
import type { SeparatorProps } from 'react-aria-components';
import type { SubmenuTriggerProps as SubmenuTriggerProps_2 } from 'react-aria-components';
import type { SwitchProps as SwitchProps_2 } from 'react-aria-components';
import { TableBodyProps } from 'react-aria-components';
import { TableHeaderProps } from 'react-aria-components';
@@ -497,21 +505,21 @@ export const componentDefinitions: {
};
readonly Menu: {
readonly classNames: {
readonly trigger: 'bui-MenuTrigger';
readonly backdrop: 'bui-MenuBackdrop';
readonly positioner: 'bui-MenuPositioner';
readonly popup: 'bui-MenuPopup';
readonly arrow: 'bui-MenuArrow';
readonly root: 'bui-Menu';
readonly popover: 'bui-MenuPopover';
readonly content: 'bui-MenuContent';
readonly section: 'bui-MenuSection';
readonly sectionHeader: 'bui-MenuSectionHeader';
readonly item: 'bui-MenuItem';
readonly group: 'bui-MenuGroup';
readonly groupLabel: 'bui-MenuGroupLabel';
readonly radioGroup: 'bui-MenuRadioGroup';
readonly radioItem: 'bui-MenuRadioItem';
readonly radioItemIndicator: 'bui-MenuRadioItemIndicator';
readonly checkboxItem: 'bui-MenuCheckboxItem';
readonly checkboxItemIndicator: 'bui-MenuCheckboxItemIndicator';
readonly submenuTrigger: 'bui-MenuSubmenuTrigger';
readonly itemListBox: 'bui-MenuItemListBox';
readonly itemListBoxCheck: 'bui-MenuItemListBoxCheck';
readonly itemContent: 'bui-MenuItemContent';
readonly itemArrow: 'bui-MenuItemArrow';
readonly separator: 'bui-MenuSeparator';
readonly searchField: 'bui-MenuSearchField';
readonly searchFieldInput: 'bui-MenuSearchFieldInput';
readonly searchFieldClear: 'bui-MenuSearchFieldClear';
readonly emptyState: 'bui-MenuEmptyState';
};
};
readonly Popover: {
@@ -976,16 +984,6 @@ export interface GridProps extends SpaceProps {
// @public
export const Header: (props: HeaderProps) => JSX_2.Element;
// @public
export interface HeaderMenuItem {
// (undocumented)
label: string;
// (undocumented)
onClick?: () => void;
// (undocumented)
value: string;
}
// @public
export const HeaderPage: (props: HeaderPageProps) => JSX_2.Element;
@@ -1004,8 +1002,6 @@ export interface HeaderPageProps {
// (undocumented)
customActions?: React.ReactNode;
// (undocumented)
menuItems?: HeaderMenuItem[];
// (undocumented)
tabs?: HeaderTab[];
// (undocumented)
title?: string;
@@ -1018,8 +1014,6 @@ export interface HeaderProps {
// (undocumented)
icon?: React.ReactNode;
// (undocumented)
menuItems?: HeaderMenuItem[];
// (undocumented)
onTabSelectionChange?: TabsProps_2['onSelectionChange'];
// (undocumented)
tabs?: HeaderTab[];
@@ -1247,52 +1241,107 @@ export const marginPropDefs: (spacingValues: string[]) => {
export type MarginProps = GetPropDefTypes<typeof marginPropDefs>;
// @public (undocumented)
export const Menu: MenuComponent;
export const Menu: (props: MenuProps<object>) => JSX_2.Element;
// @public (undocumented)
export type MenuComboboxOption = {
label: string;
value: string;
disabled?: boolean;
};
export const MenuAutocomplete: (
props: MenuAutocompleteProps<object>,
) => JSX_2.Element;
// @public (undocumented)
export interface MenuComboboxProps extends ComponentProps<'div'> {
export const MenuAutocompleteListbox: (
props: MenuAutocompleteListBoxProps<object>,
) => JSX_2.Element;
// @public (undocumented)
export interface MenuAutocompleteListBoxProps<T>
extends ListBoxProps<T>,
Omit<ListBoxProps<T>, 'children'> {
// (undocumented)
closeParentOnEsc?: boolean;
placeholder?: string;
// (undocumented)
multiselect?: boolean;
// (undocumented)
onValueChange?: (value: string[]) => void;
// (undocumented)
options: MenuComboboxOption[];
// (undocumented)
value?: string[];
placement?: PopoverProps['placement'];
}
// @public (undocumented)
export type MenuComponent = {
Root: typeof Menu_2.Root;
Trigger: typeof Menu_2.Trigger;
Portal: typeof Menu_2.Portal;
Backdrop: typeof Menu_2.Backdrop;
Positioner: typeof Menu_2.Positioner;
Popup: typeof Menu_2.Popup;
Arrow: typeof Menu_2.Arrow;
Item: typeof Menu_2.Item;
Group: typeof Menu_2.Group;
GroupLabel: typeof Menu_2.GroupLabel;
RadioGroup: typeof Menu_2.RadioGroup;
RadioItem: typeof Menu_2.RadioItem;
RadioItemIndicator: typeof Menu_2.RadioItemIndicator;
CheckboxItem: typeof Menu_2.CheckboxItem;
CheckboxItemIndicator: typeof Menu_2.CheckboxItemIndicator;
SubmenuTrigger: typeof Menu_2.SubmenuTrigger;
Separator: typeof Menu_2.Separator;
Combobox: ForwardRefExoticComponent<
MenuComboboxProps & RefAttributes<HTMLDivElement>
>;
};
export interface MenuAutocompleteProps<T>
extends MenuProps_2<T>,
Omit<MenuProps_2<T>, 'children'> {
// (undocumented)
placeholder?: string;
// (undocumented)
placement?: PopoverProps['placement'];
}
// @public (undocumented)
export const MenuItem: (props: MenuItemProps) => JSX_2.Element;
// @public (undocumented)
export interface MenuItemProps
extends MenuItemProps_2,
Omit<MenuItemProps_2, 'children'> {
// (undocumented)
children: React.ReactNode;
// (undocumented)
color?: 'primary' | 'danger';
// (undocumented)
iconStart?: React.ReactNode;
}
// @public (undocumented)
export const MenuListBox: (props: MenuListBoxProps<object>) => JSX_2.Element;
// @public (undocumented)
export const MenuListBoxItem: (props: MenuListBoxItemProps) => JSX_2.Element;
// @public (undocumented)
export interface MenuListBoxItemProps
extends ListBoxItemProps,
Omit<ListBoxItemProps, 'children'> {
// (undocumented)
children: React.ReactNode;
}
// @public (undocumented)
export interface MenuListBoxProps<T>
extends ListBoxProps<T>,
Omit<ListBoxProps<T>, 'children'> {
// (undocumented)
placement?: PopoverProps['placement'];
}
// @public (undocumented)
export interface MenuProps<T>
extends MenuProps_2<T>,
Omit<MenuProps_2<T>, 'children'> {
// (undocumented)
placement?: PopoverProps['placement'];
}
// @public (undocumented)
export const MenuSection: (props: MenuSectionProps<object>) => JSX_2.Element;
// @public (undocumented)
export interface MenuSectionProps<T>
extends MenuSectionProps_2<T>,
Omit<MenuSectionProps_2<T>, 'children'> {
// (undocumented)
children: React.ReactNode;
// (undocumented)
title: string;
}
// @public (undocumented)
export const MenuSeparator: (props: MenuSeparatorProps) => JSX_2.Element;
// @public (undocumented)
export interface MenuSeparatorProps extends SeparatorProps {}
// @public (undocumented)
export const MenuTrigger: (props: MenuTriggerProps) => JSX_2.Element;
// @public (undocumented)
export interface MenuTriggerProps extends MenuTriggerProps_2 {}
// @public (undocumented)
export type NonStylingPropDef = {
@@ -1563,6 +1612,12 @@ export type StylingPropDef = {
parseValue?: (value: string) => string | undefined;
};
// @public (undocumented)
export const SubmenuTrigger: (props: SubmenuTriggerProps) => JSX_2.Element;
// @public (undocumented)
export interface SubmenuTriggerProps extends SubmenuTriggerProps_2 {}
// @public (undocumented)
export const Switch: ForwardRefExoticComponent<
SwitchProps & RefAttributes<HTMLLabelElement>
@@ -1,5 +1,5 @@
/*
* Copyright 2024 The Backstage Authors
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,17 +16,23 @@
import type { Meta, StoryObj, StoryFn } from '@storybook/react';
import { Header } from './Header';
import { HeaderMenuItem, HeaderTab } from './types';
import { Button } from '../Button';
import { HeaderPage } from '../HeaderPage';
import type { HeaderTab } from './types';
import {
Button,
HeaderPage,
Container,
Text,
ButtonIcon,
MenuTrigger,
Menu,
MenuItem,
} from '../../';
import { MemoryRouter } from 'react-router-dom';
import { Container } from '../Container';
import { Text } from '../Text';
import { ButtonIcon } from '../ButtonIcon';
import {
RiHeartLine,
RiEmotionHappyLine,
RiCloudy2Line,
RiMore2Line,
} from '@remixicon/react';
import { HeaderPageBreadcrumb } from '../HeaderPage/types';
@@ -87,14 +93,23 @@ const tabs2: HeaderTab[] = [
},
];
const menuItems: HeaderMenuItem[] = [
const menuItems = [
{
label: 'Settings',
value: 'settings',
href: '/settings',
},
{
label: 'Invite new members',
value: 'invite-new-members',
href: '/invite-new-members',
},
{
label: 'Logout',
value: 'logout',
onClick: () => {
alert('logout');
},
},
];
@@ -190,13 +205,6 @@ export const WithTabs: Story = {
decorators: [withRouter],
};
export const WithOptions: Story = {
args: {
menuItems,
},
decorators: [withRouter],
};
export const WithCustomActions: Story = {
args: {},
decorators: [withRouter],
@@ -208,29 +216,33 @@ export const WithCustomActions: Story = {
<ButtonIcon variant="tertiary" icon={<RiCloudy2Line />} />
<ButtonIcon variant="tertiary" icon={<RiEmotionHappyLine />} />
<ButtonIcon variant="tertiary" icon={<RiHeartLine />} />
<MenuTrigger>
<ButtonIcon variant="tertiary" icon={<RiMore2Line />} />
<Menu placement="bottom end">
{menuItems.map(option => (
<MenuItem
key={option.value}
onAction={option.onClick}
href={option.href}
>
{option.label}
</MenuItem>
))}
</Menu>
</MenuTrigger>
</>
}
/>
),
};
export const WithAllOptions: Story = {
args: {
title: 'My plugin',
titleLink: '/',
menuItems,
},
decorators: [withRouter],
render: WithCustomActions.render,
};
export const WithAllOptionsAndTabs: Story = {
args: {
...WithAllOptions.args,
...WithCustomActions.args,
tabs,
},
decorators: [withRouter],
render: WithAllOptions.render,
render: WithCustomActions.render,
};
export const WithHeaderPage: Story = {
@@ -252,7 +264,6 @@ export const WithHeaderPage: Story = {
/>
<HeaderPage
title="Page title"
menuItems={args.menuItems}
tabs={tabs2}
customActions={<Button>Custom action</Button>}
breadcrumbs={breadcrumbs}
@@ -262,16 +273,12 @@ export const WithHeaderPage: Story = {
};
export const WithLayout: Story = {
args: {
menuItems,
},
decorators: layoutDecorator,
render: args => (
<>
<Header {...args} tabs={tabs} />
<HeaderPage
title="Page title"
menuItems={args.menuItems}
tabs={tabs2}
customActions={<Button>Custom action</Button>}
breadcrumbs={breadcrumbs}
@@ -281,21 +288,17 @@ export const WithLayout: Story = {
};
export const WithLayoutNoTabs: Story = {
args: {
menuItems,
},
decorators: layoutDecorator,
render: args => (
<>
<Header {...args} />
<HeaderPage title="Page title" menuItems={args.menuItems} tabs={tabs2} />
<HeaderPage title="Page title" tabs={tabs2} />
</>
),
};
export const WithEverything: Story = {
args: {
menuItems,
tabs,
titleLink: '/',
},
@@ -314,7 +317,6 @@ export const WithEverything: Story = {
/>
<HeaderPage
title="Page title"
menuItems={args.menuItems}
tabs={tabs2}
customActions={
<>
@@ -1,5 +1,5 @@
/*
* Copyright 2024 The Backstage Authors
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
+3 -11
View File
@@ -1,5 +1,5 @@
/*
* Copyright 2024 The Backstage Authors
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -32,15 +32,8 @@ declare module 'react-aria-components' {
* @public
*/
export const Header = (props: HeaderProps) => {
const {
tabs,
icon,
title,
titleLink,
menuItems,
customActions,
onTabSelectionChange,
} = props;
const { tabs, icon, title, titleLink, customActions, onTabSelectionChange } =
props;
const { classNames } = useStyles('Header');
@@ -52,7 +45,6 @@ export const Header = (props: HeaderProps) => {
icon={icon}
title={title}
titleLink={titleLink}
menuItems={menuItems}
customActions={customActions}
hasTabs={hasTabs}
/>
@@ -1,5 +1,5 @@
/*
* Copyright 2024 The Backstage Authors
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -17,10 +17,8 @@
import { Link, RouterProvider } from 'react-aria-components';
import { useStyles } from '../../hooks/useStyles';
import { useRef } from 'react';
import { RiMore2Line, RiShapesLine } from '@remixicon/react';
import { RiShapesLine } from '@remixicon/react';
import type { HeaderToolbarProps } from './types';
import { ButtonIcon } from '../ButtonIcon';
import { Menu } from '../Menu';
import { Text } from '../Text';
import { useNavigate, useHref } from 'react-router-dom';
@@ -30,7 +28,7 @@ import { useNavigate, useHref } from 'react-router-dom';
* @internal
*/
export const HeaderToolbar = (props: HeaderToolbarProps) => {
const { icon, title, titleLink, menuItems, customActions, hasTabs } = props;
const { icon, title, titleLink, customActions, hasTabs } = props;
const { classNames } = useStyles('Header');
let navigate = useNavigate();
@@ -63,34 +61,6 @@ export const HeaderToolbar = (props: HeaderToolbarProps) => {
</div>
<div className={classNames.toolbarControls} ref={toolbarControlsRef}>
{customActions}
{menuItems && (
<Menu.Root>
<Menu.Trigger
render={props => (
<ButtonIcon
size="small"
icon={<RiMore2Line />}
variant="tertiary"
{...props}
/>
)}
/>
<Menu.Portal>
<Menu.Positioner sideOffset={4} align="end">
<Menu.Popup>
{menuItems.map(option => (
<Menu.Item
key={option.value}
onClick={() => option.onClick?.()}
>
{option.label}
</Menu.Item>
))}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
)}
</div>
</div>
</div>
+2 -2
View File
@@ -1,5 +1,5 @@
/*
* Copyright 2024 The Backstage Authors
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -15,4 +15,4 @@
*/
export { Header } from './Header';
export type { HeaderProps, HeaderTab, HeaderMenuItem } from './types';
export type { HeaderProps, HeaderTab } from './types';
+1 -14
View File
@@ -1,5 +1,5 @@
/*
* Copyright 2024 The Backstage Authors
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -27,7 +27,6 @@ export interface HeaderProps {
title?: string;
titleLink?: string;
customActions?: React.ReactNode;
menuItems?: HeaderMenuItem[];
tabs?: HeaderTab[];
onTabSelectionChange?: TabsProps['onSelectionChange'];
}
@@ -49,17 +48,6 @@ export interface HeaderTab {
matchStrategy?: TabMatchStrategy;
}
/**
* Represents an option item in the header dropdown menu.
*
* @public
*/
export interface HeaderMenuItem {
label: string;
value: string;
onClick?: () => void;
}
/**
* Props for the HeaderToolbar component.
*
@@ -70,6 +58,5 @@ export interface HeaderToolbarProps {
title?: HeaderProps['title'];
titleLink?: HeaderProps['titleLink'];
customActions?: HeaderProps['customActions'];
menuItems?: HeaderProps['menuItems'];
hasTabs?: boolean;
}
@@ -1,5 +1,5 @@
/*
* Copyright 2024 The Backstage Authors
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -16,11 +16,18 @@
import type { Meta, StoryObj, StoryFn } from '@storybook/react';
import { HeaderPage } from './HeaderPage';
import type { HeaderTab, HeaderMenuItem } from '../Header/types';
import type { HeaderTab } from '../Header/types';
import { MemoryRouter } from 'react-router-dom';
import { Button } from '../Button';
import { Container } from '../Container';
import { Text } from '../Text';
import {
Button,
Container,
Text,
ButtonIcon,
MenuTrigger,
Menu,
MenuItem,
} from '../../';
import { RiMore2Line } from '@remixicon/react';
const meta = {
title: 'Backstage UI/HeaderPage',
@@ -56,14 +63,23 @@ const tabs: HeaderTab[] = [
},
];
const menuItems: HeaderMenuItem[] = [
const menuItems = [
{
label: 'Settings',
value: 'settings',
href: '/settings',
},
{
label: 'Invite new members',
value: 'invite-new-members',
href: '/invite-new-members',
},
{
label: 'Logout',
value: 'logout',
onClick: () => {
alert('logout');
},
},
];
@@ -123,18 +139,30 @@ export const WithTabs: Story = {
decorators: [withRouter],
};
export const WithMenuItems: Story = {
args: {
...Default.args,
menuItems,
},
};
export const WithCustomActions: Story = {
decorators: [withRouter],
render: () => (
<HeaderPage
{...Default.args}
customActions={<Button>Custom action</Button>}
customActions={
<>
<Button>Custom action</Button>
<MenuTrigger>
<ButtonIcon variant="tertiary" icon={<RiMore2Line />} />
<Menu placement="bottom end">
{menuItems.map(option => (
<MenuItem
key={option.value}
onAction={option.onClick}
href={option.href}
>
{option.label}
</MenuItem>
))}
</Menu>
</MenuTrigger>
</>
}
/>
),
};
@@ -163,7 +191,6 @@ export const WithEverything: Story = {
render: () => (
<HeaderPage
{...Default.args}
menuItems={menuItems}
tabs={tabs}
customActions={<Button>Custom action</Button>}
breadcrumbs={[{ label: 'Home', href: '/' }]}
@@ -1,5 +1,5 @@
/*
* Copyright 2024 The Backstage Authors
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -15,10 +15,8 @@
*/
import type { HeaderPageProps } from './types';
import { Menu } from '../Menu';
import { Text } from '../Text';
import { ButtonIcon } from '../ButtonIcon';
import { RiArrowRightSLine, RiMore2Line } from '@remixicon/react';
import { RiArrowRightSLine } from '@remixicon/react';
import { Tabs, TabList, Tab } from '../Tabs';
import { useStyles } from '../../hooks/useStyles';
import { Container } from '../Container';
@@ -31,7 +29,7 @@ import { Fragment } from 'react/jsx-runtime';
* @public
*/
export const HeaderPage = (props: HeaderPageProps) => {
const { title, menuItems, tabs, customActions, breadcrumbs } = props;
const { title, tabs, customActions, breadcrumbs } = props;
const { classNames } = useStyles('HeaderPage');
return (
@@ -58,37 +56,7 @@ export const HeaderPage = (props: HeaderPageProps) => {
{title}
</Text>
</div>
<div className={classNames.controls}>
{customActions}
{menuItems && (
<Menu.Root>
<Menu.Trigger
render={props => (
<ButtonIcon
{...props}
size="small"
icon={<RiMore2Line />}
variant="tertiary"
/>
)}
/>
<Menu.Portal>
<Menu.Positioner sideOffset={4} align="end">
<Menu.Popup>
{menuItems.map(menuItem => (
<Menu.Item
key={menuItem.value}
onClick={() => menuItem.onClick?.()}
>
{menuItem.label}
</Menu.Item>
))}
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
)}
</div>
<div className={classNames.controls}>{customActions}</div>
</div>
{tabs && (
<div className={classNames.tabsWrapper}>
@@ -1,5 +1,5 @@
/*
* Copyright 2024 The Backstage Authors
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -1,5 +1,5 @@
/*
* Copyright 2024 The Backstage Authors
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { HeaderTab, HeaderMenuItem } from '../Header/types';
import type { HeaderTab } from '../Header/types';
/**
* Props for the main HeaderPage component.
@@ -24,7 +24,6 @@ import type { HeaderTab, HeaderMenuItem } from '../Header/types';
export interface HeaderPageProps {
title?: string;
customActions?: React.ReactNode;
menuItems?: HeaderMenuItem[];
tabs?: HeaderTab[];
breadcrumbs?: HeaderPageBreadcrumb[];
}
@@ -1,243 +0,0 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
forwardRef,
useState,
useMemo,
useCallback,
ChangeEvent,
KeyboardEvent,
useRef,
useEffect,
} from 'react';
import clsx from 'clsx';
import { MenuComboboxOption, MenuComboboxProps } from './types';
import { Icon } from '../..';
import { useId } from 'react-aria';
const getListboxItemId = (listboxId: string, optionValue: string): string =>
`${listboxId}-option-${optionValue}`;
// Internal component for rendering individual items
function ComboboxItem({
option,
optionIndex,
value,
activeOptionIndex,
onItemActive,
onItemSelect,
listboxId,
}: {
option: MenuComboboxOption;
optionIndex: number;
value?: string[];
activeOptionIndex: number;
onItemActive: (index: number) => void;
onItemSelect: (value: string) => void;
listboxId: string;
}) {
const isSelected = value?.includes(option.value) ?? false;
const isHighlighted = optionIndex === activeOptionIndex;
const itemId = getListboxItemId(listboxId, option.value);
const itemRef = useRef<HTMLDivElement>(null);
// Scroll the item into view when it becomes highlighted
useEffect(() => {
if (isHighlighted && itemRef.current) {
itemRef.current.scrollIntoView({ block: 'nearest' });
}
}, [isHighlighted]);
return (
<div
ref={itemRef}
className="bui-SubmenuComboboxItem"
id={itemId}
role="option"
aria-selected={isSelected}
data-highlighted={isHighlighted ? true : undefined}
data-selected={isSelected ? true : undefined}
data-disabled={option.disabled ? true : undefined}
onMouseOver={() => !option.disabled && onItemActive(optionIndex)}
onClick={() => !option.disabled && onItemSelect(option.value)}
>
<div className="bui-SubmenuComboboxItemCheckbox">
{isSelected && <Icon aria-hidden="true" name="check" size={12} />}
</div>
<div className="bui-SubmenuComboboxItemLabel">{option.label}</div>
</div>
);
}
/** @public */
export const Combobox = forwardRef<HTMLDivElement, MenuComboboxProps>(
(props, ref) => {
const {
options,
value,
onValueChange,
multiselect = false,
className,
...rest
} = props;
const triggerId = useId();
const listboxId = `${triggerId}-listbox`;
// State management
const [filterString, setFilterString] = useState<string>('');
const [activeOptionIndex, setActiveOptionIndex] = useState<number>(0);
// Filter options based on input
const filteredOptions = useMemo(() => {
if (!filterString) return options;
const lowerFilterString = filterString.toLocaleLowerCase('en-US');
return options.filter(option =>
option.label.toLocaleLowerCase('en-US').includes(lowerFilterString),
);
}, [filterString, options]);
// Get the active descendant ID for accessibility
const activeDescendantId =
activeOptionIndex >= 0 && filteredOptions.length > 0
? getListboxItemId(listboxId, filteredOptions[activeOptionIndex].value)
: undefined;
const handleValueChange = useCallback(
(toggledValue: string) => {
let newValue: string[];
if (multiselect) {
newValue = value?.includes(toggledValue)
? value.filter(v => v !== toggledValue)
: [...(value ?? []), toggledValue];
} else {
newValue = value?.includes(toggledValue) ? [] : [toggledValue];
}
onValueChange?.(newValue);
},
[multiselect, onValueChange, value],
);
const handleSearchChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setFilterString(e.target.value);
setActiveOptionIndex(0);
e.preventDefault();
},
[],
);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
let wasEscapeKey = false;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveOptionIndex(prev =>
Math.min(prev + 1, filteredOptions.length - 1),
);
break;
case 'ArrowUp':
e.preventDefault();
setActiveOptionIndex(prev => Math.max(prev - 1, 0));
break;
case 'Home':
e.preventDefault();
setActiveOptionIndex(0);
break;
case 'End':
e.preventDefault();
setActiveOptionIndex(Math.max(filteredOptions.length - 1, 0));
break;
case 'Enter':
e.preventDefault();
if (
activeOptionIndex >= 0 &&
!filteredOptions[activeOptionIndex].disabled
) {
handleValueChange(filteredOptions[activeOptionIndex].value);
}
break;
case 'Escape':
// The Menu component should handle this
wasEscapeKey = true;
break;
default:
break;
}
if (!wasEscapeKey) {
// Stop propagation so Menu components don't prevent the input from updating
e.stopPropagation();
}
},
[filteredOptions, activeOptionIndex, handleValueChange],
);
return (
<div
ref={ref}
role="combobox"
className={clsx('bui-MenuCombobox', className)}
{...rest}
>
<input
className="bui-SubmenuComboboxSearch"
type="text"
role="combobox"
placeholder="Filter..."
aria-labelledby={triggerId}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={activeDescendantId}
aria-expanded="true"
aria-haspopup="listbox"
value={filterString}
onKeyDown={handleKeyDown}
onChange={handleSearchChange}
/>
<div
role="listbox"
id={listboxId}
tabIndex={-1}
aria-multiselectable={multiselect ? true : undefined}
className="bui-SubmenuComboboxItems"
>
{filteredOptions.length === 0 ? (
<div className="bui-SubmenuComboboxNoResults">No results found</div>
) : (
filteredOptions.map((option, index) => (
<ComboboxItem
key={option.value}
option={option}
optionIndex={index}
value={value}
activeOptionIndex={activeOptionIndex}
onItemActive={setActiveOptionIndex}
onItemSelect={handleValueChange}
listboxId={listboxId}
/>
))
)}
</div>
</div>
);
},
);
Combobox.displayName = 'Combobox';
+574 -176
View File
@@ -1,5 +1,5 @@
/*
* Copyright 2024 The Backstage Authors
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -15,14 +15,46 @@
*/
import type { Meta, StoryObj } from '@storybook/react';
import { Menu } from './Menu';
import { Text, Icon, Button, Flex } from '../../index';
import {
MenuTrigger,
SubmenuTrigger,
Menu,
MenuListBox,
MenuAutocomplete,
MenuAutocompleteListbox,
MenuItem,
MenuListBoxItem,
MenuSection,
MenuSeparator,
} from './index';
import { Button, Flex, Text } from '../..';
import {
RiChat1Line,
RiEdit2Line,
RiFileCopyLine,
RiCustomerService2Line,
RiQuestionLine,
RiSettingsLine,
RiUserLine,
RiDeleteBinLine,
RiShareBoxLine,
} from '@remixicon/react';
import { useState } from 'react';
import { Selection } from 'react-aria-components';
import { MemoryRouter } from 'react-router-dom';
import { MenuItem as AriaMenuItem } from 'react-aria-components';
const meta = {
title: 'Backstage UI/Menu',
component: Menu.Root,
} satisfies Meta<typeof Menu.Root>;
component: MenuTrigger,
decorators: [
Story => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
} satisfies Meta<typeof MenuTrigger>;
export default meta;
type Story = StoryObj<typeof meta>;
@@ -39,193 +71,559 @@ const options = [
{ label: 'Honeydew', value: 'honeydew' },
];
export const Default: Story = {
args: { children: undefined },
render: args => (
<Menu.Root {...args}>
<Menu.Trigger
render={props => (
<Button
{...props}
size="small"
variant="secondary"
iconEnd={<Icon name="chevron-down" />}
>
Menu
</Button>
)}
/>
<Menu.Portal>
<Menu.Positioner sideOffset={8} align="start">
<Menu.Popup>
<Menu.Item>Settings</Menu.Item>
<Menu.Item>Invite new members</Menu.Item>
<Menu.Item>Download app</Menu.Item>
<Menu.Item>Log out</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
export const Preview: Story = {
args: {
children: null,
},
render: () => (
<MenuTrigger>
<Button aria-label="Menu">Menu</Button>
<Menu>
<MenuItem>Edit</MenuItem>
<MenuItem>Duplicate</MenuItem>
<MenuItem>Rename</MenuItem>
<MenuSeparator />
<MenuItem iconStart={<RiShareBoxLine />}>Share</MenuItem>
<MenuItem iconStart={<RiChat1Line />}>Feedback</MenuItem>
<MenuSeparator />
<SubmenuTrigger>
<MenuItem iconStart={<RiSettingsLine />}>Settings</MenuItem>
<Menu placement="right top">
<MenuItem>Edit</MenuItem>
<MenuItem>Duplicate</MenuItem>
<MenuItem>Rename</MenuItem>
</Menu>
</SubmenuTrigger>
</Menu>
</MenuTrigger>
),
};
export const Open: Story = {
export const PreviewSubmenu: Story = {
args: {
...Default.args,
open: true,
...Preview.args,
},
render: Default.render,
render: () => (
<MenuTrigger>
<Button aria-label="Menu">Menu</Button>
<Menu>
<MenuItem>Edit</MenuItem>
<MenuItem>Duplicate</MenuItem>
<SubmenuTrigger>
<MenuItem>Submenu</MenuItem>
<Menu placement="right top">
<MenuItem>Edit</MenuItem>
<MenuItem>Duplicate</MenuItem>
<MenuItem>Rename</MenuItem>
<MenuSeparator />
<MenuItem>Share</MenuItem>
<MenuItem>Move</MenuItem>
<MenuSeparator />
<MenuItem iconStart={<RiChat1Line />}>Feedback</MenuItem>
</Menu>
</SubmenuTrigger>
</Menu>
</MenuTrigger>
),
};
export const OpenOnHover: Story = {
export const PreviewIcons: Story = {
args: {
...Default.args,
openOnHover: true,
...Preview.args,
},
render: () => (
<MenuTrigger>
<Button aria-label="Menu">Menu</Button>
<Menu>
<MenuItem iconStart={<RiFileCopyLine />}>Copy</MenuItem>
<MenuItem iconStart={<RiEdit2Line />}>Rename</MenuItem>
<MenuItem iconStart={<RiChat1Line />}>Send feedback</MenuItem>
</Menu>
</MenuTrigger>
),
};
export const PreviewSections: Story = {
args: {
...Preview.args,
},
render: () => (
<MenuTrigger>
<Button aria-label="Menu">Menu</Button>
<Menu>
<MenuSection title="My Account">
<MenuItem iconStart={<RiUserLine />}>Profile</MenuItem>
<MenuItem iconStart={<RiSettingsLine />}>Settings</MenuItem>
</MenuSection>
<MenuSection title="Support">
<MenuItem iconStart={<RiQuestionLine />}>Help Center</MenuItem>
<MenuItem iconStart={<RiCustomerService2Line />}>
Contact Support
</MenuItem>
<MenuItem iconStart={<RiChat1Line />}>Feedback</MenuItem>
</MenuSection>
</Menu>
</MenuTrigger>
),
};
export const PreviewSeparators: Story = {
args: {
...Preview.args,
},
render: () => (
<MenuTrigger>
<Button aria-label="Menu">Menu</Button>
<Menu>
<MenuItem>Edit</MenuItem>
<MenuItem>Duplicate</MenuItem>
<MenuItem>Rename</MenuItem>
<MenuSeparator />
<MenuItem>Share</MenuItem>
<MenuItem>Move</MenuItem>
<MenuSeparator />
<MenuItem iconStart={<RiChat1Line />}>Feedback</MenuItem>
</Menu>
</MenuTrigger>
),
};
export const PreviewLinks: Story = {
args: {
...Preview.args,
},
render: () => (
<MenuTrigger>
<Button aria-label="Menu">Menu</Button>
<Menu>
<MenuItem href="/home">Internal link</MenuItem>
<MenuItem href="https://www.google.com" target="_blank">
External link
</MenuItem>
<MenuItem href="mailto:test@test.com">Email link</MenuItem>
</Menu>
</MenuTrigger>
),
};
export const PreviewAutocompleteMenu: Story = {
args: {
...Preview.args,
},
render: () => (
<MenuTrigger>
<Button aria-label="Menu">Menu</Button>
<MenuAutocomplete placeholder="Filter">
<MenuItem>Create new file...</MenuItem>
<MenuItem>Create new folder...</MenuItem>
<MenuItem>Assign to...</MenuItem>
<MenuItem>Assign to me</MenuItem>
<MenuItem>Change status...</MenuItem>
<MenuItem>Change priority...</MenuItem>
<MenuItem>Add label...</MenuItem>
<MenuItem>Remove label...</MenuItem>
</MenuAutocomplete>
</MenuTrigger>
),
};
export const PreviewAutocompleteListbox: Story = {
args: {
...Preview.args,
},
render: () => {
const [selected, setSelected] = useState<Selection>(
new Set([options[2].value]),
);
return (
<Flex direction="column" gap="2" align="center">
<Text>Selected: {Array.from(selected).join(', ')}</Text>
<MenuTrigger>
<Button aria-label="Menu">Menu</Button>
<MenuAutocompleteListbox
selectedKeys={selected}
onSelectionChange={setSelected}
>
{options.map(option => (
<MenuListBoxItem key={option.value} id={option.value}>
{option.label}
</MenuListBoxItem>
))}
</MenuAutocompleteListbox>
</MenuTrigger>
</Flex>
);
},
};
export const PreviewAutocompleteListboxMultiple: Story = {
args: {
...Preview.args,
},
render: () => {
const [selected, setSelected] = useState<Selection>(
new Set([options[2].value, options[3].value]),
);
return (
<Flex direction="column" gap="2" align="center">
<Text>Selected: {Array.from(selected).join(', ')}</Text>
<MenuTrigger>
<Button aria-label="Menu">Menu</Button>
<MenuAutocompleteListbox
selectionMode="multiple"
selectedKeys={selected}
onSelectionChange={setSelected}
>
{options.map(option => (
<MenuListBoxItem key={option.value} id={option.value}>
{option.label}
</MenuListBoxItem>
))}
</MenuAutocompleteListbox>
</MenuTrigger>
</Flex>
);
},
};
export const Opened: Story = {
args: {
...Preview.args,
},
render: () => (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<Menu>
{options.map(option => (
<MenuItem key={option.value}>{option.label}</MenuItem>
))}
</Menu>
</MenuTrigger>
),
};
export const WithIcons: Story = {
args: {
...Preview.args,
},
render: () => (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<Menu>
<MenuItem iconStart={<RiFileCopyLine />}>Copy</MenuItem>
<MenuItem iconStart={<RiEdit2Line />}>Rename</MenuItem>
<MenuItem iconStart={<RiChat1Line />}>Send feedback</MenuItem>
</Menu>
</MenuTrigger>
),
};
export const WithScrolling: Story = {
args: {
...Preview.args,
},
render: () => (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu with Scrolling</Button>
<Menu>
{Array.from({ length: 50 }, (_, i) => (
<MenuItem key={i}>
Item {i + 1} - This is a long menu item to demonstrate scrolling
</MenuItem>
))}
</Menu>
</MenuTrigger>
),
};
export const WithSections: Story = {
args: {
...Preview.args,
},
render: () => (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<Menu>
<MenuSection title="My Account">
<MenuItem iconStart={<RiUserLine />}>Profile</MenuItem>
<MenuItem iconStart={<RiSettingsLine />}>Settings</MenuItem>
</MenuSection>
<MenuSection title="Support">
<MenuItem iconStart={<RiQuestionLine />}>Help Center</MenuItem>
<MenuItem iconStart={<RiCustomerService2Line />}>
Contact Support
</MenuItem>
<MenuItem iconStart={<RiChat1Line />}>Feedback</MenuItem>
</MenuSection>
</Menu>
</MenuTrigger>
),
};
export const WithSeparators: Story = {
args: {
...Preview.args,
},
render: () => (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<Menu>
<MenuItem>Edit</MenuItem>
<MenuItem>Duplicate</MenuItem>
<MenuItem>Rename</MenuItem>
<MenuSeparator />
<MenuItem>Share</MenuItem>
<MenuItem>Move</MenuItem>
<MenuSeparator />
<MenuItem iconStart={<RiChat1Line />}>Feedback</MenuItem>
</Menu>
</MenuTrigger>
),
};
export const WithColors: Story = {
args: {
...Preview.args,
},
render: () => (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<Menu>
<MenuItem>Edit</MenuItem>
<MenuItem>Duplicate</MenuItem>
<MenuItem>Rename</MenuItem>
<MenuSeparator />
<MenuItem iconStart={<RiDeleteBinLine />} color="danger">
Delete
</MenuItem>
</Menu>
</MenuTrigger>
),
};
export const WithLinks: Story = {
args: {
...Preview.args,
},
render: () => (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<Menu>
<MenuItem href="/home">Internal link</MenuItem>
<MenuItem href="https://www.google.com" target="_blank">
External link
</MenuItem>
<MenuItem href="mailto:test@test.com">Email link</MenuItem>
</Menu>
</MenuTrigger>
),
};
export const WithLinksTest: Story = {
args: {
...Preview.args,
},
render: () => (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<Menu>
<AriaMenuItem href="/home">Internal link</AriaMenuItem>
<AriaMenuItem href="https://www.google.com">External link</AriaMenuItem>
<AriaMenuItem href="mailto:test@test.com">Email link</AriaMenuItem>
</Menu>
</MenuTrigger>
),
};
export const WithListBox: Story = {
args: {
...Preview.args,
},
render: () => (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<MenuListBox>
<MenuListBoxItem>Item 1</MenuListBoxItem>
<MenuListBoxItem>Item 2</MenuListBoxItem>
<MenuListBoxItem>Item 3</MenuListBoxItem>
</MenuListBox>
</MenuTrigger>
),
};
export const WithListBoxControlled: Story = {
args: {
...Preview.args,
},
render: () => {
const [selected, setSelected] = useState<Selection>(new Set(['paul']));
return (
<Flex direction="column" gap="2" align="start">
<Text>Selected: {Array.from(selected).join(', ')}</Text>
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<MenuListBox
selectionMode="multiple"
selectedKeys={selected}
onSelectionChange={setSelected}
>
<MenuListBoxItem key="item1" id="john">
John Lennon
</MenuListBoxItem>
<MenuListBoxItem key="item2" id="paul">
Paul McCartney
</MenuListBoxItem>
<MenuListBoxItem key="item3" id="george">
George Harrison
</MenuListBoxItem>
<MenuListBoxItem key="item4" id="ringo">
Ringo Starr
</MenuListBoxItem>
</MenuListBox>
</MenuTrigger>
</Flex>
);
},
};
export const WithAutocompleteMenu: Story = {
args: {
...Preview.args,
},
render: () => (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<MenuAutocomplete placeholder="Filter">
<MenuItem>Create new file...</MenuItem>
<MenuItem>Create new folder...</MenuItem>
<MenuItem>Assign to...</MenuItem>
<MenuItem>Assign to me</MenuItem>
<MenuItem>Change status...</MenuItem>
<MenuItem>Change priority...</MenuItem>
<MenuItem>Add label...</MenuItem>
<MenuItem>Remove label...</MenuItem>
</MenuAutocomplete>
</MenuTrigger>
),
};
export const WithAutocompleteListbox: Story = {
args: {
...Preview.args,
},
render: () => {
const [selected, setSelected] = useState<Selection>(
new Set([options[2].value]),
);
return (
<Flex direction="column" gap="2" align="start">
<Text>Selected: {Array.from(selected).join(', ')}</Text>
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<MenuAutocompleteListbox
selectedKeys={selected}
onSelectionChange={setSelected}
>
{options.map(option => (
<MenuListBoxItem key={option.value} id={option.value}>
{option.label}
</MenuListBoxItem>
))}
</MenuAutocompleteListbox>
</MenuTrigger>
</Flex>
);
},
render: Default.render,
};
export const Submenu: Story = {
args: { children: undefined },
render: args => (
<Menu.Root {...args}>
<Menu.Trigger
render={props => (
<Button
{...props}
size="small"
variant="secondary"
iconEnd={<Icon name="chevron-down" />}
>
Menu
</Button>
)}
/>
<Menu.Portal>
<Menu.Positioner sideOffset={8} align="start">
<Menu.Popup>
<Menu.Item>Settings</Menu.Item>
<Menu.Item>Invite new members</Menu.Item>
<Menu.Item>Download app</Menu.Item>
<Menu.Item>Log out</Menu.Item>
<Menu.Root>
<Menu.SubmenuTrigger>Submenu</Menu.SubmenuTrigger>
<Menu.Portal>
<Menu.Positioner>
<Menu.Popup>
<Menu.Item>Submenu Item 1</Menu.Item>
<Menu.Item>Submenu Item 2</Menu.Item>
<Menu.Item>Submenu Item 3</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
args: {
...Preview.args,
},
render: () => (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<Menu>
<MenuItem>Edit</MenuItem>
<MenuItem>Duplicate</MenuItem>
<SubmenuTrigger>
<MenuItem>Submenu</MenuItem>
<Menu>
<MenuItem>Edit</MenuItem>
<MenuItem>Duplicate</MenuItem>
<MenuItem>Rename</MenuItem>
<MenuSeparator />
<MenuItem>Share</MenuItem>
<MenuItem>Move</MenuItem>
<MenuSeparator />
<MenuItem iconStart={<RiChat1Line />}>Feedback</MenuItem>
</Menu>
</SubmenuTrigger>
</Menu>
</MenuTrigger>
),
};
export const SubmenuCombobox = () => {
const [selectedValues, setSelectedValues] = useState<string[]>([]);
return (
<Flex direction="column" gap="1" align="start">
<Text style={{ marginBottom: 16 }}>
{selectedValues.length === 0
? 'Which is your favorite fruit?'
: `Yum, ${selectedValues[0]} is delicious!`}
</Text>
<Menu.Root>
<Menu.Trigger
render={props => (
<Button
{...props}
size="small"
variant="secondary"
iconEnd={<Icon name="chevron-down" />}
>
Select Fruits
</Button>
)}
/>
<Menu.Portal>
<Menu.Positioner sideOffset={4} align="start">
<Menu.Popup>
<Menu.Item>Regular Item</Menu.Item>
<Menu.Root>
<Menu.SubmenuTrigger>Fruits</Menu.SubmenuTrigger>
<Menu.Portal>
<Menu.Positioner sideOffset={8} align="start">
<Menu.Popup>
<Menu.Combobox
options={options}
value={selectedValues}
onValueChange={setSelectedValues}
/>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Menu.Item>Another Item</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</Flex>
);
export const SubmenuAutocompleteMenu: Story = {
args: {
...Preview.args,
},
render: () => (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<Menu>
<MenuItem>Edit</MenuItem>
<SubmenuTrigger>
<MenuItem>Submenu</MenuItem>
<MenuAutocomplete>
{options.map(option => (
<MenuItem key={option.value} id={option.value}>
{option.label}
</MenuItem>
))}
</MenuAutocomplete>
</SubmenuTrigger>
</Menu>
</MenuTrigger>
),
};
export const SubmenuComboboxMultiselect = () => {
const [selectedValues, setSelectedValues] = useState<string[]>([]);
export const SubmenuAutocompleteListbox: Story = {
args: {
...Preview.args,
},
render: () => {
const [selected, setSelected] = useState<Selection>(
new Set([options[2].value]),
);
return (
<Flex direction="column" gap="1" align="start">
<Text style={{ marginBottom: 16 }}>
{selectedValues.length === 0
? 'Tell us what fruits you like.'
: `${selectedValues.join(
', ',
)} would make for a great, healthy smoothy!`}
</Text>
<Menu.Root>
<Menu.Trigger
render={props => (
<Button
{...props}
size="small"
variant="secondary"
iconEnd={<Icon name="chevron-down" />}
>
Select Fruits
</Button>
)}
/>
<Menu.Portal>
<Menu.Positioner>
<Menu.Popup>
<Menu.Item>Regular Item</Menu.Item>
<Menu.Root>
<Menu.SubmenuTrigger>Fruits</Menu.SubmenuTrigger>
<Menu.Portal>
<Menu.Positioner>
<Menu.Popup>
<Menu.Combobox
multiselect
options={options}
value={selectedValues}
onValueChange={setSelectedValues}
/>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Menu.Item>Another Item</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
</Flex>
);
return (
<Flex direction="column" gap="2" align="start">
<Text>Selected: {Array.from(selected).join(', ')}</Text>
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<Menu>
<MenuItem>Edit</MenuItem>
<SubmenuTrigger>
<MenuItem>Submenu</MenuItem>
<MenuAutocompleteListbox
selectedKeys={selected}
onSelectionChange={setSelected}
>
{options.map(option => (
<MenuListBoxItem key={option.value} id={option.value}>
{option.label}
</MenuListBoxItem>
))}
</MenuAutocompleteListbox>
</SubmenuTrigger>
</Menu>
</MenuTrigger>
</Flex>
);
},
};
+228 -141
View File
@@ -1,182 +1,269 @@
.bui-MenuPositioner {
outline: 0;
z-index: 100;
}
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.bui-MenuPopup {
.bui-MenuPopover {
display: flex;
flex-direction: column;
border-radius: 0.375rem;
background-color: var(--bui-bg-surface-1);
border: 1px solid var(--bui-border);
border-radius: var(--bui-radius-2);
background: var(--bui-bg-surface-1);
color: var(--bui-fg-primary);
outline: none;
overflow: auto;
transform-origin: var(--transform-origin);
max-width: min(var(--available-width), 340px);
max-height: min(var(--available-height), 500px);
transition: transform 150ms, opacity 150ms;
position: relative;
padding-bottom: var(--bui-space-1);
transition: transform 200ms, opacity 200ms;
/* Let React Aria handle height constraints naturally */
min-height: 0;
/* Remove overflow from popover since ScrollArea will handle it */
overflow: hidden;
&[data-starting-style],
&[data-ending-style] {
&[data-entering],
&[data-exiting] {
transform: var(--origin);
opacity: 0;
transform: scale(0.9);
}
&[data-placement='top'] {
--origin: translateY(8px);
}
&[data-placement='bottom'] {
--origin: translateY(-8px);
}
&[data-placement='right'] {
--origin: translateX(-8px);
}
&[data-placement='left'] {
--origin: translateX(8px);
}
}
.bui-MenuContent {
max-height: inherit;
box-sizing: border-box;
overflow: auto;
padding: var(--bui-space-1);
min-width: 150px;
box-sizing: border-box;
outline: none;
}
/* Ensure ScrollArea works properly within MenuRA popover */
.bui-MenuPopover .bui-ScrollAreaRoot {
/* Take full height of popover */
height: 100%;
min-height: 0;
flex: 1;
/* Ensure the root container has proper height constraints */
display: flex;
flex-direction: column;
}
.bui-MenuPopover .bui-ScrollAreaScrollbar {
margin-inline: var(--bui-space-1_5);
}
.bui-MenuItem {
outline: 0;
user-select: none;
display: flex;
height: 32px;
align-items: center;
gap: var(--bui-space-2);
color: var(--bui-fg-primary);
text-decoration: none;
border-radius: var(--bui-radius-2);
margin-inline: var(--bui-space-1);
justify-content: space-between;
height: 2rem;
padding-inline: var(--bui-space-2);
border-radius: var(--bui-radius-2);
outline: none;
cursor: default;
color: var(--bui-fg-primary);
font-size: var(--bui-font-size-3);
cursor: pointer;
flex-shrink: 0;
gap: var(--bui-space-6);
&:first-child {
margin-top: var(--bui-space-1);
&[data-focused] {
background: var(--bui-bg-surface-2);
color: var(--bui-fg-primary);
}
&[data-highlighted] {
background-color: var(--bui-gray-3);
&[data-open] {
background: var(--bui-bg-surface-2);
color: var(--bui-fg-primary);
}
&[data-color='danger'] {
color: var(--bui-fg-danger);
}
&[data-color='danger'][data-focused] {
background: var(--bui-bg-danger);
color: var(--bui-fg-danger);
}
&[data-has-submenu] {
& > .bui-MenuItemArrow {
display: block;
}
}
}
.bui-MenuSubmenuTrigger {
outline: 0;
user-select: none;
.bui-MenuItemListBox {
display: flex;
height: 32px;
align-items: center;
justify-content: space-between;
gap: var(--bui-space-2);
color: var(--bui-fg-primary);
text-decoration: none;
border-radius: var(--bui-radius-2);
margin-inline: var(--bui-space-1);
height: 2rem;
padding-inline: var(--bui-space-2);
border-radius: var(--bui-radius-2);
outline: none;
cursor: default;
color: var(--bui-fg-primary);
font-size: var(--bui-font-size-3);
cursor: pointer;
flex-shrink: 0;
gap: var(--bui-space-6);
& .bui-Icon {
color: var(--bui-fg-secondary);
&:hover {
background: var(--bui-bg-surface-2);
color: var(--bui-fg-primary);
}
&:first-child {
margin-top: var(--bui-space-1);
}
&[data-popup-open],
&[data-highlighted] {
background-color: var(--bui-gray-3);
.bui-Icon {
&[data-selected] .bui-MenuItemListBoxCheck {
& > svg {
opacity: 1;
color: var(--bui-fg-primary);
}
}
}
.bui-MenuSeparator {
margin: 0.375rem 1rem;
height: 1px;
background-color: var(--color-gray-200);
}
.bui-SubmenuComboboxSearch {
padding-inline: var(--bui-space-3);
width: 100%;
height: 32px;
border: none;
border-bottom: 1px solid var(--bui-border);
background-color: var(--bui-bg-surface-1);
color: var(--bui-fg-primary);
line-height: 140%;
outline: none;
font-size: var(--bui-font-size-3);
position: sticky;
top: 0;
z-index: 1;
&::placeholder {
color: var(--bui-fg-secondary);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.bui-SubmenuComboboxItems {
overflow-y: auto;
display: flex;
flex-direction: column;
padding-top: var(--bui-space-2);
outline: none;
}
.bui-SubmenuComboboxNoResults {
padding-inline: var(--bui-space-3);
padding-top: var(--bui-space-2);
padding-bottom: var(--bui-space-4);
color: var(--bui-fg-secondary);
font-size: var(--bui-font-size-3);
}
.bui-SubmenuComboboxItem {
outline: 0;
user-select: none;
display: flex;
height: 32px;
align-items: center;
justify-content: space-between;
gap: var(--bui-space-2);
color: var(--bui-fg-primary);
text-decoration: none;
border-radius: var(--bui-radius-2);
margin-inline: var(--bui-space-1);
padding-inline: var(--bui-space-2);
font-size: var(--bui-font-size-3);
cursor: pointer;
user-select: none;
flex-shrink: 0;
&[data-highlighted] {
background-color: var(--bui-gray-3);
}
&[data-disabled] {
opacity: 0.5;
cursor: not-allowed;
}
}
.bui-SubmenuComboboxItemCheckbox {
.bui-MenuItemListBoxCheck {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
color: var(--bui-fg-primary);
flex-shrink: 0;
border-radius: var(--bui-radius-2);
border: 1px solid var(--bui-border);
background: var(--bui-bg-surface-1);
width: 1rem;
height: 1rem;
& > svg {
opacity: 0;
width: 1rem;
height: 1rem;
}
}
.bui-SubmenuComboboxItemLabel {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.bui-MenuItemContent {
display: flex;
align-items: center;
gap: var(--bui-space-2);
& > svg {
width: 1rem;
height: 1rem;
}
}
.bui-MenuItemArrow {
display: none;
width: 1rem;
height: 1rem;
& > svg {
width: 1rem;
height: 1rem;
}
}
.bui-MenuSection {
&:first-child .bui-MenuSectionHeader {
padding-top: 0;
}
}
.bui-MenuSectionHeader {
height: 2rem;
display: flex;
align-items: center;
padding-top: var(--bui-space-3);
padding-left: var(--bui-space-2);
color: var(--bui-fg-primary);
font-size: var(--bui-font-size-1);
font-weight: bold;
letter-spacing: 0.05rem;
text-transform: uppercase;
}
.bui-MenuSeparator {
height: 1px;
background: var(--bui-border);
margin-inline: var(--bui-space-1_5);
margin-block: var(--bui-space-1);
}
.bui-MenuSearchField {
position: relative;
font-family: var(--bui-font-regular);
width: 100%;
flex-shrink: 0;
&[data-empty] {
.bui-MenuSearchFieldClear {
display: none;
}
}
}
.bui-MenuSearchFieldInput {
display: flex;
align-items: center;
padding: 0 var(--bui-space-3);
border: none;
border-bottom: 1px solid var(--bui-border);
background-color: var(--bui-bg-surface-1);
font-size: var(--bui-font-size-3);
font-family: var(--bui-font-regular);
font-weight: var(--bui-font-weight-regular);
color: var(--bui-fg-primary);
width: 100%;
height: 2rem;
cursor: inherit;
outline: none;
&::-webkit-search-cancel-button,
&::-webkit-search-decoration {
-webkit-appearance: none;
}
}
.bui-MenuSearchFieldClear {
position: absolute;
right: var(--bui-space-2);
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background-color: transparent;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
color: var(--bui-fg-secondary);
transition: color 0.2s ease-in-out;
& > svg {
width: 1rem;
height: 1rem;
}
}
.bui-MenuEmptyState {
padding: var(--bui-space-1);
color: var(--bui-fg-secondary);
font-size: var(--bui-font-size-3);
font-family: var(--bui-font-regular);
font-weight: var(--bui-font-weight-regular);
}
+258 -272
View File
@@ -1,5 +1,5 @@
/*
* Copyright 2024 The Backstage Authors
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,281 +14,267 @@
* limitations under the License.
*/
import { forwardRef } from 'react';
import { Menu as MenuPrimitive } from '@base-ui-components/react/menu';
import clsx from 'clsx';
import { MenuComponent } from './types';
import { Combobox } from './Combobox';
import { Icon } from '../Icon';
import {
MenuTrigger as RAMenuTrigger,
Popover as RAPopover,
MenuItem as RAMenuItem,
Menu as RAMenu,
MenuSection as RAMenuSection,
Header as RAMenuHeader,
Separator as RAMenuSeparator,
SubmenuTrigger as RAMenuSubmenuTrigger,
Autocomplete as RAAutocomplete,
SearchField as RASearchField,
Input as RAInput,
Button as RAButton,
ListBox as RAListBox,
ListBoxItem as RAListBoxItem,
useFilter,
RouterProvider,
} from 'react-aria-components';
import { ScrollArea } from '../ScrollArea';
import { useStyles } from '../../hooks/useStyles';
import type {
MenuTriggerProps,
SubmenuTriggerProps,
MenuProps,
MenuAutocompleteProps,
MenuItemProps,
MenuSectionProps,
MenuSeparatorProps,
MenuListBoxProps,
MenuListBoxItemProps,
MenuAutocompleteListBoxProps,
} from './types';
import {
RiArrowRightSLine,
RiCheckLine,
RiCloseCircleLine,
} from '@remixicon/react';
import { isExternalLink } from '../../utils/isExternalLink';
import { useNavigate, useHref } from 'react-router-dom';
const MenuTrigger = forwardRef<
React.ElementRef<typeof MenuPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Trigger>
>(({ className, ...props }, ref) => {
const MenuEmptyState = () => {
const { classNames } = useStyles('Menu');
return (
<MenuPrimitive.Trigger
ref={ref}
className={clsx(classNames.trigger, className)}
{...props}
/>
);
});
MenuTrigger.displayName = MenuPrimitive.Trigger.displayName;
const MenuBackdrop = forwardRef<
React.ElementRef<typeof MenuPrimitive.Backdrop>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Backdrop>
>(({ className, ...props }, ref) => {
const { classNames } = useStyles('Menu');
return (
<MenuPrimitive.Backdrop
ref={ref}
className={clsx(classNames.backdrop, className)}
{...props}
/>
);
});
MenuBackdrop.displayName = MenuPrimitive.Backdrop.displayName;
const MenuPositioner = forwardRef<
React.ElementRef<typeof MenuPrimitive.Positioner>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Positioner>
>(({ className, ...props }, ref) => {
const { classNames } = useStyles('Menu');
return (
<MenuPrimitive.Positioner
ref={ref}
className={clsx(classNames.positioner, className)}
{...props}
/>
);
});
MenuPositioner.displayName = MenuPrimitive.Positioner.displayName;
const MenuPopup = forwardRef<
React.ElementRef<typeof MenuPrimitive.Popup>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Popup>
>(({ className, ...props }, ref) => {
const { classNames } = useStyles('Menu');
return (
<MenuPrimitive.Popup
ref={ref}
className={clsx(classNames.popup, className)}
{...props}
/>
);
});
MenuPopup.displayName = MenuPrimitive.Popup.displayName;
const MenuArrow = forwardRef<
React.ElementRef<typeof MenuPrimitive.Arrow>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Arrow>
>(({ className, ...props }, ref) => {
const { classNames } = useStyles('Menu');
return (
<MenuPrimitive.Arrow
ref={ref}
className={clsx(classNames.arrow, className)}
{...props}
/>
);
});
MenuArrow.displayName = MenuPrimitive.Arrow.displayName;
const MenuItem = forwardRef<
React.ElementRef<typeof MenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Item>
>(({ className, ...props }, ref) => {
const { classNames } = useStyles('Menu');
return (
<MenuPrimitive.Item
ref={ref}
className={clsx(classNames.item, className)}
{...props}
/>
);
});
MenuItem.displayName = MenuPrimitive.Item.displayName;
const MenuGroup = forwardRef<
React.ElementRef<typeof MenuPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Group>
>(({ className, ...props }, ref) => {
const { classNames } = useStyles('Menu');
return (
<MenuPrimitive.Group
ref={ref}
className={clsx(classNames.group, className)}
{...props}
/>
);
});
MenuGroup.displayName = MenuPrimitive.Group.displayName;
const MenuGroupLabel = forwardRef<
React.ElementRef<typeof MenuPrimitive.GroupLabel>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.GroupLabel>
>(({ className, ...props }, ref) => {
const { classNames } = useStyles('Menu');
return (
<MenuPrimitive.GroupLabel
ref={ref}
className={clsx(classNames.groupLabel, className)}
{...props}
/>
);
});
MenuGroupLabel.displayName = MenuPrimitive.GroupLabel.displayName;
const MenuRadioGroup = forwardRef<
React.ElementRef<typeof MenuPrimitive.RadioGroup>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.RadioGroup>
>(({ className, ...props }, ref) => {
const { classNames } = useStyles('Menu');
return (
<MenuPrimitive.RadioGroup
ref={ref}
className={clsx(classNames.radioGroup, className)}
{...props}
/>
);
});
MenuRadioGroup.displayName = MenuPrimitive.RadioGroup.displayName;
const MenuRadioItem = forwardRef<
React.ElementRef<typeof MenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.RadioItem>
>(({ className, ...props }, ref) => {
const { classNames } = useStyles('Menu');
return (
<MenuPrimitive.RadioItem
ref={ref}
className={clsx(classNames.radioItem, className)}
{...props}
/>
);
});
MenuRadioItem.displayName = MenuPrimitive.RadioItem.displayName;
const MenuRadioItemIndicator = forwardRef<
React.ElementRef<typeof MenuPrimitive.RadioItemIndicator>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.RadioItemIndicator>
>(({ className, ...props }, ref) => {
const { classNames } = useStyles('Menu');
return (
<MenuPrimitive.RadioItemIndicator
ref={ref}
className={clsx(classNames.radioItemIndicator, className)}
{...props}
/>
);
});
MenuRadioItemIndicator.displayName =
MenuPrimitive.RadioItemIndicator.displayName;
const MenuCheckboxItem = forwardRef<
React.ElementRef<typeof MenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.CheckboxItem>
>(({ className, ...props }, ref) => {
const { classNames } = useStyles('Menu');
return (
<MenuPrimitive.CheckboxItem
ref={ref}
className={clsx(classNames.checkboxItem, className)}
{...props}
/>
);
});
MenuCheckboxItem.displayName = MenuPrimitive.CheckboxItem.displayName;
const MenuCheckboxItemIndicator = forwardRef<
React.ElementRef<typeof MenuPrimitive.CheckboxItemIndicator>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.CheckboxItemIndicator>
>(({ className, ...props }, ref) => {
const { classNames } = useStyles('Menu');
return (
<MenuPrimitive.CheckboxItemIndicator
ref={ref}
className={clsx(classNames.checkboxItemIndicator, className)}
{...props}
/>
);
});
MenuCheckboxItemIndicator.displayName =
MenuPrimitive.CheckboxItemIndicator.displayName;
const MenuSubmenuTrigger = forwardRef<
React.ElementRef<typeof MenuPrimitive.SubmenuTrigger>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.SubmenuTrigger>
>(({ className, children, ...props }, ref) => {
const { classNames } = useStyles('Menu');
return (
<MenuPrimitive.SubmenuTrigger
ref={ref}
className={clsx(classNames.submenuTrigger, className)}
{...props}
>
<div>{children}</div>
<Icon
aria-label="Submenu indicator icon"
name="chevron-right"
size={20}
/>
</MenuPrimitive.SubmenuTrigger>
);
});
MenuSubmenuTrigger.displayName = MenuPrimitive.SubmenuTrigger.displayName;
const MenuSeparator = forwardRef<
React.ElementRef<typeof MenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof MenuPrimitive.Separator>
>(({ className, ...props }, ref) => {
const { classNames } = useStyles('Menu');
return (
<MenuPrimitive.Separator
ref={ref}
className={clsx(classNames.separator, className)}
{...props}
/>
);
});
MenuSeparator.displayName = MenuPrimitive.Separator.displayName;
return <div className={classNames.emptyState}>No results found.</div>;
};
/** @public */
export const Menu: MenuComponent = {
Root: MenuPrimitive.Root,
Trigger: MenuTrigger,
Portal: MenuPrimitive.Portal,
Backdrop: MenuBackdrop,
Positioner: MenuPositioner,
Popup: MenuPopup,
Arrow: MenuArrow,
Item: MenuItem,
Group: MenuGroup,
GroupLabel: MenuGroupLabel,
RadioGroup: MenuRadioGroup,
RadioItem: MenuRadioItem,
RadioItemIndicator: MenuRadioItemIndicator,
CheckboxItem: MenuCheckboxItem,
CheckboxItemIndicator: MenuCheckboxItemIndicator,
SubmenuTrigger: MenuSubmenuTrigger,
Separator: MenuSeparator,
Combobox,
export const MenuTrigger = (props: MenuTriggerProps) => {
return <RAMenuTrigger {...props} />;
};
/** @public */
export const SubmenuTrigger = (props: SubmenuTriggerProps) => {
return <RAMenuSubmenuTrigger {...props} />;
};
/** @public */
export const Menu = (props: MenuProps<object>) => {
const { placement = 'bottom start', ...rest } = props;
const { classNames } = useStyles('Menu');
return (
<RAPopover className={classNames.popover} placement={placement}>
<ScrollArea.Root>
<ScrollArea.Viewport>
<RAMenu className={classNames.content} {...rest}>
{props.children}
</RAMenu>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical" style={{}}>
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</RAPopover>
);
};
/** @public */
export const MenuListBox = (props: MenuListBoxProps<object>) => {
const {
selectionMode = 'single',
placement = 'bottom start',
...rest
} = props;
const { classNames } = useStyles('Menu');
return (
<RAPopover className={classNames.popover} placement={placement}>
<ScrollArea.Root>
<ScrollArea.Viewport>
<RAListBox
className={classNames.content}
selectionMode={selectionMode}
{...rest}
/>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical" style={{}}>
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</RAPopover>
);
};
/** @public */
export const MenuAutocomplete = (props: MenuAutocompleteProps<object>) => {
const { placement = 'bottom start', ...rest } = props;
const { classNames } = useStyles('Menu');
const { contains } = useFilter({ sensitivity: 'base' });
return (
<RAPopover className={classNames.popover} placement={placement}>
<RAAutocomplete filter={contains}>
<RASearchField className={classNames.searchField}>
<RAInput
className={classNames.searchFieldInput}
aria-label="Search"
placeholder={props.placeholder || 'Search...'}
/>
<RAButton className={classNames.searchFieldClear}>
<RiCloseCircleLine />
</RAButton>
</RASearchField>
<ScrollArea.Root>
<ScrollArea.Viewport>
<RAMenu
className={classNames.content}
renderEmptyState={() => <MenuEmptyState />}
{...rest}
/>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical" style={{}}>
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</RAAutocomplete>
</RAPopover>
);
};
/** @public */
export const MenuAutocompleteListbox = (
props: MenuAutocompleteListBoxProps<object>,
) => {
const {
selectionMode = 'single',
placement = 'bottom start',
...rest
} = props;
const { classNames } = useStyles('Menu');
const { contains } = useFilter({ sensitivity: 'base' });
return (
<RAPopover className={classNames.popover} placement={placement}>
<RAAutocomplete filter={contains}>
<RASearchField className={classNames.searchField}>
<RAInput
className={classNames.searchFieldInput}
aria-label="Search"
placeholder={props.placeholder || 'Search...'}
/>
<RAButton className={classNames.searchFieldClear}>
<RiCloseCircleLine />
</RAButton>
</RASearchField>
<ScrollArea.Root>
<ScrollArea.Viewport>
<RAListBox
className={classNames.content}
renderEmptyState={() => <MenuEmptyState />}
selectionMode={selectionMode}
{...rest}
/>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical" style={{}}>
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</RAAutocomplete>
</RAPopover>
);
};
/** @public */
export const MenuItem = (props: MenuItemProps) => {
const { iconStart, color = 'primary', children, href, ...rest } = props;
const { classNames } = useStyles('Menu');
const navigate = useNavigate();
const isLink = href !== undefined;
const isExternal = isExternalLink(href);
const content = (
<RAMenuItem
className={classNames.item}
data-color={color}
href={href}
textValue={typeof children === 'string' ? children : undefined}
{...rest}
>
<div className={classNames.itemContent}>
{iconStart}
{children}
</div>
<div className={classNames.itemArrow}>
<RiArrowRightSLine />
</div>
</RAMenuItem>
);
if (isLink && !isExternal) {
return (
<RouterProvider navigate={navigate} useHref={useHref}>
{content}
</RouterProvider>
);
}
return content;
};
/** @public */
export const MenuListBoxItem = (props: MenuListBoxItemProps) => {
const { children, ...rest } = props;
const { classNames } = useStyles('Menu');
return (
<RAListBoxItem
textValue={
typeof props.children === 'string' ? props.children : undefined
}
className={classNames.itemListBox}
{...rest}
>
<div className={classNames.itemContent}>
<div className={classNames.itemListBoxCheck}>
<RiCheckLine />
</div>
{children}
</div>
</RAListBoxItem>
);
};
/** @public */
export const MenuSection = (props: MenuSectionProps<object>) => {
const { classNames } = useStyles('Menu');
return (
<RAMenuSection className={classNames.section} {...props}>
<RAMenuHeader className={classNames.sectionHeader}>
{props.title}
</RAMenuHeader>
{props.children}
</RAMenuSection>
);
};
/** @public */
export const MenuSeparator = (props: MenuSeparatorProps) => {
const { classNames } = useStyles('Menu');
return <RAMenuSeparator className={classNames.separator} {...props} />;
};
+25 -3
View File
@@ -1,5 +1,5 @@
/*
* Copyright 2024 The Backstage Authors
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -14,5 +14,27 @@
* limitations under the License.
*/
export * from './Menu';
export * from './types';
export {
MenuTrigger,
SubmenuTrigger,
Menu,
MenuListBox,
MenuAutocomplete,
MenuAutocompleteListbox,
MenuItem,
MenuListBoxItem,
MenuSection,
MenuSeparator,
} from './Menu';
export type {
MenuTriggerProps,
SubmenuTriggerProps,
MenuProps,
MenuListBoxProps,
MenuAutocompleteProps,
MenuAutocompleteListBoxProps,
MenuItemProps,
MenuListBoxItemProps,
MenuSectionProps,
MenuSeparatorProps,
} from './types';
+67 -39
View File
@@ -14,49 +14,77 @@
* limitations under the License.
*/
import { Menu as MenuPrimitive } from '@base-ui-components/react/menu';
import {
ForwardRefExoticComponent,
RefAttributes,
ComponentProps,
} from 'react';
import type {
MenuTriggerProps as RAMenuTriggerProps,
MenuItemProps as RAMenuItemProps,
MenuProps as RAMenuProps,
MenuSectionProps as RAMenuSectionProps,
SeparatorProps as RAMenuSeparatorProps,
SubmenuTriggerProps as RAMenuSubmenuTriggerProps,
ListBoxProps as RAListBoxProps,
ListBoxItemProps as RAListBoxItemProps,
PopoverProps as RAPopoverProps,
} from 'react-aria-components';
/** @public */
export type MenuComboboxOption = {
label: string;
value: string;
disabled?: boolean;
};
export interface MenuTriggerProps extends RAMenuTriggerProps {}
/** @public */
export interface MenuComboboxProps extends ComponentProps<'div'> {
options: MenuComboboxOption[];
value?: string[];
onValueChange?: (value: string[]) => void;
multiselect?: boolean;
closeParentOnEsc?: boolean;
export interface SubmenuTriggerProps extends RAMenuSubmenuTriggerProps {}
/** @public */
export interface MenuProps<T>
extends RAMenuProps<T>,
Omit<RAMenuProps<T>, 'children'> {
placement?: RAPopoverProps['placement'];
}
/** @public */
export type MenuComponent = {
Root: typeof MenuPrimitive.Root;
Trigger: typeof MenuPrimitive.Trigger;
Portal: typeof MenuPrimitive.Portal;
Backdrop: typeof MenuPrimitive.Backdrop;
Positioner: typeof MenuPrimitive.Positioner;
Popup: typeof MenuPrimitive.Popup;
Arrow: typeof MenuPrimitive.Arrow;
Item: typeof MenuPrimitive.Item;
Group: typeof MenuPrimitive.Group;
GroupLabel: typeof MenuPrimitive.GroupLabel;
RadioGroup: typeof MenuPrimitive.RadioGroup;
RadioItem: typeof MenuPrimitive.RadioItem;
RadioItemIndicator: typeof MenuPrimitive.RadioItemIndicator;
CheckboxItem: typeof MenuPrimitive.CheckboxItem;
CheckboxItemIndicator: typeof MenuPrimitive.CheckboxItemIndicator;
SubmenuTrigger: typeof MenuPrimitive.SubmenuTrigger;
Separator: typeof MenuPrimitive.Separator;
Combobox: ForwardRefExoticComponent<
MenuComboboxProps & RefAttributes<HTMLDivElement>
>;
};
export interface MenuListBoxProps<T>
extends RAListBoxProps<T>,
Omit<RAListBoxProps<T>, 'children'> {
placement?: RAPopoverProps['placement'];
}
/** @public */
export interface MenuAutocompleteProps<T>
extends RAMenuProps<T>,
Omit<RAMenuProps<T>, 'children'> {
placeholder?: string;
placement?: RAPopoverProps['placement'];
}
/** @public */
export interface MenuAutocompleteListBoxProps<T>
extends RAListBoxProps<T>,
Omit<RAListBoxProps<T>, 'children'> {
placeholder?: string;
placement?: RAPopoverProps['placement'];
}
/** @public */
export interface MenuItemProps
extends RAMenuItemProps,
Omit<RAMenuItemProps, 'children'> {
iconStart?: React.ReactNode;
children: React.ReactNode;
color?: 'primary' | 'danger';
}
/** @public */
export interface MenuListBoxItemProps
extends RAListBoxItemProps,
Omit<RAListBoxItemProps, 'children'> {
children: React.ReactNode;
}
/** @public */
export interface MenuSectionProps<T>
extends RAMenuSectionProps<T>,
Omit<RAMenuSectionProps<T>, 'children'> {
title: string;
children: React.ReactNode;
}
/** @public */
export interface MenuSeparatorProps extends RAMenuSeparatorProps {}
+13 -13
View File
@@ -157,21 +157,21 @@ export const componentDefinitions = {
},
Menu: {
classNames: {
trigger: 'bui-MenuTrigger',
backdrop: 'bui-MenuBackdrop',
positioner: 'bui-MenuPositioner',
popup: 'bui-MenuPopup',
arrow: 'bui-MenuArrow',
root: 'bui-Menu',
popover: 'bui-MenuPopover',
content: 'bui-MenuContent',
section: 'bui-MenuSection',
sectionHeader: 'bui-MenuSectionHeader',
item: 'bui-MenuItem',
group: 'bui-MenuGroup',
groupLabel: 'bui-MenuGroupLabel',
radioGroup: 'bui-MenuRadioGroup',
radioItem: 'bui-MenuRadioItem',
radioItemIndicator: 'bui-MenuRadioItemIndicator',
checkboxItem: 'bui-MenuCheckboxItem',
checkboxItemIndicator: 'bui-MenuCheckboxItemIndicator',
submenuTrigger: 'bui-MenuSubmenuTrigger',
itemListBox: 'bui-MenuItemListBox',
itemListBoxCheck: 'bui-MenuItemListBoxCheck',
itemContent: 'bui-MenuItemContent',
itemArrow: 'bui-MenuItemArrow',
separator: 'bui-MenuSeparator',
searchField: 'bui-MenuSearchField',
searchFieldInput: 'bui-MenuSearchFieldInput',
searchFieldClear: 'bui-MenuSearchFieldClear',
emptyState: 'bui-MenuEmptyState',
},
},
Popover: {