From 93cb99dabaa46cca27cb6c29fa163258c38a4a26 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Thu, 9 Oct 2025 16:40:47 +0100 Subject: [PATCH] Add virtualizer to MenuAutocomplete Signed-off-by: Charles de Dreuille --- .../ui/src/components/Menu/Menu.stories.tsx | 290 +---------- .../ui/src/components/Menu/Menu.styles.css | 492 +++++++++--------- packages/ui/src/components/Menu/Menu.tsx | 92 ++-- .../Menu/MenuAutocomplete.stories.tsx | 200 +++++++ .../Menu/MenuAutocompleteListBox.stories.tsx | 185 +++++++ .../components/Menu/MenuListBox.stories.tsx | 89 ++++ packages/ui/src/components/Menu/types.ts | 3 + packages/ui/src/utils/componentDefinitions.ts | 1 + 8 files changed, 784 insertions(+), 568 deletions(-) create mode 100644 packages/ui/src/components/Menu/MenuAutocomplete.stories.tsx create mode 100644 packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx create mode 100644 packages/ui/src/components/Menu/MenuListBox.stories.tsx diff --git a/packages/ui/src/components/Menu/Menu.stories.tsx b/packages/ui/src/components/Menu/Menu.stories.tsx index 272d2c5342..8e6671db3d 100644 --- a/packages/ui/src/components/Menu/Menu.stories.tsx +++ b/packages/ui/src/components/Menu/Menu.stories.tsx @@ -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: () => ( - - - - Create new file... - Create new folder... - Assign to... - Assign to me - Change status... - Change priority... - Add label... - Remove label... - - - ), -}; - -export const PreviewAutocompleteListbox: Story = { - args: { - ...Preview.args, - }, - render: () => { - const [selected, setSelected] = useState( - new Set([options[2].value]), - ); - - return ( - - Selected: {Array.from(selected).join(', ')} - - - - {options.map(option => ( - - {option.label} - - ))} - - - - ); - }, -}; - -export const PreviewAutocompleteListboxMultiple: Story = { - args: { - ...Preview.args, - }, - render: () => { - const [selected, setSelected] = useState( - new Set([options[2].value, options[3].value]), - ); - - return ( - - Selected: {Array.from(selected).join(', ')} - - - - {options.map(option => ( - - {option.label} - - ))} - - - - ); - }, -}; - export const Opened: Story = { args: { ...Preview.args, @@ -320,24 +231,6 @@ export const WithIcons: Story = { ), }; -export const WithScrolling: Story = { - args: { - ...Preview.args, - }, - render: () => ( - - - - {Array.from({ length: 50 }, (_, i) => ( - - Item {i + 1} - This is a long menu item to demonstrate scrolling - - ))} - - - ), -}; - export const WithSections: Story = { args: { ...Preview.args, @@ -421,125 +314,6 @@ export const WithLinks: Story = { ), }; -export const WithLinksTest: Story = { - args: { - ...Preview.args, - }, - render: () => ( - - - - Internal link - External link - Email link - - - ), -}; - -export const WithListBox: Story = { - args: { - ...Preview.args, - }, - render: () => ( - - - - Item 1 - Item 2 - Item 3 - - - ), -}; - -export const WithListBoxControlled: Story = { - args: { - ...Preview.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: { - ...Preview.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: { - ...Preview.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: { ...Preview.args, @@ -552,7 +326,7 @@ export const Submenu: Story = { Duplicate Submenu - + Edit Duplicate Rename @@ -567,63 +341,3 @@ export const Submenu: Story = { ), }; - -export const SubmenuAutocompleteMenu: Story = { - args: { - ...Preview.args, - }, - render: () => ( - - - - Edit - - Submenu - - {options.map(option => ( - - {option.label} - - ))} - - - - - ), -}; - -export const SubmenuAutocompleteListbox: Story = { - args: { - ...Preview.args, - }, - render: () => { - const [selected, setSelected] = useState( - new Set([options[2].value]), - ); - - 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 507c772d71..be475a6cfa 100644 --- a/packages/ui/src/components/Menu/Menu.styles.css +++ b/packages/ui/src/components/Menu/Menu.styles.css @@ -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); +} diff --git a/packages/ui/src/components/Menu/Menu.tsx b/packages/ui/src/components/Menu/Menu.tsx index cea8aa2cb8..3ced5a6d37 100644 --- a/packages/ui/src/components/Menu/Menu.tsx +++ b/packages/ui/src/components/Menu/Menu.tsx @@ -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) => { /** @public */ export const MenuAutocomplete = (props: MenuAutocompleteProps) => { - 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 = ( + } + style={{ width: newMaxWidth, maxHeight }} + {...rest} + /> + ); return ( @@ -140,18 +158,18 @@ export const MenuAutocomplete = (props: MenuAutocompleteProps) => { - - - } - {...rest} - /> - - - - - + {virtualized ? ( + + {menuContent} + + ) : ( + menuContent + )} ); @@ -208,24 +226,6 @@ export const MenuItem = (props: MenuItemProps) => { const isLink = href !== undefined; const isExternal = isExternalLink(href); - const content = ( - -
- {iconStart} - {children} -
-
- -
-
- ); - if (isLink && isExternal) { return ( { onAction={() => window.open(href, '_blank', 'noopener,noreferrer')} {...rest} > +
+
+ {iconStart} + {children} +
+
+ +
+
+
+ ); + } + + return ( + +
{iconStart} {children} @@ -242,11 +264,9 @@ export const MenuItem = (props: MenuItemProps) => {
- - ); - } - - return content; +
+ + ); }; /** @public */ diff --git a/packages/ui/src/components/Menu/MenuAutocomplete.stories.tsx b/packages/ui/src/components/Menu/MenuAutocomplete.stories.tsx new file mode 100644 index 0000000000..c0e1aca932 --- /dev/null +++ b/packages/ui/src/components/Menu/MenuAutocomplete.stories.tsx @@ -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 => ( + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +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: () => ( + + + + Create new file... + Create new folder... + Assign to... + Assign to me + Change status... + Change priority... + Add label... + Remove label... + + + ), +}; + +export const PreviewAutocompleteMenu: 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 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 ( + + + + {pokemon.map((p, index) => ( + + {p.name.charAt(0).toLocaleUpperCase('en-US') + p.name.slice(1)} + + ))} + + + ); + }, +}; + +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 ( + + + + {pokemon.map((p, index) => ( + + {p.name.charAt(0).toLocaleUpperCase('en-US') + p.name.slice(1)} + + ))} + + + ); + }, +}; + +export const Submenu: Story = { + args: { + ...Default.args, + }, + render: () => ( + + + + Edit + Duplicate + + Submenu + + {options.map(option => ( + + {option.label} + + ))} + + + + + ), +}; diff --git a/packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx b/packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx new file mode 100644 index 0000000000..733d813773 --- /dev/null +++ b/packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx @@ -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 => ( + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +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( + new Set([options[2].value]), + ); + + return ( + + Selected: {Array.from(selected).join(', ')} + + + + {options.map(option => ( + + {option.label} + + ))} + + + + ); + }, +}; + +export const PreviewListbox: 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 PreviewListboxMultiple: Story = { + args: { + ...Default.args, + }, + render: () => { + const [selected, setSelected] = useState( + new Set([options[2].value, options[3].value]), + ); + + return ( + + Selected: {Array.from(selected).join(', ')} + + + + {options.map(option => ( + + {option.label} + + ))} + + + + ); + }, +}; + +export const Submenu: Story = { + args: { + ...Default.args, + }, + render: () => { + const [selected, setSelected] = useState( + new Set([options[2].value]), + ); + + return ( + + Selected: {Array.from(selected).join(', ')} + + + + Edit + Duplicate + + Submenu + + {options.map(option => ( + + {option.label} + + ))} + + + + + + ); + }, +}; diff --git a/packages/ui/src/components/Menu/MenuListBox.stories.tsx b/packages/ui/src/components/Menu/MenuListBox.stories.tsx new file mode 100644 index 0000000000..68aa071faf --- /dev/null +++ b/packages/ui/src/components/Menu/MenuListBox.stories.tsx @@ -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 => ( + + + + ), + ], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + children: null, + }, + render: () => ( + + + + Item 1 + Item 2 + Item 3 + + + ), +}; + +export const Controlled: 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 + + + + + ); + }, +}; diff --git a/packages/ui/src/components/Menu/types.ts b/packages/ui/src/components/Menu/types.ts index 683d0055b7..caf90742a8 100644 --- a/packages/ui/src/components/Menu/types.ts +++ b/packages/ui/src/components/Menu/types.ts @@ -52,6 +52,9 @@ export interface MenuAutocompleteProps Omit, 'children'> { placeholder?: string; placement?: RAPopoverProps['placement']; + virtualized?: boolean; + maxWidth?: string; + maxHeight?: string; } /** @public */ diff --git a/packages/ui/src/utils/componentDefinitions.ts b/packages/ui/src/utils/componentDefinitions.ts index 931bfacca7..fe040d09b1 100644 --- a/packages/ui/src/utils/componentDefinitions.ts +++ b/packages/ui/src/utils/componentDefinitions.ts @@ -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',