diff --git a/.changeset/curly-pans-invite.md b/.changeset/curly-pans-invite.md new file mode 100644 index 000000000..45e3719be --- /dev/null +++ b/.changeset/curly-pans-invite.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Fix flipping of popover in FilterPicker if it's already open. diff --git a/.changeset/nervous-rules-impress.md b/.changeset/nervous-rules-impress.md new file mode 100644 index 000000000..bd13cd9d3 --- /dev/null +++ b/.changeset/nervous-rules-impress.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Improved Button layout. diff --git a/.changeset/nice-comics-fix.md b/.changeset/nice-comics-fix.md new file mode 100644 index 000000000..f2accb8c2 --- /dev/null +++ b/.changeset/nice-comics-fix.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Improved FilterPicker layout with additional wrapper for consistency. diff --git a/.changeset/slow-fans-develop.md b/.changeset/slow-fans-develop.md new file mode 100644 index 000000000..ef1cb3521 --- /dev/null +++ b/.changeset/slow-fans-develop.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Fix initial state inconsistency in FilterPicker. diff --git a/.changeset/sour-donkeys-return.md b/.changeset/sour-donkeys-return.md new file mode 100644 index 000000000..ec6a42723 --- /dev/null +++ b/.changeset/sour-donkeys-return.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Overflow text ellipsis in Buttons with icons by default. diff --git a/.changeset/thirty-bikes-play.md b/.changeset/thirty-bikes-play.md new file mode 100644 index 000000000..19b8bdc01 --- /dev/null +++ b/.changeset/thirty-bikes-play.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Add `showSelectAll` and `selectAllLabel` options for ListBox, FilterListBox, and FilterPicker to add "Select All" option. The label can be customized. diff --git a/.size-limit.cjs b/.size-limit.cjs index 0d12bf0f4..9d7e4ee4d 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -20,7 +20,7 @@ module.exports = [ }), ); }, - limit: '280kB', + limit: '281kB', }, { name: 'Tree shaking (just a Button)', diff --git a/src/components/actions/Button/Button.docs.mdx b/src/components/actions/Button/Button.docs.mdx index 3eeb9c90d..2d9d83074 100644 --- a/src/components/actions/Button/Button.docs.mdx +++ b/src/components/actions/Button/Button.docs.mdx @@ -55,7 +55,7 @@ The `mods` prop accepts the following modifiers you can override: | loading | `boolean` | Displays loading spinner. | | selected | `boolean` | Displays selected state. | | with-icons | `boolean` | Indicates that the button contains at least one icon. | -| single-icon-only | `boolean` | Icon-only button without text. | +| single-icon | `boolean` | Icon-only button without text. | ## Variants diff --git a/src/components/actions/Button/Button.tsx b/src/components/actions/Button/Button.tsx index 1b37ab17c..fc889854c 100644 --- a/src/components/actions/Button/Button.tsx +++ b/src/components/actions/Button/Button.tsx @@ -10,6 +10,7 @@ import { TEXT_STYLES, } from '../../../tasty'; import { accessibilityWarning } from '../../../utils/warnings'; +import { Text } from '../../content/Text'; import { CubeActionProps } from '../Action/Action'; import { useAction } from '../use-action'; @@ -60,8 +61,16 @@ const STYLE_PROPS = [...CONTAINER_STYLES, ...TEXT_STYLES]; export const DEFAULT_BUTTON_STYLES = { display: 'inline-grid', - placeItems: 'center stretch', - placeContent: 'center', + flow: 'column', + placeItems: 'center start', + placeContent: { + '': 'center', + 'right-icon | suffix': 'center stretch', + }, + gridColumns: { + '': 'initial', + 'left-icon | loading | prefix': 'max-content', + }, position: 'relative', margin: 0, boxSizing: 'border-box', @@ -73,7 +82,6 @@ export const DEFAULT_BUTTON_STYLES = { '': '.75x', '[data-size="small"]': '.5x', }, - flow: 'column', preset: { '': 't3m', '[data-size="xsmall"]': 't4', @@ -85,19 +93,19 @@ export const DEFAULT_BUTTON_STYLES = { outlineOffset: 1, padding: { '': '.5x (1.5x - 1bw)', - '[data-size="small"] | [data-size="xsmall"]': '.5x (1x - 1bw)', + '[data-size="small"] | [data-size="xsmall"]': '.5x (1.25x - 1bw)', '[data-size="medium"]': '.5x (1.5x - 1bw)', - '[data-size="large"]': '.5x (2x - 1bw)', - '[data-size="xlarge"]': '.5x (2.25x - 1bw)', - 'single-icon-only | [data-type="link"]': 0, + '[data-size="large"]': '.5x (1.75x - 1bw)', + '[data-size="xlarge"]': '.5x (2x - 1bw)', + 'single-icon | [data-type="link"]': 0, }, width: { '': 'initial', - '[data-size="xsmall"] & single-icon-only': '@size-xs @size-xs', - '[data-size="small"] & single-icon-only': '@size-sm @size-sm', - '[data-size="medium"] & single-icon-only': '@size-md @size-md', - '[data-size="large"] & single-icon-only': '@size-lg @size-lg', - '[data-size="xlarge"] & single-icon-only': '@size-xl @size-xl', + '[data-size="xsmall"] & single-icon': '@size-xs @size-xs', + '[data-size="small"] & single-icon': '@size-sm @size-sm', + '[data-size="medium"] & single-icon': '@size-md @size-md', + '[data-size="large"] & single-icon': '@size-lg @size-lg', + '[data-size="xlarge"] & single-icon': '@size-xl @size-xl', '[data-type="link"]': 'initial', }, height: { @@ -114,6 +122,20 @@ export const DEFAULT_BUTTON_STYLES = { '': true, '[data-type="link"] & !focused': 0, }, + + ButtonIcon: { + width: 'max-content', + }, + + '& [data-element="ButtonIcon"]:first-child:not(:last-child)': { + marginLeft: '-.5x', + placeSelf: 'center start', + }, + + '& [data-element="ButtonIcon"]:last-child:not(:first-child)': { + marginRight: '-.5x', + placeSelf: 'center end', + }, } as const; // ---------- DEFAULT THEME ---------- @@ -657,12 +679,16 @@ export const Button = forwardRef(function Button( !children ); + const hasIcons = !!icon || !!rightIcon; + const modifiers = useMemo( () => ({ loading: isLoading, selected: isSelected, - 'with-icons': !!icon || !!rightIcon, - 'single-icon-only': singleIcon, + 'with-icons': hasIcons, + 'left-icon': !!icon, + 'right-icon': !!rightIcon, + 'single-icon': singleIcon, ...mods, }), [mods, isDisabled, isLoading, isSelected, singleIcon], @@ -696,7 +722,12 @@ export const Button = forwardRef(function Button( ) ) : null} - {children} + {((hasIcons && children) || (!!icon && !!rightIcon)) && + typeof children === 'string' ? ( + {children} + ) : ( + children + )} {rightIcon} ); diff --git a/src/components/actions/Menu/styled.tsx b/src/components/actions/Menu/styled.tsx index c41f9d2f0..b8b65df58 100644 --- a/src/components/actions/Menu/styled.tsx +++ b/src/components/actions/Menu/styled.tsx @@ -172,6 +172,14 @@ export const StyledItem = tasty({ placeItems: 'center', }, + '& [data-element="ButtonIcon"]:first-child:not(:last-child)': { + marginLeft: 0, + }, + + '& [data-element="ButtonIcon"]:last-child:not(:first-child)': { + marginRight: 0, + }, + Postfix: { color: { '': '#dark-03', diff --git a/src/components/fields/FilterListBox/FilterListBox.docs.mdx b/src/components/fields/FilterListBox/FilterListBox.docs.mdx index f863f52a4..19d683440 100644 --- a/src/components/fields/FilterListBox/FilterListBox.docs.mdx +++ b/src/components/fields/FilterListBox/FilterListBox.docs.mdx @@ -157,6 +157,24 @@ The `mods` property accepts the following modifiers you can override: ``` +### Multiple Selection with Select All + + + +```jsx + + Read + Write + Execute + +``` + ### With Descriptions @@ -322,9 +340,21 @@ The `mods` property accepts the following modifiers you can override: ``` -4. **Performance**: Use custom filter functions for specialized search needs -5. **UX**: Provide meaningful empty state messages -6. **Accessibility**: Always provide clear search placeholders +4. **Do**: Use `showSelectAll` for efficient multiple selection from filtered lists + ```jsx + + {/* many items */} + + ``` + +5. **Performance**: Use custom filter functions for specialized search needs +6. **UX**: Provide meaningful empty state messages +7. **Accessibility**: Always provide clear search placeholders ## Integration with Forms diff --git a/src/components/fields/FilterListBox/FilterListBox.stories.tsx b/src/components/fields/FilterListBox/FilterListBox.stories.tsx index f854d3051..46da8a864 100644 --- a/src/components/fields/FilterListBox/FilterListBox.stories.tsx +++ b/src/components/fields/FilterListBox/FilterListBox.stories.tsx @@ -6,20 +6,16 @@ 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'; @@ -48,11 +44,13 @@ const meta: Meta = { }, selectedKeys: { control: { type: 'object' }, - description: 'The selected keys in controlled multiple mode', + description: + 'The selected keys in controlled multiple mode. Use "all" to select all items or an array of keys.', }, defaultSelectedKeys: { control: { type: 'object' }, - description: 'The default selected keys in uncontrolled multiple mode', + description: + 'The default selected keys in uncontrolled multiple mode. Use "all" to select all items or an array of keys.', }, selectionMode: { options: ['single', 'multiple'], @@ -64,7 +62,8 @@ const meta: Meta = { }, allowsCustomValue: { control: { type: 'boolean' }, - description: 'Whether the FilterListBox allows custom values', + description: + 'Whether to allow entering custom values that are not present in the predefined options', table: { defaultValue: { summary: false }, }, @@ -106,7 +105,8 @@ const meta: Meta = { }, filter: { control: false, - description: 'Custom filter function for search', + description: + 'Custom filter function for determining if an option should be included in search results', }, /* Presentation */ @@ -130,7 +130,7 @@ const meta: Meta = { /* Behavior */ isCheckable: { control: { type: 'boolean' }, - description: 'Whether to show checkboxes for multiple selection', + description: 'Whether to show checkboxes for multiple selection mode', table: { defaultValue: { summary: false }, }, @@ -201,6 +201,21 @@ const meta: Meta = { action: 'option clicked', description: 'Callback when an option is clicked (non-checkbox area)', }, + showSelectAll: { + control: { type: 'boolean' }, + description: + 'Whether to show the "Select All" option in multiple selection mode', + table: { + defaultValue: { summary: false }, + }, + }, + selectAllLabel: { + control: { type: 'text' }, + description: 'Label for the "Select All" option', + table: { + defaultValue: { summary: 'Select All' }, + }, + }, }, }; @@ -1201,13 +1216,14 @@ export const VirtualizedList: StoryFn> = (args) => { selectedKeys={selectedKeys} height="300px" overflow="auto" + items={items} onSelectionChange={(keys) => setSelectedKeys(keys as string[])} > - {items.map((item) => ( + {(item) => ( {item.name} - ))} + )} @@ -1230,3 +1246,35 @@ VirtualizedList.parameters = { }, }, }; + +export const WithSelectAll: Story = { + render: (args) => ( + + {permissions.map((permission) => ( + + {permission.label} + + ))} + + ), + args: { + label: 'Select permissions with Select All', + selectionMode: 'multiple', + isCheckable: true, + showSelectAll: true, + selectAllLabel: 'All Permissions', + defaultSelectedKeys: ['read'], + searchPlaceholder: 'Search permissions...', + }, + parameters: { + docs: { + description: { + story: + 'When `showSelectAll={true}` is used with multiple selection mode in FilterListBox, a "Select All" option appears in the header above the search input. The checkbox shows indeterminate state when some items are selected, checked when all are selected, and unchecked when none are selected. The select all functionality works seamlessly with filtering - it only affects the currently visible (filtered) items.', + }, + }, + }, +}; diff --git a/src/components/fields/FilterListBox/FilterListBox.test.tsx b/src/components/fields/FilterListBox/FilterListBox.test.tsx index 4f861607f..bde9d6ce6 100644 --- a/src/components/fields/FilterListBox/FilterListBox.test.tsx +++ b/src/components/fields/FilterListBox/FilterListBox.test.tsx @@ -104,6 +104,225 @@ describe('', () => { expect(onSelectionChange).toHaveBeenCalledWith('banana'); }); + it('should pre-select option based on defaultSelectedKey', () => { + const { getByText } = render( + + {basicItems} + , + ); + + const bananaOption = getByText('Banana'); + const appleOption = getByText('Apple'); + + // Banana should be aria-selected=true, others false + expect(bananaOption.closest('li')).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(appleOption.closest('li')).toHaveAttribute( + 'aria-selected', + 'false', + ); + }); + + it('should pre-select multiple options based on defaultSelectedKeys', () => { + const { getByText } = render( + + {basicItems} + , + ); + + const appleOption = getByText('Apple'); + const bananaOption = getByText('Banana'); + const cherryOption = getByText('Cherry'); + + // Apple and Cherry should be selected, Banana should not + expect(appleOption.closest('li')).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(bananaOption.closest('li')).toHaveAttribute( + 'aria-selected', + 'false', + ); + expect(cherryOption.closest('li')).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); + + it('should pre-select all options when defaultSelectedKeys is "all"', () => { + const { getByText } = render( + + {basicItems} + , + ); + + const appleOption = getByText('Apple'); + const bananaOption = getByText('Banana'); + const cherryOption = getByText('Cherry'); + const dateOption = getByText('Date'); + const elderberryOption = getByText('Elderberry'); + + // All options should be selected + expect(appleOption.closest('li')).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(bananaOption.closest('li')).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(cherryOption.closest('li')).toHaveAttribute( + 'aria-selected', + 'true', + ); + expect(dateOption.closest('li')).toHaveAttribute('aria-selected', 'true'); + expect(elderberryOption.closest('li')).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); + + it('should work in uncontrolled mode with defaultSelectedKeys', async () => { + const onSelectionChange = jest.fn(); + + const { getByText } = render( + + {basicItems} + , + ); + + // Apple should be initially selected + const appleOption = getByText('Apple'); + expect(appleOption.closest('li')).toHaveAttribute( + 'aria-selected', + 'true', + ); + + // Click on Banana to add it to selection + const bananaOption = getByText('Banana'); + await act(async () => { + await userEvent.click(bananaOption); + }); + + // Should call onSelectionChange with both apple and banana + expect(onSelectionChange).toHaveBeenCalledWith(['apple', 'banana']); + }); + + it('should handle empty defaultSelectedKeys array', () => { + const { getByText } = render( + + {basicItems} + , + ); + + const appleOption = getByText('Apple'); + const bananaOption = getByText('Banana'); + const cherryOption = getByText('Cherry'); + + // No options should be selected + expect(appleOption.closest('li')).toHaveAttribute( + 'aria-selected', + 'false', + ); + expect(bananaOption.closest('li')).toHaveAttribute( + 'aria-selected', + 'false', + ); + expect(cherryOption.closest('li')).toHaveAttribute( + 'aria-selected', + 'false', + ); + }); + + it('should handle defaultSelectedKey with null value', () => { + const { getByText } = render( + + {basicItems} + , + ); + + const appleOption = getByText('Apple'); + const bananaOption = getByText('Banana'); + const cherryOption = getByText('Cherry'); + + // No options should be selected + expect(appleOption.closest('li')).toHaveAttribute( + 'aria-selected', + 'false', + ); + expect(bananaOption.closest('li')).toHaveAttribute( + 'aria-selected', + 'false', + ); + expect(cherryOption.closest('li')).toHaveAttribute( + 'aria-selected', + 'false', + ); + }); + + it('should preserve default selection after filtering', async () => { + const { getByText, getByPlaceholderText } = render( + + {basicItems} + , + ); + + // Apple should be initially selected + const appleOption = getByText('Apple'); + expect(appleOption.closest('li')).toHaveAttribute( + 'aria-selected', + 'true', + ); + + // Filter to show only items containing 'a' + const searchInput = getByPlaceholderText('Search...'); + await act(async () => { + await userEvent.type(searchInput, 'a'); + }); + + // Apple should still be selected after filtering + const filteredAppleOption = getByText('Apple'); + expect(filteredAppleOption.closest('li')).toHaveAttribute( + 'aria-selected', + 'true', + ); + + // Clear the search + await act(async () => { + await userEvent.clear(searchInput); + }); + + // Apple should still be selected after clearing the search + const unfilteredAppleOption = getByText('Apple'); + expect(unfilteredAppleOption.closest('li')).toHaveAttribute( + 'aria-selected', + 'true', + ); + }); + it('should support multiple selection', async () => { const onSelectionChange = jest.fn(); diff --git a/src/components/fields/FilterListBox/FilterListBox.tsx b/src/components/fields/FilterListBox/FilterListBox.tsx index 99d115785..1def34fb5 100644 --- a/src/components/fields/FilterListBox/FilterListBox.tsx +++ b/src/components/fields/FilterListBox/FilterListBox.tsx @@ -97,38 +97,38 @@ const StyledHeaderWithoutBorder = tasty(StyledHeader, { }); export interface CubeFilterListBoxProps - extends Omit, 'children'>, + extends CubeListBoxProps, 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 */ + /** Custom filter function for determining if an option should be included in search results */ 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) */ + /** Whether the FilterListBox is in loading state (shows loading icon in search input) */ isLoading?: boolean; - /** Ref for the search input */ + /** Ref for accessing the search input element */ searchInputRef?: RefObject; - /** Children (ListBox.Item and ListBox.Section elements) */ - children?: ReactNode; - /** Allow entering a custom value that is not present in the options */ + /** Whether to allow entering custom values that are not present in the predefined options */ allowsCustomValue?: boolean; - /** Mods for the FilterListBox */ + /** Additional modifiers for styling the FilterListBox */ mods?: Record; + /** Custom styles for the list box */ + listBoxStyles?: Styles; /** - * Optional callback fired when the user presses `Escape` while the search input is empty. + * Callback fired when the user presses Escape key 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. + * Whether to show checkboxes for multiple selection mode. + * This adds a checkbox icon to the left of each option. */ isCheckable?: boolean; @@ -158,7 +158,12 @@ export const FilterListBox = forwardRef(function FilterListBox< fieldProps.onSelectionChange = (key: any) => { if (props.selectionMode === 'multiple') { - onChange(key ? (Array.isArray(key) ? key : [key]) : []); + // Handle "all" selection and array selections + if (key === 'all') { + onChange('all'); + } else { + onChange(key ? (Array.isArray(key) ? key : [key]) : []); + } } else { onChange(Array.isArray(key) ? key[0] : key); } @@ -202,13 +207,16 @@ export const FilterListBox = forwardRef(function FilterListBox< defaultSelectedKeys, onSelectionChange: externalOnSelectionChange, allowsCustomValue = false, + showSelectAll, + selectAllLabel, header, footer, size = 'medium', headerStyles, footerStyles, listBoxStyles, - children, + items, + children: renderChildren, onEscape, isCheckable, onOptionClick, @@ -216,6 +224,39 @@ export const FilterListBox = forwardRef(function FilterListBox< ...otherProps } = props; + // Preserve the original `children` (may be a render function) before we + // potentially overwrite it. + let children: ReactNode = renderChildren as ReactNode; + + const renderFn = renderChildren as unknown; + + if (items && typeof renderFn === 'function') { + try { + const itemsArray = Array.from(items as Iterable); + // Execute the render function for each item to obtain /
nodes. + children = itemsArray.map((item, idx) => { + const rendered = (renderFn as (it: any) => ReactNode)(item); + // Ensure every element has a stable key: rely on the user-provided key + // inside the render function, otherwise fall back to the item itself or + // the index. This mirrors React Aria examples where the render function + // is expected to set keys, but we add a fallback for robustness. + if ( + React.isValidElement(rendered) && + (rendered as ReactElement).key == null + ) { + return React.cloneElement(rendered as ReactElement, { + key: (rendered as any)?.key ?? item?.key ?? idx, + }); + } + + return rendered as ReactNode; + }); + } catch { + // If conversion fails for some reason, we silently ignore and proceed + // with the original children value so we don't break runtime. + } + } + // Collect original option keys to avoid duplicating them as custom values. const originalKeys = useMemo(() => { const keys = new Set(); @@ -246,7 +287,9 @@ export const FilterListBox = forwardRef(function FilterListBox< if (!allowsCustomValue) return; const currentSelectedKeys = selectedKeys - ? Array.from(selectedKeys).map(String) + ? selectedKeys === 'all' + ? [] // Skip custom key detection when 'all' is selected + : Array.from(selectedKeys).map(String) : selectedKey != null ? [String(selectedKey)] : []; @@ -277,9 +320,14 @@ export const FilterListBox = forwardRef(function FilterListBox< const selectedKeysSet = new Set(); if (selectionMode === 'multiple') { - Array.from(selectedKeys ?? []).forEach((k) => - selectedKeysSet.add(String(k)), - ); + if (selectedKeys === 'all') { + // When 'all' is selected, no custom items should be treated as selected + // since 'all' means all available items, not custom ones + } else { + Array.from(selectedKeys ?? []).forEach((k) => + selectedKeysSet.add(String(k)), + ); + } } else { if (selectedKey != null) selectedKeysSet.add(String(selectedKey)); } @@ -867,11 +915,15 @@ export const FilterListBox = forwardRef(function FilterListBox< disabledKeys={props.disabledKeys} focusOnHover={focusOnHover} shouldUseVirtualFocus={true} + showSelectAll={showSelectAll} + selectAllLabel={selectAllLabel} footer={footer} footerStyles={footerStyles} mods={mods} size={size} + styles={listBoxStyles} isCheckable={isCheckable} + items={items as any} onSelectionChange={handleSelectionChange} onEscape={onEscape} onOptionClick={handleOptionClick} diff --git a/src/components/fields/FilterPicker/FilterPicker.docs.mdx b/src/components/fields/FilterPicker/FilterPicker.docs.mdx index 54bb44b23..ee4c3f982 100644 --- a/src/components/fields/FilterPicker/FilterPicker.docs.mdx +++ b/src/components/fields/FilterPicker/FilterPicker.docs.mdx @@ -35,6 +35,13 @@ Supports [Base properties](/docs/tasty-base-properties--docs) #### styles +Customizes the main wrapper element of the FilterPicker component. + +**Sub-elements:** +- None - styles apply directly to the wrapper + +#### triggerStyles + Customizes the trigger button element. **Sub-elements:** @@ -51,14 +58,23 @@ Customizes the dropdown list container within the popover. Customizes the popover dialog that contains the FilterListBox. +**Sub-elements:** +- Same as Dialog component sub-elements + #### headerStyles Customizes the header area when header prop is provided. +**Sub-elements:** +- None - styles apply directly to the header container + #### footerStyles Customizes the footer area when footer prop is provided. +**Sub-elements:** +- None - styles apply directly to the footer container + ### 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`. @@ -72,6 +88,51 @@ The `mods` property accepts the following modifiers you can override: | `placeholder` | `boolean` | Applied when no selection is made | | `selected` | `boolean` | Applied when items are selected | +## Content Patterns + +### Static Children Pattern + +The most common pattern for FilterPicker is to provide static children using `FilterPicker.Item` and `FilterPicker.Section` components: + +```jsx + + Apple + Banana + + Carrot + + +``` + +### Dynamic Content Pattern + +For large datasets or dynamic content, use the `items` prop with a render function. This pattern enables automatic virtualization for performance: + +```jsx + + {(item) => ( + + {item.name} + + )} + +``` + +**Key Benefits:** +- **Virtualization**: Automatically enabled for large lists without sections +- **Performance**: Only renders visible items in the DOM +- **Dynamic Content**: Perfect for data fetched from APIs or changing datasets +- **Memory Efficient**: Handles thousands of items smoothly + +**When to Use:** +- Lists with 50+ items +- Dynamic data from APIs +- Content that changes frequently +- When virtualization performance is needed + ## Variants ### Selection Modes @@ -111,6 +172,35 @@ The `mods` property accepts the following modifiers you can override: ``` +### Dynamic Content Pattern + + + +```jsx +const items = Array.from({ length: 100 }, (_, i) => ({ + id: `item-${i}`, + name: `Item ${i + 1}`, + description: `Description for item ${i + 1}` +})); + + + {(item) => ( + + {item.name} + + )} + +``` + ### Single Selection @@ -165,6 +255,24 @@ The `mods` property accepts the following modifiers you can override: ``` +### Multiple Selection with Select All + + + +```jsx + + Read + Write + Execute + +``` + ### Custom Summary @@ -358,16 +466,37 @@ The `mods` property accepts the following modifiers you can override: ``` -3. **Do**: Use sections for logical grouping of many options +3. **Do**: Use sections for logical grouping of small to medium lists ```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 +4. **Do**: Use dynamic content pattern for large datasets + ```jsx + // ✅ For large lists (50+ items) + + {(item) => {item.name}} + + ``` + +5. **Do**: Use `showSelectAll` for efficient multiple selection in compact interfaces + ```jsx + + {/* many items */} + + ``` + +6. **Do**: Use `triggerStyles` for custom styling needs beyond standard types +7. **Accessibility**: Always provide meaningful labels and placeholders +8. **Performance**: Use `textValue` prop for complex option content +9. **UX**: Consider using `isCheckable` for multiple selection clarity ## Integration with Forms @@ -457,10 +586,31 @@ For space-constrained interfaces: ### 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 +- **Use Dynamic Content Pattern**: For large datasets (50+ items), use the `items` prop with render function to enable automatic virtualization +- **Avoid Sections for Large Lists**: Virtualization is disabled when sections are present, so avoid sections for very large datasets +- **Use `textValue` prop**: For complex option content, provide searchable text that includes more context than just the visible label +- **Implement custom filter functions**: For specific search needs or performance-critical filtering +- **Consider debounced selection changes**: For real-time updates that trigger expensive operations + +### Virtualization + +When using the dynamic content pattern (`items` prop) without sections, FilterPicker automatically enables virtualization: + +```jsx +// ✅ Virtualization enabled - excellent performance with large datasets + + {(item) => {item.name}} + + +// ❌ Virtualization disabled - sections prevent virtualization + + + {(item) => {item.name}} + + +``` + +### Content Optimization ```jsx // Optimized for performance diff --git a/src/components/fields/FilterPicker/FilterPicker.stories.tsx b/src/components/fields/FilterPicker/FilterPicker.stories.tsx index 1cb4da01d..b30e8eee3 100644 --- a/src/components/fields/FilterPicker/FilterPicker.stories.tsx +++ b/src/components/fields/FilterPicker/FilterPicker.stories.tsx @@ -50,11 +50,13 @@ const meta: Meta = { }, selectedKeys: { control: { type: 'object' }, - description: 'The selected keys in controlled multiple mode', + description: + 'The selected keys in controlled multiple mode. Use "all" to select all items or an array of keys.', }, defaultSelectedKeys: { control: { type: 'object' }, - description: 'The default selected keys in uncontrolled multiple mode', + description: + 'The default selected keys in uncontrolled multiple mode. Use "all" to select all items or an array of keys.', }, selectionMode: { control: 'radio', @@ -66,7 +68,8 @@ const meta: Meta = { }, allowsCustomValue: { control: { type: 'boolean' }, - description: 'Whether the FilterListBox allows custom values', + description: + 'Whether to allow entering custom values that are not present in the predefined options', table: { defaultValue: { summary: false }, }, @@ -82,6 +85,11 @@ const meta: Meta = { control: { type: 'object' }, description: 'Array of keys for disabled items', }, + items: { + control: false, + description: + 'Array of items to render when using the render function pattern for large datasets with dynamic content', + }, /* Trigger */ placeholder: { @@ -90,7 +98,7 @@ const meta: Meta = { }, icon: { control: false, - description: 'Icon to show in the trigger', + description: 'Icon to show in the trigger button', }, type: { control: 'radio', @@ -111,7 +119,7 @@ const meta: Meta = { size: { control: 'radio', options: ['small', 'medium', 'large'], - description: 'Size of the picker', + description: 'Size of the picker component', table: { defaultValue: { summary: 'medium' }, }, @@ -136,7 +144,8 @@ const meta: Meta = { }, filter: { control: false, - description: 'Custom filter function for search', + description: + 'Custom filter function for determining if an option should be included in search results', }, /* Presentation */ @@ -150,13 +159,14 @@ const meta: Meta = { }, renderSummary: { control: false, - description: 'Custom renderer for the summary shown inside the trigger', + description: + 'Custom renderer for the summary shown inside the trigger when there is a selection', }, /* Behavior */ isCheckable: { control: 'boolean', - description: 'Whether to show checkboxes in multiple selection mode', + description: 'Whether to show checkboxes for multiple selection mode', table: { defaultValue: { summary: false }, }, @@ -168,6 +178,21 @@ const meta: Meta = { defaultValue: { summary: false }, }, }, + showSelectAll: { + control: { type: 'boolean' }, + description: + 'Whether to show the "Select All" option in multiple selection mode', + table: { + defaultValue: { summary: false }, + }, + }, + selectAllLabel: { + control: { type: 'text' }, + description: 'Label for the "Select All" option', + table: { + defaultValue: { summary: 'Select All' }, + }, + }, /* State */ isDisabled: { @@ -211,6 +236,32 @@ const meta: Meta = { description: 'Help or error message', }, + /* Styling */ + listBoxStyles: { + control: false, + description: + 'Custom styles for the dropdown list container within the popover', + }, + popoverStyles: { + control: false, + description: + 'Custom styles for the popover dialog that contains the FilterListBox', + }, + triggerStyles: { + control: false, + description: 'Custom styles for the trigger button element', + }, + headerStyles: { + control: false, + description: + 'Custom styles for the header area when header prop is provided', + }, + footerStyles: { + control: false, + description: + 'Custom styles for the footer area when footer prop is provided', + }, + /* Events */ onSelectionChange: { action: 'selection changed', @@ -349,6 +400,7 @@ export const SingleSelection: Story = { selectionMode: 'single', searchPlaceholder: 'Search fruits...', width: 'max 30x', + defaultSelectedKey: 'banana', }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -373,6 +425,7 @@ export const MultipleSelection: Story = { selectionMode: 'multiple', searchPlaceholder: 'Search options...', width: 'max 30x', + defaultSelectedKeys: ['banana', 'cherry'], }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -576,6 +629,7 @@ export const NoSummary: Story = { searchPlaceholder: 'Search options...', renderSummary: false, icon: , + rightIcon: null, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); @@ -672,7 +726,7 @@ export const LoadingState: Story = { isLoading: true, selectionMode: 'multiple', searchPlaceholder: 'Search options...', - width: 'max 25x', + width: '30x', }, render: (args) => ( @@ -1190,7 +1244,7 @@ export const InForm = () => { }; return ( -
+ { placeholder="Choose technology..." selectionMode="single" searchPlaceholder="Search technologies..." - value={selectedTechnology} + selectedKey={selectedTechnology} onSelectionChange={(key) => setSelectedTechnology(key as string | null)} > @@ -1234,7 +1288,7 @@ export const ComplexExample: Story = { selectionMode: 'multiple', isCheckable: true, searchPlaceholder: 'Search all filters...', - width: '40x', + width: '30x', renderSummary: ({ selectedKeys, selectedLabels }) => { if (selectedKeys.length === 0) return null; if (selectedKeys.length === 1) return `1 filter: ${selectedLabels[0]}`; @@ -1496,9 +1550,10 @@ export const VirtualizedList: Story = { setSelectedKeys(keys as string[])} > - {items.map((item) => ( + {(item: (typeof items)[number]) => ( {item.name} - ))} + )} @@ -1526,3 +1581,37 @@ export const VirtualizedList: Story = { }, }, }; + +export const WithSelectAll: Story = { + render: (args) => ( + + {permissions.map((permission) => ( + + {permission.label} + + ))} + + ), + args: { + label: 'Select permissions with Select All', + placeholder: 'Choose permissions', + selectionMode: 'multiple', + isCheckable: true, + showSelectAll: true, + selectAllLabel: 'All Permissions', + defaultSelectedKeys: ['read'], + type: 'outline', + size: 'medium', + }, + parameters: { + docs: { + description: { + story: + 'When `showSelectAll={true}` is used with multiple selection mode in FilterPicker, a "Select All" option appears in the dropdown above the search input. The checkbox shows indeterminate state when some items are selected, checked when all are selected, and unchecked when none are selected. The select all functionality works seamlessly with filtering and the trigger button shows the combined selection summary.', + }, + }, + }, +}; diff --git a/src/components/fields/FilterPicker/FilterPicker.tsx b/src/components/fields/FilterPicker/FilterPicker.tsx index 37685351a..d8c9f2aa0 100644 --- a/src/components/fields/FilterPicker/FilterPicker.tsx +++ b/src/components/fields/FilterPicker/FilterPicker.tsx @@ -1,3 +1,4 @@ +import { CollectionChildren } from '@react-types/shared'; import { Children, cloneElement, @@ -8,10 +9,11 @@ import { ReactNode, useCallback, useEffect, + useMemo, useRef, useState, } from 'react'; -import { FocusScope, useKeyboard } from 'react-aria'; +import { FocusScope, Key, useKeyboard } from 'react-aria'; import { Section as BaseSection, Item } from 'react-stately'; import { useWarn } from '../../../_internal/hooks/use-warn'; @@ -27,9 +29,10 @@ import { OUTER_STYLES, OuterStyleProps, Styles, + tasty, } from '../../../tasty'; import { mergeProps } from '../../../utils/react'; -import { Button } from '../../actions'; +import { Button, CubeButtonProps } from '../../actions'; import { Text } from '../../content/Text'; import { useFieldProps, useFormProps, wrapWithField } from '../../form'; import { Dialog, DialogTrigger } from '../../overlays/Dialog'; @@ -42,17 +45,18 @@ import { ListBox } from '../ListBox'; import type { FieldBaseProps } from '../../../shared'; export interface CubeFilterPickerProps - extends Omit, 'children'>, + extends Omit, 'size'>, BasePropsWithoutChildren, BaseStyleProps, OuterStyleProps, ColorStyleProps, - FieldBaseProps { + FieldBaseProps, + Pick { /** Placeholder text when no selection is made */ placeholder?: string; - /** Icon to show in the trigger */ + /** Icon to show in the trigger button */ icon?: ReactElement; - /** Type of button styling */ + /** Button styling type */ type?: | 'outline' | 'clear' @@ -62,48 +66,62 @@ export interface CubeFilterPickerProps | (string & {}); /** Button theme */ theme?: 'default' | 'special'; - /** Size of the component */ + /** Size of the picker component */ size?: 'small' | 'medium' | 'large'; - /** Children (FilterListBox.Item and FilterListBox.Section elements) */ - children?: ReactNode; - /** Custom styles for the list box */ + /** Custom styles for the list box popover */ listBoxStyles?: Styles; - /** Custom styles for the popover */ + /** Custom styles for the popover container */ popoverStyles?: Styles; - /** Whether the filter picker is checkable */ + /** Custom styles for the trigger button */ + triggerStyles?: Styles; + /** Whether to show checkboxes for multiple selection mode */ isCheckable?: boolean; + /** Whether to flip the popover placement */ + shouldFlip?: boolean; /** - * Custom renderer for the summary shown inside the trigger **when there is a selection**. + * 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. + * - `selectedKeys`: array of keys of the selected items or "all". * * 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. + * Set to `false` to hide the summary text completely. */ renderSummary?: | ((args: { selectedLabels: string[]; - selectedKeys: (string | number)[]; + selectedKeys: 'all' | (string | number)[]; selectedLabel?: string; selectedKey?: string | number | null; selectionMode: 'single' | 'multiple'; }) => ReactNode) | false; - /** Optional ref to access internal ListBox state (from FilterListBox) */ + /** Ref to access internal ListBox state (from FilterListBox) */ listStateRef?: MutableRefObject; - /** Mods for the FilterPicker */ + /** Additional modifiers for styling the FilterPicker */ mods?: Record; } const PROP_STYLES = [...BASE_STYLES, ...OUTER_STYLES, ...COLOR_STYLES]; +const FilterPickerWrapper = tasty({ + qa: 'FilterPicker', + styles: { + display: 'inline-grid', + flow: 'column', + gridRows: '1sf', + placeContent: 'stretch', + placeItems: 'stretch', + }, +}); + export const FilterPicker = forwardRef(function FilterPicker( props: CubeFilterPickerProps, ref: ForwardedRef, @@ -122,7 +140,12 @@ export const FilterPicker = forwardRef(function FilterPicker( fieldProps.onSelectionChange = (key: any) => { if (props.selectionMode === 'multiple') { - onChange(key ? (Array.isArray(key) ? key : [key]) : []); + // Handle "all" selection and array selections + if (key === 'all') { + onChange('all'); + } else { + onChange(key ? (Array.isArray(key) ? key : [key]) : []); + } } else { onChange(Array.isArray(key) ? key[0] : key); } @@ -137,6 +160,7 @@ export const FilterPicker = forwardRef(function FilterPicker( label, extra, icon, + rightIcon, labelStyles, isRequired, necessityIndicator, @@ -156,6 +180,7 @@ export const FilterPicker = forwardRef(function FilterPicker( labelSuffix, shouldFocusWrap, children, + shouldFlip = true, selectedKey, defaultSelectedKey, selectedKeys, @@ -165,10 +190,14 @@ export const FilterPicker = forwardRef(function FilterPicker( selectionMode = 'single', listStateRef, focusOnHover, + showSelectAll, + selectAllLabel = 'All', + items, header, footer, headerStyles, footerStyles, + triggerStyles, allowsCustomValue, renderSummary, isCheckable, @@ -186,12 +215,12 @@ export const FilterPicker = forwardRef(function FilterPicker( }); // Internal selection state (uncontrolled scenario) - const [internalSelectedKey, setInternalSelectedKey] = useState( + const [internalSelectedKey, setInternalSelectedKey] = useState( defaultSelectedKey ?? null, ); - const [internalSelectedKeys, setInternalSelectedKeys] = useState( - defaultSelectedKeys ?? [], - ); + const [internalSelectedKeys, setInternalSelectedKeys] = useState< + 'all' | Key[] + >(defaultSelectedKeys ?? []); // Track popover open/close and capture children order for session const [isPopoverOpen, setIsPopoverOpen] = useState(false); @@ -208,7 +237,8 @@ export const FilterPicker = forwardRef(function FilterPicker( ? selectedKeys : internalSelectedKeys; - // Utility to normalize React keys by stripping array prefixes like ".$" or "." + // Utility: remove React's ".$" / "." prefixes from element keys so that we + // can compare them with user-provided keys. const normalizeKeyValue = (key: any): string => { if (key == null) return ''; const str = String(key); @@ -219,11 +249,66 @@ export const FilterPicker = forwardRef(function FilterPicker( : str; }; + // --------------------------------------------------------------------------- + // Map public-facing keys (without React's "." prefix) to the actual React + // element keys that appear in the collection (which usually have the `.$` + // or `.` prefix added by React when children are in an array). This ensures + // that the key we pass to ListBox exactly matches the keys it receives from + // React Aria, so the initial selection is highlighted correctly. + // --------------------------------------------------------------------------- + + const findReactKey = useCallback( + (lookup: any): any => { + if (lookup == null) return lookup; + + const normalizedLookup = normalizeKeyValue(lookup); + let foundKey: any = lookup; + + const traverse = (nodes: ReactNode): void => { + Children.forEach(nodes, (child: any) => { + if (!child || typeof child !== 'object') return; + + if (child.key != null) { + if (normalizeKeyValue(child.key) === normalizedLookup) { + foundKey = child.key; + } + } + + if (child.props?.children) { + traverse(child.props.children); + } + }); + }; + + if (children) traverse(children as ReactNode); + + return foundKey; + }, + [children], + ); + + const mappedSelectedKey = useMemo(() => { + if (selectionMode !== 'single') return null; + return findReactKey(effectiveSelectedKey); + }, [selectionMode, effectiveSelectedKey, findReactKey]); + + const mappedSelectedKeys = useMemo(() => { + if (selectionMode !== 'multiple') return undefined as any; + + if (effectiveSelectedKeys === 'all') return 'all' as const; + + if (Array.isArray(effectiveSelectedKeys)) { + return (effectiveSelectedKeys as any[]).map((k) => findReactKey(k)); + } + + return effectiveSelectedKeys as any; + }, [selectionMode, effectiveSelectedKeys, findReactKey]); + // 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); + const nKey = String(key); if (resultSet.has(nKey)) { resultSet.delete(nKey); // toggle off if clicked twice } else { @@ -237,11 +322,39 @@ export const FilterPicker = forwardRef(function FilterPicker( const getSelectedLabels = () => { if (!children) return []; + // Handle "all" selection - return all available labels + if (selectionMode === 'multiple' && effectiveSelectedKeys === 'all') { + const allLabels: string[] = []; + + const extractAllLabels = (nodes: ReactNode): void => { + Children.forEach(nodes, (child: any) => { + if (!child || typeof child !== 'object') return; + + if (child.type === Item) { + const label = + child.props.textValue || + (typeof child.props.children === 'string' + ? child.props.children + : '') || + String(child.props.children || ''); + allLabels.push(label); + } + + if (child.props?.children) { + extractAllLabels(child.props.children); + } + }); + }; + + extractAllLabels(children as ReactNode); + return allLabels; + } + const selectedSet = new Set( - selectionMode === 'multiple' - ? (effectiveSelectedKeys || []).map(String) + selectionMode === 'multiple' && effectiveSelectedKeys !== 'all' + ? (effectiveSelectedKeys || []).map((k) => normalizeKeyValue(k)) : effectiveSelectedKey != null - ? [String(effectiveSelectedKey)] + ? [normalizeKeyValue(effectiveSelectedKey)] : [], ); @@ -252,7 +365,7 @@ export const FilterPicker = forwardRef(function FilterPicker( if (!child || typeof child !== 'object') return; if (child.type === Item) { - if (selectedSet.has(String(child.key))) { + if (selectedSet.has(normalizeKeyValue(child.key))) { const label = child.props.textValue || (typeof child.props.children === 'string' @@ -296,11 +409,11 @@ export const FilterPicker = forwardRef(function FilterPicker( }); }; - extractLabelsWithTracking(children); + extractLabelsWithTracking(children as ReactNode); // Handle custom values that don't have corresponding children const selectedKeysArr = - selectionMode === 'multiple' + selectionMode === 'multiple' && effectiveSelectedKeys !== 'all' ? (effectiveSelectedKeys || []).map(String) : effectiveSelectedKey != null ? [String(effectiveSelectedKey)] @@ -308,7 +421,7 @@ export const FilterPicker = forwardRef(function FilterPicker( // Add labels for any selected keys that weren't processed (custom values) selectedKeysArr.forEach((key) => { - if (!processedKeys.has(key)) { + if (!processedKeys.has(normalizeKeyValue(key))) { // This is a custom value, use the key as the label labels.push(key); } @@ -323,38 +436,46 @@ export const FilterPicker = forwardRef(function FilterPicker( // 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[]; + multiple: 'all' | string[]; }>({ - single: - effectiveSelectedKey != null - ? normalizeKeyValue(effectiveSelectedKey) - : null, - multiple: (effectiveSelectedKeys ?? []).map(normalizeKeyValue), + single: effectiveSelectedKey != null ? String(effectiveSelectedKey) : null, + multiple: + effectiveSelectedKeys === 'all' + ? 'all' + : (effectiveSelectedKeys ?? []).map(String), }); useEffect(() => { latestSelectionRef.current = { single: - effectiveSelectedKey != null - ? normalizeKeyValue(effectiveSelectedKey) - : null, - multiple: (effectiveSelectedKeys ?? []).map(normalizeKeyValue), + effectiveSelectedKey != null ? String(effectiveSelectedKey) : null, + multiple: + effectiveSelectedKeys === 'all' + ? 'all' + : (effectiveSelectedKeys ?? []).map(String), }; }, [effectiveSelectedKey, effectiveSelectedKeys]); const selectionsWhenClosed = useRef<{ single: string | null; - multiple: string[]; + multiple: 'all' | string[]; }>({ single: null, multiple: [] }); + // Capture the initial selection (from defaultSelectedKey(s)) so that + // the very first popover open can already use it for sorting. + useEffect(() => { + selectionsWhenClosed.current = { ...latestSelectionRef.current }; + }, []); // run only once on mount + // Function to sort children with selected items on top const getSortedChildren = 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; + // When the popover is **closed**, reuse the cached order if we have it to + // avoid unnecessary reflows. If we don't have a cache yet (first open), + // fall through to compute the sorted order so the very first render is + // already correct. + if (!isPopoverOpen && cachedChildrenOrder.current) { + return cachedChildrenOrder.current; } // Popover is open – compute (or recompute) the sorted order for this @@ -382,14 +503,19 @@ export const FilterPicker = forwardRef(function FilterPicker( // Create selected keys set for fast lookup const selectedSet = new Set(); if (selectionMode === 'multiple') { - selectionsWhenClosed.current.multiple.forEach((key) => - selectedSet.add(normalizeKeyValue(key)), - ); + if (selectionsWhenClosed.current.multiple === 'all') { + // Don't sort when "all" is selected, just return original children + return children; + } else { + (selectionsWhenClosed.current.multiple as string[]).forEach((key) => + selectedSet.add(String(key)), + ); + } } else if ( selectionMode === 'single' && selectionsWhenClosed.current.single != null ) { - selectedSet.add(normalizeKeyValue(selectionsWhenClosed.current.single)); + selectedSet.add(String(selectionsWhenClosed.current.single)); } // Helper function to check if an item is selected @@ -470,11 +596,12 @@ export const FilterPicker = forwardRef(function FilterPicker( }; // Sort the children - const childrenArray = Children.toArray(children); + const childrenArray = Children.toArray(children as ReactNode); const sortedChildren = sortChildrenArray(childrenArray); - // Cache the sorted order when popover opens - if (isPopoverOpen) { + // Cache the sorted order when popover opens or when we compute it for the + // first time before opening. + if (isPopoverOpen || !cachedChildrenOrder.current) { cachedChildrenOrder.current = sortedChildren; } @@ -513,12 +640,14 @@ export const FilterPicker = forwardRef(function FilterPicker( return null; } - let content: string | null | undefined = ''; + let content: ReactNode = ''; if (!hasSelection) { content = placeholder; } else if (selectionMode === 'single') { content = selectedLabels[0]; + } else if (effectiveSelectedKeys === 'all') { + content = selectAllLabel; } else { content = selectedLabels.join(', '); } @@ -537,6 +666,8 @@ export const FilterPicker = forwardRef(function FilterPicker( ); }; + const [shouldUpdatePosition, setShouldUpdatePosition] = useState(false); + // 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 @@ -562,10 +693,15 @@ export const FilterPicker = forwardRef(function FilterPicker( }, }); + useEffect(() => { + // Disable the update of the position while the popover is open (with a delay) to avoid jumping + setShouldUpdatePosition(!state.isOpen); + }, [state.isOpen]); + return (