Add virtualizer on all other menus

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-10-09 17:08:07 +01:00
parent 93cb99daba
commit 023f6ad7c6
6 changed files with 355 additions and 87 deletions
@@ -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 = {
</MenuTrigger>
),
};
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 (
<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>
);
},
};
@@ -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);
}
+121 -73
View File
@@ -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<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: 32,
}}
>
{menuContent}
</Virtualizer>
) : (
menuContent
)}
</RouterProvider>
</RAPopover>
);
@@ -101,24 +118,37 @@ 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: 32,
}}
>
{listBoxContent}
</Virtualizer>
) : (
listBoxContent
)}
</RAPopover>
);
};
@@ -135,6 +165,7 @@ export const MenuAutocomplete = (props: MenuAutocompleteProps<object>) => {
const { classNames } = useStyles('Menu');
const { contains } = useFilter({ sensitivity: 'base' });
let newMaxWidth = maxWidth || (virtualized ? '260px' : 'undefined');
const navigate = useNavigate();
const menuContent = (
<RAMenu
@@ -145,6 +176,64 @@ export const MenuAutocomplete = (props: MenuAutocompleteProps<object>) => {
/>
);
return (
<RAPopover className={classNames.popover} placement={placement}>
<RouterProvider navigate={navigate} useHref={useHref}>
<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>
{virtualized ? (
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight: 32,
}}
>
{menuContent}
</Virtualizer>
) : (
menuContent
)}
</RAAutocomplete>
</RouterProvider>
</RAPopover>
);
};
/** @public */
export const MenuAutocompleteListbox = (
props: MenuAutocompleteListBoxProps<object>,
) => {
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}>
<RAAutocomplete filter={contains}>
@@ -165,59 +254,16 @@ export const MenuAutocomplete = (props: MenuAutocompleteProps<object>) => {
rowHeight: 32,
}}
>
{menuContent}
{listBoxContent}
</Virtualizer>
) : (
menuContent
listBoxContent
)}
</RAAutocomplete>
</RAPopover>
);
};
/** @public */
export const MenuAutocompleteListbox = (
props: MenuAutocompleteListBoxProps<object>,
) => {
const {
selectionMode = 'single',
placement = 'bottom start',
...rest
} = props;
const { classNames } = useStyles('Menu');
const { contains } = useFilter({ sensitivity: 'base' });
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>
<RAListBox
className={classNames.content}
renderEmptyState={() => <MenuEmptyState />}
selectionMode={selectionMode}
{...rest}
/>
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical" style={{}}>
<ScrollArea.Thumb />
</ScrollArea.Scrollbar>
</ScrollArea.Root>
</RAAutocomplete>
</RAPopover>
);
};
/** @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}
>
<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>
);
@@ -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 (
<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>
);
},
};
@@ -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 (
<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>
);
},
};
+9
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 */
@@ -63,6 +69,9 @@ export interface MenuAutocompleteListBoxProps<T>
Omit<RAListBoxProps<T>, 'children'> {
placeholder?: string;
placement?: RAPopoverProps['placement'];
virtualized?: boolean;
maxWidth?: string;
maxHeight?: string;
}
/** @public */