From 023f6ad7c6c200be5745771713868d2c0f91111d Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Thu, 9 Oct 2025 17:08:07 +0100 Subject: [PATCH] Add virtualizer on all other menus Signed-off-by: Charles de Dreuille --- .../ui/src/components/Menu/Menu.stories.tsx | 71 +++++++ .../ui/src/components/Menu/Menu.styles.css | 15 +- packages/ui/src/components/Menu/Menu.tsx | 194 +++++++++++------- .../Menu/MenuAutocompleteListBox.stories.tsx | 81 +++++++- .../components/Menu/MenuListBox.stories.tsx | 72 ++++++- packages/ui/src/components/Menu/types.ts | 9 + 6 files changed, 355 insertions(+), 87 deletions(-) diff --git a/packages/ui/src/components/Menu/Menu.stories.tsx b/packages/ui/src/components/Menu/Menu.stories.tsx index 8e6671db3d..a9a45402d1 100644 --- a/packages/ui/src/components/Menu/Menu.stories.tsx +++ b/packages/ui/src/components/Menu/Menu.stories.tsx @@ -36,6 +36,7 @@ import { RiShareBoxLine, } from '@remixicon/react'; import { MemoryRouter } from 'react-router-dom'; +import { useEffect, useState } from 'react'; const meta = { title: 'Backstage UI/Menu', @@ -341,3 +342,73 @@ export const Submenu: Story = { ), }; + +export const Virtualized: 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 ( + + + + {pokemon.map((p, index) => ( + + {p.name.charAt(0).toLocaleUpperCase('en-US') + p.name.slice(1)} + + ))} + + + ); + }, +}; + +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 ( + + + + {pokemon.map((p, index) => ( + + {p.name.charAt(0).toLocaleUpperCase('en-US') + p.name.slice(1)} + + ))} + + + ); + }, +}; diff --git a/packages/ui/src/components/Menu/Menu.styles.css b/packages/ui/src/components/Menu/Menu.styles.css index be475a6cfa..cd57e170b2 100644 --- a/packages/ui/src/components/Menu/Menu.styles.css +++ b/packages/ui/src/components/Menu/Menu.styles.css @@ -121,19 +121,10 @@ } .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); + padding-inline: var(--bui-space-1); + display: block; - &:hover { + &:hover .bui-MenuItemWrapper { background: var(--bui-bg-surface-2); color: var(--bui-fg-primary); } diff --git a/packages/ui/src/components/Menu/Menu.tsx b/packages/ui/src/components/Menu/Menu.tsx index 3ced5a6d37..f202de985c 100644 --- a/packages/ui/src/components/Menu/Menu.tsx +++ b/packages/ui/src/components/Menu/Menu.tsx @@ -34,7 +34,6 @@ import { Virtualizer, ListLayout, } from 'react-aria-components'; -import { ScrollArea } from '../ScrollArea'; import { useStyles } from '../../hooks/useStyles'; import type { MenuTriggerProps, @@ -74,23 +73,41 @@ export const SubmenuTrigger = (props: SubmenuTriggerProps) => { /** @public */ export const Menu = (props: MenuProps) => { - 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 = ( + } + style={{ width: newMaxWidth, maxHeight }} + {...rest} + /> + ); return ( - - - - {props.children} - - - - - - + {virtualized ? ( + + {menuContent} + + ) : ( + menuContent + )} ); @@ -101,24 +118,37 @@ export const MenuListBox = (props: MenuListBoxProps) => { const { selectionMode = 'single', placement = 'bottom start', + virtualized = false, + maxWidth, + maxHeight, ...rest } = props; const { classNames } = useStyles('Menu'); + let newMaxWidth = maxWidth || (virtualized ? '260px' : 'undefined'); + + const listBoxContent = ( + + ); return ( - - - - - - - - + {virtualized ? ( + + {listBoxContent} + + ) : ( + listBoxContent + )} ); }; @@ -135,6 +165,7 @@ export const MenuAutocomplete = (props: MenuAutocompleteProps) => { const { classNames } = useStyles('Menu'); const { contains } = useFilter({ sensitivity: 'base' }); let newMaxWidth = maxWidth || (virtualized ? '260px' : 'undefined'); + const navigate = useNavigate(); const menuContent = ( ) => { /> ); + return ( + + + + + + + + + + {virtualized ? ( + + {menuContent} + + ) : ( + menuContent + )} + + + + ); +}; + +/** @public */ +export const MenuAutocompleteListbox = ( + props: MenuAutocompleteListBoxProps, +) => { + 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 = ( + } + selectionMode={selectionMode} + style={{ width: newMaxWidth, maxHeight }} + {...rest} + /> + ); + return ( @@ -165,59 +254,16 @@ export const MenuAutocomplete = (props: MenuAutocompleteProps) => { rowHeight: 32, }} > - {menuContent} + {listBoxContent} ) : ( - menuContent + listBoxContent )} ); }; -/** @public */ -export const MenuAutocompleteListbox = ( - props: MenuAutocompleteListBoxProps, -) => { - const { - selectionMode = 'single', - placement = 'bottom start', - ...rest - } = props; - const { classNames } = useStyles('Menu'); - const { contains } = useFilter({ sensitivity: 'base' }); - - return ( - - - - - - - - - - - } - selectionMode={selectionMode} - {...rest} - /> - - - - - - - - ); -}; - /** @public */ export const MenuItem = (props: MenuItemProps) => { const { iconStart, color = 'primary', children, href, ...rest } = props; @@ -282,11 +328,13 @@ export const MenuListBoxItem = (props: MenuListBoxItemProps) => { className={classNames.itemListBox} {...rest} > -
-
- +
+
+
+ +
+ {children}
- {children}
); diff --git a/packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx b/packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx index 733d813773..bf66f5f8fd 100644 --- a/packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx +++ b/packages/ui/src/components/Menu/MenuAutocompleteListBox.stories.tsx @@ -24,7 +24,7 @@ import { SubmenuTrigger, } from './index'; import { Button, Flex, Text } from '../..'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Selection } from 'react-aria-components'; import { MemoryRouter } from 'react-router-dom'; @@ -183,3 +183,82 @@ export const Submenu: Story = { ); }, }; + +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)} + + ))} + + + ); + }, +}; diff --git a/packages/ui/src/components/Menu/MenuListBox.stories.tsx b/packages/ui/src/components/Menu/MenuListBox.stories.tsx index 68aa071faf..72284fb6b9 100644 --- a/packages/ui/src/components/Menu/MenuListBox.stories.tsx +++ b/packages/ui/src/components/Menu/MenuListBox.stories.tsx @@ -17,7 +17,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { MenuTrigger, MenuListBox, MenuListBoxItem } from './index'; import { Button, Flex, Text } from '../..'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Selection } from 'react-aria-components'; import { MemoryRouter } from 'react-router-dom'; @@ -87,3 +87,73 @@ export const Controlled: Story = { ); }, }; + +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)} + + ))} + + + ); + }, +}; diff --git a/packages/ui/src/components/Menu/types.ts b/packages/ui/src/components/Menu/types.ts index caf90742a8..6d4852a4b3 100644 --- a/packages/ui/src/components/Menu/types.ts +++ b/packages/ui/src/components/Menu/types.ts @@ -37,6 +37,9 @@ export interface MenuProps extends RAMenuProps, Omit, 'children'> { placement?: RAPopoverProps['placement']; + virtualized?: boolean; + maxWidth?: string; + maxHeight?: string; } /** @public */ @@ -44,6 +47,9 @@ export interface MenuListBoxProps extends RAListBoxProps, Omit, 'children'> { placement?: RAPopoverProps['placement']; + virtualized?: boolean; + maxWidth?: string; + maxHeight?: string; } /** @public */ @@ -63,6 +69,9 @@ export interface MenuAutocompleteListBoxProps Omit, 'children'> { placeholder?: string; placement?: RAPopoverProps['placement']; + virtualized?: boolean; + maxWidth?: string; + maxHeight?: string; } /** @public */