Move Menu to React Aria

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-08-16 18:50:24 +02:00
parent 267259c80f
commit 800f593030
18 changed files with 1347 additions and 1221 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.
+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>
@@ -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={
<>
+2 -10
View File
@@ -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}
/>
@@ -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>
+1 -1
View File
@@ -15,4 +15,4 @@
*/
export { Header } from './Header';
export type { HeaderProps, HeaderTab, HeaderMenuItem } from './types';
export type { HeaderProps, HeaderTab } from './types';
@@ -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;
}
@@ -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: '/' }]}
@@ -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}>
@@ -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';
+368 -172
View File
@@ -15,14 +15,45 @@
*/
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,
} 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>;
@@ -40,192 +71,357 @@ const options = [
];
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>
args: {
children: null,
},
render: () => (
<MenuTrigger>
<Button>Menu</Button>
<Menu>
{options.map(option => (
<MenuItem key={option.value}>{option.label}</MenuItem>
))}
</Menu>
</MenuTrigger>
),
};
export const Open: Story = {
export const Opened: Story = {
args: {
...Default.args,
open: true,
},
render: Default.render,
render: () => (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<Menu>
{options.map(option => (
<MenuItem key={option.value}>{option.label}</MenuItem>
))}
</Menu>
</MenuTrigger>
),
};
export const OpenOnHover: Story = {
export const WithIcons: Story = {
args: {
...Default.args,
openOnHover: true,
},
render: Default.render,
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: {
...Default.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: {
...Default.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: {
...Default.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: {
...Default.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: {
...Default.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: {
...Default.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: {
...Default.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: {
...Default.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: {
...Default.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: {
...Default.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>
);
},
};
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: {
...Default.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: {
...Default.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: {
...Default.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>
);
},
};
+213 -142
View File
@@ -1,182 +1,253 @@
.bui-MenuPositioner {
outline: 0;
z-index: 100;
}
.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);
}
+257 -271
View File
@@ -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} />;
};
+24 -2
View File
@@ -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';
+68 -40
View File
@@ -1,5 +1,5 @@
/*
* Copyright 2025 The Backstage Authors
* Copyright 2024 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,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: {