Merge pull request #31375 from backstage/bui-menu-virtualizer

BUI - Add virtualizer all Menu components
This commit is contained in:
Charles de Dreuille
2025-10-09 18:04:42 +01:00
committed by GitHub
13 changed files with 1406 additions and 852 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/ui': patch
---
Add new `virtualized`, `maxWidth` and `maxHeight` props to `Menu`, `MenuListBox`, `MenuAutocomplete` and `MenuAutocompleteListBox` to allow for virtalization of long lists inside menus.
File diff suppressed because one or more lines are too long
@@ -70,6 +70,16 @@ export const menuPropDefs: Record<string, PropDef> = {
type: 'enum',
values: placementValues,
},
virtualized: {
type: 'boolean',
default: 'false',
},
maxWidth: {
type: 'number',
},
maxHeight: {
type: 'number',
},
...classNamePropDefs,
...stylePropDefs,
};
@@ -95,6 +105,16 @@ export const menuListBoxPropDefs: Record<string, PropDef> = {
type: 'enum',
values: placementValues,
},
virtualized: {
type: 'boolean',
default: 'false',
},
maxWidth: {
type: 'number',
},
maxHeight: {
type: 'number',
},
...classNamePropDefs,
...stylePropDefs,
};
@@ -104,6 +124,16 @@ export const menuAutocompletePropDefs: Record<string, PropDef> = {
type: 'enum',
values: placementValues,
},
virtualized: {
type: 'boolean',
default: 'false',
},
maxWidth: {
type: 'number',
},
maxHeight: {
type: 'number',
},
...classNamePropDefs,
...stylePropDefs,
};
@@ -113,6 +143,16 @@ export const menuAutocompleteListboxPropDefs: Record<string, PropDef> = {
type: 'enum',
values: placementValues,
},
virtualized: {
type: 'boolean',
default: 'false',
},
maxWidth: {
type: 'number',
},
maxHeight: {
type: 'number',
},
...classNamePropDefs,
...stylePropDefs,
};
+238 -241
View File
@@ -701,247 +701,6 @@
}
}
.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);
outline: none;
flex-direction: column;
min-height: 0;
transition: transform .2s, opacity .2s;
display: flex;
overflow: hidden;
&[data-entering], &[data-exiting] {
transform: var(--origin);
opacity: 0;
}
&[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 {
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-6);
outline: none;
display: flex;
&[data-focused], &[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-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-6);
outline: none;
display: flex;
&:hover {
background: var(--bui-bg-surface-2);
color: var(--bui-fg-primary);
}
&[data-selected] .bui-MenuItemListBoxCheck {
& > svg {
opacity: 1;
color: var(--bui-fg-primary);
}
}
}
.bui-MenuItemListBoxCheck {
justify-content: center;
align-items: center;
width: 1rem;
height: 1rem;
display: flex;
& > svg {
opacity: 0;
width: 1rem;
height: 1rem;
}
}
.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: 2rem;
cursor: inherit;
outline: none;
align-items: center;
display: flex;
&::-webkit-search-cancel-button, &::-webkit-search-decoration {
-webkit-appearance: none;
}
}
.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-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 {
background-color: var(--bui-bg-surface-1);
border: 1px solid var(--bui-border);
@@ -11078,3 +10837,241 @@
}
@layer legacy;
.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);
outline: none;
flex-direction: column;
min-height: 0;
transition: transform .2s, opacity .2s;
display: flex;
overflow: hidden;
&[data-entering], &[data-exiting] {
transform: var(--origin);
opacity: 0;
}
&[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;
min-width: 150px;
padding-block: var(--bui-space-1);
outline: none;
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 {
padding-inline: var(--bui-space-1);
display: block;
&[data-focused] .bui-MenuItemWrapper, &[data-open] .bui-MenuItemWrapper {
background: var(--bui-bg-surface-2);
color: var(--bui-fg-primary);
}
&[data-color="danger"] .bui-MenuItemWrapper {
color: var(--bui-fg-danger);
}
&[data-color="danger"][data-focused] .bui-MenuItemWrapper {
background: var(--bui-bg-danger);
color: var(--bui-fg-danger);
}
&[data-has-submenu] {
& > .bui-MenuItemWrapper > .bui-MenuItemArrow {
display: block;
}
}
}
.bui-MenuItemWrapper {
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-6);
outline: none;
display: flex;
}
.bui-MenuItemListBox {
padding-inline: var(--bui-space-1);
display: block;
&:hover .bui-MenuItemWrapper {
background: var(--bui-bg-surface-2);
color: var(--bui-fg-primary);
}
&[data-selected] .bui-MenuItemListBoxCheck {
& > svg {
opacity: 1;
color: var(--bui-fg-primary);
}
}
}
.bui-MenuItemListBoxCheck {
justify-content: center;
align-items: center;
width: 1rem;
height: 1rem;
display: flex;
& > svg {
opacity: 0;
width: 1rem;
height: 1rem;
}
}
.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-3);
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: 2rem;
cursor: inherit;
outline: none;
align-items: center;
display: flex;
&::-webkit-search-cancel-button, &::-webkit-search-decoration {
-webkit-appearance: none;
}
}
.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-MenuEmptyState {
padding-inline: var(--bui-space-3);
padding-block: var(--bui-space-2);
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);
}
+25
View File
@@ -516,6 +516,7 @@ export const componentDefinitions: {
readonly item: 'bui-MenuItem';
readonly itemListBox: 'bui-MenuItemListBox';
readonly itemListBoxCheck: 'bui-MenuItemListBoxCheck';
readonly itemWrapper: 'bui-MenuItemWrapper';
readonly itemContent: 'bui-MenuItemContent';
readonly itemArrow: 'bui-MenuItemArrow';
readonly separator: 'bui-MenuSeparator';
@@ -1277,9 +1278,15 @@ export interface MenuAutocompleteListBoxProps<T>
extends ListBoxProps<T>,
Omit<ListBoxProps<T>, 'children'> {
// (undocumented)
maxHeight?: string;
// (undocumented)
maxWidth?: string;
// (undocumented)
placeholder?: string;
// (undocumented)
placement?: PopoverProps['placement'];
// (undocumented)
virtualized?: boolean;
}
// @public (undocumented)
@@ -1287,9 +1294,15 @@ export interface MenuAutocompleteProps<T>
extends MenuProps_2<T>,
Omit<MenuProps_2<T>, 'children'> {
// (undocumented)
maxHeight?: string;
// (undocumented)
maxWidth?: string;
// (undocumented)
placeholder?: string;
// (undocumented)
placement?: PopoverProps['placement'];
// (undocumented)
virtualized?: boolean;
}
// @public (undocumented)
@@ -1326,7 +1339,13 @@ export interface MenuListBoxProps<T>
extends ListBoxProps<T>,
Omit<ListBoxProps<T>, 'children'> {
// (undocumented)
maxHeight?: string;
// (undocumented)
maxWidth?: string;
// (undocumented)
placement?: PopoverProps['placement'];
// (undocumented)
virtualized?: boolean;
}
// @public (undocumented)
@@ -1334,7 +1353,13 @@ export interface MenuProps<T>
extends MenuProps_2<T>,
Omit<MenuProps_2<T>, 'children'> {
// (undocumented)
maxHeight?: string;
// (undocumented)
maxWidth?: string;
// (undocumented)
placement?: PopoverProps['placement'];
// (undocumented)
virtualized?: boolean;
}
// @public (undocumented)
+63 -278
View File
@@ -19,15 +19,11 @@ import {
MenuTrigger,
SubmenuTrigger,
Menu,
MenuListBox,
MenuAutocomplete,
MenuAutocompleteListbox,
MenuItem,
MenuListBoxItem,
MenuSection,
MenuSeparator,
} from './index';
import { Button, Flex, Text } from '../..';
import { Button } from '../..';
import {
RiChat1Line,
RiEdit2Line,
@@ -39,10 +35,8 @@ import {
RiDeleteBinLine,
RiShareBoxLine,
} from '@remixicon/react';
import { useState } from 'react';
import { Selection } from 'react-aria-components';
import { MemoryRouter } from 'react-router-dom';
import { MenuItem as AriaMenuItem } from 'react-aria-components';
import { useEffect, useState } from 'react';
const meta = {
title: 'Backstage UI/Menu',
@@ -206,88 +200,6 @@ export const PreviewLinks: Story = {
),
};
export const PreviewAutocompleteMenu: Story = {
args: {
...Preview.args,
},
render: () => (
<MenuTrigger>
<Button aria-label="Menu">Menu</Button>
<MenuAutocomplete placeholder="Filter">
<MenuItem>Create new file...</MenuItem>
<MenuItem>Create new folder...</MenuItem>
<MenuItem>Assign to...</MenuItem>
<MenuItem>Assign to me</MenuItem>
<MenuItem>Change status...</MenuItem>
<MenuItem>Change priority...</MenuItem>
<MenuItem>Add label...</MenuItem>
<MenuItem>Remove label...</MenuItem>
</MenuAutocomplete>
</MenuTrigger>
),
};
export const PreviewAutocompleteListbox: Story = {
args: {
...Preview.args,
},
render: () => {
const [selected, setSelected] = useState<Selection>(
new Set([options[2].value]),
);
return (
<Flex direction="column" gap="2" align="center">
<Text>Selected: {Array.from(selected).join(', ')}</Text>
<MenuTrigger>
<Button aria-label="Menu">Menu</Button>
<MenuAutocompleteListbox
selectedKeys={selected}
onSelectionChange={setSelected}
>
{options.map(option => (
<MenuListBoxItem key={option.value} id={option.value}>
{option.label}
</MenuListBoxItem>
))}
</MenuAutocompleteListbox>
</MenuTrigger>
</Flex>
);
},
};
export const PreviewAutocompleteListboxMultiple: Story = {
args: {
...Preview.args,
},
render: () => {
const [selected, setSelected] = useState<Selection>(
new Set([options[2].value, options[3].value]),
);
return (
<Flex direction="column" gap="2" align="center">
<Text>Selected: {Array.from(selected).join(', ')}</Text>
<MenuTrigger>
<Button aria-label="Menu">Menu</Button>
<MenuAutocompleteListbox
selectionMode="multiple"
selectedKeys={selected}
onSelectionChange={setSelected}
>
{options.map(option => (
<MenuListBoxItem key={option.value} id={option.value}>
{option.label}
</MenuListBoxItem>
))}
</MenuAutocompleteListbox>
</MenuTrigger>
</Flex>
);
},
};
export const Opened: Story = {
args: {
...Preview.args,
@@ -320,24 +232,6 @@ export const WithIcons: Story = {
),
};
export const WithScrolling: Story = {
args: {
...Preview.args,
},
render: () => (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu with Scrolling</Button>
<Menu>
{Array.from({ length: 50 }, (_, i) => (
<MenuItem key={i}>
Item {i + 1} - This is a long menu item to demonstrate scrolling
</MenuItem>
))}
</Menu>
</MenuTrigger>
),
};
export const WithSections: Story = {
args: {
...Preview.args,
@@ -421,125 +315,6 @@ export const WithLinks: Story = {
),
};
export const WithLinksTest: Story = {
args: {
...Preview.args,
},
render: () => (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<Menu>
<AriaMenuItem href="/home">Internal link</AriaMenuItem>
<AriaMenuItem href="https://www.google.com">External link</AriaMenuItem>
<AriaMenuItem href="mailto:test@test.com">Email link</AriaMenuItem>
</Menu>
</MenuTrigger>
),
};
export const WithListBox: Story = {
args: {
...Preview.args,
},
render: () => (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<MenuListBox>
<MenuListBoxItem>Item 1</MenuListBoxItem>
<MenuListBoxItem>Item 2</MenuListBoxItem>
<MenuListBoxItem>Item 3</MenuListBoxItem>
</MenuListBox>
</MenuTrigger>
),
};
export const WithListBoxControlled: Story = {
args: {
...Preview.args,
},
render: () => {
const [selected, setSelected] = useState<Selection>(new Set(['paul']));
return (
<Flex direction="column" gap="2" align="start">
<Text>Selected: {Array.from(selected).join(', ')}</Text>
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<MenuListBox
selectionMode="multiple"
selectedKeys={selected}
onSelectionChange={setSelected}
>
<MenuListBoxItem key="item1" id="john">
John Lennon
</MenuListBoxItem>
<MenuListBoxItem key="item2" id="paul">
Paul McCartney
</MenuListBoxItem>
<MenuListBoxItem key="item3" id="george">
George Harrison
</MenuListBoxItem>
<MenuListBoxItem key="item4" id="ringo">
Ringo Starr
</MenuListBoxItem>
</MenuListBox>
</MenuTrigger>
</Flex>
);
},
};
export const WithAutocompleteMenu: Story = {
args: {
...Preview.args,
},
render: () => (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<MenuAutocomplete placeholder="Filter">
<MenuItem>Create new file...</MenuItem>
<MenuItem>Create new folder...</MenuItem>
<MenuItem>Assign to...</MenuItem>
<MenuItem>Assign to me</MenuItem>
<MenuItem>Change status...</MenuItem>
<MenuItem>Change priority...</MenuItem>
<MenuItem>Add label...</MenuItem>
<MenuItem>Remove label...</MenuItem>
</MenuAutocomplete>
</MenuTrigger>
),
};
export const WithAutocompleteListbox: Story = {
args: {
...Preview.args,
},
render: () => {
const [selected, setSelected] = useState<Selection>(
new Set([options[2].value]),
);
return (
<Flex direction="column" gap="2" align="start">
<Text>Selected: {Array.from(selected).join(', ')}</Text>
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<MenuAutocompleteListbox
selectedKeys={selected}
onSelectionChange={setSelected}
>
{options.map(option => (
<MenuListBoxItem key={option.value} id={option.value}>
{option.label}
</MenuListBoxItem>
))}
</MenuAutocompleteListbox>
</MenuTrigger>
</Flex>
);
},
};
export const Submenu: Story = {
args: {
...Preview.args,
@@ -552,7 +327,7 @@ export const Submenu: Story = {
<MenuItem>Duplicate</MenuItem>
<SubmenuTrigger>
<MenuItem>Submenu</MenuItem>
<Menu>
<Menu placement="right top">
<MenuItem>Edit</MenuItem>
<MenuItem>Duplicate</MenuItem>
<MenuItem>Rename</MenuItem>
@@ -568,62 +343,72 @@ export const Submenu: Story = {
),
};
export const SubmenuAutocompleteMenu: Story = {
args: {
...Preview.args,
},
render: () => (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<Menu>
<MenuItem>Edit</MenuItem>
<SubmenuTrigger>
<MenuItem>Submenu</MenuItem>
<MenuAutocomplete>
{options.map(option => (
<MenuItem key={option.value} id={option.value}>
{option.label}
</MenuItem>
))}
</MenuAutocomplete>
</SubmenuTrigger>
</Menu>
</MenuTrigger>
),
};
export const SubmenuAutocompleteListbox: Story = {
export const Virtualized: Story = {
args: {
...Preview.args,
},
render: () => {
const [selected, setSelected] = useState<Selection>(
new Set([options[2].value]),
);
const [pokemon, setPokemon] = useState<
Array<{ name: string; url: string }>
>([]);
useEffect(() => {
fetch('https://pokeapi.co/api/v2/pokemon?limit=1000')
.then(response => response.json())
.then(data => {
setPokemon(data.results);
})
.catch(error => {
console.error('Error fetching Pokemon:', error);
});
}, []);
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>
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<Menu items={pokemon} virtualized>
{pokemon.map((p, index) => (
<MenuItem key={index} id={p.name}>
{p.name.charAt(0).toLocaleUpperCase('en-US') + p.name.slice(1)}
</MenuItem>
))}
</Menu>
</MenuTrigger>
);
},
};
export const VirtualizedMaxHeight: Story = {
args: {
...Preview.args,
},
render: () => {
const [pokemon, setPokemon] = useState<
Array<{ name: string; url: string }>
>([]);
useEffect(() => {
fetch('https://pokeapi.co/api/v2/pokemon?limit=1000')
.then(response => response.json())
.then(data => {
setPokemon(data.results);
})
.catch(error => {
console.error('Error fetching Pokemon:', error);
});
}, []);
return (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<Menu items={pokemon} virtualized maxHeight="300px">
{pokemon.map((p, index) => (
<MenuItem key={index} id={p.name}>
{p.name.charAt(0).toLocaleUpperCase('en-US') + p.name.slice(1)}
</MenuItem>
))}
</Menu>
</MenuTrigger>
);
},
};
+239 -244
View File
@@ -14,258 +14,253 @@
* limitations under the License.
*/
@layer components {
.bui-MenuPopover {
display: flex;
flex-direction: column;
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;
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;
.bui-MenuPopover {
display: flex;
flex-direction: column;
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;
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-entering],
&[data-exiting] {
transform: var(--origin);
opacity: 0;
}
&[data-placement='top'] {
--origin: translateY(8px);
}
&[data-placement='bottom'] {
--origin: translateY(-8px);
}
&[data-placement='right'] {
--origin: translateX(-8px);
}
&[data-placement='left'] {
--origin: translateX(8px);
}
&[data-entering],
&[data-exiting] {
transform: var(--origin);
opacity: 0;
}
.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;
&[data-placement='top'] {
--origin: translateY(8px);
}
/* 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;
&[data-placement='bottom'] {
--origin: translateY(-8px);
}
.bui-MenuPopover .bui-ScrollAreaScrollbar {
margin-inline: var(--bui-space-1_5);
&[data-placement='right'] {
--origin: translateX(-8px);
}
.bui-MenuItem {
display: flex;
align-items: center;
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);
gap: var(--bui-space-6);
&[data-focused] {
background: var(--bui-bg-surface-2);
color: var(--bui-fg-primary);
}
&[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-MenuItemListBox {
display: flex;
align-items: center;
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);
gap: var(--bui-space-6);
&:hover {
background: var(--bui-bg-surface-2);
color: var(--bui-fg-primary);
}
&[data-selected] .bui-MenuItemListBoxCheck {
& > svg {
opacity: 1;
color: var(--bui-fg-primary);
}
}
}
.bui-MenuItemListBoxCheck {
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
& > svg {
opacity: 0;
width: 1rem;
height: 1rem;
}
}
.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);
&[data-placement='left'] {
--origin: translateX(8px);
}
}
.bui-MenuContent {
max-height: inherit;
box-sizing: border-box;
overflow: auto;
min-width: 150px;
box-sizing: border-box;
outline: none;
padding-block: var(--bui-space-1);
}
/* 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 {
padding-inline: var(--bui-space-1);
display: block;
&[data-focused] .bui-MenuItemWrapper {
background: var(--bui-bg-surface-2);
color: var(--bui-fg-primary);
}
&[data-open] .bui-MenuItemWrapper {
background: var(--bui-bg-surface-2);
color: var(--bui-fg-primary);
}
&[data-color='danger'] .bui-MenuItemWrapper {
color: var(--bui-fg-danger);
}
&[data-color='danger'][data-focused] .bui-MenuItemWrapper {
background: var(--bui-bg-danger);
color: var(--bui-fg-danger);
}
&[data-has-submenu] {
& > .bui-MenuItemWrapper > .bui-MenuItemArrow {
display: block;
}
}
}
.bui-MenuItemWrapper {
display: flex;
align-items: center;
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);
gap: var(--bui-space-6);
}
.bui-MenuItemListBox {
padding-inline: var(--bui-space-1);
display: block;
&:hover .bui-MenuItemWrapper {
background: var(--bui-bg-surface-2);
color: var(--bui-fg-primary);
}
&[data-selected] .bui-MenuItemListBoxCheck {
& > svg {
opacity: 1;
color: var(--bui-fg-primary);
}
}
}
.bui-MenuItemListBoxCheck {
display: flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
& > svg {
opacity: 0;
width: 1rem;
height: 1rem;
}
}
.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-3);
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-inline: var(--bui-space-3);
padding-block: var(--bui-space-2);
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);
}
+159 -88
View File
@@ -31,8 +31,9 @@ import {
ListBoxItem as RAListBoxItem,
useFilter,
RouterProvider,
Virtualizer,
ListLayout,
} from 'react-aria-components';
import { ScrollArea } from '../ScrollArea';
import { useStyles } from '../../hooks/useStyles';
import type {
MenuTriggerProps,
@@ -54,6 +55,9 @@ import {
import { useNavigate, useHref } from 'react-router-dom';
import { isExternalLink } from '../../utils/isExternalLink';
// The height will be used for virtualized menus. It should match the size set in CSS for each menu item.
const rowHeight = 32;
const MenuEmptyState = () => {
const { classNames } = useStyles('Menu');
@@ -72,23 +76,41 @@ export const SubmenuTrigger = (props: SubmenuTriggerProps) => {
/** @public */
export const Menu = (props: MenuProps<object>) => {
const { placement = 'bottom start', ...rest } = props;
const {
placement = 'bottom start',
virtualized = false,
maxWidth,
maxHeight,
...rest
} = props;
const { classNames } = useStyles('Menu');
const navigate = useNavigate();
let newMaxWidth = maxWidth || (virtualized ? '260px' : 'undefined');
const menuContent = (
<RAMenu
className={classNames.content}
renderEmptyState={() => <MenuEmptyState />}
style={{ width: newMaxWidth, maxHeight }}
{...rest}
/>
);
return (
<RAPopover className={classNames.popover} placement={placement}>
<RouterProvider navigate={navigate} useHref={useHref}>
<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>
{virtualized ? (
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight,
}}
>
{menuContent}
</Virtualizer>
) : (
menuContent
)}
</RouterProvider>
</RAPopover>
);
@@ -99,60 +121,92 @@ export const MenuListBox = (props: MenuListBoxProps<object>) => {
const {
selectionMode = 'single',
placement = 'bottom start',
virtualized = false,
maxWidth,
maxHeight,
...rest
} = props;
const { classNames } = useStyles('Menu');
let newMaxWidth = maxWidth || (virtualized ? '260px' : 'undefined');
const listBoxContent = (
<RAListBox
className={classNames.content}
selectionMode={selectionMode}
style={{ width: newMaxWidth, maxHeight }}
{...rest}
/>
);
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>
{virtualized ? (
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight,
}}
>
{listBoxContent}
</Virtualizer>
) : (
listBoxContent
)}
</RAPopover>
);
};
/** @public */
export const MenuAutocomplete = (props: MenuAutocompleteProps<object>) => {
const { placement = 'bottom start', ...rest } = props;
const {
placement = 'bottom start',
virtualized = false,
maxWidth,
maxHeight,
...rest
} = props;
const { classNames } = useStyles('Menu');
const { contains } = useFilter({ sensitivity: 'base' });
let newMaxWidth = maxWidth || (virtualized ? '260px' : 'undefined');
const navigate = useNavigate();
const menuContent = (
<RAMenu
className={classNames.content}
renderEmptyState={() => <MenuEmptyState />}
style={{ width: newMaxWidth, maxHeight }}
{...rest}
/>
);
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}
<RouterProvider navigate={navigate} useHref={useHref}>
<RAAutocomplete filter={contains}>
<RASearchField className={classNames.searchField}>
<RAInput
className={classNames.searchFieldInput}
aria-label="Search"
placeholder={props.placeholder || 'Search...'}
/>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical" style={{}}>
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</RAAutocomplete>
<RAButton className={classNames.searchFieldClear}>
<RiCloseCircleLine />
</RAButton>
</RASearchField>
{virtualized ? (
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight,
}}
>
{menuContent}
</Virtualizer>
) : (
menuContent
)}
</RAAutocomplete>
</RouterProvider>
</RAPopover>
);
};
@@ -164,10 +218,24 @@ export const MenuAutocompleteListbox = (
const {
selectionMode = 'single',
placement = 'bottom start',
virtualized = false,
maxWidth,
maxHeight,
...rest
} = props;
const { classNames } = useStyles('Menu');
const { contains } = useFilter({ sensitivity: 'base' });
let newMaxWidth = maxWidth || (virtualized ? '260px' : 'undefined');
const listBoxContent = (
<RAListBox
className={classNames.content}
renderEmptyState={() => <MenuEmptyState />}
selectionMode={selectionMode}
style={{ width: newMaxWidth, maxHeight }}
{...rest}
/>
);
return (
<RAPopover className={classNames.popover} placement={placement}>
@@ -182,19 +250,18 @@ export const MenuAutocompleteListbox = (
<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>
{virtualized ? (
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight,
}}
>
{listBoxContent}
</Virtualizer>
) : (
listBoxContent
)}
</RAAutocomplete>
</RAPopover>
);
@@ -208,24 +275,6 @@ export const MenuItem = (props: MenuItemProps) => {
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 (
<RAMenuItem
@@ -235,6 +284,28 @@ export const MenuItem = (props: MenuItemProps) => {
onAction={() => window.open(href, '_blank', 'noopener,noreferrer')}
{...rest}
>
<div className={classNames.itemWrapper}>
<div className={classNames.itemContent}>
{iconStart}
{children}
</div>
<div className={classNames.itemArrow}>
<RiArrowRightSLine />
</div>
</div>
</RAMenuItem>
);
}
return (
<RAMenuItem
className={classNames.item}
data-color={color}
href={href}
textValue={typeof children === 'string' ? children : undefined}
{...rest}
>
<div className={classNames.itemWrapper}>
<div className={classNames.itemContent}>
{iconStart}
{children}
@@ -242,11 +313,9 @@ export const MenuItem = (props: MenuItemProps) => {
<div className={classNames.itemArrow}>
<RiArrowRightSLine />
</div>
</RAMenuItem>
);
}
return content;
</div>
</RAMenuItem>
);
};
/** @public */
@@ -262,11 +331,13 @@ export const MenuListBoxItem = (props: MenuListBoxItemProps) => {
className={classNames.itemListBox}
{...rest}
>
<div className={classNames.itemContent}>
<div className={classNames.itemListBoxCheck}>
<RiCheckLine />
<div className={classNames.itemWrapper}>
<div className={classNames.itemContent}>
<div className={classNames.itemListBoxCheck}>
<RiCheckLine />
</div>
{children}
</div>
{children}
</div>
</RAListBoxItem>
);
@@ -0,0 +1,200 @@
/*
* 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 type { Meta, StoryObj } from '@storybook/react-vite';
import {
MenuTrigger,
SubmenuTrigger,
Menu,
MenuAutocomplete,
MenuItem,
} from './index';
import { Button } from '../..';
import { useState, useEffect } from 'react';
import { MemoryRouter } from 'react-router-dom';
const meta = {
title: 'Backstage UI/MenuAutocomplete',
component: MenuTrigger,
decorators: [
Story => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
} satisfies Meta<typeof MenuTrigger>;
export default meta;
type Story = StoryObj<typeof meta>;
const options = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Blueberry', value: 'blueberry' },
{ label: 'Cherry', value: 'cherry' },
{ label: 'Durian', value: 'durian' },
{ label: 'Elderberry', value: 'elderberry' },
{ label: 'Fig', value: 'fig' },
{ label: 'Grape', value: 'grape' },
{ label: 'Honeydew', value: 'honeydew' },
];
export const Default: Story = {
args: {
children: null,
},
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 PreviewAutocompleteMenu: Story = {
args: {
...Default.args,
},
render: () => (
<MenuTrigger>
<Button aria-label="Menu">Menu</Button>
<MenuAutocomplete placeholder="Filter">
<MenuItem>Create new file...</MenuItem>
<MenuItem>Create new folder...</MenuItem>
<MenuItem>Assign to...</MenuItem>
<MenuItem>Assign to me</MenuItem>
<MenuItem>Change status...</MenuItem>
<MenuItem>Change priority...</MenuItem>
<MenuItem>Add label...</MenuItem>
<MenuItem>Remove label...</MenuItem>
</MenuAutocomplete>
</MenuTrigger>
),
};
export const Virtualized: Story = {
args: {
...Default.args,
},
render: () => {
const [pokemon, setPokemon] = useState<
Array<{ name: string; url: string }>
>([]);
useEffect(() => {
fetch('https://pokeapi.co/api/v2/pokemon?limit=1000')
.then(response => response.json())
.then(data => {
setPokemon(data.results);
})
.catch(error => {
console.error('Error fetching Pokemon:', error);
});
}, []);
return (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<MenuAutocomplete
items={pokemon}
placeholder="Search Pokemon..."
virtualized
>
{pokemon.map((p, index) => (
<MenuItem key={index} id={p.name}>
{p.name.charAt(0).toLocaleUpperCase('en-US') + p.name.slice(1)}
</MenuItem>
))}
</MenuAutocomplete>
</MenuTrigger>
);
},
};
export const VirtualizedMaxHeight: Story = {
args: {
...Default.args,
},
render: () => {
const [pokemon, setPokemon] = useState<
Array<{ name: string; url: string }>
>([]);
useEffect(() => {
fetch('https://pokeapi.co/api/v2/pokemon?limit=1000')
.then(response => response.json())
.then(data => {
setPokemon(data.results);
})
.catch(error => {
console.error('Error fetching Pokemon:', error);
});
}, []);
return (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<MenuAutocomplete
items={pokemon}
placeholder="Search Pokemon..."
virtualized
maxHeight="300px"
>
{pokemon.map((p, index) => (
<MenuItem key={index} id={p.name}>
{p.name.charAt(0).toLocaleUpperCase('en-US') + p.name.slice(1)}
</MenuItem>
))}
</MenuAutocomplete>
</MenuTrigger>
);
},
};
export const Submenu: Story = {
args: {
...Default.args,
},
render: () => (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<Menu>
<MenuItem>Edit</MenuItem>
<MenuItem>Duplicate</MenuItem>
<SubmenuTrigger>
<MenuItem>Submenu</MenuItem>
<MenuAutocomplete placement="right top">
{options.map(option => (
<MenuItem key={option.value} id={option.value}>
{option.label}
</MenuItem>
))}
</MenuAutocomplete>
</SubmenuTrigger>
</Menu>
</MenuTrigger>
),
};
@@ -0,0 +1,264 @@
/*
* 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 type { Meta, StoryObj } from '@storybook/react-vite';
import {
MenuTrigger,
MenuAutocompleteListbox,
MenuListBoxItem,
Menu,
MenuItem,
SubmenuTrigger,
} from './index';
import { Button, Flex, Text } from '../..';
import { useEffect, useState } from 'react';
import { Selection } from 'react-aria-components';
import { MemoryRouter } from 'react-router-dom';
const meta = {
title: 'Backstage UI/MenuAutocompleteListBox',
component: MenuTrigger,
decorators: [
Story => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
} satisfies Meta<typeof MenuTrigger>;
export default meta;
type Story = StoryObj<typeof meta>;
const options = [
{ label: 'Apple', value: 'apple' },
{ label: 'Banana', value: 'banana' },
{ label: 'Blueberry', value: 'blueberry' },
{ label: 'Cherry', value: 'cherry' },
{ label: 'Durian', value: 'durian' },
{ label: 'Elderberry', value: 'elderberry' },
{ label: 'Fig', value: 'fig' },
{ label: 'Grape', value: 'grape' },
{ label: 'Honeydew', value: 'honeydew' },
];
export const Default: Story = {
args: {
children: null,
},
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 PreviewListbox: Story = {
args: {
...Default.args,
},
render: () => {
const [selected, setSelected] = useState<Selection>(
new Set([options[2].value]),
);
return (
<Flex direction="column" gap="2" align="center">
<Text>Selected: {Array.from(selected).join(', ')}</Text>
<MenuTrigger>
<Button aria-label="Menu">Menu</Button>
<MenuAutocompleteListbox
selectedKeys={selected}
onSelectionChange={setSelected}
>
{options.map(option => (
<MenuListBoxItem key={option.value} id={option.value}>
{option.label}
</MenuListBoxItem>
))}
</MenuAutocompleteListbox>
</MenuTrigger>
</Flex>
);
},
};
export const PreviewListboxMultiple: Story = {
args: {
...Default.args,
},
render: () => {
const [selected, setSelected] = useState<Selection>(
new Set([options[2].value, options[3].value]),
);
return (
<Flex direction="column" gap="2" align="center">
<Text>Selected: {Array.from(selected).join(', ')}</Text>
<MenuTrigger>
<Button aria-label="Menu">Menu</Button>
<MenuAutocompleteListbox
selectionMode="multiple"
selectedKeys={selected}
onSelectionChange={setSelected}
>
{options.map(option => (
<MenuListBoxItem key={option.value} id={option.value}>
{option.label}
</MenuListBoxItem>
))}
</MenuAutocompleteListbox>
</MenuTrigger>
</Flex>
);
},
};
export const Submenu: 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>
<Menu>
<MenuItem>Edit</MenuItem>
<MenuItem>Duplicate</MenuItem>
<SubmenuTrigger>
<MenuItem>Submenu</MenuItem>
<MenuAutocompleteListbox
selectedKeys={selected}
onSelectionChange={setSelected}
placement="right top"
>
{options.map(option => (
<MenuListBoxItem key={option.value} id={option.value}>
{option.label}
</MenuListBoxItem>
))}
</MenuAutocompleteListbox>
</SubmenuTrigger>
</Menu>
</MenuTrigger>
</Flex>
);
},
};
export const Virtualized: Story = {
args: {
...Default.args,
},
render: () => {
const [pokemon, setPokemon] = useState<
Array<{ name: string; url: string }>
>([]);
useEffect(() => {
fetch('https://pokeapi.co/api/v2/pokemon?limit=1000')
.then(response => response.json())
.then(data => {
setPokemon(data.results);
})
.catch(error => {
console.error('Error fetching Pokemon:', error);
});
}, []);
return (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<MenuAutocompleteListbox
items={pokemon}
placeholder="Search Pokemon..."
virtualized
>
{pokemon.map((p, index) => (
<MenuListBoxItem key={index} id={p.name}>
{p.name.charAt(0).toLocaleUpperCase('en-US') + p.name.slice(1)}
</MenuListBoxItem>
))}
</MenuAutocompleteListbox>
</MenuTrigger>
);
},
};
export const VirtualizedMaxHeight: Story = {
args: {
...Default.args,
},
render: () => {
const [pokemon, setPokemon] = useState<
Array<{ name: string; url: string }>
>([]);
useEffect(() => {
fetch('https://pokeapi.co/api/v2/pokemon?limit=1000')
.then(response => response.json())
.then(data => {
setPokemon(data.results);
})
.catch(error => {
console.error('Error fetching Pokemon:', error);
});
}, []);
return (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<MenuAutocompleteListbox
items={pokemon}
placeholder="Search Pokemon..."
virtualized
maxHeight="300px"
>
{pokemon.map((p, index) => (
<MenuListBoxItem key={index} id={p.name}>
{p.name.charAt(0).toLocaleUpperCase('en-US') + p.name.slice(1)}
</MenuListBoxItem>
))}
</MenuAutocompleteListbox>
</MenuTrigger>
);
},
};
@@ -0,0 +1,159 @@
/*
* 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 type { Meta, StoryObj } from '@storybook/react-vite';
import { MenuTrigger, MenuListBox, MenuListBoxItem } from './index';
import { Button, Flex, Text } from '../..';
import { useEffect, useState } from 'react';
import { Selection } from 'react-aria-components';
import { MemoryRouter } from 'react-router-dom';
const meta = {
title: 'Backstage UI/MenuListBox',
component: MenuTrigger,
decorators: [
Story => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
} satisfies Meta<typeof MenuTrigger>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
children: null,
},
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 Controlled: 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 Virtualized: Story = {
args: {
...Default.args,
},
render: () => {
const [pokemon, setPokemon] = useState<
Array<{ name: string; url: string }>
>([]);
useEffect(() => {
fetch('https://pokeapi.co/api/v2/pokemon?limit=1000')
.then(response => response.json())
.then(data => {
setPokemon(data.results);
})
.catch(error => {
console.error('Error fetching Pokemon:', error);
});
}, []);
return (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<MenuListBox items={pokemon} virtualized>
{pokemon.map((p, index) => (
<MenuListBoxItem key={index} id={p.name}>
{p.name.charAt(0).toLocaleUpperCase('en-US') + p.name.slice(1)}
</MenuListBoxItem>
))}
</MenuListBox>
</MenuTrigger>
);
},
};
export const VirtualizedMaxHeight: Story = {
args: {
...Default.args,
},
render: () => {
const [pokemon, setPokemon] = useState<
Array<{ name: string; url: string }>
>([]);
useEffect(() => {
fetch('https://pokeapi.co/api/v2/pokemon?limit=1000')
.then(response => response.json())
.then(data => {
setPokemon(data.results);
})
.catch(error => {
console.error('Error fetching Pokemon:', error);
});
}, []);
return (
<MenuTrigger isOpen>
<Button aria-label="Menu">Menu</Button>
<MenuListBox items={pokemon} virtualized maxHeight="300px">
{pokemon.map((p, index) => (
<MenuListBoxItem key={index} id={p.name}>
{p.name.charAt(0).toLocaleUpperCase('en-US') + p.name.slice(1)}
</MenuListBoxItem>
))}
</MenuListBox>
</MenuTrigger>
);
},
};
+12
View File
@@ -37,6 +37,9 @@ export interface MenuProps<T>
extends RAMenuProps<T>,
Omit<RAMenuProps<T>, 'children'> {
placement?: RAPopoverProps['placement'];
virtualized?: boolean;
maxWidth?: string;
maxHeight?: string;
}
/** @public */
@@ -44,6 +47,9 @@ export interface MenuListBoxProps<T>
extends RAListBoxProps<T>,
Omit<RAListBoxProps<T>, 'children'> {
placement?: RAPopoverProps['placement'];
virtualized?: boolean;
maxWidth?: string;
maxHeight?: string;
}
/** @public */
@@ -52,6 +58,9 @@ export interface MenuAutocompleteProps<T>
Omit<RAMenuProps<T>, 'children'> {
placeholder?: string;
placement?: RAPopoverProps['placement'];
virtualized?: boolean;
maxWidth?: string;
maxHeight?: string;
}
/** @public */
@@ -60,6 +69,9 @@ export interface MenuAutocompleteListBoxProps<T>
Omit<RAListBoxProps<T>, 'children'> {
placeholder?: string;
placement?: RAPopoverProps['placement'];
virtualized?: boolean;
maxWidth?: string;
maxHeight?: string;
}
/** @public */
@@ -165,6 +165,7 @@ export const componentDefinitions = {
item: 'bui-MenuItem',
itemListBox: 'bui-MenuItemListBox',
itemListBoxCheck: 'bui-MenuItemListBoxCheck',
itemWrapper: 'bui-MenuItemWrapper',
itemContent: 'bui-MenuItemContent',
itemArrow: 'bui-MenuItemArrow',
separator: 'bui-MenuSeparator',