Add virtualizer to MenuAutocomplete

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-10-09 16:40:47 +01:00
parent 13592794e9
commit 93cb99daba
8 changed files with 784 additions and 568 deletions
@@ -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,7 @@ 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';
const meta = {
title: 'Backstage UI/Menu',
@@ -206,88 +199,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 +231,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 +314,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 +326,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>
@@ -567,63 +341,3 @@ export const Submenu: Story = {
</MenuTrigger>
),
};
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 = {
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>
<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>
);
},
};
+248 -244
View File
@@ -14,258 +14,262 @@
* 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 {
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-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);
}
+56 -36
View File
@@ -31,6 +31,8 @@ import {
ListBoxItem as RAListBoxItem,
useFilter,
RouterProvider,
Virtualizer,
ListLayout,
} from 'react-aria-components';
import { ScrollArea } from '../ScrollArea';
import { useStyles } from '../../hooks/useStyles';
@@ -123,9 +125,25 @@ export const MenuListBox = (props: MenuListBoxProps<object>) => {
/** @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 menuContent = (
<RAMenu
className={classNames.content}
renderEmptyState={() => <MenuEmptyState />}
style={{ width: newMaxWidth, maxHeight }}
{...rest}
/>
);
return (
<RAPopover className={classNames.popover} placement={placement}>
@@ -140,18 +158,18 @@ export const MenuAutocomplete = (props: MenuAutocompleteProps<object>) => {
<RiCloseCircleLine />
</RAButton>
</RASearchField>
<ScrollArea.Root>
<ScrollArea.Viewport>
<RAMenu
className={classNames.content}
renderEmptyState={() => <MenuEmptyState />}
{...rest}
/>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical" style={{}}>
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
{virtualized ? (
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight: 32,
}}
>
{menuContent}
</Virtualizer>
) : (
menuContent
)}
</RAAutocomplete>
</RAPopover>
);
@@ -208,24 +226,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 +235,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 +264,9 @@ export const MenuItem = (props: MenuItemProps) => {
<div className={classNames.itemArrow}>
<RiArrowRightSLine />
</div>
</RAMenuItem>
);
}
return content;
</div>
</RAMenuItem>
);
};
/** @public */
@@ -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,185 @@
/*
* 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 { 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>
);
},
};
@@ -0,0 +1,89 @@
/*
* 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 { 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>
);
},
};
+3
View File
@@ -52,6 +52,9 @@ export interface MenuAutocompleteProps<T>
Omit<RAMenuProps<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',