diff --git a/.changeset/angry-boxes-speak.md b/.changeset/angry-boxes-speak.md new file mode 100644 index 00000000..4bc13bce --- /dev/null +++ b/.changeset/angry-boxes-speak.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Improve the layout of Menu component. diff --git a/.changeset/clever-rings-float.md b/.changeset/clever-rings-float.md new file mode 100644 index 00000000..ff7029c9 --- /dev/null +++ b/.changeset/clever-rings-float.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +`wrapWithField` no longer wrap the input component with a field if no label is provided and `forceField` prop is not set. diff --git a/.changeset/cool-pianos-lie.md b/.changeset/cool-pianos-lie.md new file mode 100644 index 00000000..963c33ac --- /dev/null +++ b/.changeset/cool-pianos-lie.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Add a new icon ChartKPI. diff --git a/.changeset/purple-carrots-jump.md b/.changeset/purple-carrots-jump.md new file mode 100644 index 00000000..bd54d39d --- /dev/null +++ b/.changeset/purple-carrots-jump.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": minor +--- + +Add FilterPicker component for single and multiple picker experience with a filter. diff --git a/.changeset/short-humans-crash.md b/.changeset/short-humans-crash.md new file mode 100644 index 00000000..55905145 --- /dev/null +++ b/.changeset/short-humans-crash.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": minor +--- + +Split ListBox into two components: simple ListBox and FilterListBox with search input. diff --git a/.cursor/rules/coding.mdc b/.cursor/rules/coding.mdc new file mode 100644 index 00000000..6f639a5d --- /dev/null +++ b/.cursor/rules/coding.mdc @@ -0,0 +1,12 @@ +--- +alwaysApply: true +--- + +# Flow rules +- Don't respond with "You're right!", "Great idea!" and so on. Get straight to the point. +- **Stop and describe the reason**, if you can't closely implement the task or need a different approach from what was asked. +- Do not run tests if you only changed stories or documentation since the last test run. + +# Coding rules + +- Use named imports from react (like `useCallback`) instead of using the `React` instance. Avoid: `React.useCallback`. diff --git a/.size-limit.cjs b/.size-limit.cjs index 626f2118..f869c7db 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -20,7 +20,7 @@ module.exports = [ }), ); }, - limit: '270kB', + limit: '279kB', }, { name: 'Tree shaking (just a Button)', diff --git a/package.json b/package.json index 2de1f334..ca6b8ba2 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "@react-types/shared": "^3.27.0", "@sparticuz/chromium": "^137.0.1", "@tabler/icons-react": "^3.31.0", + "@tanstack/react-virtual": "^3.13.12", "@trivago/prettier-plugin-sort-imports": "^5.2.2", "@vitejs/plugin-react": "^4.3.2", "clipboard-copy": "^4.0.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bd83dc77..d70b8ab3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -47,6 +47,9 @@ importers: '@tabler/icons-react': specifier: ^3.31.0 version: 3.31.0(react@18.2.0) + '@tanstack/react-virtual': + specifier: ^3.13.12 + version: 3.13.12(react-dom@18.2.0(react@18.2.0))(react@18.2.0) '@trivago/prettier-plugin-sort-imports': specifier: ^5.2.2 version: 5.2.2(prettier@3.2.5) @@ -2862,6 +2865,15 @@ packages: '@tabler/icons@3.31.0': resolution: {integrity: sha512-dblAdeKY3+GA1U+Q9eziZ0ooVlZMHsE8dqP0RkwvRtEsAULoKOYaCUOcJ4oW1DjWegdxk++UAt2SlQVnmeHv+g==} + '@tanstack/react-virtual@3.13.12': + resolution: {integrity: sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.13.12': + resolution: {integrity: sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==} + '@testing-library/dom@10.4.0': resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} @@ -10542,6 +10554,14 @@ snapshots: '@tabler/icons@3.31.0': {} + '@tanstack/react-virtual@3.13.12(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': + dependencies: + '@tanstack/virtual-core': 3.13.12 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + + '@tanstack/virtual-core@3.13.12': {} + '@testing-library/dom@10.4.0': dependencies: '@babel/code-frame': 7.26.2 diff --git a/src/components/actions/Button/Button.stories.tsx b/src/components/actions/Button/Button.stories.tsx index c2dc8d93..322f8a27 100644 --- a/src/components/actions/Button/Button.stories.tsx +++ b/src/components/actions/Button/Button.stories.tsx @@ -29,7 +29,7 @@ export default { }, }, size: { - options: ['small', 'medium', 'large'], + options: ['tiny', 'small', 'medium', 'large'], control: { type: 'radio' }, description: 'Button size', table: { diff --git a/src/components/actions/Button/Button.tsx b/src/components/actions/Button/Button.tsx index d2a12505..1ad61920 100644 --- a/src/components/actions/Button/Button.tsx +++ b/src/components/actions/Button/Button.tsx @@ -27,7 +27,7 @@ export interface CubeButtonProps extends CubeActionProps { | 'outline' | 'neutral' | (string & {}); - size?: 'small' | 'medium' | 'large' | (string & {}); + size?: 'tiny' | 'small' | 'medium' | 'large' | (string & {}); } export type ButtonVariant = @@ -83,20 +83,23 @@ export const DEFAULT_BUTTON_STYLES = { reset: 'button', outlineOffset: 1, padding: { - '': '0 (2x - 1bw)', - '[data-size="small"]': '0 (1x - 1bw)', - '[data-size="medium"]': '0 (1.5x - 1bw)', - '[data-size="large"]': '0 (2.25x - 1bw)', + '': '.5x (2x - 1bw)', + '[data-size="tiny"]': '.5x (1x - 1bw)', + '[data-size="small"]': '.5x (1x - 1bw)', + '[data-size="medium"]': '.5x (1.5x - 1bw)', + '[data-size="large"]': '.5x (2.25x - 1bw)', 'single-icon-only | [data-type="link"]': 0, }, width: { '': 'initial', + '[data-size="tiny"] & single-icon-only': '3.5x 3.5x', '[data-size="small"] & single-icon-only': '4x 4x', '[data-size="medium"] & single-icon-only': '5x 5x', '[data-size="large"] & single-icon-only': '6x 6x', }, height: { '': 'initial', + '[data-size="tiny"]': '3.5x 3.5x', '[data-size="small"]': '4x 4x', '[data-size="medium"]': '5x 5x', '[data-size="large"]': '6x 6x', @@ -161,13 +164,13 @@ export const DEFAULT_OUTLINE_STYLES: Styles = { fill: { '': '#dark.0', hovered: '#dark.03', - 'pressed | selected': '#dark.06', + 'pressed | (selected & !hovered)': '#dark.06', '[disabled] | disabled': '#dark.04', }, color: { '': '#dark-02', hovered: '#dark-02', - 'pressed | selected': '#dark', + 'pressed | (selected & !hovered)': '#dark', '[disabled] | disabled': '#dark-04', }, } as const; @@ -180,7 +183,7 @@ export const DEFAULT_NEUTRAL_STYLES: Styles = { fill: { '': '#dark.0', hovered: '#dark.03', - 'pressed | selected': '#dark.06', + 'pressed | (selected & !hovered)': '#dark.06', }, color: { '': '#dark-02', @@ -199,7 +202,7 @@ export const DEFAULT_CLEAR_STYLES: Styles = { fill: { '': '#purple.0', hovered: '#purple.16', - 'pressed | selected': '#purple.10', + 'pressed | (selected & !hovered)': '#purple.10', }, color: { '': '#purple-text', @@ -273,7 +276,7 @@ export const DANGER_OUTLINE_STYLES: Styles = { fill: { '': '#danger.0', hovered: '#danger.1', - 'pressed | selected': '#danger.05', + 'pressed | (selected & !hovered)': '#danger.05', '[disabled] | disabled': '#dark.04', }, color: { @@ -290,11 +293,11 @@ export const DANGER_NEUTRAL_STYLES: Styles = { fill: { '': '#dark.0', hovered: '#dark.04', - 'pressed | selected': '#dark.05', + 'pressed | (selected & !hovered)': '#dark.05', }, color: { '': '#dark-02', - 'pressed | selected': '#danger-text', + 'pressed | (selected & !hovered)': '#danger-text', '[disabled] | disabled': '#dark-04', }, } as const; @@ -308,7 +311,7 @@ export const DANGER_CLEAR_STYLES: Styles = { fill: { '': '#danger.0', hovered: '#danger.1', - 'pressed | selected': '#danger.05', + 'pressed | (selected & !hovered)': '#danger.05', }, color: { '': '#danger-text', @@ -382,7 +385,7 @@ export const SUCCESS_OUTLINE_STYLES: Styles = { fill: { '': '#success.0', hovered: '#success.1', - 'pressed | selected': '#success.05', + 'pressed | (selected & !hovered)': '#success.05', '[disabled] | disabled': '#dark.04', }, color: { @@ -399,11 +402,11 @@ export const SUCCESS_NEUTRAL_STYLES: Styles = { fill: { '': '#dark.0', hovered: '#dark.04', - 'pressed | selected': '#dark.05', + 'pressed | (selected & !hovered)': '#dark.05', }, color: { '': '#dark-02', - 'pressed | selected': '#success-text', + 'pressed | (selected & !hovered)': '#success-text', '[disabled] | disabled': '#dark-04', }, } as const; @@ -417,7 +420,7 @@ export const SUCCESS_CLEAR_STYLES: Styles = { fill: { '': '#success.0', hovered: '#success.1', - 'pressed | selected': '#success.05', + 'pressed | (selected & !hovered)': '#success.05', }, color: { '': '#success-text', @@ -489,7 +492,7 @@ export const SPECIAL_OUTLINE_STYLES: Styles = { fill: { '': '#white.0', hovered: '#white.18', - 'pressed | selected': '#white.12', + 'pressed | (selected & !hovered)': '#white.12', '[disabled] | disabled': '#white.12', }, color: { @@ -506,7 +509,7 @@ export const SPECIAL_NEUTRAL_STYLES: Styles = { fill: { '': '#white.0', hovered: '#white.12', - 'pressed | selected': '#white.18', + 'pressed | (selected & !hovered)': '#white.18', }, color: { '': '#white', @@ -619,14 +622,14 @@ export const Button = forwardRef(function Button( if (icon) { if (!specifiedLabel) { accessibilityWarning( - 'If you provide `icon` property for a Button and do not provide any children then you should specify the `label` property to make sure the Button element stays accessible.', + 'If you provide `icon` property for a Button and do not provide any children then you should specify the `aria-label` property to make sure the Button element stays accessible.', ); label = 'Unnamed'; // fix to avoid warning in production } } else { if (!specifiedLabel) { accessibilityWarning( - 'If you provide no children for a Button then you should specify the `label` property to make sure the Button element stays accessible.', + 'If you provide no children for a Button then you should specify the `aria-label` property to make sure the Button element stays accessible.', ); label = 'Unnamed'; // fix to avoid warning in production } diff --git a/src/components/actions/CommandMenu/CommandMenu.stories.tsx b/src/components/actions/CommandMenu/CommandMenu.stories.tsx index 71ac81f8..560755d7 100644 --- a/src/components/actions/CommandMenu/CommandMenu.stories.tsx +++ b/src/components/actions/CommandMenu/CommandMenu.stories.tsx @@ -757,7 +757,7 @@ HotkeyTesting.args = { }; export const MediumSize: StoryFn> = (args) => ( - + {basicCommands.map((command) => ( > = (args) => ( MediumSize.args = { searchPlaceholder: 'Medium size command palette...', autoFocus: true, + size: 'medium', }; export const WithDialog: StoryFn> = (args) => ( @@ -834,6 +835,7 @@ export const WithHeaderAndFooter: StoryFn> = ( } + size="medium" > {basicCommands.map((command) => ( > = ( WithDialogContainer.args = { searchPlaceholder: 'Search commands...', autoFocus: true, + size: 'medium', }; WithDialogContainer.play = async ({ canvasElement }) => { diff --git a/src/components/actions/CommandMenu/CommandMenu.test.tsx b/src/components/actions/CommandMenu/CommandMenu.test.tsx index db874013..ae7521a3 100644 --- a/src/components/actions/CommandMenu/CommandMenu.test.tsx +++ b/src/components/actions/CommandMenu/CommandMenu.test.tsx @@ -878,22 +878,6 @@ describe('CommandMenu', () => { expect(commandMenu).toHaveAttribute('data-is-tray'); }); - it('should apply modal mod when used inside a modal dialog', () => { - const { DialogContext } = require('../../overlays/Dialog/context'); - - render( - - - Item 1 - Item 2 - - , - ); - - const commandMenu = screen.getByTestId('test-command-menu'); - expect(commandMenu).toHaveAttribute('data-is-modal'); - }); - it('should not apply any special mods when used standalone', () => { render( @@ -905,7 +889,6 @@ describe('CommandMenu', () => { const commandMenu = screen.getByTestId('test-command-menu'); expect(commandMenu).not.toHaveAttribute('data-is-popover'); expect(commandMenu).not.toHaveAttribute('data-is-tray'); - expect(commandMenu).not.toHaveAttribute('data-is-modal'); }); }); }); diff --git a/src/components/actions/CommandMenu/CommandMenu.tsx b/src/components/actions/CommandMenu/CommandMenu.tsx index b5cd621d..c151f49e 100644 --- a/src/components/actions/CommandMenu/CommandMenu.tsx +++ b/src/components/actions/CommandMenu/CommandMenu.tsx @@ -24,7 +24,6 @@ import { Styles, } from '../../../tasty'; import { mergeProps } from '../../../utils/react'; -import { useDialogContext } from '../../overlays/Dialog/context'; import { TooltipProvider } from '../../overlays/Tooltip/TooltipProvider'; import { useMenuContext } from '../Menu'; import { CubeMenuProps } from '../Menu/Menu'; @@ -42,7 +41,6 @@ import { StyledCommandMenu, StyledEmptyState, StyledLoadingWrapper, - StyledMenuWrapper, StyledSearchInput, } from './styled'; @@ -125,8 +123,6 @@ function CommandMenuBase( const searchInputRef = useRef(null); const contextProps = useMenuContext(); - const dialogContext = useDialogContext(); - // Convert string[] to Set for React Aria compatibility const ariaSelectedKeys = selectedKeys ? new Set(selectedKeys) : undefined; const ariaDefaultSelectedKeys = defaultSelectedKeys @@ -527,11 +523,9 @@ function CommandMenuBase( useSyncRef(contextProps, menuRef); const mods = useMemo(() => { - // Determine mods based on dialog context and menu context - let popoverMod = - completeProps.mods?.popover || dialogContext?.type === 'popover'; - let trayMod = completeProps.mods?.tray || dialogContext?.type === 'tray'; - let modalMod = dialogContext?.type === 'modal'; + // Determine mods based on menu context + let popoverMod = completeProps.mods?.popover; + let trayMod = completeProps.mods?.tray; return { sections: viewHasSections, @@ -539,15 +533,8 @@ function CommandMenuBase( header: !!header, popover: popoverMod, tray: trayMod, - modal: modalMod, }; - }, [ - viewHasSections, - footer, - header, - completeProps.mods, - dialogContext?.type, - ]); + }, [viewHasSections, footer, header, completeProps.mods]); return ( ( > {/* Header */} {header && ( - + {header} )} @@ -748,25 +739,23 @@ function CommandMenuBase( {/* Menu Content - always render unless loading */} {!isLoading && !showEmptyState && ( - - - {renderedItems} - - + + {renderedItems} + )} {/* Empty State - show when search term exists but no results */} @@ -776,7 +765,11 @@ function CommandMenuBase( {/* Footer */} {footer && ( - + {footer} )} diff --git a/src/components/actions/Menu/Menu.test.tsx b/src/components/actions/Menu/Menu.test.tsx index 7b3dac2f..5de26d97 100644 --- a/src/components/actions/Menu/Menu.test.tsx +++ b/src/components/actions/Menu/Menu.test.tsx @@ -560,25 +560,35 @@ describe('', () => { // Header and footer tests it('should render header when provided', () => { - const { getByText } = render( + const { getByText, getByRole } = render( {basicItems} , ); + // Header content should be visible expect(getByText('Menu Header')).toBeInTheDocument(); + + // Header element should have the correct role and qa attribute + const header = getByRole('heading', { level: 3 }); + expect(header).toBeInTheDocument(); + expect(header).toHaveAttribute('data-qa', 'Header'); }); it('should render footer when provided', () => { - const { container } = render( + const { getByText, getByRole } = render( {basicItems} , ); - // Footer sets the footer modifier - const menu = container.querySelector('[role="menu"]'); - expect(menu).toHaveAttribute('data-is-footer', ''); + // Footer content should be visible + expect(getByText('Menu Footer')).toBeInTheDocument(); + + // Footer element should have the correct role and qa attribute + const footer = getByRole('footer'); + expect(footer).toBeInTheDocument(); + expect(footer).toHaveAttribute('data-qa', 'Footer'); }); it('should handle onClose callback', () => { @@ -602,12 +612,12 @@ describe('', () => { , ); - const menu = container.querySelector('[data-qa="custom-menu"]'); - expect(menu).toBeInTheDocument(); + const menuWrapper = container.querySelector('[data-qa="custom-menu"]'); + expect(menuWrapper).toBeInTheDocument(); }); it('should apply mods based on content', () => { - const { container } = render( + const { container, getByRole } = render( ', () => { , ); - const menu = container.querySelector('[role="menu"]'); + // Check wrapper mods (header, footer are on wrapper) + const menuWrapper = container.querySelector('[data-qa="Menu"]'); + expect(menuWrapper).toHaveAttribute('data-is-header', ''); + expect(menuWrapper).toHaveAttribute('data-is-footer', ''); + + // Check menu list mods (sections mod is on the menu list) + const menu = getByRole('menu'); expect(menu).toHaveAttribute('data-is-sections', ''); - expect(menu).toHaveAttribute('data-is-header', ''); - expect(menu).toHaveAttribute('data-is-footer', ''); }); // Ref tests @@ -1010,7 +1024,7 @@ describe('Menu popover mod', () => { it('should apply popover mod when context provides it', () => { const { MenuContext } = require('./context'); - const { getByRole } = render( + const { container } = render( Item 1 @@ -1019,26 +1033,26 @@ describe('Menu popover mod', () => { , ); - const menu = getByRole('menu'); - expect(menu).toHaveAttribute('data-is-popover'); + const menuWrapper = container.querySelector('[data-qa="Menu"]'); + expect(menuWrapper).toHaveAttribute('data-is-popover'); }); it('should not apply popover mod when used standalone', () => { - const { getByRole } = render( + const { container } = render( Item 1 Item 2 , ); - const menu = getByRole('menu'); - expect(menu).not.toHaveAttribute('data-is-popover'); + const menuWrapper = container.querySelector('[data-qa="Menu"]'); + expect(menuWrapper).not.toHaveAttribute('data-is-popover'); }); it('should not apply popover mod when context provides false', () => { const { MenuContext } = require('./context'); - const { getByRole } = render( + const { container } = render( Item 1 @@ -1047,8 +1061,8 @@ describe('Menu popover mod', () => { , ); - const menu = getByRole('menu'); - expect(menu).not.toHaveAttribute('data-is-popover'); + const menuWrapper = container.querySelector('[data-qa="Menu"]'); + expect(menuWrapper).not.toHaveAttribute('data-is-popover'); }); }); diff --git a/src/components/actions/Menu/Menu.tsx b/src/components/actions/Menu/Menu.tsx index 32dd03c5..d1949b84 100644 --- a/src/components/actions/Menu/Menu.tsx +++ b/src/components/actions/Menu/Menu.tsx @@ -32,6 +32,7 @@ import { StyledFooter, StyledHeader, StyledMenu, + StyledMenuWrapper, } from './styled'; export interface CubeMenuProps @@ -45,6 +46,9 @@ export interface CubeMenuProps // @deprecated header?: ReactNode; footer?: ReactNode; + menuStyles?: Styles; + headerStyles?: Styles; + footerStyles?: Styles; styles?: Styles; itemStyles?: Styles; sectionStyles?: Styles; @@ -80,6 +84,9 @@ function Menu( const { header, footer, + menuStyles, + headerStyles, + footerStyles, itemStyles, sectionStyles, sectionHeadingStyles, @@ -135,17 +142,19 @@ function Menu( [completeProps], ); - const defaultProps = { - qa, - styles, - 'data-size': size, - mods: { - sections: hasSections, + const wrapperMods = useMemo(() => { + return { + popover: completeProps.mods?.popover, footer: !!footer, header: !!header, - popover: completeProps.mods?.popover, - }, - }; + }; + }, [completeProps.mods?.popover, footer, header]); + + const menuMods = useMemo(() => { + return { + sections: hasSections, + }; + }, [hasSections]); // Sync the ref stored in the context object with the DOM ref returned by useDOMRef. // The helper from @react-aria/utils expects the context object as the first argument @@ -236,15 +245,41 @@ function Menu( ]); return ( - - {header && {header}} - {renderedItems} - {footer && {footer}} - + {header ? ( + + {header} + + ) : ( +
+ )} + + {renderedItems} + + {footer ? ( + + {footer} + + ) : ( +
+ )} + ); } diff --git a/src/components/actions/Menu/MenuItem.tsx b/src/components/actions/Menu/MenuItem.tsx index 34e4e710..cbb8f1b7 100644 --- a/src/components/actions/Menu/MenuItem.tsx +++ b/src/components/actions/Menu/MenuItem.tsx @@ -15,7 +15,7 @@ import { Space } from '../../layout/Space'; import { useMenuContext } from './context'; import { StyledItem } from './styled'; -export type MenuSelectionType = 'checkbox' | 'radio'; +export type MenuSelectionType = 'checkbox' | 'radio' | 'checkmark'; export interface MenuItemProps { item: Node; @@ -31,6 +31,7 @@ export interface MenuItemProps { // Returns icon corresponding to selection type const getSelectionTypeIcon = (selectionIcon?: MenuSelectionType) => { switch (selectionIcon) { + case 'checkmark': case 'checkbox': return ; case 'radio': diff --git a/src/components/actions/Menu/styled.tsx b/src/components/actions/Menu/styled.tsx index 9a2c5657..cd0091fe 100644 --- a/src/components/actions/Menu/styled.tsx +++ b/src/components/actions/Menu/styled.tsx @@ -2,9 +2,32 @@ import { DEFAULT_BUTTON_STYLES, DEFAULT_NEUTRAL_STYLES } from '..'; import { tasty } from '../../../tasty'; import { Space } from '../../layout/Space'; +export const StyledMenuWrapper = tasty({ + qa: 'Menu', + styles: { + display: 'grid', + gridRows: 'max-content 1sf max-content', + fill: '#white', + margin: 0, + padding: 0, + border: true, + radius: '(1cr + 1bw)', + shadow: { + '': '', + 'popover | tray': '0px 5px 15px #dark.05', + }, + height: { + '': 'initial', + popover: 'initial max-content (50vh - 4x)', + tray: 'initial max-content (100vh - 4x)', + }, + boxSizing: 'border-box', + }, +}); + export const StyledMenu = tasty({ as: 'ul', - qa: 'Menu', + qa: 'MenuList', styles: { display: 'flex', flow: 'column', @@ -12,7 +35,7 @@ export const StyledMenu = tasty({ '': '1bw', sections: false, }, - fill: '#white', + boxSizing: 'border-box', margin: 0, padding: { '': '0.5x', @@ -22,15 +45,6 @@ export const StyledMenu = tasty({ '': 'auto', section: '', }, - border: { - '': '#border', - section: false, - }, - radius: '(1cr + 1bw)', - boxShadow: { - '': '', - popover: '0px 5px 15px #dark.05', - }, scrollbar: 'styled', }, }); @@ -52,29 +66,41 @@ export const StyledDivider = tasty({ export const StyledHeader = tasty(Space, { qa: 'Header', as: 'div', + role: 'heading', + 'aria-level': 3, styles: { color: '#dark-02', preset: 't3', placeContent: 'space-between', placeItems: 'center', whiteSpace: 'nowrap', - padding: '.5x 1x', - height: 'min 4x', + padding: '.5x 1.5x', + height: { + '': 'min 4x', + '[data-size="medium"]': 'min 5x', + }, + boxSizing: 'border-box', + border: 'bottom', }, }); export const StyledFooter = tasty(Space, { qa: 'Footer', as: 'div', + role: 'footer', styles: { color: '#dark-02', preset: 't3', - border: '#border top', placeContent: 'space-between', placeItems: 'center', whiteSpace: 'nowrap', - padding: '.5x 1x', - height: 'min 4x', + padding: '.5x 1.5x', + height: { + '': 'min 4x', + '[data-size="medium"]': 'min 5x', + }, + boxSizing: 'border-box', + border: 'top', }, }); @@ -114,9 +140,9 @@ export const StyledItem = tasty({ fill: { '': '#clear', focused: '#dark.03', - selected: '#dark.06', - 'selected & focused': '#dark.09', - pressed: '#dark.06', + selected: '#dark.09', + 'selected & focused': '#dark.12', + pressed: '#dark.09', disabled: '#clear', }, color: { @@ -125,8 +151,8 @@ export const StyledItem = tasty({ disabled: '#dark-04', }, cursor: { - '': 'pointer', - disabled: 'default', + '': 'default', + disabled: 'not-allowed', }, shadow: '#clear', padding: { diff --git a/src/components/content/Tag/Tag.tsx b/src/components/content/Tag/Tag.tsx index 3a8250ec..e6d3379c 100644 --- a/src/components/content/Tag/Tag.tsx +++ b/src/components/content/Tag/Tag.tsx @@ -123,7 +123,7 @@ function Tag(allProps: CubeTagProps, ref) { {isClosable ? ( - + ) : undefined} diff --git a/src/components/fields/FilterListBox/FilterListBox.docs.mdx b/src/components/fields/FilterListBox/FilterListBox.docs.mdx new file mode 100644 index 00000000..c4045bbf --- /dev/null +++ b/src/components/fields/FilterListBox/FilterListBox.docs.mdx @@ -0,0 +1,430 @@ +import { Meta, Canvas, Story, Controls } from '@storybook/blocks'; +import { FilterListBox } from './FilterListBox'; +import * as FilterListBoxStories from './FilterListBox.stories'; + + + +# FilterListBox + +A searchable list selection component that combines a ListBox with an integrated search input. Users can filter through options in real-time while maintaining full keyboard navigation and accessibility. Built with React Aria's accessibility features and the Cube `tasty` style system for theming. + +## When to Use + +- Present a searchable list of selectable options for large datasets +- Enable real-time filtering through options as users type +- Create searchable selection interfaces for data with many entries +- Build filterable form controls that need to remain visible +- Provide quick option discovery in lengthy lists +- Display structured data with searchable sections and descriptions +- Customize empty state messages when search yields no results + +## Component + + + +--- + +### Properties + + + +### Base Properties + +Supports [Base properties](/docs/tasty-base-properties--docs) + +### Styling Properties + +#### styles + +Customizes the root wrapper element of the component. + +**Sub-elements:** +- `ValidationState` - Container for validation and loading indicators + +#### searchInputStyles + +Customizes the search input field appearance. + +**Sub-elements:** +- `Prefix` - Container for search icon +- `InputIcon` - The search or loading icon + +#### listStyles + +Customizes the list container element that holds the filtered options. + +#### optionStyles + +Customizes individual option elements. + +**Sub-elements:** +- `Label` - The main text of each option +- `Description` - Secondary descriptive text for options +- `Content` - Container for label and description +- `Checkbox` - Checkbox element when `isCheckable={true}` +- `CheckboxWrapper` - Wrapper around the checkbox + +#### sectionStyles + +Customizes section wrapper elements. + +#### headingStyles + +Customizes section heading elements. + +#### headerStyles + +Customizes the header area when header prop is provided. + +#### footerStyles + +Customizes the footer area when footer prop is provided. + +### Style Properties + +These properties allow direct style application without using the `styles` prop: `display`, `font`, `preset`, `hide`, `opacity`, `whiteSpace`, `color`, `fill`, `fade`, `width`, `height`, `flexBasis`, `flexGrow`, `flexShrink`, `flex`, `gridArea`, `order`, `gridColumn`, `gridRow`, `placeSelf`, `alignSelf`, `justifySelf`, `zIndex`, `margin`, `inset`, `position`, `reset`, `padding`, `paddingInline`, `paddingBlock`, `shadow`, `border`, `radius`, `overflow`, `scrollbar`, `outline`, `textAlign`, `flow`, `placeItems`, `placeContent`, `alignItems`, `alignContent`, `justifyItems`, `justifyContent`, `align`, `justify`, `gap`, `columnGap`, `rowGap`, `gridColumns`, `gridRows`, `gridTemplate`, `gridAreas`. + +### Modifiers + +The `mods` property accepts the following modifiers you can override: + +| Modifier | Type | Description | +|----------|------|-------------| +| `invalid` | `boolean` | Applied when `validationState="invalid"` | +| `valid` | `boolean` | Applied when `validationState="valid"` | +| `disabled` | `boolean` | Applied when `isDisabled={true}` | +| `focused` | `boolean` | Applied when the FilterListBox has focus | +| `loading` | `boolean` | Applied when `isLoading={true}` | +| `searchable` | `boolean` | Always true for FilterListBox | + +## Variants + +### Selection Modes + +- `single` - Allows selecting only one item at a time +- `multiple` - Allows selecting multiple items + +### Sizes + +- `small` - Compact size for dense interfaces + +## Examples + +### Basic Usage + + + +```jsx + + Apple + Banana + Cherry + +``` + +### With Sections + + + +```jsx + + + Apple + Banana + + + Carrot + Broccoli + + +``` + +### Multiple Selection + + + +```jsx + + Read + Write + Execute + +``` + +### With Descriptions + + + +```jsx + + + Apple + + + Banana + + +``` + +### With Custom Filter + + + +```jsx + + text.toLowerCase().startsWith(search.toLowerCase()) + } +> + JavaScript + TypeScript + Python + +``` + +### Loading State + + + +```jsx + + Loading Item 1 + Loading Item 2 + +``` + +### Custom Empty State + + + +```jsx + + Searchable Item + +``` + +### With Header and Footer + + + +```jsx + + Languages + 12 + + } + footer={ + + Popular languages shown + + } +> + JavaScript + Python + +``` + +### Custom Values + +```jsx + + React + Vue.js + +``` + +## Accessibility + +### Keyboard Navigation + +- `Tab` - Moves focus to the search input +- `Arrow Down/Up` - Navigate through filtered options +- `Enter` - Select the focused option +- `Space` - In multiple selection mode, toggle selection +- `Escape` - Clear search input or close (if empty) +- `Home/End` - Move to first/last option + +### Screen Reader Support + +- Search input announces as "combobox" with proper state +- Filtered results are announced when search changes +- Selected items are announced immediately +- Loading states are communicated to screen readers +- Empty search results are properly announced + +### ARIA Properties + +- `aria-label` - Provides accessible label for the FilterListBox +- `aria-expanded` - Indicates the expanded state of the listbox +- `aria-haspopup` - Indicates the search input controls a listbox +- `aria-activedescendant` - Tracks the focused option +- `aria-describedby` - Associates help text and descriptions + +## Best Practices + +1. **Do**: Use for lists with more than 10-15 options + ```jsx + + {countries.map(country => ( + {country.name} + ))} + + ``` + +2. **Don't**: Use for very small lists (under 5-7 options) + ```jsx + // ❌ Avoid for small lists - use ListBox instead + + Yes + No + + ``` + +3. **Do**: Provide `textValue` for complex option content + ```jsx + + +
+ John Doe +
john.doe@company.com
+
+
+ ``` + +4. **Performance**: Use custom filter functions for specialized search needs +5. **UX**: Provide meaningful empty state messages +6. **Accessibility**: Always provide clear search placeholders + +## Integration with Forms + +This component supports all [Field properties](/docs/forms-field--docs) when used within a Form. The component automatically handles form validation, field states, and integrates with form submission. + +```jsx +
+ + + React + Vue.js + + + Node.js + Python + + +
+``` + +## Advanced Features + +### Custom Filter Functions + +FilterListBox supports custom filter functions for specialized search behavior: + +```jsx +// Starts-with filter +const startsWithFilter = (text, search) => + text.toLowerCase().startsWith(search.toLowerCase()); + +// Fuzzy search filter +const fuzzyFilter = (text, search) => { + const searchChars = search.toLowerCase().split(''); + const textLower = text.toLowerCase(); + let searchIndex = 0; + + for (const char of textLower) { + if (char === searchChars[searchIndex]) { + searchIndex++; + if (searchIndex === searchChars.length) return true; + } + } + return false; +}; + + + {/* items */} + +``` + +### Custom Values + +When `allowsCustomValue={true}`, users can add new options by typing: + +```jsx + + Existing Option + +``` + +Custom values: +- Are automatically added when selected +- Persist across popover sessions +- Appear in the list with selected items +- Can be removed like any other selection + +## Performance + +### Optimization Tips + +- Use `textValue` prop for complex option content to improve search performance +- Implement debounced search for very large datasets +- Consider FilterPicker for trigger-based interfaces +- Use sections sparingly for very large lists + +```jsx +// Optimized for performance + + + +``` + +## Related Components + +- [ListBox](/docs/forms-listbox--docs) - Simple list selection without search +- [FilterPicker](/docs/forms-filterpicker--docs) - FilterListBox in a trigger-based popover +- [ComboBox](/docs/forms-combobox--docs) - Dropdown with search and text input +- [Select](/docs/forms-select--docs) - Dropdown selection without search +- [SearchInput](/docs/forms-searchinput--docs) - Standalone search input component diff --git a/src/components/fields/FilterListBox/FilterListBox.spec.md b/src/components/fields/FilterListBox/FilterListBox.spec.md new file mode 100644 index 00000000..89f75192 --- /dev/null +++ b/src/components/fields/FilterListBox/FilterListBox.spec.md @@ -0,0 +1,250 @@ +# FilterListBox Component Specification + +## Overview + +The `FilterListBox` component is a searchable list selection component that combines a search input with a ListBox. It provides real-time filtering capabilities, custom value support, and full accessibility features. Built on top of the base ListBox component with React Aria patterns. + +## Architecture + +### Component Hierarchy + +``` +FilterListBox (forwardRef) +├── FilterListBoxWrapperElement (tasty styled container) +│ ├── StyledHeaderWithoutBorder (optional header) +│ ├── SearchWrapperElement (search input container) +│ │ ├── SearchInputElement (input field) +│ │ └── div[data-element="Prefix"] (search/loading icon) +│ └── ListBox (internal list component) +│ ├── Option components (filtered items) +│ └── ListBoxSection components (filtered sections) +``` + +### Core Dependencies + +- **Base ListBox**: Extends `CubeListBoxProps` and uses internal `ListBox` component +- **React Aria**: `useFilter`, `useKeyboard` for search and navigation +- **React Stately**: `Item`, `Section` for collection management +- **Form Integration**: Custom form hooks (`useFieldProps`, `useFormProps`) +- **Styling**: Tasty design system with styled components + +## Key Features + +### 1. Real-time Search & Filtering +- **Search Input**: Integrated search field at the top of the component +- **Filter Function**: Configurable text filtering with default `contains` behavior +- **Multi-type Filtering**: Supports filtering of both items and sections +- **Empty State**: Customizable "no results" message when filters yield no matches + +### 2. Custom Value Support +- **allowsCustomValue**: Enables users to enter values not present in the original options +- **Dynamic Options**: Custom values are added to the collection and persist in selection +- **Custom Value Management**: Tracks custom keys separately from original options +- **Selection Priority**: Selected custom values appear at the top of the list + +### 3. Advanced Keyboard Navigation +- **Arrow Navigation**: Up/Down arrows navigate through filtered results +- **Home/End/PageUp/PageDown**: Jump to first/last visible items +- **Enter/Space**: Select focused item +- **Escape Handling**: Clear search or trigger parent close behavior + +### 4. Focus Management +- **Virtual Focus**: Uses `shouldUseVirtualFocus={true}` to keep DOM focus in search input +- **Focus Preservation**: Maintains keyboard navigation through filtered items +- **Auto-focus Management**: Automatically focuses first available item when collection changes + +### 5. Form Integration +- **Value Mapping**: Supports both single and multiple selection modes +- **Validation State**: Inherits validation from form system +- **Custom Value Integration**: Seamlessly integrates custom values with form state + +## Component Props Interface + +### Core Search Props +```typescript +interface SearchProps { + searchPlaceholder?: string; // Search input placeholder + autoFocus?: boolean; // Auto-focus search input + filter?: FilterFn; // Custom filter function + emptyLabel?: ReactNode; // Custom "no results" message + searchInputStyles?: Styles; // Search input styling + searchInputRef?: RefObject; +} +``` + +### Custom Value Props +```typescript +interface CustomValueProps { + allowsCustomValue?: boolean; // Enable custom value entry + onEscape?: () => void; // Escape key handler for parent components +} +``` + +### Enhanced Selection Props +```typescript +interface EnhancedSelectionProps extends CubeListBoxProps { + isCheckable?: boolean; // Show checkboxes in multiple mode + onOptionClick?: (key: Key) => void; // Click handler for content area +} +``` + +## Implementation Details + +### Filtering Logic +The component implements a multi-stage filtering system: + +```typescript +// 1. Base filtering with user-provided or default filter function +const textFilterFn = filter || contains; + +// 2. Recursive filtering supporting sections +const filterChildren = (childNodes: ReactNode): ReactNode => { + // Filters items and sections recursively + // Preserves section structure when items match +}; + +// 3. Custom value enhancement +if (allowsCustomValue && searchTerm && !termExists) { + // Adds custom value option at the end +} +``` + +### Custom Value Management +```typescript +// State management for custom values +const [customKeys, setCustomKeys] = useState>(new Set()); + +// Integration with selection +const mergedChildren = useMemo(() => { + // Combines original children with custom value items + // Promotes selected custom values to the top +}, [children, customKeys, selectedKeys]); +``` + +### Virtual Focus System +- **Search Input Focus**: DOM focus remains in the search input +- **Visual Focus**: ListBox manages visual focus indication +- **Keyboard Navigation**: Custom keyboard handlers bridge input and list navigation +- **ARIA Integration**: Proper aria-activedescendant management + +### Loading States +- **Global Loading**: `isLoading` prop shows loading spinner in search input +- **Search Icon**: Alternates between search icon and loading spinner + +## Styling System + +### Container Structure +- **FilterListBoxWrapperElement**: Main container with grid layout +- **SearchWrapperElement**: Search input container with input wrapper styles +- **SearchInputElement**: Styled input field with clear background + +### Grid Layout +```css +gridRows: 'max-content max-content 1sf' +/* Header (optional) | Search Input | ListBox (flexible) */ +``` + +### Modifier States +- **focused**: Search input has focus +- **invalid/valid**: Validation state styling +- **loading**: Loading state indication +- **searchable**: Always true for this component + +## Performance Considerations + +### Filtering Performance +- **Memoized Filtering**: Filter results are memoized based on search value and children +- **Recursive Processing**: Efficient recursive filtering for sectioned content +- **Key Normalization**: Optimized string operations for key comparison + +### Virtual Scrolling +- **Inherited Virtualization**: Benefits from ListBox virtualization for large filtered results +- **Dynamic Height**: Proper height estimation for filtered content + +### Memory Management +- **Custom Key Cleanup**: Tracks and cleans up unused custom values +- **Effect Dependencies**: Carefully managed effect dependencies to prevent unnecessary re-renders + +## Integration Patterns + +### With FilterPicker +```typescript +// FilterPicker uses FilterListBox as its internal component + +``` + +### Form Integration +```typescript +// Value mapping for form compatibility +valuePropsMapper: ({ value, onChange }) => ({ + selectedKey: selectionMode === 'single' ? value : undefined, + selectedKeys: selectionMode === 'multiple' ? value : undefined, + onSelectionChange: (selection) => onChange(selection) +}) +``` + +## Accessibility Features + +### Search Input Accessibility +- **Role**: `combobox` with proper ARIA attributes +- **ARIA Expanded**: Always `true` to indicate expanded listbox +- **ARIA Controls**: Links to the internal listbox +- **Active Descendant**: Points to currently focused list item + +### Keyboard Interaction +- **Standard Input**: Text entry, selection, clipboard operations +- **List Navigation**: Arrow keys, Home/End, Page Up/Down +- **Selection**: Enter/Space to select items +- **Escape**: Progressive escape (clear search → close component) + +### Screen Reader Support +- **Proper Labeling**: Inherits aria-label from parent or label prop +- **Live Regions**: Filter results are announced +- **Focus Management**: Clear focus flow between input and list + +## Common Use Cases + +1. **Searchable Dropdowns**: Large option lists with search capability +2. **Tag Input Systems**: Multi-select with custom value entry +3. **Filter Interfaces**: Data filtering with real-time preview +4. **Autocomplete Components**: Search with suggested and custom options +5. **Category Pickers**: Searchable grouped content selection + +## Testing Considerations + +### Search Functionality +- Search input value changes +- Filter result accuracy +- Empty state handling +- Custom value creation + +### Keyboard Navigation +- Arrow key navigation through filtered results +- Enter/Space selection behavior +- Escape key handling (clear search vs close) +- Home/End/Page navigation + +### Custom Values +- Custom value creation and persistence +- Selection state with custom values +- Form integration with custom values + +## Browser Compatibility + +- **Input Events**: Modern input event handling +- **CSS Grid**: Grid layout for component structure +- **ARIA Support**: Full ARIA combobox pattern support +- **Focus Management**: Advanced focus coordination + +## Migration Notes + +When upgrading or modifying: +- Custom filter functions may need adjustment for new filtering logic +- Virtual focus behavior changes require testing with parent components +- Custom value handling affects selection state management +- Search input styling may inherit from base input components \ No newline at end of file diff --git a/src/components/fields/FilterListBox/FilterListBox.stories.tsx b/src/components/fields/FilterListBox/FilterListBox.stories.tsx new file mode 100644 index 00000000..b1bc9125 --- /dev/null +++ b/src/components/fields/FilterListBox/FilterListBox.stories.tsx @@ -0,0 +1,1221 @@ +import { StoryFn, StoryObj } from '@storybook/react'; +import { userEvent, within } from '@storybook/test'; +import { useState } from 'react'; + +import { + CheckIcon, + DatabaseIcon, + FilterIcon, + PlusIcon, + RightIcon, + SearchIcon, + SettingsIcon, + UserIcon, +} from '../../../icons'; +import { baseProps } from '../../../stories/lists/baseProps'; +import { Button } from '../../actions/Button/Button'; +import { Badge } from '../../content/Badge/Badge'; +import { Paragraph } from '../../content/Paragraph'; +import { Text } from '../../content/Text'; +import { Title } from '../../content/Title'; +import { Form, SubmitButton } from '../../form'; +import { Flow } from '../../layout/Flow'; +import { Space } from '../../layout/Space'; +import { Dialog } from '../../overlays/Dialog/Dialog'; +import { DialogTrigger } from '../../overlays/Dialog/DialogTrigger'; + +import { CubeFilterListBoxProps, FilterListBox } from './FilterListBox'; + +import type { Meta } from '@storybook/react'; + +const meta: Meta = { + title: 'Forms/FilterListBox', + component: FilterListBox, + parameters: { + controls: { + exclude: baseProps, + }, + }, + argTypes: { + /* Content */ + selectedKey: { + control: { type: 'text' }, + description: 'The selected key in controlled mode', + }, + defaultSelectedKey: { + control: { type: 'text' }, + description: 'The default selected key in uncontrolled mode', + }, + selectedKeys: { + control: { type: 'object' }, + description: 'The selected keys in controlled multiple mode', + }, + defaultSelectedKeys: { + control: { type: 'object' }, + description: 'The default selected keys in uncontrolled multiple mode', + }, + selectionMode: { + options: ['single', 'multiple'], + control: { type: 'radio' }, + description: 'Selection mode', + table: { + defaultValue: { summary: 'single' }, + }, + }, + allowsCustomValue: { + control: { type: 'boolean' }, + description: 'Whether the FilterListBox allows custom values', + table: { + defaultValue: { summary: false }, + }, + }, + disallowEmptySelection: { + control: { type: 'boolean' }, + description: 'Whether to disallow empty selection', + table: { + defaultValue: { summary: false }, + }, + }, + disabledKeys: { + control: { type: 'object' }, + description: 'Array of keys for disabled items', + }, + + /* Search */ + searchPlaceholder: { + control: { type: 'text' }, + description: 'Placeholder text for the search input', + table: { + defaultValue: { summary: 'Search...' }, + }, + }, + autoFocus: { + control: { type: 'boolean' }, + description: 'Whether the search input should have autofocus', + table: { + defaultValue: { summary: false }, + }, + }, + emptyLabel: { + control: { type: 'text' }, + description: + 'Custom label to display when no results are found after filtering', + table: { + defaultValue: { summary: 'No results found' }, + }, + }, + filter: { + control: false, + description: 'Custom filter function for search', + }, + + /* Presentation */ + size: { + options: ['small', 'medium'], + control: { type: 'radio' }, + description: 'FilterListBox size', + table: { + defaultValue: { summary: 'small' }, + }, + }, + header: { + control: { type: 'text' }, + description: 'Custom header content', + }, + footer: { + control: { type: 'text' }, + description: 'Custom footer content', + }, + + /* Behavior */ + isCheckable: { + control: { type: 'boolean' }, + description: 'Whether to show checkboxes for multiple selection', + table: { + defaultValue: { summary: false }, + }, + }, + + /* State */ + isLoading: { + control: { type: 'boolean' }, + description: 'Whether the FilterListBox is loading', + table: { + defaultValue: { summary: false }, + }, + }, + isDisabled: { + control: { type: 'boolean' }, + description: 'Whether the FilterListBox is disabled', + table: { + defaultValue: { summary: false }, + }, + }, + isRequired: { + control: { type: 'boolean' }, + description: 'Whether the field is required', + table: { + defaultValue: { summary: false }, + }, + }, + validationState: { + options: [undefined, 'valid', 'invalid'], + control: { type: 'radio' }, + description: 'Validation state of the FilterListBox', + table: { + defaultValue: { summary: undefined }, + }, + }, + + /* Field */ + label: { + control: { type: 'text' }, + description: 'Label text', + }, + description: { + control: { type: 'text' }, + description: 'Field description', + }, + message: { + control: { type: 'text' }, + description: 'Help or error message', + }, + + /* Events */ + onSelectionChange: { + action: 'selection changed', + description: 'Callback when selection changes', + }, + onEscape: { + action: 'escape pressed', + description: 'Callback when Escape key is pressed', + }, + onOptionClick: { + action: 'option clicked', + description: 'Callback when an option is clicked (non-checkbox area)', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// Sample data for stories +const fruits = [ + { key: 'apple', label: 'Apple', description: 'Crisp and sweet red fruit' }, + { + key: 'banana', + label: 'Banana', + description: 'Yellow tropical fruit rich in potassium', + }, + { + key: 'cherry', + label: 'Cherry', + description: 'Small red stone fruit with sweet flavor', + }, + { + key: 'date', + label: 'Date', + description: 'Sweet dried fruit from date palm', + }, + { + key: 'elderberry', + label: 'Elderberry', + description: 'Dark purple berry with tart flavor', + }, + { key: 'fig', label: 'Fig', description: 'Sweet fruit with soft flesh' }, + { + key: 'grape', + label: 'Grape', + description: 'Small sweet fruit that grows in clusters', + }, + { + key: 'honeydew', + label: 'Honeydew', + description: 'Sweet green melon with pale flesh', + }, +]; + +const vegetables = [ + { + key: 'carrot', + label: 'Carrot', + description: 'Orange root vegetable high in beta-carotene', + }, + { + key: 'broccoli', + label: 'Broccoli', + description: 'Green cruciferous vegetable packed with nutrients', + }, + { + key: 'spinach', + label: 'Spinach', + description: 'Leafy green vegetable rich in iron', + }, + { key: 'pepper', label: 'Bell Pepper', description: 'Colorful sweet pepper' }, + { + key: 'tomato', + label: 'Tomato', + description: 'Red fruit commonly used as vegetable', + }, +]; + +const herbs = [ + { + key: 'basil', + label: 'Basil', + description: 'Aromatic herb used in Mediterranean cooking', + }, + { + key: 'oregano', + label: 'Oregano', + description: 'Pungent herb popular in Italian cuisine', + }, + { + key: 'thyme', + label: 'Thyme', + description: 'Fragrant herb with earthy flavor', + }, + { + key: 'parsley', + label: 'Parsley', + description: 'Fresh herb used for garnish and flavor', + }, + { + key: 'cilantro', + label: 'Cilantro', + description: 'Bright herb with citrusy flavor', + }, +]; + +const permissions = [ + { key: 'read', label: 'Read', description: 'View content and data' }, + { key: 'write', label: 'Write', description: 'Create and edit content' }, + { key: 'delete', label: 'Delete', description: 'Remove content permanently' }, + { key: 'admin', label: 'Admin', description: 'Full administrative access' }, + { + key: 'moderate', + label: 'Moderate', + description: 'Review and approve content', + }, + { key: 'share', label: 'Share', description: 'Share content with others' }, +]; + +const languages = [ + { + key: 'javascript', + label: 'JavaScript', + description: 'Dynamic, interpreted programming language', + }, + { + key: 'python', + label: 'Python', + description: 'High-level, general-purpose programming language', + }, + { + key: 'typescript', + label: 'TypeScript', + description: 'Strongly typed programming language based on JavaScript', + }, + { + key: 'rust', + label: 'Rust', + description: + 'Systems programming language focused on safety and performance', + }, + { + key: 'go', + label: 'Go', + description: 'Open source programming language supported by Google', + }, + { + key: 'java', + label: 'Java', + description: 'Object-oriented programming language', + }, + { + key: 'csharp', + label: 'C#', + description: 'Modern object-oriented language', + }, + { key: 'php', label: 'PHP', description: 'Server-side scripting language' }, +]; + +const Template: StoryFn> = (args) => ( + + {fruits.slice(0, 6).map((fruit) => ( + {fruit.label} + ))} + +); + +export const Default: Story = { + render: Template, + args: { + label: 'Choose a fruit', + searchPlaceholder: 'Search fruits...', + }, +}; + +export const SingleSelection: Story = { + render: (args) => ( + + {fruits.map((fruit) => ( + {fruit.label} + ))} + + ), + args: { + label: 'Choose your favorite fruit', + selectionMode: 'single', + defaultSelectedKey: 'apple', + searchPlaceholder: 'Search fruits...', + }, +}; + +export const MultipleSelection: Story = { + render: (args) => ( + + {permissions.map((permission) => ( + + {permission.label} + + ))} + + ), + args: { + label: 'Select permissions', + selectionMode: 'multiple', + defaultSelectedKeys: ['read', 'write'], + searchPlaceholder: 'Filter permissions...', + }, +}; + +export const WithDescriptions: StoryFn> = ( + args, +) => ( + + {fruits.slice(0, 5).map((fruit) => ( + + {fruit.label} + + ))} + +); +WithDescriptions.args = { + label: 'Choose a fruit', + searchPlaceholder: 'Search fruits...', +}; + +export const WithSections: StoryFn> = (args) => ( + + + {fruits.slice(0, 3).map((fruit) => ( + {fruit.label} + ))} + + + {vegetables.slice(0, 3).map((vegetable) => ( + + {vegetable.label} + + ))} + + + {herbs.slice(0, 3).map((herb) => ( + {herb.label} + ))} + + +); +WithSections.args = { + label: 'Choose an ingredient', + searchPlaceholder: 'Search ingredients...', +}; + +export const WithSectionsAndDescriptions: StoryFn< + CubeFilterListBoxProps +> = (args) => ( + + + {fruits.slice(0, 3).map((fruit) => ( + + {fruit.label} + + ))} + + + {vegetables.slice(0, 3).map((vegetable) => ( + + {vegetable.label} + + ))} + + + {herbs.slice(0, 3).map((herb) => ( + + {herb.label} + + ))} + + +); +WithSectionsAndDescriptions.args = { + label: 'Choose an ingredient', + searchPlaceholder: 'Search ingredients...', +}; + +export const WithHeaderAndFooter: StoryFn> = ( + args, +) => ( + + + Programming Languages + 12 + + + + } + > + {languages.slice(0, 5).map((language) => ( + + {language.label} + + ))} + +); +WithHeaderAndFooter.args = { + label: 'Choose your preferred programming language', + searchPlaceholder: 'Search languages...', +}; + +export const CheckableMultipleSelection: Story = { + render: (args) => ( + + {permissions.map((permission) => ( + + {permission.label} + + ))} + + ), + args: { + label: 'Select user permissions', + selectionMode: 'multiple', + isCheckable: true, + defaultSelectedKeys: ['read', 'write'], + searchPlaceholder: 'Filter permissions...', + }, + parameters: { + docs: { + description: { + story: + 'When `isCheckable={true}` and `selectionMode="multiple"`, checkboxes appear on the left of each option. The checkbox is only visible when the item is hovered or selected.', + }, + }, + }, +}; + +export const AllowsCustomValue: Story = { + render: (args) => ( + + {fruits.slice(0, 5).map((fruit) => ( + {fruit.label} + ))} + + ), + args: { + label: 'Select or add fruits', + allowsCustomValue: true, + selectionMode: 'multiple', + searchPlaceholder: 'Search or type new fruit...', + }, + parameters: { + docs: { + description: { + story: + "When `allowsCustomValue={true}`, users can type custom values that aren't in the predefined list. Custom values appear at the bottom of search results.", + }, + }, + }, +}; + +export const DisabledItems: Story = { + render: (args) => ( + + + Available Option 1 + + Disabled Option 1 + + Available Option 2 + + Disabled Option 2 + + Available Option 3 + + + ), + args: { + label: 'Select an option', + selectionMode: 'single', + disabledKeys: ['disabled1', 'disabled2'], + searchPlaceholder: 'Search options...', + }, + parameters: { + docs: { + description: { + story: + 'Individual items can be disabled using the `disabledKeys` prop. Disabled items cannot be selected and are visually distinguished.', + }, + }, + }, +}; + +export const DisallowEmptySelection: Story = { + render: (args) => ( + + {fruits.slice(0, 4).map((fruit) => ( + {fruit.label} + ))} + + ), + args: { + label: 'Must select one option', + selectionMode: 'single', + disallowEmptySelection: true, + defaultSelectedKey: 'apple', + searchPlaceholder: 'Search fruits...', + }, + parameters: { + docs: { + description: { + story: + 'When `disallowEmptySelection={true}`, the user cannot deselect the last selected item, ensuring at least one item is always selected.', + }, + }, + }, +}; + +export const WithTextValue: Story = { + render: (args) => ( + + + + Basic Plan + Free + + + + + Pro Plan + $19/month + + + + + Enterprise Plan + Custom + + + + ), + args: { + label: 'Choose your plan', + selectionMode: 'single', + searchPlaceholder: 'Search plans...', + }, + parameters: { + docs: { + description: { + story: + 'Use the `textValue` prop when option content is complex (JSX) to provide searchable text that includes more context than just the visible label.', + }, + }, + }, +}; + +export const CustomFilter: StoryFn> = (args) => ( + { + // Custom filter: starts with search term (case insensitive) + return text.toLowerCase().startsWith(search.toLowerCase()); + }} + > + {languages.slice(0, 6).map((language) => ( + + {language.label} + + ))} + +); +CustomFilter.args = { + label: 'Programming Language', + searchPlaceholder: 'Type first letters...', + description: 'Custom filter that matches items starting with your input', +}; + +export const LoadingState: Story = { + render: (args) => ( + + Loading Item 1 + Loading Item 2 + Loading Item 3 + + ), + args: { + label: 'Choose an item', + isLoading: true, + searchPlaceholder: 'Loading data...', + }, + parameters: { + docs: { + description: { + story: + 'When `isLoading={true}`, a loading icon appears in the search input and the placeholder can indicate loading state.', + }, + }, + }, +}; + +export const CustomEmptyState: Story = { + render: (args) => ( + + 🔍 + No matching countries found + + Try searching for a different country name + + + } + > + United States + Canada + United Kingdom + Germany + France + + ), + args: { + label: 'Select country', + searchPlaceholder: 'Search countries...', + description: + "Try searching for something that doesn't exist to see the custom empty state", + }, + parameters: { + docs: { + description: { + story: + 'Customize the empty state message shown when search results are empty using the `emptyLabel` prop. Can be a string or custom JSX.', + }, + }, + }, +}; + +export const AutoFocus: Story = { + render: (args) => ( + + {fruits.slice(0, 5).map((fruit) => ( + {fruit.label} + ))} + + ), + args: { + label: 'Auto-focused search', + autoFocus: true, + searchPlaceholder: 'Start typing immediately...', + }, + parameters: { + docs: { + description: { + story: + 'Use `autoFocus={true}` to automatically focus the search input when the component mounts. Useful in dialogs and other focused contexts.', + }, + }, + }, +}; + +export const DisabledState: StoryFn> = (args) => ( + + Option 1 + Option 2 + Option 3 + +); +DisabledState.args = { + label: 'Disabled FilterListBox', + isDisabled: true, + searchPlaceholder: 'Cannot search...', +}; + +export const ValidationStates: StoryFn> = () => ( + + + Valid Option 1 + Valid Option 2 + + + + Invalid Option 1 + Invalid Option 2 + + +); + +export const ControlledExample: StoryFn> = () => { + const [selectedKey, setSelectedKey] = useState('apple'); + + return ( + + setSelectedKey(key as string | null)} + > + {fruits.slice(0, 6).map((fruit) => ( + {fruit.label} + ))} + + + + Selected: {selectedKey || 'None'} + + + + + + + + ); +}; + +export const MultipleControlledExample: StoryFn< + CubeFilterListBoxProps +> = () => { + const [selectedKeys, setSelectedKeys] = useState(['read', 'write']); + + return ( + + setSelectedKeys(keys as string[])} + > + {permissions.map((permission) => ( + + {permission.label} + + ))} + + + + Selected:{' '} + + {selectedKeys.length ? selectedKeys.join(', ') : 'None'} + + + + + + + + + ); +}; + +export const InForm: StoryFn = () => { + const [value, setValue] = useState(null); + + return ( +
{ + alert(`Form submitted with: ${JSON.stringify(data, null, 2)}`); + }} + > + setValue(key as string)} + > + + United States + Canada + Mexico + + + United Kingdom + Germany + France + + + + Submit +
+ ); +}; + +export const InDialog: StoryFn = () => { + const [isOpen, setIsOpen] = useState(false); + + return ( + + + + + + React + Vue.js + Angular + + + Express.js + Fastify + Koa.js + + + + + ); +}; + +export const AsyncLoading: StoryFn = () => { + const [isLoading, setIsLoading] = useState(false); + const [items, setItems] = useState([ + 'Apple', + 'Banana', + 'Cherry', + 'Date', + 'Elderberry', + ]); + + const refreshData = () => { + setIsLoading(true); + // Simulate API call + setTimeout(() => { + setItems([ + 'Avocado', + 'Blueberry', + 'Coconut', + 'Dragonfruit', + 'Fig', + 'Grape', + 'Honeydew', + 'Kiwi', + 'Lemon', + 'Mango', + ]); + setIsLoading(false); + }, 2000); + }; + + return ( + + + + + {items.map((item) => ( + + {item} + + ))} + + + ); +}; + +export const WithIcons: Story = { + render: (args) => ( + + + + + + Users + + + + + + Permissions + + + + + + + + Database + + + + + + Settings + + + + + ), + args: { + label: 'System Administration', + selectionMode: 'single', + searchPlaceholder: 'Search admin options...', + }, + parameters: { + docs: { + description: { + story: + 'FilterListBox options can include icons to improve visual clarity and help users quickly identify options during search.', + }, + }, + }, +}; + +export const WithCustomStyles: StoryFn = () => ( + + Purple Theme + Blue Theme + Green Theme + Red Theme + +); + +WithCustomStyles.play = async ({ canvasElement }) => { + const canvas = within(canvasElement); + + // Find the search input + const searchInput = canvas.getByPlaceholderText( + 'Search with custom styles...', + ); + + // Type a custom value + await userEvent.type(searchInput, 'Orange Theme'); + + // Wait a moment for the input to register + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Press Enter to add the custom value + await userEvent.keyboard('{Enter}'); +}; + +export const EscapeKeyHandling: StoryFn> = () => { + const [selectedKey, setSelectedKey] = useState('apple'); + const [escapeCount, setEscapeCount] = useState(0); + + return ( + + setSelectedKey(key as string | null)} + onEscape={() => { + setEscapeCount((prev) => prev + 1); + // Custom escape behavior - could close a parent modal, etc. + }} + > + {fruits.slice(0, 4).map((fruit) => ( + {fruit.label} + ))} + + + + Selected: {selectedKey || 'None'} + + + Escape key pressed: {escapeCount} times + + + Focus the search input and press Escape to trigger custom handling + + + ); +}; + +EscapeKeyHandling.parameters = { + docs: { + description: { + story: + 'Use the `onEscape` prop to provide custom behavior when the Escape key is pressed with empty search input, such as closing a parent modal.', + }, + }, +}; + +export const VirtualizedList: StoryFn> = (args) => { + const [selectedKeys, setSelectedKeys] = useState([]); + + // Generate a large list of items with varying content to test virtualization + // Mix items with and without descriptions to test dynamic sizing + const items = Array.from({ length: 100 }, (_, i) => ({ + id: `item-${i}`, + name: `Item ${i + 1}${i % 7 === 0 ? ' - This is a longer item name to test dynamic sizing' : ''}`, + description: + i % 3 === 0 + ? `This is a description for item ${i + 1}. It varies in length to test virtualization with dynamic item heights.` + : undefined, + })); + + return ( + + + Large list with {items.length} items with varying heights. + Virtualization is automatically enabled when there are no sections. + Scroll down and back up to test smooth virtualization. + + + setSelectedKeys(keys as string[])} + > + {items.map((item) => ( + + {item.name} + + ))} + + + + Selected:{' '} + + {selectedKeys.length} / {items.length} items + + {selectedKeys.length > 0 && + ` (${selectedKeys.slice(0, 3).join(', ')}${selectedKeys.length > 3 ? '...' : ''})`} + + + ); +}; + +VirtualizedList.parameters = { + docs: { + description: { + story: + 'When a FilterListBox contains many items and has no sections, virtualization is automatically enabled to improve performance. Only visible items are rendered in the DOM, providing smooth scrolling even with large datasets. This story includes items with varying heights to demonstrate stable virtualization without scroll jumping.', + }, + }, +}; diff --git a/src/components/fields/FilterListBox/FilterListBox.test.tsx b/src/components/fields/FilterListBox/FilterListBox.test.tsx new file mode 100644 index 00000000..861f1339 --- /dev/null +++ b/src/components/fields/FilterListBox/FilterListBox.test.tsx @@ -0,0 +1,1149 @@ +import { createRef } from 'react'; + +import { FilterListBox } from '../../../index'; +import { act, render, renderWithRoot, userEvent } from '../../../test'; + +jest.mock('../../../_internal/hooks/use-warn'); + +describe('', () => { + const basicItems = [ + Apple, + Banana, + Cherry, + Date, + Elderberry, + ]; + + const sectionsItems = [ + + Apple + Banana + , + + Carrot + Broccoli + , + ]; + + describe('Basic functionality', () => { + it('should render with search input and options', () => { + const { getByRole, getByPlaceholderText } = render( + + {basicItems} + , + ); + + expect(getByRole('listbox')).toBeInTheDocument(); + expect(getByPlaceholderText('Search fruits...')).toBeInTheDocument(); + expect(getByRole('option', { name: 'Apple' })).toBeInTheDocument(); + expect(getByRole('option', { name: 'Banana' })).toBeInTheDocument(); + }); + + it('should work in uncontrolled mode', async () => { + const onSelectionChange = jest.fn(); + + const { getByRole, getByText } = render( + + {basicItems} + , + ); + + const listbox = getByRole('listbox'); + expect(listbox).toBeInTheDocument(); + + // Select a different option + const bananaOption = getByText('Banana'); + await act(async () => { + await userEvent.click(bananaOption); + }); + + expect(onSelectionChange).toHaveBeenCalledWith('banana'); + }); + + it('should work in controlled mode', async () => { + const onSelectionChange = jest.fn(); + + const { getByText, rerender } = render( + + {basicItems} + , + ); + + // Select a different option + const bananaOption = getByText('Banana'); + await act(async () => { + await userEvent.click(bananaOption); + }); + + expect(onSelectionChange).toHaveBeenCalledWith('banana'); + + // Update to controlled selection + rerender( + + {basicItems} + , + ); + + // Check that the option is actually selected in the UI + // Note: aria-selected might be on a child element or handled differently + expect(onSelectionChange).toHaveBeenCalledWith('banana'); + }); + + it('should support multiple selection', async () => { + const onSelectionChange = jest.fn(); + + const { getByText } = render( + + {basicItems} + , + ); + + const appleOption = getByText('Apple'); + const bananaOption = getByText('Banana'); + + await act(async () => { + await userEvent.click(appleOption); + }); + + expect(onSelectionChange).toHaveBeenCalledWith(['apple']); + + await act(async () => { + await userEvent.click(bananaOption); + }); + + expect(onSelectionChange).toHaveBeenCalledWith(['apple', 'banana']); + }); + }); + + describe('Search functionality', () => { + it('should filter options based on search input', async () => { + const { getByPlaceholderText, getByText, queryByText } = render( + + {basicItems} + , + ); + + const searchInput = getByPlaceholderText('Search...'); + + // Initially all options should be visible + expect(getByText('Apple')).toBeInTheDocument(); + expect(getByText('Banana')).toBeInTheDocument(); + expect(getByText('Cherry')).toBeInTheDocument(); + + // Search for "app" + await act(async () => { + await userEvent.type(searchInput, 'app'); + }); + + // Only Apple should be visible + expect(getByText('Apple')).toBeInTheDocument(); + expect(queryByText('Banana')).not.toBeInTheDocument(); + expect(queryByText('Cherry')).not.toBeInTheDocument(); + }); + + it('should clear search on escape key', async () => { + const { getByPlaceholderText, getByText } = render( + + {basicItems} + , + ); + + const searchInput = getByPlaceholderText('Search...'); + + // Type something to filter + await act(async () => { + await userEvent.type(searchInput, 'app'); + }); + + expect(searchInput).toHaveValue('app'); + + // Press escape to clear + await act(async () => { + await userEvent.keyboard('{Escape}'); + }); + + expect(searchInput).toHaveValue(''); + expect(getByText('Banana')).toBeInTheDocument(); // Should be visible again + }); + + it('should show empty state when no results found', async () => { + const { getByPlaceholderText, getByText, queryByText } = render( + + {basicItems} + , + ); + + const searchInput = getByPlaceholderText('Search...'); + + // Search for something that doesn't exist + await act(async () => { + await userEvent.type(searchInput, 'xyz'); + }); + + expect(getByText('No results found')).toBeInTheDocument(); + expect(queryByText('Apple')).not.toBeInTheDocument(); + }); + + it('should show custom empty label when provided', async () => { + const { getByPlaceholderText, getByText } = render( + + {basicItems} + , + ); + + const searchInput = getByPlaceholderText('Search...'); + + await act(async () => { + await userEvent.type(searchInput, 'xyz'); + }); + + expect(getByText('No fruits match your search')).toBeInTheDocument(); + }); + + it('should reset selection when no search results are found', async () => { + const onSelectionChange = jest.fn(); + + const { getByPlaceholderText, getByText } = render( + + {basicItems} + , + ); + + const searchInput = getByPlaceholderText('Search...'); + + // First, select an item by typing a search term that matches + await act(async () => { + await userEvent.type(searchInput, 'app'); + }); + + // Navigate to the first item and select it + await act(async () => { + await userEvent.keyboard('{ArrowDown}'); + }); + + await act(async () => { + await userEvent.keyboard('{Enter}'); + }); + + expect(onSelectionChange).toHaveBeenCalledWith('apple'); + + // Clear the search input and type something that doesn't match + await act(async () => { + await userEvent.clear(searchInput); + }); + + await act(async () => { + await userEvent.type(searchInput, 'xyz'); + }); + + // Verify empty state is shown + expect(getByText('No results found')).toBeInTheDocument(); + + // Try to press Enter - should not trigger selection change since no items are focused + const selectionChangeCallCount = onSelectionChange.mock.calls.length; + + await act(async () => { + await userEvent.keyboard('{Enter}'); + }); + + // Selection should not have changed + expect(onSelectionChange).toHaveBeenCalledTimes(selectionChangeCallCount); + }); + + it('should support custom filter function', async () => { + const customFilter = jest.fn((text, search) => + text.toLowerCase().startsWith(search.toLowerCase()), + ); + + const { getByPlaceholderText, getByText, queryByText } = render( + + {basicItems} + , + ); + + const searchInput = getByPlaceholderText('Search...'); + + // Search for "a" - should only show Apple (starts with A) + await act(async () => { + await userEvent.type(searchInput, 'a'); + }); + + expect(getByText('Apple')).toBeInTheDocument(); + expect(queryByText('Banana')).not.toBeInTheDocument(); // Banana contains 'a' but doesn't start with it + expect(customFilter).toHaveBeenCalled(); + }); + + it('should filter sections and preserve section structure', async () => { + const { getByPlaceholderText, getByText, queryByText } = render( + + {sectionsItems} + , + ); + + const searchInput = getByPlaceholderText('Search...'); + + // Search for "app" - should show Apple under Fruits section + await act(async () => { + await userEvent.type(searchInput, 'app'); + }); + + expect(getByText('Fruits')).toBeInTheDocument(); // Section should be visible + expect(getByText('Apple')).toBeInTheDocument(); + expect(queryByText('Vegetables')).not.toBeInTheDocument(); // Empty section should be hidden + expect(queryByText('Banana')).not.toBeInTheDocument(); + }); + + it('should use textValue prop for complex content', async () => { + const { getByPlaceholderText, getByText, queryByText } = render( + + +
+ Basic Plan +
Free tier
+
+
+ +
+ Pro Plan +
Paid tier
+
+
+
, + ); + + const searchInput = getByPlaceholderText('Search...'); + + // Search for "free" - should match the textValue, not the JSX content + await act(async () => { + await userEvent.type(searchInput, 'free'); + }); + + expect(getByText('Basic Plan')).toBeInTheDocument(); + expect(queryByText('Pro Plan')).not.toBeInTheDocument(); + }); + }); + + describe('Loading state', () => { + it('should show loading icon when isLoading is true', () => { + const { container } = render( + + {basicItems} + , + ); + + expect( + container.querySelector('[data-element="InputIcon"]'), + ).toBeInTheDocument(); + }); + + it('should show search icon when not loading', () => { + const { container } = render( + {basicItems}, + ); + + expect( + container.querySelector('[data-element="InputIcon"]'), + ).toBeInTheDocument(); + }); + }); + + describe('Disabled state', () => { + it('should disable search input and prevent selection when disabled', async () => { + const onSelectionChange = jest.fn(); + + const { getByPlaceholderText, getByText } = render( + + {basicItems} + , + ); + + const searchInput = getByPlaceholderText('Search...'); + expect(searchInput).toBeDisabled(); + + const appleOption = getByText('Apple'); + await act(async () => { + await userEvent.click(appleOption); + }); + + expect(onSelectionChange).not.toHaveBeenCalled(); + }); + }); + + describe('Accessibility', () => { + it('should have proper ARIA attributes', () => { + const { getByRole, getByPlaceholderText } = render( + + {basicItems} + , + ); + + const listbox = getByRole('listbox'); + const searchInput = getByPlaceholderText('Search fruits...'); + + // Check that the search input has proper attributes + expect(searchInput).toHaveAttribute('type', 'search'); + expect(searchInput).toHaveAttribute('role', 'combobox'); + expect(searchInput).toHaveAttribute('aria-expanded', 'true'); + + // Check that listbox exists and is properly connected + expect(listbox).toBeInTheDocument(); + }); + + it('should support keyboard navigation from search to options', async () => { + const { getByPlaceholderText, getByText } = render( + + {basicItems} + , + ); + + const searchInput = getByPlaceholderText('Search...'); + + // Focus search input + searchInput.focus(); + expect(searchInput).toHaveFocus(); + + // Arrow down should move focus to first option + await act(async () => { + await userEvent.keyboard('{ArrowDown}'); + }); + + // Note: This tests the keyboard handler, actual focus behavior may depend on React Aria implementation + }); + + it('should support autofocus on search input', () => { + const { getByPlaceholderText } = render( + + {basicItems} + , + ); + + const searchInput = getByPlaceholderText('Search...'); + expect(searchInput).toHaveAttribute('data-autofocus', ''); + }); + }); + + describe('Form integration', () => { + it('should integrate with form field wrapper', () => { + const { getByPlaceholderText } = render( + + {basicItems} + , + ); + + const searchInput = getByPlaceholderText('Search fruits...'); + expect(searchInput).toBeInTheDocument(); + expect(searchInput).toHaveAttribute('type', 'search'); + }); + }); + + describe('Refs', () => { + it('should forward ref to wrapper element', () => { + const ref = createRef(); + + render( + + {basicItems} + , + ); + + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); + + it('should forward searchInputRef to search input', () => { + const searchInputRef = createRef(); + + render( + + {basicItems} + , + ); + + expect(searchInputRef.current).toBeInstanceOf(HTMLInputElement); + expect(searchInputRef.current).toHaveAttribute('type', 'search'); + }); + + it('should forward listRef to list element', () => { + const listRef = createRef(); + + render( + + {basicItems} + , + ); + + expect(listRef.current).toBeInstanceOf(HTMLElement); + expect(listRef.current).toHaveAttribute('role', 'listbox'); + }); + }); + + describe('Validation states', () => { + it('should apply validation styles', () => { + const { container, rerender } = render( + + {basicItems} + , + ); + + expect(container.firstChild).toHaveAttribute('data-is-valid'); + + rerender( + + {basicItems} + , + ); + + expect(container.firstChild).toHaveAttribute('data-is-invalid'); + }); + }); + + describe('Custom styling', () => { + it('should apply custom styles to search input', () => { + const { container } = render( + + {basicItems} + , + ); + + // Check that search input has custom styles applied + const searchInput = container.querySelector('input[type="search"]'); + expect(searchInput).toBeInTheDocument(); + }); + + it('should apply custom styles to options', () => { + const { container } = render( + + {basicItems} + , + ); + + const options = container.querySelectorAll('[role="option"]'); + expect(options.length).toBeGreaterThan(0); + }); + }); + + describe('Custom values (allowsCustomValue)', () => { + it('should not show custom option when allowsCustomValue is false', async () => { + const { getByPlaceholderText, queryByText } = render( + + {basicItems} + , + ); + + const searchInput = getByPlaceholderText('Search...'); + + // Search for a value that doesn't exist + await act(async () => { + await userEvent.type(searchInput, 'mango'); + }); + + // Should not show the custom option + expect(queryByText('mango')).not.toBeInTheDocument(); + }); + + it('should show custom option when allowsCustomValue is true and search term is unique', async () => { + const { getByPlaceholderText, getByText } = render( + + {basicItems} + , + ); + + const searchInput = getByPlaceholderText('Search...'); + + // Search for a value that doesn't exist + await act(async () => { + await userEvent.type(searchInput, 'mango'); + }); + + // Should show the custom option + expect(getByText('mango')).toBeInTheDocument(); + }); + + it('should not show custom option when search term matches existing option key', async () => { + const { getByPlaceholderText, getAllByText } = render( + + {basicItems} + , + ); + + const searchInput = getByPlaceholderText('Search...'); + + // Search for an existing key + await act(async () => { + await userEvent.type(searchInput, 'apple'); + }); + + // Should only show the original Apple option, not a duplicate custom one + const appleOptions = getAllByText('Apple'); + expect(appleOptions).toHaveLength(1); + }); + + it('should allow selecting custom value in single selection mode', async () => { + const onSelectionChange = jest.fn(); + + const { getByPlaceholderText, getByText } = render( + + {basicItems} + , + ); + + const searchInput = getByPlaceholderText('Search...'); + + // Search for a custom value + await act(async () => { + await userEvent.type(searchInput, 'mango'); + }); + + // Click on the custom option + const customOption = getByText('mango'); + await act(async () => { + await userEvent.click(customOption); + }); + + expect(onSelectionChange).toHaveBeenCalledWith('mango'); + }); + + it('should allow selecting custom values in multiple selection mode', async () => { + const onSelectionChange = jest.fn(); + + const { getByPlaceholderText, getByText } = render( + + {basicItems} + , + ); + + const searchInput = getByPlaceholderText('Search...'); + + // First select an existing option + const appleOption = getByText('Apple'); + await act(async () => { + await userEvent.click(appleOption); + }); + + expect(onSelectionChange).toHaveBeenCalledWith(['apple']); + + // Now search for and select a custom value + await act(async () => { + await userEvent.clear(searchInput); + await userEvent.type(searchInput, 'mango'); + }); + + const customOption = getByText('mango'); + await act(async () => { + await userEvent.click(customOption); + }); + + expect(onSelectionChange).toHaveBeenLastCalledWith(['apple', 'mango']); + }); + + it('should persist custom values after they are selected', async () => { + const onSelectionChange = jest.fn(); + + const { getByPlaceholderText, getByText } = render( + + {basicItems} + , + ); + + const searchInput = getByPlaceholderText('Search...'); + + // Search for and select a custom value + await act(async () => { + await userEvent.type(searchInput, 'mango'); + }); + + const customOption = getByText('mango'); + await act(async () => { + await userEvent.click(customOption); + }); + + // Clear search + await act(async () => { + await userEvent.clear(searchInput); + }); + + // The custom value should still be visible and selectable + expect(getByText('mango')).toBeInTheDocument(); + }); + + it('should remove custom values when they are deselected', async () => { + const onSelectionChange = jest.fn(); + + const { getByPlaceholderText, getByText, queryByText } = render( + + {basicItems} + , + ); + + const searchInput = getByPlaceholderText('Search...'); + + // Search for and select a custom value + await act(async () => { + await userEvent.type(searchInput, 'mango'); + }); + + const customOption = getByText('mango'); + await act(async () => { + await userEvent.click(customOption); + }); + + expect(onSelectionChange).toHaveBeenCalledWith(['mango']); + + // Deselect the custom value by clicking it again + await act(async () => { + await userEvent.click(customOption); + }); + + expect(onSelectionChange).toHaveBeenLastCalledWith([]); + + // Clear search to check if custom value is removed + await act(async () => { + await userEvent.clear(searchInput); + }); + + // The custom value should no longer be visible + expect(queryByText('mango')).not.toBeInTheDocument(); + }); + + it('should handle multiple custom values correctly', async () => { + const onSelectionChange = jest.fn(); + + const { getByPlaceholderText, getByText } = render( + + {basicItems} + , + ); + + const searchInput = getByPlaceholderText('Search...'); + + // Add first custom value + await act(async () => { + await userEvent.type(searchInput, 'mango'); + }); + + const mangoOption = getByText('mango'); + await act(async () => { + await userEvent.click(mangoOption); + }); + + // Add second custom value + await act(async () => { + await userEvent.clear(searchInput); + await userEvent.type(searchInput, 'kiwi'); + }); + + const kiwiOption = getByText('kiwi'); + await act(async () => { + await userEvent.click(kiwiOption); + }); + + expect(onSelectionChange).toHaveBeenLastCalledWith(['mango', 'kiwi']); + + // Clear search and verify both custom values are visible + await act(async () => { + await userEvent.clear(searchInput); + }); + + expect(getByText('mango')).toBeInTheDocument(); + expect(getByText('kiwi')).toBeInTheDocument(); + }); + + it('should not show custom option when search is empty', async () => { + const { getByPlaceholderText, queryByText } = render( + + {basicItems} + , + ); + + const searchInput = getByPlaceholderText('Search...'); + + // Initially no custom option should be shown + expect(queryByText('custom')).not.toBeInTheDocument(); + + // Type something then clear it + await act(async () => { + await userEvent.type(searchInput, 'custom'); + }); + + expect(queryByText('custom')).toBeInTheDocument(); + + await act(async () => { + await userEvent.clear(searchInput); + }); + + // Custom option should disappear when search is cleared + expect(queryByText('custom')).not.toBeInTheDocument(); + }); + + it('should show custom option at the end of filtered results', async () => { + const { getByPlaceholderText, getAllByRole } = render( + + {basicItems} + , + ); + + const searchInput = getByPlaceholderText('Search...'); + + // Search for 'xyz' which should not match any existing options but show custom option + await act(async () => { + await userEvent.type(searchInput, 'xyz'); + }); + + const options = getAllByRole('option'); + + // Should only have the custom 'xyz' option + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('xyz'); + }); + + it('should work with keyboard navigation to select custom values', async () => { + const onSelectionChange = jest.fn(); + + const { getByPlaceholderText } = render( + + {basicItems} + , + ); + + const searchInput = getByPlaceholderText('Search...'); + + // Search for a custom value + await act(async () => { + await userEvent.type(searchInput, 'mango'); + }); + + // Use arrow key to navigate to the custom option and select with Enter + await act(async () => { + await userEvent.keyboard('{ArrowDown}'); + await userEvent.keyboard('{Enter}'); + }); + + expect(onSelectionChange).toHaveBeenCalledWith('mango'); + }); + }); + + describe('Virtualization', () => { + beforeEach(() => { + // Mock scroll container dimensions for React Virtual to work + Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { + get() { + return this.tagName === 'UL' ? 300 : 50; // Listbox container: 300px, items: 50px + }, + configurable: true, + }); + + Object.defineProperty(HTMLElement.prototype, 'clientHeight', { + get() { + return this.tagName === 'UL' ? 300 : 50; + }, + configurable: true, + }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + // Create 60 items to trigger virtualization (threshold is 30) + const createManyItems = () => { + const items: JSX.Element[] = []; + + // First 10 items without description + for (let i = 1; i <= 10; i++) { + items.push( + {`Item ${i}`}, + ); + } + + // Next 50 items with descriptions for some of them + for (let i = 11; i <= 60; i++) { + const hasDescription = i % 5 === 0; // Every 5th item has description + items.push( + {`Item ${i}`}, + ); + } + + return items; + }; + + it('should activate virtualization with 60 items and handle filtering correctly', async () => { + const items = createManyItems(); + + const { container } = renderWithRoot( + + {items} + , + ); + + // Wait for component to render + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + // Check that virtualization is active by looking for a virtual container with height + const virtualContainer = container.querySelector( + '[style*="height:"][style*="px"]', + ); + expect(virtualContainer).toBeInTheDocument(); + + // Verify that some options are rendered (virtualization shows only visible items) + const visibleOptions = container.querySelectorAll('[role="option"]'); + expect(visibleOptions.length).toBeGreaterThan(0); + // In test environment with mocks, all items might be rendered, but in real virtualization fewer would be shown + expect(visibleOptions.length).toBeLessThanOrEqual(60); + + // Check for search input + const searchInput = container.querySelector('input[type="search"]'); + expect(searchInput).toBeInTheDocument(); + + if (searchInput) { + // Filter to show only items containing "1" + await act(async () => { + await userEvent.type(searchInput, '1'); + }); + + // After filtering, should still have some visible options + const filteredOptions = container.querySelectorAll('[role="option"]'); + expect(filteredOptions.length).toBeGreaterThan(0); + + // Clear search to show all items again + await act(async () => { + await userEvent.clear(searchInput); + }); + + // Should have visible items again + const allOptions = container.querySelectorAll('[role="option"]'); + expect(allOptions.length).toBeGreaterThan(0); + } + }, 30000); // 30 second timeout + + it('should handle item sizing correctly for items with different content', async () => { + const items = createManyItems(); + + const { container } = renderWithRoot( + + {items} + , + ); + + // Wait for component to render + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + // Check that virtualization is active + const virtualContainer = container.querySelector( + '[style*="height:"][style*="px"]', + ) as HTMLElement; + expect(virtualContainer).toBeInTheDocument(); + + // Verify that the virtual container has some reasonable height (should be substantial for 60 items) + const totalHeight = parseInt(virtualContainer?.style.height || '0'); + // In test environment, height might be 0 or mocked, but in real virtualization it would be substantial + expect(totalHeight).toBeGreaterThanOrEqual(0); + + // Verify some items are rendered + const visibleOptions = container.querySelectorAll('[role="option"]'); + expect(visibleOptions.length).toBeGreaterThan(0); + + // Check that items are properly positioned in virtual containers + const firstVisibleOption = visibleOptions[0]; + if (firstVisibleOption) { + const itemContainer = firstVisibleOption.closest( + '[style*="position: absolute"]', + ); + expect(itemContainer).toBeInTheDocument(); + } + }, 15000); // 15 second timeout + + it('should maintain correct content structure after filtering', async () => { + const items = createManyItems(); + + const { container } = renderWithRoot( + + {items} + , + ); + + // Wait for component to render + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + const searchInput = container.querySelector('input[type="search"]'); + expect(searchInput).toBeInTheDocument(); + + if (searchInput) { + // Filter to show items with "15" (which has a description) + await act(async () => { + await userEvent.type(searchInput, '15'); + }); + + // Should have some filtered options + const filteredOptions = container.querySelectorAll('[role="option"]'); + expect(filteredOptions.length).toBeGreaterThan(0); + + // Filter to show items with "1" (includes items 1, 10, 11-19, etc.) + await act(async () => { + await userEvent.clear(searchInput); + await userEvent.type(searchInput, '1'); + }); + + // Should have some filtered options + const moreFilteredOptions = + container.querySelectorAll('[role="option"]'); + expect(moreFilteredOptions.length).toBeGreaterThan(0); + } + }, 15000); // 15 second timeout + + it('should handle selection in virtualized list', async () => { + const items = createManyItems(); + const onSelectionChange = jest.fn(); + + const { container } = renderWithRoot( + + {items} + , + ); + + // Wait for component to render + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + // Find any visible item in the virtualized list + const visibleOptions = container.querySelectorAll('[role="option"]'); + expect(visibleOptions.length).toBeGreaterThan(0); + + const firstVisibleOption = visibleOptions[0] as HTMLElement; + + await act(async () => { + await userEvent.click(firstVisibleOption); + }); + + // Verify that selection change was called (we can't predict the exact key due to virtualization) + expect(onSelectionChange).toHaveBeenCalled(); + }, 10000); // 10 second timeout + }); +}); diff --git a/src/components/fields/FilterListBox/FilterListBox.tsx b/src/components/fields/FilterListBox/FilterListBox.tsx new file mode 100644 index 00000000..f99c1817 --- /dev/null +++ b/src/components/fields/FilterListBox/FilterListBox.tsx @@ -0,0 +1,893 @@ +import { Key } from '@react-types/shared'; +import React, { + ForwardedRef, + forwardRef, + ReactElement, + ReactNode, + RefObject, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { useFilter, useKeyboard } from 'react-aria'; +import { Section as BaseSection, Item } from 'react-stately'; + +import { LoadingIcon, SearchIcon } from '../../../icons'; +import { useProviderProps } from '../../../provider'; +import { + BASE_STYLES, + COLOR_STYLES, + extractStyles, + OUTER_STYLES, + Styles, + tasty, +} from '../../../tasty'; +import { mergeProps, modAttrs, useCombinedRefs } from '../../../utils/react'; +import { useFocus } from '../../../utils/react/interactions'; +import { StyledHeader } from '../../actions/Menu/styled'; +import { Block } from '../../Block'; +import { useFieldProps, useFormProps, wrapWithField } from '../../form'; +import { CubeListBoxProps, ListBox } from '../ListBox/ListBox'; +import { + DEFAULT_INPUT_STYLES, + INPUT_WRAPPER_STYLES, +} from '../TextInput/TextInputBase'; + +import type { FieldBaseProps } from '../../../shared'; + +type FilterFn = (textValue: string, inputValue: string) => boolean; + +const FilterListBoxWrapperElement = tasty({ + styles: { + display: 'grid', + flow: 'column', + gridColumns: '1sf', + gridRows: 'max-content max-content 1sf', + gap: 0, + position: 'relative', + radius: true, + color: '#dark-02', + transition: 'theme', + outline: { + '': '#purple-03.0', + 'invalid & focused': '#danger.50', + focused: '#purple-03', + }, + height: { + '': false, + popover: 'initial max-content (50vh - 4x)', + }, + border: { + '': true, + focused: '#purple-text', + valid: '#success-text.50', + invalid: '#danger-text.50', + disabled: true, + popover: false, + }, + }, +}); + +const SearchWrapperElement = tasty({ + styles: { + ...INPUT_WRAPPER_STYLES, + border: 'bottom', + radius: '1r top', + fill: '#clear', + }, +}); + +const SearchInputElement = tasty({ + as: 'input', + styles: { + ...DEFAULT_INPUT_STYLES, + fill: '#clear', + }, +}); + +const StyledHeaderWithoutBorder = tasty(StyledHeader, { + styles: { + border: false, + }, +}); + +export interface CubeFilterListBoxProps + extends Omit, 'children'>, + FieldBaseProps { + /** Placeholder text for the search input */ + searchPlaceholder?: string; + /** Whether the search input should have autofocus */ + autoFocus?: boolean; + /** The filter function used to determine if an option should be included in the filtered list */ + filter?: FilterFn; + /** Custom label to display when no results are found after filtering */ + emptyLabel?: ReactNode; + /** Custom styles for the search input */ + searchInputStyles?: Styles; + /** Whether the FilterListBox as a whole is loading (generic loading indicator) */ + isLoading?: boolean; + /** Ref for the search input */ + searchInputRef?: RefObject; + /** Children (ListBox.Item and ListBox.Section elements) */ + children?: ReactNode; + /** Allow entering a custom value that is not present in the options */ + allowsCustomValue?: boolean; + /** Mods for the FilterListBox */ + mods?: Record; + + /** + * Optional callback fired when the user presses `Escape` while the search input is empty. + * Can be used by parent components (e.g. FilterPicker) to close an enclosing Dialog. + */ + onEscape?: () => void; + + /** + * Whether the options in the FilterListBox are checkable. + * This adds a checkbox icon to the left of the option. + */ + isCheckable?: boolean; + + /** + * Callback fired when an option is clicked but not on the checkbox area. + * Used by FilterPicker to close the popover on non-checkbox clicks. + */ + onOptionClick?: (key: Key) => void; +} + +const PROP_STYLES = [...BASE_STYLES, ...OUTER_STYLES, ...COLOR_STYLES]; + +export const FilterListBox = forwardRef(function FilterListBox< + T extends object, +>(props: CubeFilterListBoxProps, ref: ForwardedRef) { + props = useProviderProps(props); + props = useFormProps(props); + props = useFieldProps(props, { + valuePropsMapper: ({ value, onChange }) => { + const fieldProps: any = {}; + + if (props.selectionMode === 'multiple') { + fieldProps.selectedKeys = value || []; + } else { + fieldProps.selectedKey = value ?? null; + } + + fieldProps.onSelectionChange = (key: any) => { + if (props.selectionMode === 'multiple') { + onChange(key ? (Array.isArray(key) ? key : [key]) : []); + } else { + onChange(Array.isArray(key) ? key[0] : key); + } + }; + + return fieldProps; + }, + }); + + let { + qa, + label, + extra, + labelStyles, + isRequired, + necessityIndicator, + validationState, + isDisabled, + isLoading, + searchPlaceholder = 'Search...', + autoFocus, + filter, + mods: externalMods, + emptyLabel, + searchInputStyles, + listStyles, + optionStyles, + sectionStyles, + headingStyles, + searchInputRef, + listRef, + message, + description, + styles, + focusOnHover, + labelSuffix, + selectedKey, + defaultSelectedKey, + selectedKeys, + defaultSelectedKeys, + onSelectionChange: externalOnSelectionChange, + allowsCustomValue = false, + header, + footer, + size = 'small', + headerStyles, + footerStyles, + listBoxStyles, + children, + onEscape, + isCheckable, + onOptionClick, + selectionMode = 'single', + ...otherProps + } = props; + + // Collect original option keys to avoid duplicating them as custom values. + const originalKeys = useMemo(() => { + const keys = new Set(); + + const collectKeys = (nodes: ReactNode): void => { + React.Children.forEach(nodes, (child: any) => { + if (!child || typeof child !== 'object') return; + + if (child.type === Item) { + if (child.key != null) keys.add(String(child.key)); + } + + if (child.props?.children) { + collectKeys(child.props.children); + } + }); + }; + + if (children) collectKeys(children); + return keys; + }, [children]); + + // State to keep track of custom (user-entered) items that were selected. + const [customKeys, setCustomKeys] = useState>(new Set()); + + // Initialize custom keys from current selection + React.useEffect(() => { + if (!allowsCustomValue) return; + + const currentSelectedKeys = selectedKeys + ? Array.from(selectedKeys).map(String) + : selectedKey != null + ? [String(selectedKey)] + : []; + + if (currentSelectedKeys.length === 0) return; + + const keysToAdd = currentSelectedKeys.filter((k) => !originalKeys.has(k)); + + if (keysToAdd.length) { + setCustomKeys((prev) => new Set([...Array.from(prev), ...keysToAdd])); + } + }, [allowsCustomValue, selectedKeys, selectedKey, originalKeys]); + + // Merge original children with any custom items so they persist in the list. + // If there are selected custom values, they should appear on top with other + // selected items (which are already sorted by the parent component, e.g. FilterPicker). + const mergedChildren: ReactNode = useMemo(() => { + if (!children && customKeys.size === 0) return children; + + // Build React elements for custom values (kept stable via their key). + const customArray = Array.from(customKeys).map((key) => ( + + {key} + + )); + + // Identify which custom keys are currently selected so we can promote them. + const selectedKeysSet = new Set(); + + if (selectionMode === 'multiple') { + Array.from(selectedKeys ?? []).forEach((k) => + selectedKeysSet.add(String(k)), + ); + } else { + if (selectedKey != null) selectedKeysSet.add(String(selectedKey)); + } + + const selectedCustom: ReactNode[] = []; + const unselectedCustom: ReactNode[] = []; + + customArray.forEach((item: any) => { + if (selectedKeysSet.has(String(item.key))) { + selectedCustom.push(item); + } else { + unselectedCustom.push(item); + } + }); + + if (!children) { + // No original items – just return selected custom followed by the rest. + return [...selectedCustom, ...unselectedCustom]; + } + + const originalArray = Array.isArray(children) ? children : [children]; + + // Final order: selected custom items -> original array (already possibly + // sorted by parent) -> unselected custom items. + return [...selectedCustom, ...originalArray, ...unselectedCustom]; + }, [children, customKeys, selectionMode, selectedKey, selectedKeys]); + + // Determine an aria-label for the internal ListBox to avoid React Aria warnings. + const innerAriaLabel = + (props as any)['aria-label'] || + (typeof label === 'string' ? label : undefined); + + const [searchValue, setSearchValue] = useState(''); + const { contains } = useFilter({ sensitivity: 'base' }); + + // Choose the text filter function: user-provided `filter` prop (if any) + // or the default `contains` helper from `useFilter`. + const textFilterFn = useMemo( + () => filter || contains, + [filter, contains], + ); + + // Filter children based on search value + const filteredChildren = useMemo(() => { + const term = searchValue.trim(); + + if (!term || !mergedChildren) { + return mergedChildren; + } + + // Helper to check if a node matches the search term + const nodeMatches = (node: any): boolean => { + if (!node?.props) return false; + + // Get text content from the node + const textValue = + node.props.textValue || + (typeof node.props.children === 'string' ? node.props.children : '') || + String(node.props.children || ''); + + return textFilterFn(textValue, term); + }; + + // Helper to filter React children recursively + const filterChildren = (childNodes: ReactNode): ReactNode => { + if (!childNodes) return null; + + const childArray = Array.isArray(childNodes) ? childNodes : [childNodes]; + const filteredNodes: ReactNode[] = []; + + childArray.forEach((child: any) => { + if (!child || typeof child !== 'object') { + return; + } + + // Handle ListBox.Section + if ( + child.type === BaseSection || + child.type?.displayName === 'Section' + ) { + const sectionChildren = Array.isArray(child.props.children) + ? child.props.children + : [child.props.children]; + + const filteredSectionChildren = sectionChildren.filter( + (sectionChild: any) => { + return ( + sectionChild && + typeof sectionChild === 'object' && + nodeMatches(sectionChild) + ); + }, + ); + + if (filteredSectionChildren.length > 0) { + // Store filtered children in a way that doesn't require cloning the section + const filteredSection = { + ...child, + filteredChildren: filteredSectionChildren, + }; + filteredNodes.push(filteredSection); + } + } + // Handle ListBox.Item + else if (child.type === Item) { + if (nodeMatches(child)) { + filteredNodes.push(child); + } + } + // Handle other components + else if (nodeMatches(child)) { + filteredNodes.push(child); + } + }); + + return filteredNodes; + }; + + return filterChildren(mergedChildren); + }, [mergedChildren, searchValue, textFilterFn]); + + // Handle custom values if allowed + const enhancedChildren = useMemo(() => { + let childrenToProcess = filteredChildren; + + // Handle custom values if allowed + if (!allowsCustomValue) return childrenToProcess; + + const term = searchValue.trim(); + if (!term) return childrenToProcess; + + // Helper to determine if the term is already present (exact match on rendered textValue or the key). + const doesTermExist = (nodes: ReactNode): boolean => { + let exists = false; + + const checkNodes = (childNodes: ReactNode): void => { + React.Children.forEach(childNodes, (child: any) => { + if (!child || typeof child !== 'object') return; + + // Check items directly + if (child.type === Item) { + const childText = + child.props.textValue || + (typeof child.props.children === 'string' + ? child.props.children + : '') || + String(child.props.children ?? ''); + + if (term === childText || String(child.key) === term) { + exists = true; + } + } + + // Recurse into sections or other wrappers + if (child.props?.children) { + checkNodes(child.props.children); + } + }); + }; + + checkNodes(nodes); + return exists; + }; + + if (doesTermExist(mergedChildren)) { + return childrenToProcess; + } + + // Append the custom option at the end. + const customOption = ( + + {term} + + ); + + if (Array.isArray(childrenToProcess)) { + return [...childrenToProcess, customOption]; + } + + if (childrenToProcess) { + return [childrenToProcess, customOption]; + } + + return customOption; + }, [allowsCustomValue, filteredChildren, mergedChildren, searchValue]); + + // Convert custom objects back to React elements for rendering + const finalChildren = useMemo(() => { + if (!enhancedChildren) return enhancedChildren; + + const convertToReactElements = (nodes: ReactNode): ReactNode => { + if (!nodes) return nodes; + + const nodeArray = Array.isArray(nodes) ? nodes : [nodes]; + + return nodeArray.map((node: any, index) => { + if (!node || typeof node !== 'object') { + return node; + } + + // Handle our custom filtered section objects + if (node.filteredChildren) { + const childrenToUse = node.filteredChildren; + // Return the original section but with the processed children + return React.cloneElement(node, { + key: node.key || index, + children: childrenToUse, + }); + } + + // Handle regular React elements + return node; + }); + }; + + return convertToReactElements(enhancedChildren); + }, [enhancedChildren]); + + styles = extractStyles(otherProps, PROP_STYLES, styles); + + ref = useCombinedRefs(ref); + searchInputRef = useCombinedRefs(searchInputRef); + listRef = useCombinedRefs(listRef); + + const { isFocused, focusProps } = useFocus({ isDisabled }); + const isInvalid = validationState === 'invalid'; + + const listBoxRef = useRef(null); + + // Ref to access internal ListBox state (selection manager, etc.) + const listStateRef = useRef(null); + + // No separate focusedKey state needed; rely directly on selectionManager.focusedKey. + + // When the search value changes, the visible collection of items may change as well. + // If the currently focused item is no longer visible, move virtual focus to the first + // available item so that arrow navigation and Enter behaviour continue to work. + // If there are no available items, reset the selection so Enter won't select anything. + useLayoutEffect(() => { + const listState = listStateRef.current; + + if (!listState) return; + + const { selectionManager, collection } = listState; + + // Helper to collect visible item keys (supports sections) + const collectVisibleKeys = (nodes: Iterable, out: Key[]) => { + const term = searchValue.trim(); + for (const node of nodes) { + if (node.type === 'item') { + const text = node.textValue ?? String(node.rendered ?? ''); + if (!term || textFilterFn(text, term)) { + out.push(node.key); + } + } else if (node.childNodes) { + collectVisibleKeys(node.childNodes, out); + } + } + }; + + const visibleKeys: Key[] = []; + collectVisibleKeys(collection, visibleKeys); + + // If there are no visible items, reset the focused key so Enter won't select anything + if (visibleKeys.length === 0) { + selectionManager.setFocusedKey(null); + return; + } + + // Early exit if the current focused key is still present in the visible items. + const currentFocused = selectionManager.focusedKey; + if (currentFocused != null && visibleKeys.includes(currentFocused)) { + return; + } + + // Set focus to the first visible item + selectionManager.setFocusedKey(visibleKeys[0]); + }, [searchValue, enhancedChildren, textFilterFn]); + + // Keyboard navigation handler for search input + const { keyboardProps } = useKeyboard({ + onKeyDown: (e) => { + if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { + e.preventDefault(); + + const listState = listStateRef.current; + if (!listState) return; + + const { selectionManager, collection } = listState; + + // Helper to collect visible item keys (supports sections) + const collectVisibleKeys = (nodes: Iterable, out: Key[]) => { + const term = searchValue.trim(); + for (const node of nodes) { + if (node.type === 'item') { + const text = node.textValue ?? String(node.rendered ?? ''); + if (!term || textFilterFn(text, term)) { + out.push(node.key); + } + } else if (node.childNodes) { + collectVisibleKeys(node.childNodes, out); + } + } + }; + + const visibleKeys: Key[] = []; + collectVisibleKeys(collection, visibleKeys); + + if (visibleKeys.length === 0) return; + + const isArrowDown = e.key === 'ArrowDown'; + const direction = isArrowDown ? 1 : -1; + + const currentKey = selectionManager.focusedKey; + + let nextKey: Key | null = null; + + if (currentKey == null) { + // If nothing focused yet, pick first/last depending on direction + nextKey = isArrowDown + ? visibleKeys[0] + : visibleKeys[visibleKeys.length - 1]; + } else { + const currentIndex = visibleKeys.indexOf(currentKey); + if (currentIndex !== -1) { + const newIndex = currentIndex + direction; + if (newIndex >= 0 && newIndex < visibleKeys.length) { + nextKey = visibleKeys[newIndex]; + } else { + // Wrap around + nextKey = isArrowDown + ? visibleKeys[0] + : visibleKeys[visibleKeys.length - 1]; + } + } else { + // Fallback + nextKey = isArrowDown + ? visibleKeys[0] + : visibleKeys[visibleKeys.length - 1]; + } + } + + if (nextKey != null) { + // Mark this focus change as keyboard navigation + if (listState.lastFocusSourceRef) { + listState.lastFocusSourceRef.current = 'keyboard'; + } + selectionManager.setFocusedKey(nextKey); + } + } else if ( + e.key === 'Home' || + e.key === 'End' || + e.key === 'PageUp' || + e.key === 'PageDown' + ) { + e.preventDefault(); + + const listState = listStateRef.current; + if (!listState) return; + + const { selectionManager, collection } = listState; + + // Helper to collect visible item keys (supports sections) + const collectVisibleKeys = (nodes: Iterable, out: Key[]) => { + const term = searchValue.trim(); + for (const node of nodes) { + if (node.type === 'item') { + const text = node.textValue ?? String(node.rendered ?? ''); + if (!term || textFilterFn(text, term)) { + out.push(node.key); + } + } else if (node.childNodes) { + collectVisibleKeys(node.childNodes, out); + } + } + }; + + const visibleKeys: Key[] = []; + collectVisibleKeys(collection, visibleKeys); + + if (visibleKeys.length === 0) return; + + const targetKey = + e.key === 'Home' || e.key === 'PageUp' + ? visibleKeys[0] + : visibleKeys[visibleKeys.length - 1]; + + // Mark this focus change as keyboard navigation + if (listState.lastFocusSourceRef) { + listState.lastFocusSourceRef.current = 'keyboard'; + } + selectionManager.setFocusedKey(targetKey); + } else if (e.key === 'Enter' || (e.key === ' ' && !searchValue)) { + const listState = listStateRef.current; + + if (!listState) return; + + const keyToSelect = listState.selectionManager.focusedKey; + + if (keyToSelect != null) { + e.preventDefault(); + listState.selectionManager.select(keyToSelect, e); + + if ( + e.key === 'Enter' && + isCheckable && + onEscape && + selectionMode === 'multiple' + ) { + onEscape(); + } + } + } else if (e.key === 'Escape') { + if (searchValue) { + // Clear the current search if any text is present. + e.preventDefault(); + setSearchValue(''); + } else { + // Notify parent that Escape was pressed on an empty input. + if (onEscape) { + e.preventDefault(); + onEscape(); + } + } + } + }, + }); + + const mods = useMemo( + () => ({ + invalid: isInvalid, + valid: validationState === 'valid', + disabled: !!isDisabled, + focused: isFocused, + loading: !!isLoading, + searchable: true, + ...externalMods, + }), + [ + isInvalid, + validationState, + isDisabled, + isFocused, + isLoading, + externalMods, + ], + ); + + const hasResults = + enhancedChildren && + (Array.isArray(enhancedChildren) + ? enhancedChildren.length > 0 + : enhancedChildren !== null); + + const showEmptyMessage = !hasResults && searchValue.trim(); + + // Handler must be defined before we render ListBox so we can pass it. + const handleSelectionChange = (selection: any) => { + if (allowsCustomValue) { + // Normalize current selection into an array of string keys + let selectedValues: string[] = []; + + if (selection != null) { + if (Array.isArray(selection)) { + selectedValues = selection.map(String); + } else { + selectedValues = [String(selection)]; + } + } + + // Build next custom keys set based on selected values + const nextSet = new Set(); + + selectedValues.forEach((val) => { + if (!originalKeys.has(val)) { + nextSet.add(val); + } + }); + + // Update internal custom keys state + setCustomKeys(nextSet); + } + + if (externalOnSelectionChange) { + (externalOnSelectionChange as any)(selection); + } + }; + + // Custom option click handler that ensures search input receives focus + const handleOptionClick = (key: Key) => { + // Focus the search input to enable keyboard navigation + // Use setTimeout to ensure this happens after React state updates + setTimeout(() => { + if (searchInputRef.current) { + searchInputRef.current.focus(); + } + }, 0); + + // Call the original onOptionClick if provided + if (onOptionClick) { + onOptionClick(key); + } + }; + + const searchInput = ( + + { + const value = e.target.value; + setSearchValue(value); + }} + {...keyboardProps} + {...modAttrs(mods)} + /> +
+
+ {isLoading ? : } +
+
+
+ ); + + const filterListBoxField = ( + + {header ? ( + + {header} + + ) : ( +
+ )} + {searchInput} + {showEmptyMessage ? ( +
+ + {emptyLabel !== undefined ? emptyLabel : 'No results found'} + +
+ ) : ( + + {finalChildren as any} + + )} + + ); + + return wrapWithField, 'children'>>( + filterListBoxField, + ref, + mergeProps({ ...props, styles: undefined }, {}), + ); +}) as unknown as (( + props: CubeFilterListBoxProps & { ref?: ForwardedRef }, +) => ReactElement) & { Item: typeof Item; Section: typeof BaseSection }; + +FilterListBox.Item = ListBox.Item; + +FilterListBox.Section = BaseSection; + +Object.defineProperty(FilterListBox, 'cubeInputType', { + value: 'FilterListBox', + enumerable: false, + configurable: false, +}); diff --git a/src/components/fields/FilterListBox/index.ts b/src/components/fields/FilterListBox/index.ts new file mode 100644 index 00000000..98055de5 --- /dev/null +++ b/src/components/fields/FilterListBox/index.ts @@ -0,0 +1 @@ +export * from './FilterListBox'; diff --git a/src/components/fields/FilterPicker/FilterPicker.docs.mdx b/src/components/fields/FilterPicker/FilterPicker.docs.mdx new file mode 100644 index 00000000..0f047c29 --- /dev/null +++ b/src/components/fields/FilterPicker/FilterPicker.docs.mdx @@ -0,0 +1,481 @@ +import { Meta, Canvas, Story, Controls } from '@storybook/blocks'; +import { FilterPicker } from './FilterPicker'; +import * as FilterPickerStories from './FilterPicker.stories'; + + + +# FilterPicker + +A versatile selection component that combines a trigger button with a searchable dropdown. It provides a space-efficient interface for selecting one or multiple items from a filtered list, with support for sections, custom summaries, and various UI states. Built with React Aria's accessibility features and the Cube `tasty` style system. + +## When to Use + +- Creating filter interfaces where users need to select from predefined options +- Building advanced search and filtering systems with multiple criteria +- Implementing tag-based selection systems with multiple categories +- Designing compact selection interfaces where space is limited +- Providing searchable selection without taking up permanent screen space +- Building user preference panels with organized option groups + +## Component + + + +--- + +### Properties + + + +### Base Properties + +Supports [Base properties](/docs/tasty-base-properties--docs) + +### Styling Properties + +#### styles + +Customizes the trigger button element. + +**Sub-elements:** +- None - styles apply directly to the trigger button + +#### listBoxStyles + +Customizes the dropdown list container within the popover. + +**Sub-elements:** +- Same as FilterListBox: `Label`, `Description`, `Content`, `Checkbox`, `CheckboxWrapper` + +#### popoverStyles + +Customizes the popover dialog that contains the FilterListBox. + +#### headerStyles + +Customizes the header area when header prop is provided. + +#### footerStyles + +Customizes the footer area when footer prop is provided. + +### Style Properties + +These properties allow direct style application without using the `styles` prop: `width`, `height`, `margin`, `padding`, `position`, `inset`, `zIndex`, `gridArea`, `order`, `gridColumn`, `gridRow`, `placeSelf`, `alignSelf`, `justifySelf`, `opacity`, `color`, `fill`, `fade`. + +### Modifiers + +The `mods` property accepts the following modifiers you can override: + +| Modifier | Type | Description | +|----------|------|-------------| +| `placeholder` | `boolean` | Applied when no selection is made | +| `selected` | `boolean` | Applied when items are selected | + +## Variants + +### Selection Modes + +- `single` - Allows selecting only one item at a time +- `multiple` - Allows selecting multiple items + +### Button Types + +- `outline` - Default outlined button style +- `clear` - Transparent background button +- `primary` - Primary brand color button +- `secondary` - Secondary color button +- `neutral` - Neutral color button + +### Sizes + +- `small` - Compact size for dense interfaces +- `medium` - Standard size for general use +- `large` - Emphasized size for important actions + +## Examples + +### Basic Usage + + + +```jsx + + Apple + Banana + Cherry + +``` + +### Single Selection + + + +```jsx + + Apple + Banana + +``` + +### Multiple Selection + + + +```jsx + + + Apple + Banana + + + Carrot + Broccoli + + +``` + +### With Checkboxes + + + +```jsx + + Option 1 + Option 2 + +``` + +### Custom Summary + + + +```jsx + { + if (selectedKeys.length === 0) return null; + if (selectedKeys.length === 1) return `${selectedLabels[0]} selected`; + return `${selectedKeys.length} items selected`; + }} +> + Item 1 + Item 2 + +``` + +### No Summary (Icon Only) + + + +```jsx +} + aria-label="Apply filters" +> + Filter 1 + Filter 2 + +``` + +### With Header and Footer + + + +```jsx + + Languages + 12 + + } + footer={ + + Popular languages shown + + } +> + + JavaScript + React + + +``` + +### Different Button Types + + + +```jsx + + + Item 1 + + + + Item 1 + + + + Item 1 + + +``` + +### Different Sizes + + + +```jsx + + + Item 1 + + + + Item 1 + + + + Item 1 + + +``` + +### With Custom Values + + + +```jsx + + Existing Option 1 + Existing Option 2 + +``` + +### Complex Example + + + +```jsx + { + if (selectedKeys.length === 0) return null; + if (selectedKeys.length <= 3) + return `${selectedKeys.length} filters: ${selectedLabels.join(', ')}`; + return `${selectedKeys.length} filters applied`; + }} + header={ + + + Filter Options + + } +> + + Created Today + Created This Week + + + Active Items + Draft Items + + +``` + +## Accessibility + +### Keyboard Navigation + +- `Tab` - Moves focus to the trigger button +- `Space/Enter` - Opens the dropdown popover +- `Arrow Keys` - Navigate through options (when popover is open) +- `Escape` - Closes the popover or clears search + +### Screen Reader Support + +- Trigger button announces current selection state +- Popover opening/closing is announced +- Search functionality is properly communicated +- Selection changes are announced immediately +- Loading and validation states are communicated + +### ARIA Properties + +- `aria-label` - Provides accessible label for the trigger button +- `aria-expanded` - Indicates whether the popover is open +- `aria-haspopup` - Indicates the button controls a listbox +- `aria-describedby` - Associates help text and descriptions + +## Best Practices + +1. **Do**: Provide clear, descriptive labels for the trigger + ```jsx + + Electronics + + ``` + +2. **Don't**: Use overly long option texts that will be truncated + ```jsx + // ❌ Avoid very long option text + + This is an extremely long option text that will be truncated + + ``` + +3. **Do**: Use sections for logical grouping of many options + ```jsx + + Technology + + ``` + +4. **Accessibility**: Always provide meaningful labels and placeholders +5. **Performance**: Use `textValue` prop for complex option content +6. **UX**: Consider using `isCheckable` for multiple selection clarity + +## Integration with Forms + +This component supports all [Field properties](/docs/forms-field--docs) when used within a Form. The component automatically handles form validation, field states, and integrates with form submission. + +```jsx +
+ + + Phones + Laptops + + + Shirts + Pants + + +
+``` + +## Advanced Features + +### Custom Summary Rendering + +FilterPicker supports custom summary functions for the trigger display: + +```jsx +const renderSummary = ({ selectedLabels, selectedKeys, selectionMode }) => { + if (selectionMode === 'single') { + return selectedLabels[0] ? `Selected: ${selectedLabels[0]}` : null; + } + + if (selectedKeys.length === 0) return null; + if (selectedKeys.length === 1) return selectedLabels[0]; + if (selectedKeys.length <= 3) return selectedLabels.join(', '); + return `${selectedKeys.length} items selected`; +}; + + + {/* items */} + +``` + +### Custom Values + +When `allowsCustomValue={true}`, users can add new options: + +```jsx + + Existing Option + +``` + +Custom values: +- Are automatically added when typed and selected +- Persist across popover sessions +- Appear in the selection summary +- Can be removed like any other selection + +### Icon-Only Mode + +For space-constrained interfaces: + +```jsx +} + aria-label="Apply filters" + type="clear" +> + {/* options */} + +``` + +## Performance + +### Optimization Tips + +- Use `textValue` prop for complex option content +- Implement custom filter functions for specific search needs +- Use sections sparingly for very large lists +- Consider debounced selection changes for real-time updates + +```jsx +// Optimized for performance + + + +``` + +## Related Components + +- [FilterListBox](/docs/forms-filterlistbox--docs) - The underlying searchable list component +- [Select](/docs/forms-select--docs) - Use for simple selection without search functionality +- [ComboBox](/docs/forms-combobox--docs) - Use when users need to enter custom values +- [ListBox](/docs/forms-listbox--docs) - Use for basic list selection without search +- [Button](/docs/actions-button--docs) - The underlying trigger component diff --git a/src/components/fields/FilterPicker/FilterPicker.spec.md b/src/components/fields/FilterPicker/FilterPicker.spec.md new file mode 100644 index 00000000..a0143064 --- /dev/null +++ b/src/components/fields/FilterPicker/FilterPicker.spec.md @@ -0,0 +1,363 @@ +# FilterPicker Component Specification + +## Overview + +The `FilterPicker` component is a button-triggered popover that contains a FilterListBox for searchable selection. It combines the convenience of a dropdown trigger with the power of a searchable list, supporting both single and multiple selection with intelligent option sorting and custom summary rendering. + +## Architecture + +### Component Hierarchy + +``` +FilterPicker (forwardRef) +└── DialogTrigger (popover container) + ├── Button (trigger element) + │ ├── Icon (optional) + │ ├── Text content (summary/placeholder) + │ └── DirectionIcon (caret indicator) + └── Dialog (popover content) + └── FilterListBox (internal search and selection) + ├── SearchInput + └── ListBox (with sorted options) +``` + +### Core Dependencies + +- **FilterListBox**: Uses FilterListBox as internal selection component +- **Dialog System**: `DialogTrigger`, `Dialog` for popover behavior +- **Button Component**: Styled trigger button with states +- **Form Integration**: Custom form hooks (`useFieldProps`, `useFormProps`) +- **React Stately**: `Item`, `Section` for collection management + +## Key Features + +### 1. Smart Option Sorting +- **Selection-Based Sorting**: Selected items automatically move to the top +- **Session-Based Persistence**: Maintains sorted order during popover session +- **Section-Aware**: Sorts items within sections while preserving section structure +- **Performance Optimized**: Uses memoization and caching to prevent layout thrashing + +### 2. Custom Summary Rendering +- **Flexible Display**: Custom `renderSummary` function for trigger content +- **Mode-Aware**: Different parameters for single vs multiple selection +- **Selection Context**: Provides both keys and labels to render function +- **Fallback Behavior**: Automatic comma-separated list when no custom renderer + +### 3. Popover Management +- **Auto-Close Behavior**: Closes on selection (single mode) or content click (multiple mode) +- **Escape Integration**: Connects FilterListBox escape behavior to popover close +- **Focus Management**: Auto-focus search input when opened +- **Position Management**: Uses Dialog system for smart positioning + +### 4. Selection State Management +- **Controlled/Uncontrolled**: Supports both controlled and uncontrolled state +- **Key Normalization**: Handles React key formatting and deduplication +- **Custom Value Integration**: Seamlessly works with FilterListBox custom values +- **Form Compatibility**: Full integration with form system value mapping + +### 5. Advanced Interaction Patterns +- **Checkbox Mode**: Optional checkboxes for clear multiple selection UX +- **Click Behavior**: Differentiated click handling for checkbox vs content areas +- **Loading States**: Button loading state integration +- **Validation States**: Visual validation state feedback on trigger + +## Component Props Interface + +### Core Selection Props +```typescript +interface SelectionProps { + selectionMode?: 'single' | 'multiple'; + selectedKey?: string | null; + selectedKeys?: string[]; + defaultSelectedKey?: string | null; + defaultSelectedKeys?: string[]; + onSelectionChange?: (selection: any) => void; +} +``` + +### Display Props +```typescript +interface DisplayProps { + placeholder?: string; // Text when no selection + icon?: ReactElement; // Leading icon in trigger + type?: ButtonType; // Button styling type + theme?: 'default' | 'special'; // Button theme + size?: 'small' | 'medium' | 'large'; +} +``` + +### Advanced Props +```typescript +interface AdvancedProps { + renderSummary?: (args: { + selectedLabels: string[]; + selectedKeys: (string | number)[]; + selectedLabel?: string; + selectedKey?: string | number | null; + selectionMode: 'single' | 'multiple'; + }) => ReactNode | false; + + listStateRef?: MutableRefObject; + isCheckable?: boolean; + allowsCustomValue?: boolean; +} +``` + +## Implementation Details + +### Option Sorting Algorithm +```typescript +const getSortedChildren = useCallback(() => { + // 1. Cache current order when popover is open to prevent re-flow + if (!isPopoverOpen && cachedChildrenOrder.current) { + return cachedChildrenOrder.current; + } + + // 2. Only sort when there were selections in previous session + if (!hadSelectionsWhenClosed) { + return children; + } + + // 3. Process items and sections separately + const sortChildrenArray = (childrenArray) => { + const selected = []; + const unselected = []; + + // Handle sections: sort items within each section + // Handle items: group by selection status + + return [...selected, ...unselected]; + }; +}, [children, effectiveSelectedKeys, selectionMode, isPopoverOpen]); +``` + +### Key Normalization System +The component handles React's automatic key prefixing: + +```typescript +const normalizeKeyValue = (key: any): string => { + const str = String(key); + return str.startsWith('.$') + ? str.slice(2) // Remove ".$ prefix + : str.startsWith('.') + ? str.slice(1) // Remove ". prefix + : str; +}; + +const processSelectionArray = (iterable: Iterable): string[] => { + const resultSet = new Set(); + for (const key of iterable) { + const nKey = normalizeKeyValue(key); + // Toggle behavior for duplicate selections + if (resultSet.has(nKey)) { + resultSet.delete(nKey); + } else { + resultSet.add(nKey); + } + } + return Array.from(resultSet); +}; +``` + +### Summary Rendering Logic +```typescript +const renderTriggerContent = () => { + // Custom renderer takes precedence + if (hasSelection && typeof renderSummary === 'function') { + return renderSummary({ + selectedLabels, + selectedKeys: effectiveSelectedKeys, + selectedLabel: selectionMode === 'single' ? selectedLabels[0] : undefined, + selectedKey: selectionMode === 'single' ? effectiveSelectedKey : null, + selectionMode + }); + } + + // No custom renderer or renderSummary === false + if (renderSummary === false) return null; + + // Default behavior: placeholder or joined labels + return hasSelection + ? (selectionMode === 'single' + ? selectedLabels[0] + : selectedLabels.join(', ')) + : placeholder; +}; +``` + +### State Synchronization +The component maintains several state references for optimal performance: + +```typescript +// Current selection state (reactive) +const latestSelectionRef = useRef({ single: null, multiple: [] }); + +// Selection state when popover was last closed (for sorting) +const selectionsWhenClosed = useRef({ single: null, multiple: [] }); + +// Cached children order during open session (prevents re-flow) +const cachedChildrenOrder = useRef(null); +``` + +## Styling System + +### Button Integration +- **Type Variants**: `outline`, `clear`, `primary`, `secondary`, `neutral` +- **Theme Support**: `default`, `special`, validation themes +- **State Modifiers**: `placeholder`, `selected`, `loading`, `disabled` +- **Size Variants**: `small`, `medium`, `large` + +### Popover Styling +- **Dialog Container**: Uses Dialog component styling system +- **FilterListBox Integration**: Passes through style props to internal FilterListBox +- **Position Awareness**: Adapts styling based on popover placement + +### Trigger States +```typescript +mods={{ + placeholder: !hasSelection, // When no selection made + selected: hasSelection, // When items are selected + ...externalMods // Additional custom modifiers +}} +``` + +## Interaction Patterns + +### Single Selection Mode +1. **Click Trigger**: Opens popover with auto-focused search +2. **Select Item**: Closes popover immediately, updates selection +3. **Escape**: Closes popover (via FilterListBox escape handling) + +### Multiple Selection Mode +1. **Click Trigger**: Opens popover with current selection sorted to top +2. **Checkbox Interaction**: Toggles selection, keeps popover open +3. **Content Click**: (Optional) Closes popover if `onOptionClick` configured +4. **Escape**: Closes popover + +### Keyboard Interaction +- **Trigger Focus**: Standard button keyboard behavior +- **Popover Navigation**: Full FilterListBox keyboard support +- **Escape Chains**: FilterListBox escape → popover close + +## Performance Considerations + +### Sorting Optimization +- **Memoization**: Sorting is memoized based on selection state and popover state +- **Layout Stability**: Cached order prevents re-flow during fade-out animations +- **Conditional Sorting**: Only sorts when there were previous selections + +### Selection Processing +- **Key Normalization**: Efficient string operations for React key handling +- **Set-Based Operations**: Uses Set for O(1) duplicate detection and toggle operations +- **Ref-Based State**: Synchronous state access via refs for event handlers + +### Rendering Optimization +- **Child Processing**: Memoized children processing for large lists +- **Label Extraction**: Efficient label extraction with tracking +- **Custom Value Integration**: Seamless integration without re-processing + +## Integration Patterns + +### Form Integration +```typescript +// Standard form field integration +valuePropsMapper: ({ value, onChange }) => ({ + selectedKey: selectionMode === 'single' ? value : undefined, + selectedKeys: selectionMode === 'multiple' ? value : undefined, + onSelectionChange: onChange +}) +``` + +### Dialog System Integration +```typescript + + {renderTrigger} + {(close) => ( + + + + )} + +``` + +### Custom Summary Examples +```typescript +// Simple count display +renderSummary={({ selectedLabels, selectionMode }) => + selectionMode === 'multiple' + ? `${selectedLabels.length} items selected` + : selectedLabels[0] +} + +// Custom component rendering +renderSummary={({ selectedLabels }) => + + {selectedLabels.map(label => {label})} + +} +``` + +## Accessibility Features + +### Button Accessibility +- **ARIA Label**: Inherits from `aria-label` or `label` prop +- **Button Role**: Standard button role with proper states +- **Keyboard Support**: Enter/Space to open popover + +### Popover Accessibility +- **Focus Management**: Auto-focus to search input on open +- **Escape Handling**: Closes popover on escape +- **Outside Click**: Dismissible behavior + +### Selection Accessibility +- **FilterListBox Integration**: Full FilterListBox accessibility features +- **State Announcements**: Selection changes announced via FilterListBox +- **Keyboard Navigation**: Complete keyboard access to all options + +## Common Use Cases + +1. **Multi-Select Filters**: Dashboard filters with searchable options +2. **Tag Selection**: Content tagging with custom value support +3. **Category Assignment**: Hierarchical category selection with sections +4. **User/Group Pickers**: Searchable user selection with custom display +5. **Status Selectors**: Status/priority selection with visual indicators + +## Testing Considerations + +### Trigger Behavior +- Button states and styling +- Popover open/close behavior +- Loading state display +- Validation state indication + +### Selection Logic +- Single vs multiple selection modes +- Key normalization and deduplication +- Custom value integration +- Form integration + +### Sorting Behavior +- Selected items appear at top +- Section structure preservation +- Performance with large lists +- Layout stability during animations + +### Custom Rendering +- Summary function integration +- Error handling for render functions +- Fallback behavior when renderSummary returns invalid content + +## Browser Compatibility + +- **Dialog Support**: Modern browser popover/dialog features +- **Focus Management**: Advanced focus coordination +- **CSS Animations**: Smooth popover transitions +- **Event Handling**: Complex event delegation patterns + +## Migration Notes + +When upgrading or modifying: +- Custom renderSummary functions may need parameter adjustments +- Sorting behavior changes could affect layout expectations +- Key normalization changes may impact selection state handling +- Dialog integration requires testing with various popover configurations \ No newline at end of file diff --git a/src/components/fields/FilterPicker/FilterPicker.stories.tsx b/src/components/fields/FilterPicker/FilterPicker.stories.tsx new file mode 100644 index 00000000..833cea21 --- /dev/null +++ b/src/components/fields/FilterPicker/FilterPicker.stories.tsx @@ -0,0 +1,1528 @@ +import { userEvent, within } from '@storybook/test'; +import { useState } from 'react'; + +import { + CheckIcon, + DatabaseIcon, + EditIcon, + FilterIcon, + PlusIcon, + RightIcon, + SearchIcon, + SettingsIcon, + UserIcon, +} from '../../../icons'; +import { Button } from '../../actions/Button/Button'; +import { Badge } from '../../content/Badge/Badge'; +import { Paragraph } from '../../content/Paragraph'; +import { Tag } from '../../content/Tag/Tag'; +import { Text } from '../../content/Text'; +import { Title } from '../../content/Title'; +import { Form } from '../../form'; +import { Flow } from '../../layout/Flow'; +import { Space } from '../../layout/Space'; + +import { FilterPicker } from './FilterPicker'; + +import type { Meta, StoryObj } from '@storybook/react'; + +const meta: Meta = { + title: 'Forms/FilterPicker', + component: FilterPicker, + parameters: { + layout: 'centered', + docs: { + description: { + component: + 'FilterPicker is a versatile selection component that combines a trigger button with a filterable dropdown. It supports both single and multiple selection modes, custom summaries, and various UI states.', + }, + }, + }, + argTypes: { + /* Content */ + selectedKey: { + control: { type: 'text' }, + description: 'The selected key in controlled mode', + }, + defaultSelectedKey: { + control: { type: 'text' }, + description: 'The default selected key in uncontrolled mode', + }, + selectedKeys: { + control: { type: 'object' }, + description: 'The selected keys in controlled multiple mode', + }, + defaultSelectedKeys: { + control: { type: 'object' }, + description: 'The default selected keys in uncontrolled multiple mode', + }, + selectionMode: { + control: 'radio', + options: ['single', 'multiple'], + description: 'Selection mode for the picker', + table: { + defaultValue: { summary: 'single' }, + }, + }, + allowsCustomValue: { + control: { type: 'boolean' }, + description: 'Whether the FilterListBox allows custom values', + table: { + defaultValue: { summary: false }, + }, + }, + disallowEmptySelection: { + control: { type: 'boolean' }, + description: 'Whether to disallow empty selection', + table: { + defaultValue: { summary: false }, + }, + }, + disabledKeys: { + control: { type: 'object' }, + description: 'Array of keys for disabled items', + }, + + /* Trigger */ + placeholder: { + control: 'text', + description: 'Placeholder text when no selection is made', + }, + icon: { + control: false, + description: 'Icon to show in the trigger', + }, + type: { + control: 'radio', + options: ['outline', 'clear', 'primary', 'secondary', 'neutral'], + description: 'Button styling type', + table: { + defaultValue: { summary: 'outline' }, + }, + }, + theme: { + control: 'radio', + options: ['default', 'special'], + description: 'Button theme', + table: { + defaultValue: { summary: 'default' }, + }, + }, + size: { + control: 'radio', + options: ['small', 'medium', 'large'], + description: 'Size of the picker', + table: { + defaultValue: { summary: 'small' }, + }, + }, + + /* Search */ + searchPlaceholder: { + control: 'text', + description: 'Placeholder text in the search input', + }, + autoFocus: { + control: { type: 'boolean' }, + description: 'Whether the search input should have autofocus', + table: { + defaultValue: { summary: false }, + }, + }, + emptyLabel: { + control: { type: 'text' }, + description: + 'Custom label to display when no results are found after filtering', + }, + filter: { + control: false, + description: 'Custom filter function for search', + }, + + /* Presentation */ + header: { + control: { type: 'text' }, + description: 'Custom header content', + }, + footer: { + control: { type: 'text' }, + description: 'Custom footer content', + }, + renderSummary: { + control: false, + description: 'Custom renderer for the summary shown inside the trigger', + }, + + /* Behavior */ + isCheckable: { + control: 'boolean', + description: 'Whether to show checkboxes in multiple selection mode', + table: { + defaultValue: { summary: false }, + }, + }, + + /* State */ + isDisabled: { + control: 'boolean', + description: 'Whether the picker is disabled', + table: { + defaultValue: { summary: false }, + }, + }, + isLoading: { + control: 'boolean', + description: 'Whether the picker is in loading state', + table: { + defaultValue: { summary: false }, + }, + }, + isRequired: { + control: { type: 'boolean' }, + description: 'Whether the field is required', + table: { + defaultValue: { summary: false }, + }, + }, + validationState: { + control: 'radio', + options: [undefined, 'valid', 'invalid'], + description: 'Validation state of the picker', + }, + + /* Field */ + label: { + control: { type: 'text' }, + description: 'Label text', + }, + description: { + control: { type: 'text' }, + description: 'Field description', + }, + message: { + control: { type: 'text' }, + description: 'Help or error message', + }, + + /* Events */ + onSelectionChange: { + action: 'selection changed', + description: 'Callback when selection changes', + }, + onEscape: { + action: 'escape pressed', + description: 'Callback when Escape key is pressed', + }, + onOptionClick: { + action: 'option clicked', + description: 'Callback when an option is clicked (non-checkbox area)', + }, + }, +}; + +export default meta; +type Story = StoryObj; + +// Sample data for stories +const fruits = [ + { key: 'apple', label: 'Apple', description: 'Crisp and sweet red fruit' }, + { + key: 'banana', + label: 'Banana', + description: 'Yellow tropical fruit rich in potassium', + }, + { + key: 'cherry', + label: 'Cherry', + description: 'Small red stone fruit with sweet flavor', + }, + { + key: 'date', + label: 'Date', + description: 'Sweet dried fruit from date palm', + }, + { + key: 'elderberry', + label: 'Elderberry', + description: 'Dark purple berry with tart flavor', + }, + { key: 'fig', label: 'Fig', description: 'Sweet fruit with soft flesh' }, + { + key: 'grape', + label: 'Grape', + description: 'Small sweet fruit that grows in clusters', + }, +]; + +const vegetables = [ + { + key: 'carrot', + label: 'Carrot', + description: 'Orange root vegetable high in beta-carotene', + }, + { + key: 'broccoli', + label: 'Broccoli', + description: 'Green cruciferous vegetable packed with nutrients', + }, + { + key: 'spinach', + label: 'Spinach', + description: 'Leafy green vegetable rich in iron', + }, + { key: 'pepper', label: 'Bell Pepper', description: 'Colorful sweet pepper' }, + { + key: 'tomato', + label: 'Tomato', + description: 'Red fruit commonly used as vegetable', + }, +]; + +const grains = [ + { key: 'rice', label: 'Rice', description: 'Staple grain eaten worldwide' }, + { + key: 'quinoa', + label: 'Quinoa', + description: 'Protein-rich seed often used as grain', + }, + { key: 'oats', label: 'Oats', description: 'Nutritious cereal grain' }, + { key: 'barley', label: 'Barley', description: 'Versatile cereal grain' }, +]; + +const permissions = [ + { key: 'read', label: 'Read', description: 'View content and data' }, + { key: 'write', label: 'Write', description: 'Create and edit content' }, + { key: 'delete', label: 'Delete', description: 'Remove content permanently' }, + { key: 'admin', label: 'Admin', description: 'Full administrative access' }, + { + key: 'moderate', + label: 'Moderate', + description: 'Review and approve content', + }, + { key: 'share', label: 'Share', description: 'Share content with others' }, +]; + +const languages = [ + { key: 'javascript', label: 'JavaScript', category: 'Frontend' }, + { key: 'typescript', label: 'TypeScript', category: 'Frontend' }, + { key: 'react', label: 'React', category: 'Frontend' }, + { key: 'vue', label: 'Vue.js', category: 'Frontend' }, + { key: 'python', label: 'Python', category: 'Backend' }, + { key: 'nodejs', label: 'Node.js', category: 'Backend' }, + { key: 'rust', label: 'Rust', category: 'Backend' }, + { key: 'go', label: 'Go', category: 'Backend' }, + { key: 'sql', label: 'SQL', category: 'Database' }, + { key: 'mongodb', label: 'MongoDB', category: 'Database' }, + { key: 'redis', label: 'Redis', category: 'Database' }, + { key: 'postgres', label: 'PostgreSQL', category: 'Database' }, +]; + +export const Default: Story = { + args: { + label: 'Select Options', + placeholder: 'Choose items...', + searchPlaceholder: 'Search options...', + width: 'max 30x', + }, + render: (args) => ( + + {fruits.map((fruit) => ( + + {fruit.label} + + ))} + + ), +}; + +export const SingleSelection: Story = { + args: { + label: 'Choose a Fruit', + placeholder: 'Select one fruit...', + selectionMode: 'single', + searchPlaceholder: 'Search fruits...', + width: 'max 30x', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole('button'); + await userEvent.click(trigger); + }, + render: (args) => ( + + {fruits.map((fruit) => ( + + {fruit.label} + + ))} + + ), +}; + +export const MultipleSelection: Story = { + args: { + label: 'Select Multiple Options', + placeholder: 'Choose items...', + selectionMode: 'multiple', + searchPlaceholder: 'Search options...', + width: 'max 30x', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole('button'); + await userEvent.click(trigger); + }, + render: (args) => ( + + + {fruits.map((fruit) => ( + + {fruit.label} + + ))} + + + {vegetables.map((vegetable) => ( + + {vegetable.label} + + ))} + + + ), +}; + +export const WithCheckboxes: Story = { + args: { + label: 'Select with Checkboxes', + placeholder: 'Choose items...', + selectionMode: 'multiple', + isCheckable: true, + searchPlaceholder: 'Search options...', + width: 'max 30x', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole('button'); + await userEvent.click(trigger); + }, + render: (args) => ( + + {permissions.map((permission) => ( + + {permission.label} + + ))} + + ), + parameters: { + docs: { + description: { + story: + 'When `isCheckable={true}` and `selectionMode="multiple"`, checkboxes appear on the left of each option. The checkbox is only visible when the item is hovered or selected.', + }, + }, + }, +}; + +export const WithDescriptions: Story = { + args: { + label: 'Options with Descriptions', + placeholder: 'Choose items...', + selectionMode: 'single', + searchPlaceholder: 'Search options...', + width: 'max 35x', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole('button'); + await userEvent.click(trigger); + }, + render: (args) => ( + + {fruits.slice(0, 5).map((fruit) => ( + + {fruit.label} + + ))} + + ), + parameters: { + docs: { + description: { + story: + 'Options can include descriptions to provide additional context for each choice.', + }, + }, + }, +}; + +export const WithSections: Story = { + args: { + label: 'Organized by Sections', + placeholder: 'Choose items...', + selectionMode: 'multiple', + searchPlaceholder: 'Search ingredients...', + width: 'max 30x', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole('button'); + await userEvent.click(trigger); + }, + render: (args) => ( + + + {fruits.slice(0, 3).map((fruit) => ( + + {fruit.label} + + ))} + + + {vegetables.slice(0, 3).map((vegetable) => ( + + {vegetable.label} + + ))} + + + {grains.slice(0, 3).map((grain) => ( + + {grain.label} + + ))} + + + ), + parameters: { + docs: { + description: { + story: + 'Use sections to organize related options into logical groups for better usability.', + }, + }, + }, +}; + +export const CustomSummary: Story = { + args: { + label: 'Custom Summary Display', + placeholder: 'Choose items...', + selectionMode: 'multiple', + searchPlaceholder: 'Search options...', + width: 'max 30x', + renderSummary: ({ selectedLabels, selectedKeys, selectionMode }) => { + if (selectionMode === 'single') { + return selectedLabels[0] ? `Selected: ${selectedLabels[0]}` : null; + } + if (selectedKeys.length === 0) return null; + if (selectedKeys.length === 1) return `${selectedLabels[0]} selected`; + return `${selectedKeys.length} items selected (${selectedLabels.slice(0, 2).join(', ')}${selectedKeys.length > 2 ? '...' : ''})`; + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole('button'); + await userEvent.click(trigger); + }, + render: (args) => ( + + + {fruits.map((fruit) => ( + + {fruit.label} + + ))} + + + {vegetables.map((vegetable) => ( + + {vegetable.label} + + ))} + + + ), + parameters: { + docs: { + description: { + story: + 'Use the `renderSummary` prop to customize how the selection is displayed in the trigger button.', + }, + }, + }, +}; + +export const NoSummary: Story = { + args: { + label: 'No Summary Display', + selectionMode: 'multiple', + searchPlaceholder: 'Search options...', + renderSummary: false, + icon: , + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole('button'); + await userEvent.click(trigger); + }, + render: (args) => ( + + {fruits.map((fruit) => ( + + {fruit.label} + + ))} + + ), + parameters: { + docs: { + description: { + story: + 'When `renderSummary={false}`, no text is shown in the trigger, making it useful for icon-only filter buttons.', + }, + }, + }, +}; + +export const WithHeaderAndFooter: Story = { + args: { + label: 'Programming Languages', + placeholder: 'Select languages...', + selectionMode: 'multiple', + searchPlaceholder: 'Search languages...', + width: 'max 35x', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole('button'); + await userEvent.click(trigger); + }, + render: (args) => ( + + + Programming Languages + 12 + + + + } + > + {['Frontend', 'Backend', 'Database'].map((category) => ( + + {languages + .filter((lang) => lang.category === category) + .map((lang) => ( + + {lang.label} + + ))} + + ))} + + ), + parameters: { + docs: { + description: { + story: + 'Add custom header and footer content to provide additional context or actions for the picker.', + }, + }, + }, +}; + +export const LoadingState: Story = { + args: { + label: 'Loading Data', + placeholder: 'Loading...', + isLoading: true, + selectionMode: 'multiple', + searchPlaceholder: 'Search options...', + width: 'max 25x', + }, + render: (args) => ( + + {fruits.map((fruit) => ( + + {fruit.label} + + ))} + + ), + parameters: { + docs: { + description: { + story: + 'Show a loading spinner in the trigger button while data is being fetched.', + }, + }, + }, +}; + +export const DisabledState: Story = { + args: { + label: 'Disabled Picker', + placeholder: 'Cannot select...', + isDisabled: true, + selectionMode: 'multiple', + searchPlaceholder: 'Search options...', + width: 'max 25x', + }, + render: (args) => ( + + {fruits.map((fruit) => ( + + {fruit.label} + + ))} + + ), + parameters: { + docs: { + description: { + story: + 'Disable the entire picker when selection should not be allowed.', + }, + }, + }, +}; + +export const DisabledItems: Story = { + args: { + label: 'With Disabled Items', + placeholder: 'Choose items...', + selectionMode: 'single', + disabledKeys: ['banana', 'cherry'], + searchPlaceholder: 'Search options...', + width: 'max 25x', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole('button'); + await userEvent.click(trigger); + }, + render: (args) => ( + + {fruits.slice(0, 5).map((fruit) => ( + + {fruit.label} + + ))} + + ), + parameters: { + docs: { + description: { + story: + 'Individual items can be disabled using the `disabledKeys` prop. Disabled items cannot be selected and are visually distinguished.', + }, + }, + }, +}; + +export const DisallowEmptySelection: Story = { + args: { + label: 'Must Select One', + placeholder: 'Choose one...', + selectionMode: 'single', + disallowEmptySelection: true, + defaultSelectedKey: 'apple', + searchPlaceholder: 'Search options...', + width: 'max 25x', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole('button'); + await userEvent.click(trigger); + }, + render: (args) => ( + + {fruits.slice(0, 4).map((fruit) => ( + + {fruit.label} + + ))} + + ), + parameters: { + docs: { + description: { + story: + 'When `disallowEmptySelection={true}`, the user cannot deselect the last selected item, ensuring at least one item is always selected.', + }, + }, + }, +}; + +export const ValidationStates: Story = { + args: { + selectionMode: 'multiple', + searchPlaceholder: 'Search options...', + width: 'max 25x', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const triggers = canvas.getAllByRole('button'); + + // Click the invalid one to show validation state + await userEvent.click(triggers[1]); + }, + render: (args) => ( + + + {fruits.slice(0, 4).map((fruit) => ( + + {fruit.label} + + ))} + + + + {fruits.slice(0, 4).map((fruit) => ( + + {fruit.label} + + ))} + + + ), + parameters: { + docs: { + description: { + story: + 'Show validation states with appropriate styling and colors to indicate valid or invalid selections.', + }, + }, + }, +}; + +export const DifferentSizes: Story = { + args: { + label: 'Size Variants', + placeholder: 'Choose size...', + selectionMode: 'single', + searchPlaceholder: 'Search...', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const triggers = canvas.getAllByRole('button'); + + // Click the medium size button + await userEvent.click(triggers[1]); + }, + render: (args) => ( + + + {fruits.slice(0, 3).map((fruit) => ( + + {fruit.label} + + ))} + + + + {fruits.slice(0, 3).map((fruit) => ( + + {fruit.label} + + ))} + + + + {fruits.slice(0, 3).map((fruit) => ( + + {fruit.label} + + ))} + + + ), + parameters: { + docs: { + description: { + story: + 'FilterPicker supports three sizes: `small`, `medium`, and `large` to fit different interface requirements.', + }, + }, + }, +}; + +export const DifferentTypes: Story = { + args: { + label: 'Type Variants', + placeholder: 'Choose type...', + selectionMode: 'single', + searchPlaceholder: 'Search...', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const triggers = canvas.getAllByRole('button'); + + // Click the primary button + await userEvent.click(triggers[1]); + }, + render: (args) => ( + + + {fruits.slice(0, 3).map((fruit) => ( + + {fruit.label} + + ))} + + + + {fruits.slice(0, 3).map((fruit) => ( + + {fruit.label} + + ))} + + + + {fruits.slice(0, 3).map((fruit) => ( + + {fruit.label} + + ))} + + + + {fruits.slice(0, 3).map((fruit) => ( + + {fruit.label} + + ))} + + + ), + parameters: { + docs: { + description: { + story: + 'Use different button types to match your interface design: `outline`, `primary`, `secondary`, `clear`, and `neutral`.', + }, + }, + }, +}; + +export const WithIcons: Story = { + args: { + label: 'With Icons', + placeholder: 'Filter by category...', + selectionMode: 'multiple', + searchPlaceholder: 'Search categories...', + width: 'max 30x', + icon: , + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole('button'); + await userEvent.click(trigger); + }, + render: (args) => ( + + + + + + Users + + + + + + Permissions + + + + + + + + Database + + + + + + Settings + + + + + ), + parameters: { + docs: { + description: { + story: + 'Include icons in both the trigger button and the options to improve visual clarity and user experience.', + }, + }, + }, +}; + +export const WithCustomValues: Story = { + args: { + label: 'Custom Values Allowed', + placeholder: 'Type or select...', + selectionMode: 'multiple', + allowsCustomValue: true, + searchPlaceholder: 'Search or add custom value...', + width: 'max 30x', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole('button'); + await userEvent.click(trigger); + }, + render: (args) => ( + + {fruits.slice(0, 4).map((fruit) => ( + + {fruit.label} + + ))} + + ), + parameters: { + docs: { + description: { + story: + 'When `allowsCustomValue={true}`, users can enter and select values that are not in the predefined list. Custom values are automatically added to the list when selected and persist across popover sessions.', + }, + }, + }, +}; + +export const WithTextValue: Story = { + args: { + label: 'Complex Content', + placeholder: 'Choose plan...', + selectionMode: 'single', + searchPlaceholder: 'Search plans...', + width: 'max 30x', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole('button'); + await userEvent.click(trigger); + }, + render: (args) => ( + + + + Basic Plan + Free + + + + + Pro Plan + $19/month + + + + + Enterprise Plan + Custom + + + + ), + parameters: { + docs: { + description: { + story: + 'Use the `textValue` prop when option content is complex (JSX) to provide searchable text that includes more context than just the visible label.', + }, + }, + }, +}; + +export const ControlledExample = () => { + const [selectedKey, setSelectedKey] = useState('apple'); + + return ( + + setSelectedKey(key as string | null)} + > + {fruits.slice(0, 5).map((fruit) => ( + + {fruit.label} + + ))} + + + + Selected: {selectedKey || 'None'} + + + + + + + + ); +}; + +export const MultipleControlledExample = () => { + const [selectedKeys, setSelectedKeys] = useState(['read', 'write']); + + return ( + + setSelectedKeys(keys as string[])} + > + {permissions.map((permission) => ( + + {permission.label} + + ))} + + + + Selected:{' '} + + {selectedKeys.length ? selectedKeys.join(', ') : 'None'} + + + + + + + + + ); +}; + +export const InForm = () => { + const [selectedTechnology, setSelectedTechnology] = useState( + null, + ); + + const handleSubmit = (data: any) => { + alert(`Form submitted with technology: ${data.technology || 'None'}`); + }; + + return ( +
+ setSelectedTechnology(key as string | null)} + > + + {languages + .filter((lang) => lang.category === 'Frontend') + .map((lang) => ( + + {lang.label} + + ))} + + + {languages + .filter((lang) => lang.category === 'Backend') + .map((lang) => ( + + {lang.label} + + ))} + + + + Submit +
+ ); +}; + +export const ComplexExample: Story = { + args: { + label: 'Advanced Filter System', + placeholder: 'Apply filters...', + selectionMode: 'multiple', + isCheckable: true, + searchPlaceholder: 'Search all filters...', + width: '40x', + renderSummary: ({ selectedKeys, selectedLabels }) => { + if (selectedKeys.length === 0) return null; + if (selectedKeys.length === 1) return `1 filter: ${selectedLabels[0]}`; + if (selectedKeys.length <= 3) + return `${selectedKeys.length} filters: ${selectedLabels.join(', ')}`; + return `${selectedKeys.length} filters applied`; + }, + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole('button'); + await userEvent.click(trigger); + }, + render: (args) => ( + + + + Filter Options + 24 available + + + + + } + > + + + Created Today + + + Created This Week + + + Created This Month + + + Recently Modified + + + + + + Active Items + + + Draft Items + + + Archived Items + + + Pending Review + + + + + + Important + + + Urgent + + + Low Priority + + + + + + Has Attachments + + + Has Comments + + + Shared Items + + + Favorite Items + + + + ), + parameters: { + docs: { + description: { + story: + 'A comprehensive example showcasing multiple sections, custom header/footer, checkboxes, custom summary, and a wide variety of filter options that might be found in a real application.', + }, + }, + }, +}; + +export const CustomInputComponent: Story = { + args: {}, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByTestId('Picker'); + await userEvent.click(trigger); + }, + render: (args) => { + const CustomTagInput = () => { + const [selectedKeys, setSelectedKeys] = useState([ + 'typescript', + 'react', + ]); + + const availableOptions = [ + { key: 'javascript', label: 'JavaScript', theme: 'purple' }, + { key: 'typescript', label: 'TypeScript', theme: 'blue' }, + { key: 'react', label: 'React', theme: 'cyan' }, + { key: 'vue', label: 'Vue.js', theme: 'green' }, + { key: 'python', label: 'Python', theme: 'yellow' }, + { key: 'nodejs', label: 'Node.js', theme: 'lime' }, + { key: 'rust', label: 'Rust', theme: 'orange' }, + { key: 'go', label: 'Go', theme: 'teal' }, + ]; + + const selectedOptions = availableOptions.filter((option) => + selectedKeys.includes(option.key), + ); + + const handleSelectionChange = (keys: string[]) => { + setSelectedKeys(keys); + }; + + const handleTagRemove = (keyToRemove: string) => { + setSelectedKeys((prev) => prev.filter((key) => key !== keyToRemove)); + }; + + return ( + + + Custom Tag Input Component + + + + {selectedOptions.length > 0 && ( + + {/* Tags display */} + {selectedOptions.map((option) => ( + handleTagRemove(option.key)} + > + {option.label} + + ))} + + )} + + {/* FilterPicker trigger */} + } + aria-label="Add technology" + searchPlaceholder="Search technologies..." + onSelectionChange={handleSelectionChange} + > + {availableOptions.map((option) => ( + + {option.label} + + ))} + + + + + Selected: {selectedKeys.length} / {availableOptions.length}{' '} + technologies + + + ); + }; + + return ; + }, + parameters: { + docs: { + description: { + story: + 'A custom input component combining FilterPicker with Tag components. Selected items are displayed as removable tags, and the FilterPicker trigger shows only a plus icon for adding new items.', + }, + }, + }, +}; + +export const VirtualizedList: Story = { + args: { + label: 'Virtualized Large Dataset', + placeholder: 'Select from large list...', + selectionMode: 'multiple', + searchPlaceholder: 'Search through 100+ items...', + width: 'max 35x', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole('button'); + await userEvent.click(trigger); + }, + render: (args) => { + const [selectedKeys, setSelectedKeys] = useState([]); + + // Generate a large list of items with varying content to trigger virtualization + // Mix items with and without descriptions to test dynamic sizing + const items = Array.from({ length: 100 }, (_, i) => ({ + id: `item-${i}`, + name: `Item ${i + 1}${i % 7 === 0 ? ' - This is a longer item name to test dynamic sizing' : ''}`, + description: + i % 3 === 0 + ? `This is a description for item ${i + 1}. It varies in length to test virtualization with dynamic item heights.` + : undefined, + })); + + return ( + + + Large list with {items.length} items with varying heights. + Virtualization is automatically enabled when there are no sections. + Scroll down and back up to test smooth virtualization. + + + setSelectedKeys(keys as string[])} + > + {items.map((item) => ( + + {item.name} + + ))} + + + + Selected: {selectedKeys.length} / {items.length} items + {selectedKeys.length > 0 && + ` (${selectedKeys.slice(0, 3).join(', ')}${selectedKeys.length > 3 ? '...' : ''})`} + + + ); + }, + parameters: { + docs: { + description: { + story: + 'When a FilterPicker contains many items and has no sections, virtualization is automatically enabled to improve performance. Only visible items are rendered in the DOM, providing smooth scrolling even with large datasets.', + }, + }, + }, +}; diff --git a/src/components/fields/FilterPicker/FilterPicker.test.tsx b/src/components/fields/FilterPicker/FilterPicker.test.tsx new file mode 100644 index 00000000..5918b2a7 --- /dev/null +++ b/src/components/fields/FilterPicker/FilterPicker.test.tsx @@ -0,0 +1,846 @@ +import { createRef } from 'react'; + +import { FilterPicker } from '../../../index'; +import { act, renderWithRoot, userEvent, within } from '../../../test'; + +jest.mock('../../../_internal/hooks/use-warn'); + +describe('', () => { + const basicItems = [ + Apple, + Banana, + Cherry, + Date, + Elderberry, + ]; + + const sectionsItems = [ + + Apple + Banana + Cherry + , + + Carrot + Broccoli + Spinach + , + ]; + + describe('Basic functionality', () => { + it('should render trigger button with placeholder', () => { + const { getByRole } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + expect(trigger).toBeInTheDocument(); + expect(trigger).toHaveTextContent('Choose fruits...'); + }); + + it('should open popover when clicked', async () => { + const { getByRole, getByText } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + + // Click to open popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Verify popover opened and options are visible + expect(getByText('Apple')).toBeInTheDocument(); + expect(getByText('Banana')).toBeInTheDocument(); + }); + + it('should close popover when item is selected in single mode', async () => { + const { getByRole, getByText, queryByText } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + + // Open popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Select an item + await act(async () => { + await userEvent.click(getByText('Apple')); + }); + + // Wait a bit for the popover to close + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + // Verify popover closed (Apple option should not be visible) + expect(queryByText('Banana')).not.toBeInTheDocument(); + }); + + it('should open and close popover when trigger is clicked', async () => { + const { getByRole, getByText, queryByText } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + + // Open popover + await act(async () => { + await userEvent.click(trigger); + }); + + expect(getByText('Apple')).toBeInTheDocument(); + + // Close popover by clicking trigger again + await act(async () => { + await userEvent.click(trigger); + }); + + // Wait for animation + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + expect(queryByText('Apple')).not.toBeInTheDocument(); + }); + }); + + describe('Selection sorting functionality', () => { + it('should NOT sort selected items to top while popover is open', async () => { + const { getByRole, getByText } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + + // Open popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Verify initial order + let listbox = getByRole('listbox'); + let options = within(listbox).getAllByRole('option'); + expect(options[0]).toHaveTextContent('Apple'); + expect(options[1]).toHaveTextContent('Banana'); + expect(options[2]).toHaveTextContent('Cherry'); + expect(options[3]).toHaveTextContent('Date'); + expect(options[4]).toHaveTextContent('Elderberry'); + + // Select Date (3rd item) + await act(async () => { + await userEvent.click(getByText('Date')); + }); + + // Select Banana (1st item) + await act(async () => { + await userEvent.click(getByText('Banana')); + }); + + // Order should remain the same while popover is open + listbox = getByRole('listbox'); + options = within(listbox).getAllByRole('option'); + expect(options[0]).toHaveTextContent('Apple'); + expect(options[1]).toHaveTextContent('Banana'); + expect(options[2]).toHaveTextContent('Cherry'); + expect(options[3]).toHaveTextContent('Date'); + expect(options[4]).toHaveTextContent('Elderberry'); + }); + + it('should sort selected items to top when popover reopens in multiple mode', async () => { + const { getByRole, getByText } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + + // Open popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Select Cherry (2nd item) and Elderberry (4th item) + await act(async () => { + await userEvent.click(getByText('Cherry')); + }); + await act(async () => { + await userEvent.click(getByText('Elderberry')); + }); + + // Close popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Reopen popover - selected items should be sorted to top + await act(async () => { + await userEvent.click(trigger); + }); + + const listbox = getByRole('listbox'); + const reorderedOptions = within(listbox).getAllByRole('option'); + expect(reorderedOptions[0]).toHaveTextContent('Cherry'); + expect(reorderedOptions[1]).toHaveTextContent('Elderberry'); + expect(reorderedOptions[2]).toHaveTextContent('Apple'); + expect(reorderedOptions[3]).toHaveTextContent('Banana'); + expect(reorderedOptions[4]).toHaveTextContent('Date'); + }, 10000); // 10 second timeout + + it('should sort selected items to top within their sections', async () => { + const { getByRole, getByText } = renderWithRoot( + + {sectionsItems} + , + ); + + const trigger = getByRole('button'); + + // Step 1: Open the popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Step 1.5: Verify initial order within sections + let listbox = getByRole('listbox'); + let fruitsSection = within(listbox).getByText('Fruits').closest('li'); + let vegetablesSection = within(listbox) + .getByText('Vegetables') + .closest('li'); + + let fruitsOptions = within(fruitsSection!).getAllByRole('option'); + let vegetablesOptions = within(vegetablesSection!).getAllByRole('option'); + + expect(fruitsOptions[0]).toHaveTextContent('Apple'); + expect(fruitsOptions[1]).toHaveTextContent('Banana'); + expect(fruitsOptions[2]).toHaveTextContent('Cherry'); + + expect(vegetablesOptions[0]).toHaveTextContent('Carrot'); + expect(vegetablesOptions[1]).toHaveTextContent('Broccoli'); + expect(vegetablesOptions[2]).toHaveTextContent('Spinach'); + + // Step 2: Select items from each section (Cherry from Fruits, Spinach from Vegetables) + await act(async () => { + await userEvent.click(getByText('Cherry')); + }); + await act(async () => { + await userEvent.click(getByText('Spinach')); + }); + + // Step 3: Close popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Step 4: Reopen popover and verify sorting within sections + await act(async () => { + await userEvent.click(trigger); + }); + + listbox = getByRole('listbox'); + fruitsSection = within(listbox).getByText('Fruits').closest('li'); + vegetablesSection = within(listbox).getByText('Vegetables').closest('li'); + + fruitsOptions = within(fruitsSection!).getAllByRole('option'); + vegetablesOptions = within(vegetablesSection!).getAllByRole('option'); + + // Check that Cherry is first in Fruits section + expect(fruitsOptions[0]).toHaveTextContent('Cherry'); + expect(fruitsOptions[1]).toHaveTextContent('Apple'); + expect(fruitsOptions[2]).toHaveTextContent('Banana'); + + // Check that Spinach is first in Vegetables section + expect(vegetablesOptions[0]).toHaveTextContent('Spinach'); + expect(vegetablesOptions[1]).toHaveTextContent('Carrot'); + expect(vegetablesOptions[2]).toHaveTextContent('Broccoli'); + }, 10000); // 10 second timeout + + it('should maintain sorting when items are deselected and popover reopens', async () => { + const { getByRole, getByText } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + + // Open popover and select items + await act(async () => { + await userEvent.click(trigger); + }); + + // Verify initial order + let listbox = getByRole('listbox'); + let options = within(listbox).getAllByRole('option'); + expect(options[0]).toHaveTextContent('Apple'); + expect(options[1]).toHaveTextContent('Banana'); + expect(options[2]).toHaveTextContent('Cherry'); + expect(options[3]).toHaveTextContent('Date'); + expect(options[4]).toHaveTextContent('Elderberry'); + + await act(async () => { + await userEvent.click(getByText('Date')); + }); + await act(async () => { + await userEvent.click(getByText('Banana')); + }); + + // Close and reopen + await act(async () => { + await userEvent.click(trigger); + }); + await act(async () => { + await userEvent.click(trigger); + }); + + // Verify selected items are now sorted to top + listbox = getByRole('listbox'); + options = within(listbox).getAllByRole('option'); + expect(options[0]).toHaveTextContent('Banana'); + expect(options[1]).toHaveTextContent('Date'); + expect(options[2]).toHaveTextContent('Apple'); + expect(options[3]).toHaveTextContent('Cherry'); + expect(options[4]).toHaveTextContent('Elderberry'); + + // Deselect Date + await act(async () => { + await userEvent.click(getByText('Date')); + }); + + // Close and reopen again + await act(async () => { + await userEvent.click(trigger); + }); + await act(async () => { + await userEvent.click(trigger); + }); + + // Verify only Banana is now at the top + listbox = getByRole('listbox'); + options = within(listbox).getAllByRole('option'); + expect(options[0]).toHaveTextContent('Banana'); + expect(options[1]).toHaveTextContent('Apple'); + expect(options[2]).toHaveTextContent('Cherry'); + expect(options[3]).toHaveTextContent('Date'); + expect(options[4]).toHaveTextContent('Elderberry'); + }, 10000); // 10 second timeout + + it('should not reorder items when selecting additional items after reopening popover', async () => { + const { getByRole, getByText } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + + // Step 1: Open popover and select items + await act(async () => { + await userEvent.click(trigger); + }); + + await act(async () => { + await userEvent.click(getByText('Cherry')); + }); + await act(async () => { + await userEvent.click(getByText('Elderberry')); + }); + + // Step 2: Close and reopen to trigger sorting + await act(async () => { + await userEvent.click(trigger); + }); + await act(async () => { + await userEvent.click(trigger); + }); + + // Step 3: Verify sorted order + let listbox = getByRole('listbox'); + let options = within(listbox).getAllByRole('option'); + expect(options[0]).toHaveTextContent('Cherry'); + expect(options[1]).toHaveTextContent('Elderberry'); + expect(options[2]).toHaveTextContent('Apple'); + expect(options[3]).toHaveTextContent('Banana'); + expect(options[4]).toHaveTextContent('Date'); + + // Step 4: Select additional item (should not trigger reordering while open) + await act(async () => { + await userEvent.click(getByText('Apple')); + }); + + // Step 5: Verify order remains stable (no resorting while popover is open) + listbox = getByRole('listbox'); + options = within(listbox).getAllByRole('option'); + expect(options[0]).toHaveTextContent('Cherry'); + expect(options[1]).toHaveTextContent('Elderberry'); + expect(options[2]).toHaveTextContent('Apple'); + expect(options[3]).toHaveTextContent('Banana'); + expect(options[4]).toHaveTextContent('Date'); + }, 10000); // 10 second timeout + + it('should work correctly in single selection mode', async () => { + const { getByRole, getByText } = renderWithRoot( + + {basicItems} + , + ); + + const trigger = getByRole('button'); + + // Open popover and select an item + await act(async () => { + await userEvent.click(trigger); + }); + + await act(async () => { + await userEvent.click(getByText('Date')); + }); + + // Wait for popover to close (single selection auto-closes) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + // Reopen to check sorting + await act(async () => { + await userEvent.click(trigger); + }); + + const listbox = getByRole('listbox'); + const options = within(listbox).getAllByRole('option'); + + // In single mode, selected item should be sorted to top + expect(options[0]).toHaveTextContent('Date'); + expect(options[1]).toHaveTextContent('Apple'); + expect(options[2]).toHaveTextContent('Banana'); + expect(options[3]).toHaveTextContent('Cherry'); + expect(options[4]).toHaveTextContent('Elderberry'); + }, 10000); // 10 second timeout + }); + + describe('Form integration', () => { + it('should work with form field wrapper', async () => { + const { getByRole } = renderWithRoot( + + {basicItems} + , + ); + + // Wait for component to render + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + const trigger = getByRole('button'); + expect(trigger).toBeInTheDocument(); + }); + }); + + describe('Refs', () => { + it('should forward ref to wrapper element', async () => { + const ref = createRef(); + + const { container } = renderWithRoot( + + {basicItems} + , + ); + + // Wait for component to render + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + // Check that the component renders properly + expect(container.firstChild).toBeInTheDocument(); + }); + }); + + describe('isCheckable prop functionality', () => { + it('should show checkboxes when isCheckable is true in multiple selection mode', async () => { + const { getByRole } = renderWithRoot( + + {basicItems} + , + ); + + // Wait for component to render + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + const trigger = getByRole('button'); + + // Open the popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Look for checkboxes (they should be present when isCheckable=true in multiple mode) + const listbox = getByRole('listbox'); + const checkboxes = within(listbox).getAllByTestId(/CheckIcon/); + expect(checkboxes.length).toBeGreaterThan(0); + }); + + it('should not show checkboxes when isCheckable is false', async () => { + const { getByRole } = renderWithRoot( + + {basicItems} + , + ); + + // Wait for component to render + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + const trigger = getByRole('button'); + + // Open the popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Look for checkboxes (they should not be present when isCheckable=false) + const listbox = getByRole('listbox'); + const checkboxes = within(listbox).queryAllByRole('checkbox'); + expect(checkboxes).toHaveLength(0); + }); + + it('should not show checkboxes in single selection mode even when isCheckable is true', async () => { + const { getByRole } = renderWithRoot( + + {basicItems} + , + ); + + // Wait for component to render + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + const trigger = getByRole('button'); + + // Open the popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Look for checkboxes (they should not be present in single selection mode) + const listbox = getByRole('listbox'); + const checkboxes = within(listbox).queryAllByRole('checkbox'); + expect(checkboxes).toHaveLength(0); + }); + + it('should handle different click behaviors: checkbox click keeps popover open, content click closes popover', async () => { + const { getByRole, getByText, queryByRole } = renderWithRoot( + + {basicItems} + , + ); + + // Wait for component to render + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + const trigger = getByRole('button'); + + // Open the popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Click on the content area of an option (not the checkbox) + const appleOption = getByText('Apple'); + await act(async () => { + await userEvent.click(appleOption); + }); + + // For checkable items in multiple mode, content click should close the popover + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + // Popover should be closed + expect(queryByRole('listbox')).not.toBeInTheDocument(); + }); + }); + + describe('allowsCustomValue functionality', () => { + it('should support custom values and display them in trigger', async () => { + const onSelectionChange = jest.fn(); + + const { getByRole, getByPlaceholderText, getByText } = renderWithRoot( + + {basicItems} + , + ); + + // Wait for component to render + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + const trigger = getByRole('button'); + + // Initially trigger should show placeholder + expect(trigger).toHaveTextContent('Choose fruits...'); + + // Open the popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Type a custom value + const searchInput = getByPlaceholderText('Search...'); + await act(async () => { + await userEvent.type(searchInput, 'mango'); + }); + + // Select the custom value + const customOption = getByText('mango'); + await act(async () => { + await userEvent.click(customOption); + }); + + expect(onSelectionChange).toHaveBeenCalledWith('mango'); + + // Wait for popover to close + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + }); + + // Trigger should now show the custom value + expect(trigger).toHaveTextContent('mango'); + }); + + it('should support custom values in multiple selection mode', async () => { + const onSelectionChange = jest.fn(); + + const { getByRole, getByPlaceholderText, getByText } = renderWithRoot( + + {basicItems} + , + ); + + // Wait for component to render + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + const trigger = getByRole('button'); + + // Open the popover + await act(async () => { + await userEvent.click(trigger); + }); + + // First select a regular option + await act(async () => { + await userEvent.click(getByText('Apple')); + }); + + expect(onSelectionChange).toHaveBeenCalledWith(['apple']); + + // Type a custom value + const searchInput = getByPlaceholderText('Search...'); + await act(async () => { + await userEvent.type(searchInput, 'mango'); + }); + + // Select the custom value + const customOption = getByText('mango'); + await act(async () => { + await userEvent.click(customOption); + }); + + expect(onSelectionChange).toHaveBeenCalledWith(['apple', 'mango']); + }); + + it('should persist custom values across popover sessions', async () => { + const { getByRole, getByPlaceholderText, getByText } = renderWithRoot( + + {basicItems} + , + ); + + // Wait for component to render + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + const trigger = getByRole('button'); + + // Open popover and add custom value + await act(async () => { + await userEvent.click(trigger); + }); + + const searchInput = getByPlaceholderText('Search...'); + await act(async () => { + await userEvent.type(searchInput, 'mango'); + }); + + await act(async () => { + await userEvent.click(getByText('mango')); + }); + + // Close popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Reopen popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Custom value should still be visible in the list + expect(getByRole('option', { name: 'mango' })).toBeInTheDocument(); + }); + + it('should show selected custom values in the list when popover reopens', async () => { + const { getByRole, getByPlaceholderText, getByText } = renderWithRoot( + + {basicItems} + , + ); + + // Wait for component to render + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + const trigger = getByRole('button'); + + // Session 1: Add a custom value and select Apple + await act(async () => { + await userEvent.click(trigger); + }); + + const searchInput = getByPlaceholderText('Search...'); + await act(async () => { + await userEvent.type(searchInput, 'mango'); + }); + + await act(async () => { + await userEvent.click(getByText('mango')); + }); + + await act(async () => { + await userEvent.clear(searchInput); + }); + + await act(async () => { + await userEvent.click(getByText('Apple')); + }); + + // Close popover + await act(async () => { + await userEvent.click(trigger); + }); + + // Session 2: Reopen and verify both are visible and selected items are sorted to top + await act(async () => { + await userEvent.click(trigger); + }); + + const listbox = getByRole('listbox'); + const options = within(listbox).getAllByRole('option'); + + // Selected custom value and Apple should be at the top + expect(options[0]).toHaveTextContent('mango'); + expect(options[1]).toHaveTextContent('Apple'); + }); + }); +}); diff --git a/src/components/fields/FilterPicker/FilterPicker.tsx b/src/components/fields/FilterPicker/FilterPicker.tsx new file mode 100644 index 00000000..7ef96ae3 --- /dev/null +++ b/src/components/fields/FilterPicker/FilterPicker.tsx @@ -0,0 +1,713 @@ +import { FocusableRef } from '@react-types/shared'; +import React, { + ForwardedRef, + forwardRef, + ReactElement, + ReactNode, + useRef, + useState, +} from 'react'; +import { Section as BaseSection, Item } from 'react-stately'; + +import { useWarn } from '../../../_internal/hooks/use-warn'; +import { DirectionIcon } from '../../../icons'; +import { useProviderProps } from '../../../provider'; +import { + BASE_STYLES, + BasePropsWithoutChildren, + BaseStyleProps, + COLOR_STYLES, + ColorStyleProps, + extractStyles, + OUTER_STYLES, + OuterStyleProps, + Styles, +} from '../../../tasty'; +import { mergeProps } from '../../../utils/react'; +import { Button } from '../../actions'; +import { Text } from '../../content/Text'; +import { useFieldProps, useFormProps, wrapWithField } from '../../form'; +import { Dialog, DialogTrigger } from '../../overlays/Dialog'; +import { + CubeFilterListBoxProps, + FilterListBox, +} from '../FilterListBox/FilterListBox'; +import { ListBox } from '../ListBox'; + +import type { FieldBaseProps } from '../../../shared'; + +export interface CubeFilterPickerProps + extends Omit, 'children'>, + BasePropsWithoutChildren, + BaseStyleProps, + OuterStyleProps, + ColorStyleProps, + FieldBaseProps { + /** Placeholder text when no selection is made */ + placeholder?: string; + /** Icon to show in the trigger */ + icon?: ReactElement; + /** Type of button styling */ + type?: + | 'outline' + | 'clear' + | 'primary' + | 'secondary' + | 'neutral' + | (string & {}); + /** Button theme */ + theme?: 'default' | 'special'; + /** Size of the component */ + size?: 'small' | 'medium' | 'large'; + /** Children (FilterListBox.Item and FilterListBox.Section elements) */ + children?: ReactNode; + /** Custom styles for the list box */ + listBoxStyles?: Styles; + /** Custom styles for the popover */ + popoverStyles?: Styles; + /** Whether the filter picker is checkable */ + isCheckable?: boolean; + + /** + * Custom renderer for the summary shown inside the trigger **when there is a selection**. + * + * For `selectionMode="multiple"` the function receives: + * - `selectedLabels`: array of labels of the selected items. + * - `selectedKeys`: array of keys of the selected items. + * + * For `selectionMode="single"` the function receives: + * - `selectedLabel`: label of the selected item. + * - `selectedKey`: key of the selected item. + * + * The function should return a `ReactNode` that will be rendered inside the trigger. + */ + renderSummary?: + | ((args: { + selectedLabels: string[]; + selectedKeys: (string | number)[]; + selectedLabel?: string; + selectedKey?: string | number | null; + selectionMode: 'single' | 'multiple'; + }) => ReactNode) + | false; + + /** Optional ref to access internal ListBox state (from FilterListBox) */ + listStateRef?: React.MutableRefObject; + /** Mods for the FilterPicker */ + mods?: Record; +} + +const PROP_STYLES = [...BASE_STYLES, ...OUTER_STYLES, ...COLOR_STYLES]; + +export const FilterPicker = forwardRef(function FilterPicker( + props: CubeFilterPickerProps, + ref: ForwardedRef, +) { + props = useProviderProps(props); + props = useFormProps(props); + props = useFieldProps(props, { + valuePropsMapper: ({ value, onChange }) => { + const fieldProps: any = {}; + + if (props.selectionMode === 'multiple') { + fieldProps.selectedKeys = value || []; + } else { + fieldProps.selectedKey = value ?? null; + } + + fieldProps.onSelectionChange = (key: any) => { + if (props.selectionMode === 'multiple') { + onChange(key ? (Array.isArray(key) ? key : [key]) : []); + } else { + onChange(Array.isArray(key) ? key[0] : key); + } + }; + + return fieldProps; + }, + }); + + let { + qa, + label, + extra, + icon, + labelStyles, + isRequired, + necessityIndicator, + validationState, + isDisabled, + isLoading, + message, + mods: externalMods, + description, + placeholder, + size = 'small', + styles, + listBoxStyles, + popoverStyles, + type = 'outline', + theme = 'default', + labelSuffix, + children, + selectedKey, + defaultSelectedKey, + selectedKeys, + defaultSelectedKeys, + disabledKeys, + onSelectionChange, + selectionMode = 'single', + listStateRef, + focusOnHover, + header, + footer, + headerStyles, + footerStyles, + allowsCustomValue, + renderSummary, + isCheckable, + ...otherProps + } = props; + + styles = extractStyles(otherProps, PROP_STYLES, styles); + + // Warn if isCheckable is false in single selection mode + useWarn(isCheckable === false && selectionMode === 'single', { + key: ['filterpicker-checkable-single-mode'], + args: [ + 'CubeUIKit: isCheckable=false is not recommended in single selection mode as it may confuse users about selection behavior.', + ], + }); + + // Internal selection state (uncontrolled scenario) + const [internalSelectedKey, setInternalSelectedKey] = useState( + defaultSelectedKey ?? null, + ); + const [internalSelectedKeys, setInternalSelectedKeys] = useState( + defaultSelectedKeys ?? [], + ); + + // Track popover open/close and capture children order for session + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const cachedChildrenOrder = useRef(null); + const triggerRef = useRef(null); + + const isControlledSingle = selectedKey !== undefined; + const isControlledMultiple = selectedKeys !== undefined; + + const effectiveSelectedKey = isControlledSingle + ? selectedKey + : internalSelectedKey; + const effectiveSelectedKeys = isControlledMultiple + ? selectedKeys + : internalSelectedKeys; + + // Utility to normalize React keys by stripping array prefixes like ".$" or "." + const normalizeKeyValue = (key: any): string => { + if (key == null) return ''; + const str = String(key); + return str.startsWith('.$') + ? str.slice(2) + : str.startsWith('.') + ? str.slice(1) + : str; + }; + + // Given an iterable of keys (array or Set) toggle membership for duplicates + const processSelectionArray = (iterable: Iterable): string[] => { + const resultSet = new Set(); + for (const key of iterable) { + const nKey = normalizeKeyValue(key); + if (resultSet.has(nKey)) { + resultSet.delete(nKey); // toggle off if clicked twice + } else { + resultSet.add(nKey); // select + } + } + return Array.from(resultSet); + }; + + // Helper to get selected item labels for display + const getSelectedLabels = () => { + if (!children) return []; + + const selectedSet = new Set( + selectionMode === 'multiple' + ? (effectiveSelectedKeys || []).map(String) + : effectiveSelectedKey != null + ? [String(effectiveSelectedKey)] + : [], + ); + + const labels: string[] = []; + + const extractLabels = (nodes: ReactNode): void => { + React.Children.forEach(nodes, (child: any) => { + if (!child || typeof child !== 'object') return; + + if (child.type === Item) { + if (selectedSet.has(String(child.key))) { + const label = + child.props.textValue || + (typeof child.props.children === 'string' + ? child.props.children + : '') || + String(child.props.children || ''); + labels.push(label); + } + } + + if (child.props?.children) { + extractLabels(child.props.children); + } + }); + }; + + const processedKeys = new Set(); + + // Modified extractLabels to track which keys we've processed + const extractLabelsWithTracking = (nodes: ReactNode): void => { + React.Children.forEach(nodes, (child: any) => { + if (!child || typeof child !== 'object') return; + + if (child.type === Item) { + const childKey = String(child.key); + if (selectedSet.has(childKey)) { + const label = + child.props.textValue || + (typeof child.props.children === 'string' + ? child.props.children + : '') || + String(child.props.children || ''); + labels.push(label); + processedKeys.add(childKey); + } + } + + if (child.props?.children) { + extractLabelsWithTracking(child.props.children); + } + }); + }; + + extractLabelsWithTracking(children); + + // Handle custom values that don't have corresponding children + const selectedKeysArr = + selectionMode === 'multiple' + ? (effectiveSelectedKeys || []).map(String) + : effectiveSelectedKey != null + ? [String(effectiveSelectedKey)] + : []; + + // Add labels for any selected keys that weren't processed (custom values) + selectedKeysArr.forEach((key) => { + if (!processedKeys.has(key)) { + // This is a custom value, use the key as the label + labels.push(key); + } + }); + + return labels; + }; + + const selectedLabels = getSelectedLabels(); + const hasSelection = selectedLabels.length > 0; + + // Always keep the latest selection in a ref (with normalized keys) so that we can read it synchronously in the popover close effect. + const latestSelectionRef = useRef<{ + single: string | null; + multiple: string[]; + }>({ + single: + effectiveSelectedKey != null + ? normalizeKeyValue(effectiveSelectedKey) + : null, + multiple: (effectiveSelectedKeys ?? []).map(normalizeKeyValue), + }); + + React.useEffect(() => { + latestSelectionRef.current = { + single: + effectiveSelectedKey != null + ? normalizeKeyValue(effectiveSelectedKey) + : null, + multiple: (effectiveSelectedKeys ?? []).map(normalizeKeyValue), + }; + }, [effectiveSelectedKey, effectiveSelectedKeys]); + const selectionsWhenClosed = useRef<{ + single: string | null; + multiple: string[]; + }>({ single: null, multiple: [] }); + + // Function to sort children with selected items on top + const getSortedChildren = React.useCallback(() => { + if (!children) return children; + + // When the popover is **closed** we don't want to trigger any resorting – + // that could cause visible re-flows during the fade-out animation. Simply + // reuse whatever order we had while it was open (if available). + if (!isPopoverOpen) { + return cachedChildrenOrder.current ?? children; + } + + // Popover is open – compute (or recompute) the sorted order for this + // session. + + // Determine if there were any selections when the popover was previously closed. + const hadSelectionsWhenClosed = + selectionMode === 'multiple' + ? selectionsWhenClosed.current.multiple.length > 0 + : selectionsWhenClosed.current.single !== null; + + // Only apply sorting when there were selections in the previous session. + // We intentionally do not depend on the `isPopoverOpen` flag here because that + // flag is updated **after** the first render triggered by clicking the + // trigger button. Relying on it caused a timing issue where the very first + // render of a freshly-opened popover was unsorted. By removing the + // `isPopoverOpen` check we ensure items are already sorted during that first + // render while still maintaining stable order within an open popover thanks + // to the `cachedChildrenOrder` guard above. + + if (!hadSelectionsWhenClosed) { + return children; + } + + // Create selected keys set for fast lookup + const selectedSet = new Set(); + if (selectionMode === 'multiple') { + selectionsWhenClosed.current.multiple.forEach((key) => + selectedSet.add(normalizeKeyValue(key)), + ); + } else if ( + selectionMode === 'single' && + selectionsWhenClosed.current.single != null + ) { + selectedSet.add(normalizeKeyValue(selectionsWhenClosed.current.single)); + } + + // Helper function to check if an item is selected + const isItemSelected = (child: any): boolean => { + return ( + child?.key != null && selectedSet.has(normalizeKeyValue(child.key)) + ); + }; + + // Helper function to sort children array + const sortChildrenArray = (childrenArray: ReactNode[]): ReactNode[] => { + const cloneWithNormalizedKey = (item: any) => + React.cloneElement(item, { + key: normalizeKeyValue(item.key), + }); + + const selected: ReactNode[] = []; + const unselected: ReactNode[] = []; + + childrenArray.forEach((child: any) => { + if (!child || typeof child !== 'object') { + unselected.push(child); + return; + } + + // Handle sections - sort items within each section + if ( + child.type === BaseSection || + child.type?.displayName === 'Section' + ) { + const sectionChildren = Array.isArray(child.props.children) + ? child.props.children + : [child.props.children]; + + const selectedItems: ReactNode[] = []; + const unselectedItems: ReactNode[] = []; + + sectionChildren.forEach((sectionChild: any) => { + if ( + sectionChild && + typeof sectionChild === 'object' && + (sectionChild.type === Item || + sectionChild.type?.displayName === 'Item') + ) { + const clonedItem = cloneWithNormalizedKey(sectionChild); + + if (isItemSelected(sectionChild)) { + selectedItems.push(clonedItem); + } else { + unselectedItems.push(clonedItem); + } + } else { + unselectedItems.push(sectionChild); + } + }); + + // Create new section with sorted children, preserving React element properly + unselected.push( + React.cloneElement(child, { + ...child.props, + children: [...selectedItems, ...unselectedItems], + }), + ); + } + // Handle non-section elements (items, dividers, etc.) + else { + const clonedItem = cloneWithNormalizedKey(child); + + if (isItemSelected(child)) { + selected.push(clonedItem); + } else { + unselected.push(clonedItem); + } + } + }); + + return [...selected, ...unselected]; + }; + + // Sort the children + const childrenArray = React.Children.toArray(children); + const sortedChildren = sortChildrenArray(childrenArray); + + // Cache the sorted order when popover opens + if (isPopoverOpen) { + cachedChildrenOrder.current = sortedChildren; + } + + return sortedChildren; + }, [ + children, + effectiveSelectedKeys, + effectiveSelectedKey, + selectionMode, + isPopoverOpen, + ]); + + // FilterListBox handles custom values internally when allowsCustomValue={true} + // We only provide the sorted original children + const finalChildren = getSortedChildren(); + + // Function to close popover and focus trigger button + const closeAndFocus = React.useCallback((close: () => void) => { + close(); + // Use setTimeout to ensure the popover closes first, then focus the trigger + setTimeout(() => { + if (triggerRef.current) { + triggerRef.current.focus(); + } + }, 0); + }, []); + + const renderTriggerContent = () => { + // When there is a selection and a custom summary renderer is provided – use it. + if (hasSelection && typeof renderSummary === 'function') { + if (selectionMode === 'single') { + return renderSummary({ + selectedLabel: selectedLabels[0], + selectedKey: effectiveSelectedKey ?? null, + selectedLabels, + selectedKeys: effectiveSelectedKeys as any, + selectionMode: 'single', + }); + } + + return renderSummary({ + selectedLabels, + selectedKeys: effectiveSelectedKeys as any, + selectionMode: 'multiple', + }); + } else if (hasSelection && renderSummary === false) { + return null; + } + + let content: string | null | undefined = ''; + + if (!hasSelection) { + content = placeholder; + } else if (selectionMode === 'single') { + content = selectedLabels[0]; + } else { + content = selectedLabels.join(', '); + } + + if (!content) { + return null; + } + + return ( + + {content} + + ); + }; + + // The trigger is rendered as a function so we can access the dialog state + const renderTrigger = (state) => { + // Track popover open/close state to control sorting + React.useEffect(() => { + if (state.isOpen !== isPopoverOpen) { + setIsPopoverOpen(state.isOpen); + if (!state.isOpen) { + // Popover just closed – preserve the current sorted order so the + // fade-out animation keeps its layout unchanged. We only need to + // record the latest selection for the next session. + selectionsWhenClosed.current = { ...latestSelectionRef.current }; + } + } + }, [state.isOpen, isPopoverOpen]); + + return ( + + ); + }; + + const filterPickerField = ( + + {renderTrigger} + {(close) => ( + + closeAndFocus(close)} + onOptionClick={(key) => { + // For FilterPicker, clicking the content area should close the popover + // in multiple selection mode (single mode already closes via onSelectionChange) + if (selectionMode === 'multiple' && isCheckable) { + closeAndFocus(close); + } + }} + onSelectionChange={(selection) => { + // No need to change any flags - children order is cached + + // Update internal state if uncontrolled + if (selectionMode === 'single') { + if (!isControlledSingle) { + setInternalSelectedKey(selection as any); + } + } else { + if (!isControlledMultiple) { + let normalized: any = selection; + + if (Array.isArray(selection)) { + normalized = processSelectionArray(selection); + } else if ( + selection && + typeof selection === 'object' && + selection instanceof Set + ) { + normalized = processSelectionArray(selection as any); + } + + setInternalSelectedKeys(normalized as any); + } + } + + // Update latest selection ref synchronously + if (selectionMode === 'single') { + latestSelectionRef.current.single = selection as any; + } else { + if (Array.isArray(selection)) { + latestSelectionRef.current.multiple = Array.from( + new Set(processSelectionArray(selection)), + ); + } else if ( + selection && + typeof selection === 'object' && + selection instanceof Set + ) { + latestSelectionRef.current.multiple = Array.from( + new Set(processSelectionArray(selection as any)), + ); + } else { + latestSelectionRef.current.multiple = selection as any; + } + } + + onSelectionChange?.(selection as any); + + if (selectionMode === 'single') { + closeAndFocus(close); + } + }} + > + {finalChildren} + + + )} + + ); + + return wrapWithField, 'children'>>( + filterPickerField, + ref as any, + mergeProps( + { + ...props, + styles: undefined, + }, + {}, + ), + ); +}) as unknown as (( + props: CubeFilterPickerProps & { ref?: ForwardedRef }, +) => ReactElement) & { Item: typeof Item; Section: typeof BaseSection }; + +FilterPicker.Item = ListBox.Item; + +FilterPicker.Section = BaseSection; + +Object.defineProperty(FilterPicker, 'cubeInputType', { + value: 'FilterPicker', + enumerable: false, + configurable: false, +}); diff --git a/src/components/fields/FilterPicker/index.ts b/src/components/fields/FilterPicker/index.ts new file mode 100644 index 00000000..081ed946 --- /dev/null +++ b/src/components/fields/FilterPicker/index.ts @@ -0,0 +1 @@ +export * from './FilterPicker'; diff --git a/src/components/fields/ListBox/ListBox.docs.mdx b/src/components/fields/ListBox/ListBox.docs.mdx index ccdeeeea..f1f464dc 100644 --- a/src/components/fields/ListBox/ListBox.docs.mdx +++ b/src/components/fields/ListBox/ListBox.docs.mdx @@ -6,17 +6,16 @@ import * as ListBoxStories from './ListBox.stories'; # ListBox -A list box component that allows users to select one or more items from a list of options. It supports sections, descriptions, optional search functionality, and full keyboard navigation with virtual focus. Built with React Aria's `useListBox` for accessibility and the Cube `tasty` style system for theming. +A versatile list selection component that allows users to select one or more items from a list of options. Built with React Aria's accessibility features and the Cube `tasty` style system, it supports sections, descriptions, keyboard navigation, and virtualization for large datasets. ## When to Use - Present a list of selectable options in a contained area - Enable single or multiple selection from a set of choices -- Provide searchable selection for large lists of options - Display structured data with sections and descriptions - Create custom selection interfaces that need to remain visible - Build form controls that require persistent option visibility -- Customize empty state messages when no results are found +- Use when you have a small to medium number of options (for larger lists with search, consider FilterListBox) ## Component @@ -41,10 +40,6 @@ Customizes the root wrapper element of the component. **Sub-elements:** - `ValidationState` - Container for validation and loading indicators -#### searchInputStyles - -Customizes the search input when `isSearchable` is true. - #### listStyles Customizes the list container element. @@ -56,6 +51,9 @@ Customizes individual option elements. **Sub-elements:** - `Label` - The main text of each option - `Description` - Secondary descriptive text for options +- `Content` - Container for label and description +- `Checkbox` - Checkbox element when `isCheckable={true}` +- `CheckboxWrapper` - Wrapper around the checkbox #### sectionStyles @@ -65,15 +63,17 @@ Customizes section wrapper elements. Customizes section heading elements. -#### emptyLabel +#### headerStyles -Custom label to display when no results are found after filtering. Accepts any ReactNode including text, JSX elements, or components. Defaults to "No results found". +Customizes the header area when header prop is provided. -### Style Properties +#### footerStyles + +Customizes the footer area when footer prop is provided. -The ListBox component supports all standard style properties: +### Style Properties -`display`, `font`, `preset`, `hide`, `opacity`, `whiteSpace`, `gridArea`, `order`, `gridColumn`, `gridRow`, `placeSelf`, `alignSelf`, `justifySelf`, `zIndex`, `margin`, `inset`, `position`, `width`, `height`, `flexBasis`, `flexGrow`, `flexShrink`, `flex`, `reset`, `padding`, `paddingInline`, `paddingBlock`, `shadow`, `border`, `radius`, `overflow`, `scrollbar`, `outline`, `textAlign`, `color`, `fill`, `fade`, `textTransform`, `fontWeight`, `fontStyle`, `flow`, `placeItems`, `placeContent`, `alignItems`, `alignContent`, `justifyItems`, `justifyContent`, `align`, `justify`, `gap`, `columnGap`, `rowGap`, `gridColumns`, `gridRows`, `gridTemplate`, `gridAreas` +These properties allow direct style application without using the `styles` prop: `display`, `font`, `preset`, `hide`, `opacity`, `whiteSpace`, `color`, `fill`, `fade`, `width`, `height`, `flexBasis`, `flexGrow`, `flexShrink`, `flex`, `gridArea`, `order`, `gridColumn`, `gridRow`, `placeSelf`, `alignSelf`, `justifySelf`, `zIndex`, `margin`, `inset`, `position`, `reset`, `padding`, `paddingInline`, `paddingBlock`, `shadow`, `border`, `radius`, `overflow`, `scrollbar`, `outline`, `textAlign`, `flow`, `placeItems`, `placeContent`, `alignItems`, `alignContent`, `justifyItems`, `justifyContent`, `align`, `justify`, `gap`, `columnGap`, `rowGap`, `gridColumns`, `gridRows`, `gridTemplate`, `gridAreas`. ### Modifiers @@ -81,64 +81,37 @@ The `mods` property accepts the following modifiers you can override: | Modifier | Type | Description | |----------|------|-------------| -| invalid | `boolean` | Whether the ListBox has validation errors | -| valid | `boolean` | Whether the ListBox is valid | -| disabled | `boolean` | Whether the ListBox is disabled | -| focused | `boolean` | Whether the ListBox has focus | -| loading | `boolean` | Whether the ListBox is in loading state | -| searchable | `boolean` | Whether the ListBox includes search functionality | +| `invalid` | `boolean` | Applied when `validationState="invalid"` | +| `valid` | `boolean` | Applied when `validationState="valid"` | +| `disabled` | `boolean` | Applied when `isDisabled={true}` | +| `focused` | `boolean` | Applied when the ListBox has focus | +| `header` | `boolean` | Applied when header prop is provided | +| `footer` | `boolean` | Applied when footer prop is provided | ## Variants +### Selection Modes + +- `single` - Allows selecting only one item at a time +- `multiple` - Allows selecting multiple items +- `none` - No selection allowed (display only) + ### Sizes -- `small` - Compact size for dense interfaces -- `default` - Standard size -- `large` - Emphasized size for important selections +- `small` - Compact size for dense interfaces (32px item height) +- `medium` - Standard size for general use (40px item height) ## Examples ### Basic Usage -```jsx - - Apple - Banana - Cherry - -``` - -### With Search - - + ```jsx - + Apple Banana Cherry - {/* More items... */} - -``` - -### Custom Empty Label - - - -```jsx - - Apple - Banana ``` @@ -148,15 +121,18 @@ The `mods` property accepts the following modifiers you can override: ```jsx - + React - + Vue.js - - Angular - ``` @@ -185,288 +161,183 @@ The `mods` property accepts the following modifiers you can override: HTML CSS JavaScript - React ``` -### Controlled Selection +### With Header and Footer - + ```jsx -const [selectedKey, setSelectedKey] = useState('apple'); - + Languages + 12 + + } + footer={ + + Popular languages shown + + } > - Apple - Banana - Cherry - -``` - -### Different Sizes - -```jsx - - Option 1 - - - - Option 1 + JavaScript + Python ``` -### Validation States +### Controlled Selection - + ```jsx - - Valid Option - +const [selectedKey, setSelectedKey] = useState('apple'); - Option 1 + Apple + Banana ``` -### Disabled State +### Virtualized Large Lists - + ```jsx - - Option 1 - Option 2 + + {Array.from({ length: 1000 }, (_, i) => ( + Item {i + 1} + ))} ``` -### Search with Loading State +### In Forms - + ```jsx - - Option 1 - +
+ + + React + Vue.js + + + Submit +
``` ## Accessibility ### Keyboard Navigation -#### Search Field Navigation (when `isSearchable` is true) - -- `Tab` - Moves focus to the search input -- `Arrow Down/Up` - Moves virtual focus through options while keeping input focused -- `Enter` - Selects the currently highlighted option -- `Space` - In multiple selection mode, toggles selection of the highlighted option -- `Left/Right Arrow` - Moves text cursor within the search input (normal text editing) -- `Escape` - Clears the search input - -#### Direct ListBox Navigation (when search is not used) - -- `Tab` - Moves focus to the ListBox -- `Arrow Down/Up` - Moves focus to the next/previous option +- `Tab` - Moves focus to/from the ListBox +- `Arrow Keys` - Navigate between options +- `Space/Enter` - Select/deselect the focused option +- `Home/End` - Move to first/last option +- `Page Up/Page Down` - Move up/down by multiple items +- `Escape` - Deselect all items (if onEscape not provided) ### Screen Reader Support -- Component announces as "listbox" to screen readers -- Current selection and total options are announced -- Section headings are properly associated with their options -- Option descriptions are read along with option labels -- When using search, the currently highlighted option is announced via `aria-activedescendant` -- Virtual focus ensures smooth navigation without focus jumps -- Loading and validation states are communicated +- ListBox announces as "listbox" with proper role +- Selected items are announced as "selected" +- Section headings are properly associated with their items +- Selection changes are announced immediately +- Item descriptions are read along with labels ### ARIA Properties - `aria-label` - Provides accessible label when no visible label exists -- `aria-labelledby` - References external label elements -- `aria-describedby` - References additional descriptive text -- `aria-multiselectable` - Indicates if multiple selection is allowed -- `aria-activedescendant` - References the currently highlighted option (especially with search) -- `aria-controls` - Links the search input to the listbox it controls -- `aria-required` - Indicates if selection is required -- `aria-invalid` - Indicates validation state +- `aria-labelledby` - Associates with external label element +- `aria-describedby` - Associates with description text +- `aria-multiselectable` - Indicates multiple selection capability +- `aria-activedescendant` - Tracks focused item for screen readers ## Best Practices -1. **Do**: Provide clear, descriptive labels and option text +1. **Do**: Provide clear, descriptive labels for options ```jsx - - - JavaScript - - + + React + ``` -2. **Don't**: Use ListBox for navigation or actions +2. **Don't**: Use ListBox for very large datasets without virtualization ```jsx - {/* Use Menu or navigation components instead */} - Go Home - Logout + // ❌ Avoid for 1000+ items without virtualization + + {hugeArray.map(item => {item.name})} ``` -3. **Performance**: Enable search for lists with more than 10-15 options -4. **Organization**: Use sections to group related options logically -5. **Descriptions**: Provide helpful descriptions for complex or technical options -6. **Validation**: Provide clear error messages for validation failures -7. **Selection**: Consider multiple selection for scenarios where users might need several options -8. **Search Experience**: When using search, ensure the virtual focus behavior feels natural to users -9. **Keyboard Users**: Test that arrow key navigation works smoothly with the search input +3. **Do**: Use sections to organize related options + ```jsx + + React + + ``` -## Integration with Forms +4. **Accessibility**: Always provide meaningful labels and descriptions +5. **Performance**: Use virtualization for lists with 50+ items +6. **UX**: Consider FilterListBox for searchable lists with many options - +## Integration with Forms -This component supports all [Field properties](/docs/forms-field--docs) when used within a Form. +This component supports all [Field properties](/docs/forms-field--docs) when used within a Form. The component automatically handles form validation, field states, and integrates with form submission. ```jsx
- - React - Vue.js - - - Node.js - Python - + Email Notifications + Newsletter - - Submit
``` -## Integration with Popover Dialog - - - -ListBox can be effectively used inside a popover Dialog controlled by DialogTrigger to create dropdown-style selection interfaces that provide more space and functionality than traditional Select components. By removing the Dialog's default padding and border, the ListBox appears directly as the popover content. - -```jsx -import { useState } from 'react'; -import { Button } from '../../actions/Button/Button'; -import { Dialog } from '../../overlays/Dialog/Dialog'; -import { DialogTrigger } from '../../overlays/Dialog/DialogTrigger'; - -const [selectedKey, setSelectedKey] = useState(null); - - - - - - - - React - - - Vue.js - - - Angular - - - - - Node.js - - - Python - - - Java - - - - - -``` - -### Benefits of ListBox in Popover - -- **More Space**: Unlike traditional Select dropdowns, popovers can accommodate larger lists with descriptions and sections -- **Rich Content**: Support for descriptions, icons, and complex layouts within options -- **Search Functionality**: Built-in search makes it easy to find options in large lists -- **Better Accessibility**: Full keyboard navigation and screen reader support -- **Custom Positioning**: Flexible placement options relative to the trigger button +## Performance -### Best Practices for Popover Integration +### Virtualization -1. **Clean Appearance**: Remove Dialog's default padding and border (`padding: 0, border: false`) and apply border/radius directly to ListBox for a seamless popover appearance -2. **Auto Focus**: Use `autoFocus` to automatically focus the search input when the popover opens, improving keyboard navigation -3. **Size Management**: Set appropriate width and height constraints to prevent the popover from becoming too large -4. **Placement**: Use `placement="bottom start"` or similar to ensure good positioning relative to the trigger -5. **Search**: Enable search for lists with many options to improve user experience -6. **Selection Feedback**: Display the current selection outside the popover so users know what's selected -7. **Mobile Considerations**: The popover will automatically convert to a modal on mobile devices +ListBox automatically enables virtualization when: +- The list contains more than 30 items +- No sections are present (sections disable virtualization) +- Improves performance with large datasets -## Suggested Improvements +### Optimization Tips -- Add support for custom option rendering with more complex layouts -- Implement virtual scrolling for very large lists (1000+ items) -- Add support for option groups with different selection behaviors -- Consider adding drag-and-drop reordering functionality -- Implement async loading with pagination for dynamic data -- Add support for option icons and avatars -- Consider adding keyboard shortcuts for common actions (select all, clear all) -- Enhance search with fuzzy matching and highlighting of matched text -- Add support for custom filtering functions beyond simple text matching +- Use `textValue` prop for complex option content +- Avoid changing selection state too frequently +- Use sections sparingly for very large lists +- Consider FilterListBox for searchable large lists ## Related Components -- [Select](/docs/forms-select--docs) - For dropdown selection that saves space -- [ComboBox](/docs/forms-combobox--docs) - For searchable selection with text input -- [RadioGroup](/docs/forms-radiogroup--docs) - For single selection with radio buttons -- [Checkbox](/docs/forms-checkbox--docs) - For multiple selection with checkboxes -- [Menu](/docs/actions-menu--docs) - For action-oriented lists and navigation \ No newline at end of file +- [FilterListBox](/docs/forms-filterlistbox--docs) - ListBox with integrated search functionality +- [FilterPicker](/docs/forms-filterpicker--docs) - ListBox in a trigger-based popover +- [Select](/docs/forms-select--docs) - Dropdown selection without persistent visibility +- [ComboBox](/docs/forms-combobox--docs) - Dropdown with text input and search +- [RadioGroup](/docs/forms-radiogroup--docs) - Single selection with radio buttons +- [CheckboxGroup](/docs/forms-checkbox--docs) - Multiple selection with checkboxes \ No newline at end of file diff --git a/src/components/fields/ListBox/ListBox.spec.md b/src/components/fields/ListBox/ListBox.spec.md new file mode 100644 index 00000000..c114e0bf --- /dev/null +++ b/src/components/fields/ListBox/ListBox.spec.md @@ -0,0 +1,222 @@ +# ListBox Component Specification + +## Overview + +The `ListBox` component is a fully-featured, accessible selection list component built on top of React Aria and React Stately. It provides a flexible interface for displaying collections of selectable items with support for single/multiple selection, keyboard navigation, virtualization, and form integration. + +## Architecture + +### Component Hierarchy + +``` +ListBox (forwardRef) +├── ListBoxWrapperElement (tasty styled container) +│ ├── StyledHeader (optional header) +│ ├── ListBoxScrollElement (scroll container) +│ │ └── ListElement (ul container) +│ │ ├── Option components (li items) +│ │ └── ListBoxSection components (grouped sections) +│ └── StyledFooter (optional footer) +``` + +### Core Dependencies + +- **React Aria**: `useListBox`, `useOption`, `useListBoxSection`, `useKeyboard` +- **React Stately**: `useListState`, `Item`, `Section` +- **Virtualization**: `@tanstack/react-virtual` for performance with large datasets +- **Form Integration**: Custom form hooks (`useFieldProps`, `useFormProps`) +- **Styling**: Tasty design system with styled components + +## Key Features + +### 1. Selection Modes +- **Single Selection**: `selectionMode="single"` (default) + - Uses `selectedKey` and `defaultSelectedKey` props + - Returns single key in `onSelectionChange` +- **Multiple Selection**: `selectionMode="multiple"` + - Uses `selectedKeys` and `defaultSelectedKeys` props + - Returns array of keys in `onSelectionChange` + - Optional checkbox indicators with `isCheckable` prop + +### 2. Virtualization +- Automatic virtualization for large lists without sections +- Uses `@tanstack/react-virtual` for performance optimization +- Dynamic height estimation based on content (descriptions, size) +- Overscan of 10 items for smooth scrolling + +### 3. Accessibility Features +- Full ARIA listbox pattern implementation +- Keyboard navigation (Arrow keys, Home, End, Page Up/Down) +- Screen reader support with proper announcements +- Focus management with `shouldUseVirtualFocus` option +- Optional visual focus vs DOM focus separation + +### 4. Form Integration +- Seamless integration with form system via `useFieldProps` +- Validation state support (`valid`, `invalid`) +- Disabled state handling +- Field wrapping with labels, descriptions, and error messages + +### 5. Content Organization +- **Sections**: Grouped content with headings and dividers +- **Headers/Footers**: Custom content areas above/below the list +- **Item Structure**: Label + optional description support + +## Component Props Interface + +### Core Selection Props +```typescript +interface SelectionProps { + selectionMode?: 'single' | 'multiple'; + selectedKey?: Key | null; // Single mode + selectedKeys?: Key[]; // Multiple mode + defaultSelectedKey?: Key | null; // Single mode + defaultSelectedKeys?: Key[]; // Multiple mode + onSelectionChange?: (key: Key | null | Key[]) => void; +} +``` + +### Styling Props +```typescript +interface StylingProps { + listStyles?: Styles; // List container styles + optionStyles?: Styles; // Individual option styles + sectionStyles?: Styles; // Section wrapper styles + headingStyles?: Styles; // Section heading styles + headerStyles?: Styles; // Header area styles + footerStyles?: Styles; // Footer area styles + size?: 'small' | 'medium'; +} +``` + +### Behavior Props +```typescript +interface BehaviorProps { + isDisabled?: boolean; + focusOnHover?: boolean; // DOM focus follows pointer (default: true) + shouldUseVirtualFocus?: boolean; // Keep DOM focus external (default: false) + isCheckable?: boolean; // Show checkboxes in multiple mode + onEscape?: () => void; // Custom escape key handling + onOptionClick?: (key: Key) => void; // Click handler for option content +} +``` + +## Implementation Details + +### State Management +- Uses `useListState` from React Stately for collection and selection state +- Converts between public API (scalar keys) and React Stately API (Set-based keys) +- Exposes internal state via optional `stateRef` for parent component access + +### Virtualization Logic +```typescript +// Virtualization is enabled when: +const shouldVirtualize = !hasSections; + +// Height estimation based on content: +estimateSize: (index) => { + if (item.props?.description) return 49; // With description + return size === 'small' ? 33 : 41; // Label only +} +``` + +### Focus Management +Two focus modes supported: +1. **Standard Focus**: DOM focus moves with selection (default) +2. **Virtual Focus**: DOM focus stays external, visual focus follows selection + +### Event Handling +- **Keyboard**: Arrow navigation, selection, escape handling +- **Mouse/Touch**: Click selection with proper touch behavior +- **Custom Handlers**: Option-specific click handling for complex interactions + +### Form Integration +The component integrates with the form system through value prop mapping: +```typescript +valuePropsMapper: ({ value, onChange }) => { + if (selectionMode === 'multiple') { + return { selectedKeys: value || [], onSelectionChange: onChange }; + } else { + return { selectedKey: value ?? null, onSelectionChange: onChange }; + } +} +``` + +## Styling System + +### Tasty Styles Structure +- **Base Container**: Border, focus outlines, validation states +- **Scroll Container**: Overflow handling, scrollbar styling +- **Options**: Hover, focus, selection, disabled states +- **Sections**: Grouping, dividers, headings +- **Checkboxes**: Visibility, colors, transitions + +### Responsive Behavior +- Supports responsive style values through Tasty system +- Automatic size adjustments based on `size` prop +- Flexible layout with CSS Grid + +## Performance Considerations + +### Virtualization +- Automatically enabled for non-sectioned lists +- Reduces DOM nodes for large datasets +- Dynamic height measurement for variable content + +### Memoization +- Selection change handlers are memoized +- Virtual items array is memoized based on collection changes +- Style calculations are optimized through Tasty caching + +### Memory Management +- Proper cleanup of virtualization observers +- Ref cleanup and focus management +- Event handler cleanup on unmount + +## Extension Points + +### Custom Styling +- All major elements accept custom styles via props +- Modifier-based styling for different states +- CSS custom properties support for theming + +### Custom Content +- Headers and footers for additional UI elements +- Rich content support in options (descriptions, icons) +- Section headings and organization + +### Behavior Customization +- Custom escape key handling +- Focus behavior configuration +- Selection behavior overrides + +## Common Use Cases + +1. **Simple Selection Lists**: Basic item selection with labels +2. **Searchable Lists**: External input with virtual focus +3. **Multi-Select with Checkboxes**: Clear selection indicators +4. **Grouped Content**: Sections with headings and dividers +5. **Large Datasets**: Virtualized scrolling for performance +6. **Form Fields**: Integrated validation and error states + +## Testing Considerations + +- Uses `qa` prop for test selectors +- Proper ARIA attributes for accessibility testing +- Focus management testability +- Selection state verification +- Keyboard interaction testing + +## Browser Compatibility + +- Modern browsers with CSS Grid support +- React 18+ for concurrent features +- Proper fallbacks for older browsers through Tasty system + +## Migration Notes + +When upgrading or modifying: +- Selection API changes require careful prop mapping +- Virtualization changes may affect layout +- Form integration changes require testing with form providers +- Accessibility features should be regression tested \ No newline at end of file diff --git a/src/components/fields/ListBox/ListBox.stories.tsx b/src/components/fields/ListBox/ListBox.stories.tsx index bc5c809b..06191b75 100644 --- a/src/components/fields/ListBox/ListBox.stories.tsx +++ b/src/components/fields/ListBox/ListBox.stories.tsx @@ -1,15 +1,31 @@ -import { StoryFn } from '@storybook/react'; +import { StoryFn, StoryObj } from '@storybook/react'; import { useState } from 'react'; +import { + CheckIcon, + DatabaseIcon, + EditIcon, + FilterIcon, + PlusIcon, + RightIcon, + SettingsIcon, + UserIcon, +} from '../../../icons'; import { baseProps } from '../../../stories/lists/baseProps'; import { Button } from '../../actions/Button/Button'; +import { Badge } from '../../content/Badge/Badge'; +import { Text } from '../../content/Text'; +import { Title } from '../../content/Title'; import { Form } from '../../form'; +import { Space } from '../../layout/Space'; import { Dialog } from '../../overlays/Dialog/Dialog'; import { DialogTrigger } from '../../overlays/Dialog/DialogTrigger'; import { CubeListBoxProps, ListBox } from './ListBox'; -export default { +import type { Meta } from '@storybook/react'; + +const meta: Meta = { title: 'Forms/ListBox', component: ListBox, parameters: { @@ -27,67 +43,80 @@ export default { control: { type: 'text' }, description: 'The default selected key in uncontrolled mode', }, + selectedKeys: { + control: { type: 'object' }, + description: 'The selected keys in controlled multiple mode', + }, + defaultSelectedKeys: { + control: { type: 'object' }, + description: 'The default selected keys in uncontrolled multiple mode', + }, selectionMode: { - options: ['single', 'multiple', 'none'], + options: ['single', 'multiple'], control: { type: 'radio' }, description: 'Selection mode', table: { defaultValue: { summary: 'single' }, }, }, - - /* Search */ - isSearchable: { + disallowEmptySelection: { control: { type: 'boolean' }, - description: 'Whether the ListBox includes a search input', + description: 'Whether to disallow empty selection', table: { defaultValue: { summary: false }, }, }, - searchPlaceholder: { - control: { type: 'text' }, - description: 'Placeholder text for the search input', - table: { - defaultValue: { summary: 'Search...' }, - }, + disabledKeys: { + control: { type: 'object' }, + description: 'Array of keys for disabled items', }, - autoFocus: { - control: { type: 'boolean' }, - description: 'Whether the search input should have autofocus', + + /* Presentation */ + size: { + options: ['small', 'medium'], + control: { type: 'radio' }, + description: 'ListBox size', table: { - defaultValue: { summary: false }, + defaultValue: { summary: 'small' }, }, }, - emptyLabel: { + header: { + control: { type: 'text' }, + description: 'Custom header content', + }, + footer: { control: { type: 'text' }, + description: 'Custom footer content', + }, + + /* Behavior */ + focusOnHover: { + control: { type: 'boolean' }, description: - 'Custom label to display when no results are found after filtering', + 'Whether moving pointer over an option moves DOM focus to it', table: { - defaultValue: { summary: 'No results found' }, + defaultValue: { summary: true }, }, }, - - /* Presentation */ - size: { - options: ['small', 'default', 'large'], - control: { type: 'radio' }, - description: 'ListBox size', + shouldUseVirtualFocus: { + control: { type: 'boolean' }, + description: 'Whether to use virtual focus instead of DOM focus', table: { - defaultValue: { summary: 'default' }, + defaultValue: { summary: false }, }, }, - - /* State */ - isDisabled: { + isCheckable: { control: { type: 'boolean' }, - description: 'Whether the ListBox is disabled', + description: 'Whether to show checkboxes for multiple selection', table: { defaultValue: { summary: false }, }, }, - SearchLoadingState: { + + /* State */ + isDisabled: { control: { type: 'boolean' }, - description: 'Whether the listbox is loading. Works only with search.', + description: 'Whether the ListBox is disabled', table: { defaultValue: { summary: false }, }, @@ -100,19 +129,84 @@ export default { }, }, validationState: { - options: ['valid', 'invalid'], + options: [undefined, 'valid', 'invalid'], control: { type: 'radio' }, description: 'Validation state', }, + /* Field */ + label: { + control: { type: 'text' }, + description: 'Label text', + }, + description: { + control: { type: 'text' }, + description: 'Field description', + }, + message: { + control: { type: 'text' }, + description: 'Help or error message', + }, + /* Events */ onSelectionChange: { action: 'selection changed', description: 'Callback when selection changes', }, + onEscape: { + action: 'escape pressed', + description: 'Callback when Escape key is pressed', + }, + onOptionClick: { + action: 'option clicked', + description: 'Callback when an option is clicked (non-checkbox area)', + }, }, }; +export default meta; +type Story = StoryObj; + +// Sample data for stories +const fruits = [ + { key: 'apple', label: 'Apple' }, + { key: 'banana', label: 'Banana' }, + { key: 'cherry', label: 'Cherry' }, + { key: 'date', label: 'Date' }, + { key: 'elderberry', label: 'Elderberry' }, + { key: 'fig', label: 'Fig' }, + { key: 'grape', label: 'Grape' }, + { key: 'honeydew', label: 'Honeydew' }, +]; + +const vegetables = [ + { key: 'carrot', label: 'Carrot' }, + { key: 'broccoli', label: 'Broccoli' }, + { key: 'spinach', label: 'Spinach' }, + { key: 'pepper', label: 'Bell Pepper' }, + { key: 'tomato', label: 'Tomato' }, +]; + +const grains = [ + { key: 'rice', label: 'Rice' }, + { key: 'quinoa', label: 'Quinoa' }, + { key: 'oats', label: 'Oats' }, + { key: 'barley', label: 'Barley' }, +]; + +const permissions = [ + { key: 'read', label: 'Read', description: 'View content and data' }, + { key: 'write', label: 'Write', description: 'Create and edit content' }, + { key: 'delete', label: 'Delete', description: 'Remove content permanently' }, + { key: 'admin', label: 'Admin', description: 'Full administrative access' }, + { + key: 'moderate', + label: 'Moderate', + description: 'Review and approve content', + }, + { key: 'share', label: 'Share', description: 'Share content with others' }, +]; + const Template: StoryFn> = (args) => ( Apple @@ -123,31 +217,44 @@ const Template: StoryFn> = (args) => ( ); -export const Default = Template.bind({}); -Default.args = { - label: 'Select a fruit', - selectionMode: 'single', +export const Default: Story = { + render: Template, + args: { + label: 'Select a fruit', + selectionMode: 'single', + }, }; -export const WithSearch: StoryFn> = (args) => ( - - Apple - Banana - Cherry - Date - Elderberry - Fig - Grape - Honeydew - Kiwi - Lemon - -); -WithSearch.args = { - label: 'Search fruits', - isSearchable: true, - searchPlaceholder: 'Type to search fruits...', - selectionMode: 'single', +export const SingleSelection: Story = { + render: (args) => ( + + {fruits.map((fruit) => ( + {fruit.label} + ))} + + ), + args: { + label: 'Choose your favorite fruit', + selectionMode: 'single', + defaultSelectedKey: 'apple', + }, +}; + +export const MultipleSelection: Story = { + render: (args) => ( + + {permissions.map((permission) => ( + + {permission.label} + + ))} + + ), + args: { + label: 'Select permissions', + selectionMode: 'multiple', + defaultSelectedKeys: ['read', 'write'], + }, }; export const WithDescriptions: StoryFn> = (args) => ( @@ -201,75 +308,231 @@ WithSections.args = { selectionMode: 'single', }; -export const WithSearchAndSections: StoryFn> = (args) => ( - - +export const WithHeaderAndFooter: StoryFn> = (args) => ( + + + Programming Languages + 12 + + + + } + > + + JavaScript + + + Python + + + TypeScript + + + Rust + + + Go + + +); +WithHeaderAndFooter.args = { + label: 'Choose your preferred programming language', + selectionMode: 'single', +}; + +export const CheckableMultipleSelection: Story = { + render: (args) => ( + + {permissions.map((permission) => ( + + {permission.label} + + ))} + + ), + args: { + label: 'Select user permissions', + selectionMode: 'multiple', + isCheckable: true, + defaultSelectedKeys: ['read', 'write'], + }, + parameters: { + docs: { + description: { + story: + 'When `isCheckable={true}` and `selectionMode="multiple"`, checkboxes appear on the left of each option. The checkbox is only visible when the item is hovered or selected.', + }, + }, + }, +}; + +export const DifferentSizes: Story = { + render: (args) => ( + +
+ + Small Size + + + + {fruits.slice(0, 4).map((fruit) => ( + {fruit.label} + ))} + +
+ +
+ + Medium Size + + + + {fruits.slice(0, 4).map((fruit) => ( + {fruit.label} + ))} + +
+
+ ), + args: { + selectionMode: 'single', + }, + parameters: { + docs: { + description: { + story: + 'ListBox supports two sizes: `small` (32px item height) for dense interfaces and `medium` (40px item height) for standard use.', + }, + }, + }, +}; + +export const DisabledItems: Story = { + render: (args) => ( + + Available Option 1 + Disabled Option 1 + Available Option 2 + Disabled Option 2 + Available Option 3 + + ), + args: { + label: 'Select an option', + selectionMode: 'single', + disabledKeys: ['disabled1', 'disabled2'], + }, + parameters: { + docs: { + description: { + story: + 'Individual items can be disabled using the `disabledKeys` prop. Disabled items cannot be selected and are visually distinguished.', + }, + }, + }, +}; + +export const DisallowEmptySelection: Story = { + render: (args) => ( + + {fruits.slice(0, 4).map((fruit) => ( + {fruit.label} + ))} + + ), + args: { + label: 'Must select one option', + selectionMode: 'single', + disallowEmptySelection: true, + defaultSelectedKey: 'apple', + }, + parameters: { + docs: { + description: { + story: + 'When `disallowEmptySelection={true}`, the user cannot deselect the last selected item, ensuring at least one item is always selected.', + }, + }, + }, +}; + +export const WithTextValue: Story = { + render: (args) => ( + - React - - - Vue.js - - - Angular - -
- - - Node.js - - - Python + + Basic Plan + Free + - Java + + Pro Plan + $19/month + - - - PostgreSQL - - - MongoDB + + Enterprise Plan + Custom + - - Redis - - -
-); -WithSearchAndSections.args = { - label: 'Choose technologies', - isSearchable: true, - searchPlaceholder: 'Search technologies...', - selectionMode: 'single', -}; - -export const MultipleSelection: StoryFn> = (args) => ( - - HTML - CSS - JavaScript - TypeScript - React - Vue.js - Angular - -); -MultipleSelection.args = { - label: 'Select skills (multiple)', - selectionMode: 'multiple', - isSearchable: true, - searchPlaceholder: 'Search skills...', +
+ ), + args: { + label: 'Choose your plan', + selectionMode: 'single', + }, + parameters: { + docs: { + description: { + story: + 'Use the `textValue` prop when option content is complex (JSX) to provide accessible text for screen readers and keyboard navigation.', + }, + }, + }, }; export const DisabledState: StoryFn> = (args) => ( @@ -285,28 +548,14 @@ DisabledState.args = { selectionMode: 'single', }; -export const SearchLoadingState: StoryFn> = (args) => ( - - Option 1 - Option 2 - Option 3 - -); -SearchLoadingState.args = { - label: 'Loading ListBox', - isSearchable: true, - searchPlaceholder: 'Search...', - isLoading: true, - selectionMode: 'single', -}; - export const ValidationStates: StoryFn> = () => ( -
+ Valid Option Another Option @@ -317,21 +566,20 @@ export const ValidationStates: StoryFn> = () => ( validationState="invalid" selectionMode="single" defaultSelectedKey="option1" - errorMessage="Please select a valid option" + message="Please select a different option" > Option 1 Option 2 -
+ ); export const ControlledExample: StoryFn> = () => { const [selectedKey, setSelectedKey] = useState('apple'); return ( -
+ > = () => { Date -

Selected: {selectedKey || 'None'}

+ + Selected: {selectedKey || 'None'} + -
- - -
-
+ + + + + + ); +}; + +export const MultipleControlledExample: StoryFn> = () => { + const [selectedKeys, setSelectedKeys] = useState(['read', 'write']); + + return ( + + setSelectedKeys(keys as string[])} + > + {permissions.map((permission) => ( + + {permission.label} + + ))} + + + + Selected:{' '} + + {selectedKeys.length ? selectedKeys.join(', ') : 'None'} + + + + + + + + ); }; @@ -363,12 +670,10 @@ export const InForm: StoryFn> = () => {
> = () => { const [selectedKey, setSelectedKey] = useState(null); return ( -
-

+ + Selected technology: {selectedKey || 'None'} -

+ > = () => { -
+ ); }; @@ -543,52 +838,192 @@ InPopover.parameters = { }, }; -InPopover.play = async ({ canvasElement }) => { - const canvas = canvasElement; - const button = canvas.querySelector('button'); +export const VirtualizedList: StoryFn> = (args) => { + const [selected, setSelected] = useState(null); + + // Generate a large list of items with varying content to test virtualization + // Mix items with and without descriptions to test dynamic sizing + const items = Array.from({ length: 100 }, (_, i) => ({ + id: `item-${i}`, + name: `Item ${i + 1}${i % 7 === 0 ? ' - This is a longer item name to test dynamic sizing' : ''}`, + description: + i % 3 === 0 + ? `This is a description for item ${i + 1}. It varies in length to test virtualization with dynamic item heights.` + : undefined, + })); + + return ( + + + Large list with {items.length} items with varying heights. + Virtualization is automatically enabled when there are no sections. + Scroll down and back up to test smooth virtualization. + - if (button) { - // Simulate clicking the button to open the popover - button.click(); + + {items.map((item) => ( + + {item.name} + + ))} + - // Wait a moment for the popover to open and autoFocus to take effect - await new Promise((resolve) => setTimeout(resolve, 100)); - } + + Selected: {selected || 'None'} + + + ); }; -export const WithAutoFocus: StoryFn> = (args) => ( - - Apple - Banana - Cherry - Date - Elderberry - Fig - Grape - Honeydew - Kiwi - Lemon - -); -WithAutoFocus.args = { - label: 'Search fruits (auto-focused)', - isSearchable: true, - autoFocus: true, - searchPlaceholder: 'Search input is auto-focused...', - selectionMode: 'single', +VirtualizedList.parameters = { + docs: { + description: { + story: + 'When a ListBox contains many items and has no sections, virtualization is automatically enabled to improve performance. Only visible items are rendered in the DOM, providing smooth scrolling even with large datasets. This story includes items with varying heights to demonstrate stable virtualization without scroll jumping.', + }, + }, }; -export const CustomEmptyLabel: StoryFn> = (args) => ( - - Apple - Banana - Cherry - -); -CustomEmptyLabel.args = { - label: 'Search with custom empty message', - isSearchable: true, - searchPlaceholder: 'Try searching for "orange"...', - emptyLabel: '🔍 Nothing matches your search criteria', - selectionMode: 'single', +export const WithIcons: Story = { + render: (args) => ( + + + + + + Users + + + + + + Permissions + + + + + + + + Database + + + + + + Settings + + + + + ), + args: { + label: 'System Administration', + selectionMode: 'single', + }, + parameters: { + docs: { + description: { + story: + 'ListBox options can include icons to improve visual clarity and help users quickly identify options.', + }, + }, + }, +}; + +export const FocusBehavior: Story = { + render: (args) => ( + +
+ + Standard Focus (focusOnHover=true) + + + Moving pointer over options will focus them + + + + {fruits.slice(0, 4).map((fruit) => ( + {fruit.label} + ))} + +
+ +
+ + No Focus on Hover (focusOnHover=false) + + + Use keyboard or click to focus options + + + + {fruits.slice(0, 4).map((fruit) => ( + {fruit.label} + ))} + +
+
+ ), + args: { + selectionMode: 'single', + }, + parameters: { + docs: { + description: { + story: + 'The `focusOnHover` prop controls whether moving the pointer over an option automatically focuses it. Set to `false` for components where focus should remain elsewhere (like searchable lists).', + }, + }, + }, +}; + +export const EscapeKeyHandling: StoryFn> = () => { + const [selectedKey, setSelectedKey] = useState('apple'); + const [escapeCount, setEscapeCount] = useState(0); + + return ( + + setSelectedKey(key as string | null)} + onEscape={() => { + setEscapeCount((prev) => prev + 1); + // Custom escape behavior - could close a parent modal, etc. + }} + > + Apple + Banana + Cherry + + + + Selected: {selectedKey || 'None'} + + + Escape key pressed: {escapeCount} times + + + Focus the ListBox and press Escape to trigger custom handling + + + ); +}; + +EscapeKeyHandling.parameters = { + docs: { + description: { + story: + 'Use the `onEscape` prop to provide custom behavior when the Escape key is pressed, such as closing a parent modal. When provided, this prevents the default selection clearing behavior.', + }, + }, }; diff --git a/src/components/fields/ListBox/ListBox.test.tsx b/src/components/fields/ListBox/ListBox.test.tsx index 429a8c74..e6f33c1c 100644 --- a/src/components/fields/ListBox/ListBox.test.tsx +++ b/src/components/fields/ListBox/ListBox.test.tsx @@ -1,7 +1,14 @@ import { createRef } from 'react'; import { Field, ListBox } from '../../../index'; -import { act, render, renderWithForm, userEvent, waitFor } from '../../../test'; +import { + act, + render, + renderWithForm, + screen, + userEvent, + waitFor, +} from '../../../test'; jest.mock('../../../_internal/hooks/use-warn'); @@ -159,27 +166,6 @@ describe('', () => { expect(secondCall.sort()).toEqual(['apple', 'banana']); }); - it('should support search functionality', async () => { - const { getByRole, getByText, queryByText } = render( - - {basicItems} - , - ); - - const searchInput = getByRole('searchbox'); - expect(searchInput).toBeInTheDocument(); - - // Type in search input - await act(async () => { - await userEvent.type(searchInput, 'app'); - }); - - // Only Apple should be visible - expect(getByText('Apple')).toBeInTheDocument(); - expect(queryByText('Banana')).not.toBeInTheDocument(); - expect(queryByText('Cherry')).not.toBeInTheDocument(); - }); - it('should handle disabled state', () => { const { getByRole } = render( @@ -215,10 +201,18 @@ describe('', () => { it('should support items with descriptions', () => { const { getByText } = render( - + Apple - + Banana , @@ -232,24 +226,16 @@ describe('', () => { it('should correctly assign refs', () => { const listRef = createRef(); - const searchInputRef = createRef(); const { getByRole } = render( - + {basicItems} , ); const listbox = getByRole('listbox'); - const searchInput = getByRole('searchbox'); expect(listRef.current).toBe(listbox); - expect(searchInputRef.current).toBe(searchInput); }); it('should handle keyboard navigation', async () => { @@ -294,48 +280,6 @@ describe('', () => { expect(listbox.closest('[data-is-valid]')).toBeInTheDocument(); }); - it('should handle search loading state', () => { - const { container } = render( - - {basicItems} - , - ); - - // Check that LoadingIcon is rendered instead of SearchIcon - const loadingIcon = container.querySelector( - '[data-element="InputIcon"] svg', - ); - expect(loadingIcon).toBeInTheDocument(); - }); - - it('should filter sections when searching', async () => { - const { getByRole, getByText, queryByText } = render( - - - Apple - Banana - - - Carrot - Broccoli - - , - ); - - const searchInput = getByRole('searchbox'); - - // Search for "app" - should only show Apple and Fruits section - await act(async () => { - await userEvent.type(searchInput, 'app'); - }); - - expect(getByText('Fruits')).toBeInTheDocument(); - expect(getByText('Apple')).toBeInTheDocument(); - expect(queryByText('Banana')).not.toBeInTheDocument(); - expect(queryByText('Vegetables')).not.toBeInTheDocument(); - expect(queryByText('Carrot')).not.toBeInTheDocument(); - }); - it('should clear selection when null is passed', async () => { const onSelectionChange = jest.fn(); @@ -367,26 +311,6 @@ describe('', () => { expect(appleOption.closest('li')).toHaveAttribute('aria-selected', 'false'); }); - it('should handle empty search results', async () => { - const { getByRole, queryByText } = render( - - {basicItems} - , - ); - - const searchInput = getByRole('searchbox'); - - // Search for something that doesn't exist - await act(async () => { - await userEvent.type(searchInput, 'xyz'); - }); - - // No items should be visible - expect(queryByText('Apple')).not.toBeInTheDocument(); - expect(queryByText('Banana')).not.toBeInTheDocument(); - expect(queryByText('Cherry')).not.toBeInTheDocument(); - }); - it('should work with form integration and onSelectionChange handler together', async () => { const onSelectionChangeMock = jest.fn(); @@ -474,52 +398,24 @@ describe('', () => { expect(['apple', 'banana', 'cherry']).toContain(selectedValue); }); - it('should handle keyboard navigation in both searchable and non-searchable modes', async () => { - // Test non-searchable ListBox - const onSelectionChangeNonSearchable = jest.fn(); - const { getByRole: getByRoleNonSearchable, unmount } = render( - + it('should handle keyboard navigation', async () => { + const onSelectionChange = jest.fn(); + const { getByRole } = render( + {basicItems} , ); - const listboxNonSearchable = getByRoleNonSearchable('listbox'); + const listbox = getByRole('listbox'); // Focus and navigate await act(async () => { - listboxNonSearchable.focus(); - await userEvent.keyboard('{ArrowDown}'); - await userEvent.keyboard('{Enter}'); - }); - - expect(onSelectionChangeNonSearchable).toHaveBeenCalledTimes(1); - unmount(); - - // Test searchable ListBox - const onSelectionChangeSearchable = jest.fn(); - const { getByRole: getByRoleSearchable } = render( - - {basicItems} - , - ); - - const searchInput = getByRoleSearchable('searchbox'); - - // Focus search input and navigate - await act(async () => { - searchInput.focus(); + listbox.focus(); await userEvent.keyboard('{ArrowDown}'); await userEvent.keyboard('{Enter}'); }); - expect(onSelectionChangeSearchable).toHaveBeenCalledTimes(1); + expect(onSelectionChange).toHaveBeenCalledTimes(1); }); it('should apply focused mod to the focused item during keyboard navigation', async () => { diff --git a/src/components/fields/ListBox/ListBox.tsx b/src/components/fields/ListBox/ListBox.tsx index 58d331d4..77441528 100644 --- a/src/components/fields/ListBox/ListBox.tsx +++ b/src/components/fields/ListBox/ListBox.tsx @@ -1,17 +1,19 @@ +import { useHover } from '@react-aria/interactions'; +import { useVirtualizer } from '@tanstack/react-virtual'; import { + CSSProperties, ForwardedRef, forwardRef, + MutableRefObject, ReactElement, ReactNode, RefObject, - useCallback, + useEffect, useMemo, useRef, - useState, } from 'react'; import { AriaListBoxProps, - useFilter, useKeyboard, useListBox, useListBoxSection, @@ -19,7 +21,8 @@ import { } from 'react-aria'; import { Section as BaseSection, Item, useListState } from 'react-stately'; -import { LoadingIcon, SearchIcon } from '../../../icons'; +import { useWarn } from '../../../_internal/hooks/use-warn'; +import { CheckIcon } from '../../../icons'; import { useProviderProps } from '../../../provider'; import { BASE_STYLES, @@ -29,28 +32,30 @@ import { Styles, tasty, } from '../../../tasty'; -import { mergeProps, modAttrs, useCombinedRefs } from '../../../utils/react'; +import { mergeProps, useCombinedRefs } from '../../../utils/react'; import { useFocus } from '../../../utils/react/interactions'; -import { Block } from '../../Block'; -import { useFieldProps, useFormProps, wrapWithField } from '../../form'; +// Import Menu styled components for header and footer import { - DEFAULT_INPUT_STYLES, - INPUT_WRAPPER_STYLES, -} from '../TextInput/TextInputBase'; + StyledDivider, + StyledFooter, + StyledHeader, + StyledSectionHeading, +} from '../../actions/Menu/styled'; +import { useFieldProps, useFormProps, wrapWithField } from '../../form'; import type { CollectionBase, Key } from '@react-types/shared'; import type { FieldBaseProps } from '../../../shared'; -type FilterFn = (textValue: string, inputValue: string) => boolean; - const ListBoxWrapperElement = tasty({ + qa: 'ListBox', styles: { - display: 'flex', + display: 'grid', + gridColumns: '1sf', + gridRows: 'max-content 1sf max-content', flow: 'column', gap: 0, position: 'relative', - radius: true, - fill: '#white', + radius: '1cr', color: '#dark-02', transition: 'theme', outline: { @@ -64,34 +69,33 @@ const ListBoxWrapperElement = tasty({ valid: '#success-text.50', invalid: '#danger-text.50', disabled: true, + 'popover | searchable': false, }, }, }); -const SearchWrapperElement = tasty({ +const ListElement = tasty({ + as: 'ul', styles: { - ...INPUT_WRAPPER_STYLES, - border: '#clear', - radius: '1r top', - borderBottom: '1bw solid #border', + display: 'block', + padding: 0, + listStyle: 'none', + boxSizing: 'border-box', + margin: { + '': '.5x .5x 0 .5x', + sections: '0 .5x', + }, + height: 'max-content', }, }); -const SearchInputElement = tasty({ - as: 'input', - styles: DEFAULT_INPUT_STYLES, -}); - -const ListElement = tasty({ - as: 'ul', +// NEW: dedicated scroll container for ListBox +const ListBoxScrollElement = tasty({ + as: 'div', styles: { - display: 'flex', - gap: '1bw', - flow: 'column', - margin: '0', - padding: '.5x', - listStyle: 'none', - height: 'auto', + display: 'grid', + gridColumns: '1sf', + gridRows: '1sf', overflow: 'auto', scrollbar: 'styled', }, @@ -101,13 +105,25 @@ const OptionElement = tasty({ as: 'li', styles: { display: 'flex', - flow: 'column', - gap: '.25x', - padding: '.75x 1x', + flow: 'row', + placeItems: 'center start', + gap: '.75x', + padding: '.5x 1x', + margin: { + '': '0 0 1bw 0', + ':last-of-type': '0', + }, + height: { + '[data-size="small"]': 'min 4x', + '[data-size="medium"]': 'min 5x', + }, + boxSizing: 'border-box', radius: '1r', - cursor: 'pointer', + cursor: { + '': 'default', + disabled: 'not-allowed', + }, transition: 'theme', - outline: 0, border: 0, userSelect: 'none', color: { @@ -119,18 +135,69 @@ const OptionElement = tasty({ }, fill: { '': '#clear', - focused: '#dark.03', - selected: '#dark.06', - 'selected & focused': '#dark.09', - pressed: '#dark.06', + 'hovered | focused': '#dark.03', + selected: '#dark.09', + 'selected & (hovered | focused)': '#dark.12', + 'selected & hovered & focused': '#dark.15', + pressed: '#dark.09', valid: '#success-bg', invalid: '#danger-bg', disabled: '#clear', }, + outline: 0, + backgroundClip: 'padding-box', + + CheckboxWrapper: { + cursor: 'pointer', + padding: '.75x', + margin: '-.75x', + }, + + Checkbox: { + display: 'grid', + placeItems: 'center', + radius: '.5r', + width: '(2x - 2bw)', + height: '(2x - 2bw)', + flexShrink: 0, + transition: 'theme', + opacity: { + '': 0, + 'selected | :hover | focused': 1, + }, + fill: { + '': '#white', + selected: '#purple-text', + 'invalid & !selected': '#white', + 'invalid & selected': '#danger', + disabled: '#dark.12', + }, + color: { + '': '#white', + 'disabled & !selected': '#clear', + }, + border: { + '': '#dark.30', + invalid: '#danger', + 'disabled | (selected & !invalid)': '#clear', + }, + }, + + Content: { + display: 'flex', + flow: 'column', + gap: '.25x', + flex: 1, + width: 'max 100%', + }, Label: { preset: 't3', color: 'inherit', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + width: 'max 100%', }, Description: { @@ -138,6 +205,10 @@ const OptionElement = tasty({ color: { '': '#dark-03', }, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + width: 'max 100%', }, }, }); @@ -146,23 +217,19 @@ const SectionWrapperElement = tasty({ as: 'li', styles: { display: 'block', - }, -}); - -const SectionHeadingElement = tasty({ - styles: { - preset: 't4 strong', - color: '#dark-03', - padding: '.5x 1x .25x', - userSelect: 'none', + padding: { + '': 0, + ':last-of-type': '0 0 .5x 0', + }, }, }); const SectionListElement = tasty({ + qa: 'ListBoxSectionList', as: 'ul', styles: { display: 'flex', - gap: '1bw', + gap: '0', flow: 'column', margin: '0', padding: '0', @@ -170,31 +237,10 @@ const SectionListElement = tasty({ }, }); -const DividerElement = tasty({ - as: 'li', - styles: { - height: '1bw', - fill: '#border', - margin: '.5x 0', - }, -}); - export interface CubeListBoxProps extends Omit, 'onSelectionChange'>, CollectionBase, FieldBaseProps { - /** Whether the ListBox is searchable */ - isSearchable?: boolean; - /** Placeholder text for the search input */ - searchPlaceholder?: string; - /** Whether the search input should have autofocus */ - autoFocus?: boolean; - /** The filter function used to determine if an option should be included in the filtered list */ - filter?: FilterFn; - /** Custom label to display when no results are found after filtering */ - emptyLabel?: ReactNode; - /** Custom styles for the search input */ - searchInputStyles?: Styles; /** Custom styles for the list container */ listStyles?: Styles; /** Custom styles for options */ @@ -205,20 +251,65 @@ export interface CubeListBoxProps headingStyles?: Styles; /** Whether the ListBox is disabled */ isDisabled?: boolean; - /** Whether the ListBox as a whole is loading (generic loading indicator) */ - isLoading?: boolean; /** The selected key(s) */ selectedKey?: Key | null; - selectedKeys?: Key[] | 'all'; + selectedKeys?: Key[]; /** Default selected key(s) */ defaultSelectedKey?: Key | null; - defaultSelectedKeys?: Key[] | 'all'; + defaultSelectedKeys?: Key[]; /** Selection change handler */ onSelectionChange?: (key: Key | null | Key[]) => void; - /** Ref for the search input */ - searchInputRef?: RefObject; /** Ref for the list */ - listRef?: RefObject; + listRef?: RefObject; + /** + * Ref to access the internal ListState instance. + * This allows parent components to access selection state and other list functionality. + */ + stateRef?: MutableRefObject; + + /** + * When true (default) moving the pointer over an option will move DOM focus to that option. + * Set to false for components that keep DOM focus outside (e.g. searchable FilterListBox). + */ + focusOnHover?: boolean; + /** Custom header content */ + header?: ReactNode; + /** Custom footer content */ + footer?: ReactNode; + /** Custom styles for the header */ + headerStyles?: Styles; + /** Custom styles for the footer */ + footerStyles?: Styles; + /** Mods for the ListBox */ + mods?: Record; + /** Size of the ListBox */ + size?: 'small' | 'medium'; + + /** + * When true, ListBox will use virtual focus. This keeps actual DOM focus + * outside of the individual option elements (e.g. for searchable lists + * where focus stays within an external input). + * Defaults to false for backward compatibility. + */ + shouldUseVirtualFocus?: boolean; + + /** + * Optional callback fired when the user presses Escape key. + * When provided, this prevents React Aria's default Escape behavior (selection reset). + */ + onEscape?: () => void; + + /** + * Whether the options in the ListBox are checkable. + * This adds a checkbox icon to the left of the option. + */ + isCheckable?: boolean; + + /** + * Callback fired when an option is clicked but not on the checkbox area. + * Used by FilterPicker to close the popover on non-checkbox clicks. + */ + onOptionClick?: (key: Key) => void; } const PROP_STYLES = [...BASE_STYLES, ...OUTER_STYLES, ...COLOR_STYLES]; @@ -234,30 +325,16 @@ export const ListBox = forwardRef(function ListBox( const fieldProps: any = {}; if (props.selectionMode === 'multiple') { - fieldProps.selectedKeys = Array.isArray(value) - ? value - : value - ? [value] - : []; + fieldProps.selectedKeys = value || []; } else { fieldProps.selectedKey = value ?? null; } fieldProps.onSelectionChange = (key: any) => { if (props.selectionMode === 'multiple') { - if (Array.isArray(key)) { - onChange(key); - } else if (key instanceof Set) { - onChange(Array.from(key)); - } else { - onChange(key ? [key] : []); - } + onChange(key ? (Array.isArray(key) ? key : [key]) : []); } else { - if (key instanceof Set) { - onChange(key.size === 0 ? null : Array.from(key)[0]); - } else { - onChange(key); - } + onChange(Array.isArray(key) ? key[0] : key); } }; @@ -274,90 +351,36 @@ export const ListBox = forwardRef(function ListBox( necessityIndicator, validationState, isDisabled, - isLoading, - isSearchable = false, - searchPlaceholder = 'Search...', - autoFocus, - filter, - emptyLabel, - searchInputStyles, listStyles, optionStyles, sectionStyles, headingStyles, - searchInputRef, listRef, message, description, styles, + mods: externalMods, + size = 'small', labelSuffix, selectedKey, defaultSelectedKey, selectedKeys, defaultSelectedKeys, + shouldUseVirtualFocus, onSelectionChange, + stateRef, + focusOnHover, + header, + footer, + headerStyles, + footerStyles, + escapeKeyBehavior, + onEscape, + isCheckable, + onOptionClick, ...otherProps } = props; - const [searchValue, setSearchValue] = useState(''); - const { contains } = useFilter({ sensitivity: 'base' }); - - // Choose the text filter function: user-provided `filter` prop (if any) - // or the default `contains` helper from `useFilter`. - const textFilterFn = useMemo( - () => filter || contains, - [filter, contains], - ); - - // Collection-level filter function expected by `useListState`. - // It converts the text filter (textValue, searchValue) ⟶ boolean - // into the shape `(nodes) => Iterable>`. - // The current `searchValue` is captured in the closure – every re-render - // produces a new function so React Stately updates the collection when the - // search term changes. - const collectionFilter = useCallback( - (nodes: Iterable): Iterable => { - const term = searchValue.trim(); - - // If there is no search term, return nodes untouched to avoid - // unnecessary object allocations. - if (!term) { - return nodes; - } - - // Recursive helper to filter sections and items. - const filterNodes = (iter: Iterable): any[] => { - const result: any[] = []; - - for (const node of iter) { - if (node.type === 'section') { - const filteredChildren = filterNodes(node.childNodes); - - if (filteredChildren.length) { - // Preserve the original node but replace `childNodes` with the - // filtered iterable so that React-Stately can still traverse it. - result.push({ - ...node, - childNodes: filteredChildren, - }); - } - } else { - const text = node.textValue ?? String(node.rendered ?? ''); - - if (textFilterFn(text, term)) { - result.push(node); - } - } - } - - return result; - }; - - return filterNodes(nodes); - }, - [searchValue, textFilterFn], - ); - // Wrap onSelectionChange to prevent selection when disabled and handle React Aria's Set format const externalSelectionHandler = onSelectionChange || (props as any).onChange; @@ -391,7 +414,6 @@ export const ListBox = forwardRef(function ListBox( // Prepare props for useListState with correct selection props const listStateProps: any = { ...props, - filter: collectionFilter, onSelectionChange: wrappedOnSelectionChange, isDisabled, selectionMode: props.selectionMode || 'single', @@ -400,14 +422,12 @@ export const ListBox = forwardRef(function ListBox( // Set selection props based on mode if (listStateProps.selectionMode === 'multiple') { if (selectedKeys !== undefined) { - listStateProps.selectedKeys = - selectedKeys === 'all' ? 'all' : new Set(selectedKeys as Key[]); + listStateProps.selectedKeys = new Set(selectedKeys as Key[]); } if (defaultSelectedKeys !== undefined) { - listStateProps.defaultSelectedKeys = - defaultSelectedKeys === 'all' - ? 'all' - : new Set(defaultSelectedKeys as Key[]); + listStateProps.defaultSelectedKeys = new Set( + defaultSelectedKeys as Key[], + ); } // Remove single-selection props if any delete listStateProps.selectedKey; @@ -430,12 +450,55 @@ export const ListBox = forwardRef(function ListBox( delete listStateProps.defaultSelectedKey; } - const listState = useListState(listStateProps); + const listState = useListState({ + ...listStateProps, + }); + + // Track whether the last focus change was due to keyboard navigation + const lastFocusSourceRef = useRef<'keyboard' | 'mouse' | 'other'>('other'); + + // Expose the list state instance via the provided ref (if any) + if (stateRef) { + stateRef.current = { + ...listState, + lastFocusSourceRef, + }; + } + + // Warn if isCheckable is false in single selection mode + useWarn(isCheckable === false && props.selectionMode === 'single', { + key: ['listbox-checkable-single-mode'], + args: [ + 'CubeUIKit: isCheckable=false is not recommended in single selection mode as it may confuse users about selection behavior.', + ], + }); + + // Custom keyboard handling to prevent selection clearing on Escape while allowing overlay dismiss + const { keyboardProps } = useKeyboard({ + onKeyDown: (e) => { + // Mark focus changes from keyboard navigation + if ( + e.key === 'ArrowDown' || + e.key === 'ArrowUp' || + e.key === 'Home' || + e.key === 'End' || + e.key === 'PageUp' || + e.key === 'PageDown' + ) { + lastFocusSourceRef.current = 'keyboard'; + } + + if (e.key === 'Escape' && onEscape) { + // Don't prevent default - let the overlay system handle closing + // But we'll call onEscape to potentially override the default selection clearing + onEscape(); + } + }, + }); styles = extractStyles(otherProps, PROP_STYLES, styles); ref = useCombinedRefs(ref); - searchInputRef = useCombinedRefs(searchInputRef); listRef = useCombinedRefs(listRef); const { listBoxProps } = useListBox( @@ -443,8 +506,9 @@ export const ListBox = forwardRef(function ListBox( ...props, 'aria-label': props['aria-label'] || label?.toString(), isDisabled, - shouldUseVirtualFocus: isSearchable, + shouldUseVirtualFocus: shouldUseVirtualFocus ?? false, shouldFocusWrap: true, + escapeKeyBehavior: onEscape ? 'none' : 'clearSelection', }, listState, listRef, @@ -453,101 +517,95 @@ export const ListBox = forwardRef(function ListBox( const { isFocused, focusProps } = useFocus({ isDisabled }); const isInvalid = validationState === 'invalid'; - // Keyboard navigation handler for search input - const { keyboardProps } = useKeyboard({ - onKeyDown: (e) => { - if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { - e.preventDefault(); - - const isArrowDown = e.key === 'ArrowDown'; - const { selectionManager, collection } = listState; - const currentKey = selectionManager.focusedKey; - - // Helper function to find next selectable item (skip section headers and disabled items) - const findNextSelectableKey = ( - startKey: any, - direction: 'forward' | 'backward', - ) => { - let nextKey = startKey; - const keyGetter = - direction === 'forward' - ? collection.getKeyAfter.bind(collection) - : collection.getKeyBefore.bind(collection); - - while (nextKey != null) { - const item = collection.getItem(nextKey); - // Use SelectionManager's canSelectItem method for proper validation - if ( - item && - item.type !== 'section' && - selectionManager.canSelectItem(nextKey) - ) { - return nextKey; - } - nextKey = keyGetter(nextKey); - } + // ----- Virtualization logic ----- + const itemsArray = useMemo( + () => [...listState.collection], + [listState.collection], + ); - return null; - }; - - // Helper function to find first/last selectable item - const findFirstLastSelectableKey = ( - direction: 'forward' | 'backward', - ) => { - const allKeys = Array.from(collection.getKeys()); - const keysToCheck = - direction === 'forward' ? allKeys : allKeys.reverse(); - - for (const key of keysToCheck) { - const item = collection.getItem(key); - if ( - item && - item.type !== 'section' && - selectionManager.canSelectItem(key) - ) { - return key; - } - } + const hasSections = useMemo( + () => itemsArray.some((i) => i.type === 'section'), + [itemsArray], + ); - return null; - }; + const shouldVirtualize = !hasSections; - let nextKey: any = null; - const direction = isArrowDown ? 'forward' : 'backward'; + // Use ref to ensure estimateSize always accesses current itemsArray + const itemsArrayRef = useRef(itemsArray); + itemsArrayRef.current = itemsArray; - if (currentKey == null) { - // No current focus, find first/last selectable item - nextKey = findFirstLastSelectableKey(direction); - } else { - // Find next selectable item from current position - const candidateKey = - direction === 'forward' - ? collection.getKeyAfter(currentKey) - : collection.getKeyBefore(currentKey); + // Scroll container ref for virtualization + const scrollRef = useRef(null); - nextKey = findNextSelectableKey(candidateKey, direction); + const rowVirtualizer = useVirtualizer({ + count: shouldVirtualize ? itemsArray.length : 0, + getScrollElement: () => scrollRef.current, + estimateSize: (index: number) => { + const currentItem: any = itemsArrayRef.current[index]; + + if (currentItem?.props?.description) { + return 49; + } + return size === 'small' ? 33 : 41; + }, + measureElement: (el) => { + return el.offsetHeight + 1; + }, + overscan: 10, + }); - // If no next key found and focus wrapping is enabled, wrap to first/last selectable item - if (nextKey == null) { - nextKey = findFirstLastSelectableKey(direction); + // Trigger remeasurement when items change (for filtering scenarios) + useEffect(() => { + if (shouldVirtualize) { + rowVirtualizer.measure(); + } + }, [shouldVirtualize, itemsArray, rowVirtualizer]); + + // Keep focused item visible when virtualizing, but only for keyboard navigation + useEffect(() => { + if (!shouldVirtualize) return; + const focusedKey = listState.selectionManager.focusedKey; + if (focusedKey != null) { + const idx = itemsArrayRef.current.findIndex( + (it) => it.key === focusedKey, + ); + if (idx !== -1) { + // Check if the focused item is actually visible in the current viewport + // (not just rendered due to overscan) + const scrollElement = scrollRef.current; + if (scrollElement) { + const scrollTop = scrollElement.scrollTop; + const viewportHeight = scrollElement.clientHeight; + const viewportBottom = scrollTop + viewportHeight; + + // Find the virtual item for this index + const virtualItems = rowVirtualizer.getVirtualItems(); + const virtualItem = virtualItems.find((item) => item.index === idx); + + let isAlreadyVisible = false; + if (virtualItem) { + const itemTop = virtualItem.start; + const itemBottom = virtualItem.start + virtualItem.size; + + // Check if the item is fully visible in the viewport + // We should scroll if the item is partially hidden + isAlreadyVisible = + itemTop >= scrollTop && itemBottom <= viewportBottom; } - } - if (nextKey != null) { - selectionManager.setFocusedKey(nextKey); - } - } else if (e.key === 'Enter' || (e.key === ' ' && !searchValue.trim())) { - const focusedKey = listState.selectionManager.focusedKey; - if (focusedKey != null) { - e.preventDefault(); - - // Use the SelectionManager's select method which handles all selection logic - // including single vs multiple selection modes and modifier keys - listState.selectionManager.select(focusedKey, e); + // Only scroll if the item is not already visible AND the focus change was due to keyboard navigation + if (!isAlreadyVisible && lastFocusSourceRef.current === 'keyboard') { + rowVirtualizer.scrollToIndex(idx, { align: 'auto' }); + } } } - }, - }); + } + }, [shouldVirtualize, listState.selectionManager.focusedKey, itemsArray]); + + // Merge React Aria listbox props with custom keyboard props so both sets of + // event handlers (e.g. Arrow navigation *and* our Escape handler) are + // preserved. + const mergedListBoxProps = mergeProps(listBoxProps, keyboardProps); const mods = useMemo( () => ({ @@ -555,131 +613,148 @@ export const ListBox = forwardRef(function ListBox( valid: validationState === 'valid', disabled: isDisabled, focused: isFocused, - loading: isLoading, - searchable: isSearchable, + header: !!header, + footer: !!footer, + ...externalMods, }), [ isInvalid, validationState, isDisabled, isFocused, - isLoading, - isSearchable, + header, + footer, + externalMods, ], ); - const searchInput = isSearchable ? ( - - { - const value = e.target.value; - setSearchValue(value); - }} - {...keyboardProps} - {...modAttrs(mods)} - /> -
-
- {isLoading ? : } -
-
-
- ) : null; - const listBoxField = ( - {searchInput} - - {(() => { - const renderedItems: ReactNode[] = []; - let isFirstSection = true; - - for (const item of listState.collection) { - if (item.type === 'section') { - if (!isFirstSection) { - renderedItems.push( - , - ); - } - - renderedItems.push( - , - ); - - isFirstSection = false; - } else { - renderedItems.push( -