From 800f59303059061a5338ec43f22b35cf1d6cc587 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sat, 16 Aug 2025 18:50:24 +0200 Subject: [PATCH] Move Menu to React Aria Signed-off-by: Charles de Dreuille --- .changeset/shy-walls-talk.md | 5 + packages/ui/css/styles.css | 319 ++++++----- packages/ui/report.api.md | 185 +++--- .../src/components/Header/Header.stories.tsx | 76 +-- packages/ui/src/components/Header/Header.tsx | 12 +- .../src/components/Header/HeaderToolbar.tsx | 34 +- packages/ui/src/components/Header/index.tsx | 2 +- packages/ui/src/components/Header/types.ts | 13 - .../HeaderPage/HeaderPage.stories.tsx | 55 +- .../src/components/HeaderPage/HeaderPage.tsx | 38 +- .../ui/src/components/HeaderPage/types.ts | 3 +- packages/ui/src/components/Menu/Combobox.tsx | 243 -------- .../ui/src/components/Menu/Menu.stories.tsx | 540 ++++++++++++------ .../ui/src/components/Menu/Menu.styles.css | 355 +++++++----- packages/ui/src/components/Menu/Menu.tsx | 528 +++++++++-------- packages/ui/src/components/Menu/index.ts | 26 +- packages/ui/src/components/Menu/types.ts | 108 ++-- packages/ui/src/utils/componentDefinitions.ts | 26 +- 18 files changed, 1347 insertions(+), 1221 deletions(-) create mode 100644 .changeset/shy-walls-talk.md delete mode 100644 packages/ui/src/components/Menu/Combobox.tsx diff --git a/.changeset/shy-walls-talk.md b/.changeset/shy-walls-talk.md new file mode 100644 index 0000000000..d47fc14b02 --- /dev/null +++ b/.changeset/shy-walls-talk.md @@ -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. diff --git a/packages/ui/css/styles.css b/packages/ui/css/styles.css index 5d4011ff6d..a9635723ee 100644 --- a/packages/ui/css/styles.css +++ b/packages/ui/css/styles.css @@ -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 { diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index c099a31e05..b5c61edc9c 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -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; // @public (undocumented) -export const Menu: MenuComponent; +export const Menu: (props: MenuProps) => JSX_2.Element; // @public (undocumented) -export type MenuComboboxOption = { - label: string; - value: string; - disabled?: boolean; -}; +export const MenuAutocomplete: ( + props: MenuAutocompleteProps, +) => JSX_2.Element; // @public (undocumented) -export interface MenuComboboxProps extends ComponentProps<'div'> { +export const MenuAutocompleteListbox: ( + props: MenuAutocompleteListBoxProps, +) => JSX_2.Element; + +// @public (undocumented) +export interface MenuAutocompleteListBoxProps + extends ListBoxProps, + Omit, '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 - >; -}; +export interface MenuAutocompleteProps + extends MenuProps_2, + Omit, '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 { + // (undocumented) + children: React.ReactNode; + // (undocumented) + color?: 'primary' | 'danger'; + // (undocumented) + iconStart?: React.ReactNode; +} + +// @public (undocumented) +export const MenuListBox: (props: MenuListBoxProps) => JSX_2.Element; + +// @public (undocumented) +export const MenuListBoxItem: (props: MenuListBoxItemProps) => JSX_2.Element; + +// @public (undocumented) +export interface MenuListBoxItemProps + extends ListBoxItemProps, + Omit { + // (undocumented) + children: React.ReactNode; +} + +// @public (undocumented) +export interface MenuListBoxProps + extends ListBoxProps, + Omit, 'children'> { + // (undocumented) + placement?: PopoverProps['placement']; +} + +// @public (undocumented) +export interface MenuProps + extends MenuProps_2, + Omit, 'children'> { + // (undocumented) + placement?: PopoverProps['placement']; +} + +// @public (undocumented) +export const MenuSection: (props: MenuSectionProps) => JSX_2.Element; + +// @public (undocumented) +export interface MenuSectionProps + extends MenuSectionProps_2, + Omit, '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 diff --git a/packages/ui/src/components/Header/Header.stories.tsx b/packages/ui/src/components/Header/Header.stories.tsx index 1674e5e0be..90ed88a3c1 100644 --- a/packages/ui/src/components/Header/Header.stories.tsx +++ b/packages/ui/src/components/Header/Header.stories.tsx @@ -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 = { } /> } /> } /> + + } /> + + {menuItems.map(option => ( + + {option.label} + + ))} + + } /> ), }; -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 = { /> Custom action} breadcrumbs={breadcrumbs} @@ -262,16 +273,12 @@ export const WithHeaderPage: Story = { }; export const WithLayout: Story = { - args: { - menuItems, - }, decorators: layoutDecorator, render: args => ( <>
Custom action} breadcrumbs={breadcrumbs} @@ -281,21 +288,17 @@ export const WithLayout: Story = { }; export const WithLayoutNoTabs: Story = { - args: { - menuItems, - }, decorators: layoutDecorator, render: args => ( <>
- + ), }; export const WithEverything: Story = { args: { - menuItems, tabs, titleLink: '/', }, @@ -314,7 +317,6 @@ export const WithEverything: Story = { /> diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 01856aa3ce..b57fabf6e4 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -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} /> diff --git a/packages/ui/src/components/Header/HeaderToolbar.tsx b/packages/ui/src/components/Header/HeaderToolbar.tsx index 6daa24efa1..f1b2f8ead9 100644 --- a/packages/ui/src/components/Header/HeaderToolbar.tsx +++ b/packages/ui/src/components/Header/HeaderToolbar.tsx @@ -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) => {
{customActions} - {menuItems && ( - - ( - } - variant="tertiary" - {...props} - /> - )} - /> - - - - {menuItems.map(option => ( - option.onClick?.()} - > - {option.label} - - ))} - - - - - )}
diff --git a/packages/ui/src/components/Header/index.tsx b/packages/ui/src/components/Header/index.tsx index ba382fb79d..260c3fd0a2 100644 --- a/packages/ui/src/components/Header/index.tsx +++ b/packages/ui/src/components/Header/index.tsx @@ -15,4 +15,4 @@ */ export { Header } from './Header'; -export type { HeaderProps, HeaderTab, HeaderMenuItem } from './types'; +export type { HeaderProps, HeaderTab } from './types'; diff --git a/packages/ui/src/components/Header/types.ts b/packages/ui/src/components/Header/types.ts index baf9281cfe..ce70e46e22 100644 --- a/packages/ui/src/components/Header/types.ts +++ b/packages/ui/src/components/Header/types.ts @@ -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; } diff --git a/packages/ui/src/components/HeaderPage/HeaderPage.stories.tsx b/packages/ui/src/components/HeaderPage/HeaderPage.stories.tsx index b470ca3f63..c4cc49451d 100644 --- a/packages/ui/src/components/HeaderPage/HeaderPage.stories.tsx +++ b/packages/ui/src/components/HeaderPage/HeaderPage.stories.tsx @@ -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: () => ( Custom action} + customActions={ + <> + + + } /> + + {menuItems.map(option => ( + + {option.label} + + ))} + + + + } /> ), }; @@ -163,7 +191,6 @@ export const WithEverything: Story = { render: () => ( Custom action} breadcrumbs={[{ label: 'Home', href: '/' }]} diff --git a/packages/ui/src/components/HeaderPage/HeaderPage.tsx b/packages/ui/src/components/HeaderPage/HeaderPage.tsx index cd3c683e50..80050815fd 100644 --- a/packages/ui/src/components/HeaderPage/HeaderPage.tsx +++ b/packages/ui/src/components/HeaderPage/HeaderPage.tsx @@ -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} -
- {customActions} - {menuItems && ( - - ( - } - variant="tertiary" - /> - )} - /> - - - - {menuItems.map(menuItem => ( - menuItem.onClick?.()} - > - {menuItem.label} - - ))} - - - - - )} -
+
{customActions}
{tabs && (
diff --git a/packages/ui/src/components/HeaderPage/types.ts b/packages/ui/src/components/HeaderPage/types.ts index 38e3872c11..80133030b1 100644 --- a/packages/ui/src/components/HeaderPage/types.ts +++ b/packages/ui/src/components/HeaderPage/types.ts @@ -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[]; } diff --git a/packages/ui/src/components/Menu/Combobox.tsx b/packages/ui/src/components/Menu/Combobox.tsx deleted file mode 100644 index 796b0921ad..0000000000 --- a/packages/ui/src/components/Menu/Combobox.tsx +++ /dev/null @@ -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(null); - - // Scroll the item into view when it becomes highlighted - useEffect(() => { - if (isHighlighted && itemRef.current) { - itemRef.current.scrollIntoView({ block: 'nearest' }); - } - }, [isHighlighted]); - - return ( -
!option.disabled && onItemActive(optionIndex)} - onClick={() => !option.disabled && onItemSelect(option.value)} - > -
- {isSelected &&
-
{option.label}
-
- ); -} - -/** @public */ -export const Combobox = forwardRef( - (props, ref) => { - const { - options, - value, - onValueChange, - multiselect = false, - className, - ...rest - } = props; - - const triggerId = useId(); - const listboxId = `${triggerId}-listbox`; - - // State management - const [filterString, setFilterString] = useState(''); - const [activeOptionIndex, setActiveOptionIndex] = useState(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) => { - 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 ( -
- -
- {filteredOptions.length === 0 ? ( -
No results found
- ) : ( - filteredOptions.map((option, index) => ( - - )) - )} -
-
- ); - }, -); -Combobox.displayName = 'Combobox'; diff --git a/packages/ui/src/components/Menu/Menu.stories.tsx b/packages/ui/src/components/Menu/Menu.stories.tsx index 7f491452a2..ceedd05b44 100644 --- a/packages/ui/src/components/Menu/Menu.stories.tsx +++ b/packages/ui/src/components/Menu/Menu.stories.tsx @@ -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; + component: MenuTrigger, + decorators: [ + Story => ( + + + + ), + ], +} satisfies Meta; export default meta; type Story = StoryObj; @@ -40,192 +71,357 @@ const options = [ ]; export const Default: Story = { - args: { children: undefined }, - render: args => ( - - ( - - )} - /> - - - - Settings - Invite new members - Download app - Log out - - - - + args: { + children: null, + }, + render: () => ( + + + + {options.map(option => ( + {option.label} + ))} + + ), }; -export const Open: Story = { +export const Opened: Story = { args: { ...Default.args, - open: true, }, - render: Default.render, + render: () => ( + + + + {options.map(option => ( + {option.label} + ))} + + + ), }; -export const OpenOnHover: Story = { +export const WithIcons: Story = { args: { ...Default.args, - openOnHover: true, }, - render: Default.render, + render: () => ( + + + + }>Copy + }>Rename + }>Send feedback + + + ), +}; + +export const WithScrolling: Story = { + args: { + ...Default.args, + }, + render: () => ( + + + + {Array.from({ length: 50 }, (_, i) => ( + + Item {i + 1} - This is a long menu item to demonstrate scrolling + + ))} + + + ), +}; + +export const WithSections: Story = { + args: { + ...Default.args, + }, + render: () => ( + + + + + }>Profile + }>Settings + + + }>Help Center + }> + Contact Support + + }>Feedback + + + + ), +}; + +export const WithSeparators: Story = { + args: { + ...Default.args, + }, + render: () => ( + + + + Edit + Duplicate + Rename + + Share + Move + + }>Feedback + + + ), +}; + +export const WithColors: Story = { + args: { + ...Default.args, + }, + render: () => ( + + + + Edit + Duplicate + Rename + + } color="danger"> + Delete + + + + ), +}; + +export const WithLinks: Story = { + args: { + ...Default.args, + }, + render: () => ( + + + + Internal link + + External link + + Email link + + + ), +}; + +export const WithLinksTest: Story = { + args: { + ...Default.args, + }, + render: () => ( + + + + Internal link + External link + Email link + + + ), +}; + +export const WithListBox: Story = { + args: { + ...Default.args, + }, + render: () => ( + + + + Item 1 + Item 2 + Item 3 + + + ), +}; + +export const WithListBoxControlled: Story = { + args: { + ...Default.args, + }, + render: () => { + const [selected, setSelected] = useState(new Set(['paul'])); + + return ( + + Selected: {Array.from(selected).join(', ')} + + + + + John Lennon + + + Paul McCartney + + + George Harrison + + + Ringo Starr + + + + + ); + }, +}; + +export const WithAutocompleteMenu: Story = { + args: { + ...Default.args, + }, + render: () => ( + + + + Create new file... + Create new folder... + Assign to... + Assign to me + Change status... + Change priority... + Add label... + Remove label... + + + ), +}; + +export const WithAutocompleteListbox: Story = { + args: { + ...Default.args, + }, + render: () => { + const [selected, setSelected] = useState( + new Set([options[2].value]), + ); + + return ( + + Selected: {Array.from(selected).join(', ')} + + + + {options.map(option => ( + + {option.label} + + ))} + + + + ); + }, }; export const Submenu: Story = { - args: { children: undefined }, - render: args => ( - - ( - - )} - /> - - - - Settings - Invite new members - Download app - Log out - - Submenu - - - - Submenu Item 1 - Submenu Item 2 - Submenu Item 3 - - - - - - - - + args: { + ...Default.args, + }, + render: () => ( + + + + Edit + Duplicate + + Submenu + + Edit + Duplicate + Rename + + Share + Move + + }>Feedback + + + + ), }; -export const SubmenuCombobox = () => { - const [selectedValues, setSelectedValues] = useState([]); - - return ( - - - {selectedValues.length === 0 - ? 'Which is your favorite fruit?' - : `Yum, ${selectedValues[0]} is delicious!`} - - - ( - - )} - /> - - - - Regular Item - - Fruits - - - - - - - - - Another Item - - - - - - ); +export const SubmenuAutocompleteMenu: Story = { + args: { + ...Default.args, + }, + render: () => ( + + + + Edit + + Submenu + + {options.map(option => ( + + {option.label} + + ))} + + + + + ), }; -export const SubmenuComboboxMultiselect = () => { - const [selectedValues, setSelectedValues] = useState([]); +export const SubmenuAutocompleteListbox: Story = { + args: { + ...Default.args, + }, + render: () => { + const [selected, setSelected] = useState( + new Set([options[2].value]), + ); - return ( - - - {selectedValues.length === 0 - ? 'Tell us what fruits you like.' - : `${selectedValues.join( - ', ', - )} would make for a great, healthy smoothy!`} - - - ( - - )} - /> - - - - Regular Item - - Fruits - - - - - - - - - Another Item - - - - - - ); + return ( + + Selected: {Array.from(selected).join(', ')} + + + + Edit + + Submenu + + {options.map(option => ( + + {option.label} + + ))} + + + + + + ); + }, }; diff --git a/packages/ui/src/components/Menu/Menu.styles.css b/packages/ui/src/components/Menu/Menu.styles.css index fe1275cb56..ab7a2e0028 100644 --- a/packages/ui/src/components/Menu/Menu.styles.css +++ b/packages/ui/src/components/Menu/Menu.styles.css @@ -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); } diff --git a/packages/ui/src/components/Menu/Menu.tsx b/packages/ui/src/components/Menu/Menu.tsx index 9c475fdf5f..dbf358f99e 100644 --- a/packages/ui/src/components/Menu/Menu.tsx +++ b/packages/ui/src/components/Menu/Menu.tsx @@ -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, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { +const MenuEmptyState = () => { const { classNames } = useStyles('Menu'); - return ( - - ); -}); -MenuTrigger.displayName = MenuPrimitive.Trigger.displayName; - -const MenuBackdrop = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { classNames } = useStyles('Menu'); - - return ( - - ); -}); -MenuBackdrop.displayName = MenuPrimitive.Backdrop.displayName; - -const MenuPositioner = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { classNames } = useStyles('Menu'); - - return ( - - ); -}); -MenuPositioner.displayName = MenuPrimitive.Positioner.displayName; - -const MenuPopup = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { classNames } = useStyles('Menu'); - - return ( - - ); -}); -MenuPopup.displayName = MenuPrimitive.Popup.displayName; - -const MenuArrow = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { classNames } = useStyles('Menu'); - - return ( - - ); -}); -MenuArrow.displayName = MenuPrimitive.Arrow.displayName; - -const MenuItem = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { classNames } = useStyles('Menu'); - - return ( - - ); -}); -MenuItem.displayName = MenuPrimitive.Item.displayName; - -const MenuGroup = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { classNames } = useStyles('Menu'); - - return ( - - ); -}); -MenuGroup.displayName = MenuPrimitive.Group.displayName; - -const MenuGroupLabel = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { classNames } = useStyles('Menu'); - - return ( - - ); -}); -MenuGroupLabel.displayName = MenuPrimitive.GroupLabel.displayName; - -const MenuRadioGroup = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { classNames } = useStyles('Menu'); - - return ( - - ); -}); -MenuRadioGroup.displayName = MenuPrimitive.RadioGroup.displayName; - -const MenuRadioItem = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { classNames } = useStyles('Menu'); - - return ( - - ); -}); -MenuRadioItem.displayName = MenuPrimitive.RadioItem.displayName; - -const MenuRadioItemIndicator = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { classNames } = useStyles('Menu'); - - return ( - - ); -}); -MenuRadioItemIndicator.displayName = - MenuPrimitive.RadioItemIndicator.displayName; - -const MenuCheckboxItem = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { classNames } = useStyles('Menu'); - - return ( - - ); -}); -MenuCheckboxItem.displayName = MenuPrimitive.CheckboxItem.displayName; - -const MenuCheckboxItemIndicator = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { classNames } = useStyles('Menu'); - - return ( - - ); -}); -MenuCheckboxItemIndicator.displayName = - MenuPrimitive.CheckboxItemIndicator.displayName; - -const MenuSubmenuTrigger = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => { - const { classNames } = useStyles('Menu'); - - return ( - -
{children}
- -
- ); -}); -MenuSubmenuTrigger.displayName = MenuPrimitive.SubmenuTrigger.displayName; - -const MenuSeparator = forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { - const { classNames } = useStyles('Menu'); - - return ( - - ); -}); -MenuSeparator.displayName = MenuPrimitive.Separator.displayName; + return
No results found.
; +}; /** @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 ; +}; + +/** @public */ +export const SubmenuTrigger = (props: SubmenuTriggerProps) => { + return ; +}; + +/** @public */ +export const Menu = (props: MenuProps) => { + const { placement = 'bottom start', ...rest } = props; + const { classNames } = useStyles('Menu'); + + return ( + + + + + {props.children} + + + + + + + + ); +}; + +/** @public */ +export const MenuListBox = (props: MenuListBoxProps) => { + const { + selectionMode = 'single', + placement = 'bottom start', + ...rest + } = props; + const { classNames } = useStyles('Menu'); + + return ( + + + + + + + + + + + ); +}; + +/** @public */ +export const MenuAutocomplete = (props: MenuAutocompleteProps) => { + const { placement = 'bottom start', ...rest } = props; + const { classNames } = useStyles('Menu'); + const { contains } = useFilter({ sensitivity: 'base' }); + + return ( + + + + + + + + + + + } + {...rest} + /> + + + + + + + + ); +}; + +/** @public */ +export const MenuAutocompleteListbox = ( + props: MenuAutocompleteListBoxProps, +) => { + const { + selectionMode = 'single', + placement = 'bottom start', + ...rest + } = props; + const { classNames } = useStyles('Menu'); + const { contains } = useFilter({ sensitivity: 'base' }); + + return ( + + + + + + + + + + + } + selectionMode={selectionMode} + {...rest} + /> + + + + + + + + ); +}; + +/** @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 = ( + +
+ {iconStart} + {children} +
+
+ +
+
+ ); + + if (isLink && !isExternal) { + return ( + + {content} + + ); + } + + return content; +}; + +/** @public */ +export const MenuListBoxItem = (props: MenuListBoxItemProps) => { + const { children, ...rest } = props; + const { classNames } = useStyles('Menu'); + + return ( + +
+
+ +
+ {children} +
+
+ ); +}; + +/** @public */ +export const MenuSection = (props: MenuSectionProps) => { + const { classNames } = useStyles('Menu'); + + return ( + + + {props.title} + + {props.children} + + ); +}; + +/** @public */ +export const MenuSeparator = (props: MenuSeparatorProps) => { + const { classNames } = useStyles('Menu'); + + return ; }; diff --git a/packages/ui/src/components/Menu/index.ts b/packages/ui/src/components/Menu/index.ts index 2f404d927a..553e43a590 100644 --- a/packages/ui/src/components/Menu/index.ts +++ b/packages/ui/src/components/Menu/index.ts @@ -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'; diff --git a/packages/ui/src/components/Menu/types.ts b/packages/ui/src/components/Menu/types.ts index 9071f9a921..b6e6026c48 100644 --- a/packages/ui/src/components/Menu/types.ts +++ b/packages/ui/src/components/Menu/types.ts @@ -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 + extends RAMenuProps, + Omit, '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 - >; -}; +export interface MenuListBoxProps + extends RAListBoxProps, + Omit, 'children'> { + placement?: RAPopoverProps['placement']; +} + +/** @public */ +export interface MenuAutocompleteProps + extends RAMenuProps, + Omit, 'children'> { + placeholder?: string; + placement?: RAPopoverProps['placement']; +} + +/** @public */ +export interface MenuAutocompleteListBoxProps + extends RAListBoxProps, + Omit, 'children'> { + placeholder?: string; + placement?: RAPopoverProps['placement']; +} + +/** @public */ +export interface MenuItemProps + extends RAMenuItemProps, + Omit { + iconStart?: React.ReactNode; + children: React.ReactNode; + color?: 'primary' | 'danger'; +} + +/** @public */ +export interface MenuListBoxItemProps + extends RAListBoxItemProps, + Omit { + children: React.ReactNode; +} + +/** @public */ +export interface MenuSectionProps + extends RAMenuSectionProps, + Omit, 'children'> { + title: string; + children: React.ReactNode; +} + +/** @public */ +export interface MenuSeparatorProps extends RAMenuSeparatorProps {} diff --git a/packages/ui/src/utils/componentDefinitions.ts b/packages/ui/src/utils/componentDefinitions.ts index cda8b6d324..05fc027d45 100644 --- a/packages/ui/src/utils/componentDefinitions.ts +++ b/packages/ui/src/utils/componentDefinitions.ts @@ -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: {