diff --git a/.changeset/metal-pumpkins-appear.md b/.changeset/metal-pumpkins-appear.md new file mode 100644 index 000000000..44d66ed02 --- /dev/null +++ b/.changeset/metal-pumpkins-appear.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Full items prop support in FilterPicker. diff --git a/src/components/actions/Button/Button.tsx b/src/components/actions/Button/Button.tsx index fc889854c..30c9bea84 100644 --- a/src/components/actions/Button/Button.tsx +++ b/src/components/actions/Button/Button.tsx @@ -124,7 +124,7 @@ export const DEFAULT_BUTTON_STYLES = { }, ButtonIcon: { - width: 'max-content', + width: 'min 1fs', }, '& [data-element="ButtonIcon"]:first-child:not(:last-child)': { @@ -722,8 +722,7 @@ export const Button = forwardRef(function Button( ) ) : null} - {((hasIcons && children) || (!!icon && !!rightIcon)) && - typeof children === 'string' ? ( + {hasIcons && typeof children === 'string' ? ( {children} ) : ( children diff --git a/src/components/fields/FilterListBox/FilterListBox.stories.tsx b/src/components/fields/FilterListBox/FilterListBox.stories.tsx index 46da8a864..81b82e779 100644 --- a/src/components/fields/FilterListBox/FilterListBox.stories.tsx +++ b/src/components/fields/FilterListBox/FilterListBox.stories.tsx @@ -1187,12 +1187,12 @@ EscapeKeyHandling.parameters = { }; export const VirtualizedList: StoryFn> = (args) => { - const [selectedKeys, setSelectedKeys] = useState([]); + const [selectedKeys, setSelectedKeys] = useState(['item-2']); // 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}`, + id: `item-${i + 1}`, name: `Item ${i + 1}${i % 7 === 0 ? ' - This is a longer item name to test dynamic sizing' : ''}`, description: i % 3 === 0 diff --git a/src/components/fields/FilterPicker/FilterPicker.stories.tsx b/src/components/fields/FilterPicker/FilterPicker.stories.tsx index b30e8eee3..aaa0196d1 100644 --- a/src/components/fields/FilterPicker/FilterPicker.stories.tsx +++ b/src/components/fields/FilterPicker/FilterPicker.stories.tsx @@ -1526,12 +1526,12 @@ export const VirtualizedList: Story = { await userEvent.click(trigger); }, render: (args) => { - const [selectedKeys, setSelectedKeys] = useState([]); + const [selectedKeys, setSelectedKeys] = useState(['item-2']); // 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}`, + id: `item-${i + 1}`, name: `Item ${i + 1}${i % 7 === 0 ? ' - This is a longer item name to test dynamic sizing' : ''}`, description: i % 3 === 0 @@ -1584,15 +1584,15 @@ export const VirtualizedList: Story = { export const WithSelectAll: Story = { render: (args) => ( - - {permissions.map((permission) => ( + + {(permission: any) => ( {permission.label} - ))} + )} ), args: { @@ -1603,8 +1603,7 @@ export const WithSelectAll: Story = { showSelectAll: true, selectAllLabel: 'All Permissions', defaultSelectedKeys: ['read'], - type: 'outline', - size: 'medium', + width: '30x', }, parameters: { docs: { diff --git a/src/components/fields/FilterPicker/FilterPicker.test.tsx b/src/components/fields/FilterPicker/FilterPicker.test.tsx index 5918b2a72..38b987228 100644 --- a/src/components/fields/FilterPicker/FilterPicker.test.tsx +++ b/src/components/fields/FilterPicker/FilterPicker.test.tsx @@ -843,4 +843,75 @@ describe('', () => { expect(options[1]).toHaveTextContent('Apple'); }); }); + + describe('Items prop functionality', () => { + const itemsWithLabels = [ + { key: 'apple', label: 'Red Apple' }, + { key: 'banana', label: 'Yellow Banana' }, + { key: 'cherry', label: 'Sweet Cherry' }, + ]; + + it('should display labels correctly when using items prop with label property', async () => { + const { getByRole } = renderWithRoot( + + {(item) => ( + {item.label} + )} + , + ); + + // Wait for component to render + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + const trigger = getByRole('button'); + + // Should display the label, not the key + expect(trigger).toHaveTextContent('Red Apple'); + expect(trigger).not.toHaveTextContent('apple'); + }); + + it('should display correct labels in multiple selection mode with items prop', async () => { + const renderSummary = jest.fn( + ({ selectedLabels }) => `Selected: ${selectedLabels.join(', ')}`, + ); + + const { getByRole } = renderWithRoot( + + {(item) => ( + {item.label} + )} + , + ); + + // Wait for component to render + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + // Check that renderSummary was called with correct labels + expect(renderSummary).toHaveBeenCalledWith({ + selectedLabels: ['Red Apple', 'Sweet Cherry'], + selectedKeys: ['apple', 'cherry'], + selectionMode: 'multiple', + }); + + const trigger = getByRole('button'); + expect(trigger).toHaveTextContent('Selected: Red Apple, Sweet Cherry'); + }); + }); }); diff --git a/src/components/fields/FilterPicker/FilterPicker.tsx b/src/components/fields/FilterPicker/FilterPicker.tsx index d8c9f2aa0..9004bfa6f 100644 --- a/src/components/fields/FilterPicker/FilterPicker.tsx +++ b/src/components/fields/FilterPicker/FilterPicker.tsx @@ -14,7 +14,7 @@ import { useState, } from 'react'; import { FocusScope, Key, useKeyboard } from 'react-aria'; -import { Section as BaseSection, Item } from 'react-stately'; +import { Section as BaseSection, Item, ListState } from 'react-stately'; import { useWarn } from '../../../_internal/hooks/use-warn'; import { DirectionIcon } from '../../../icons'; @@ -44,6 +44,15 @@ import { ListBox } from '../ListBox'; import type { FieldBaseProps } from '../../../shared'; +// Define interface for items that can have keys +interface ItemWithKey { + key?: string | number; + id?: string | number; + textValue?: string; + children?: ItemWithKey[]; + [key: string]: unknown; +} + export interface CubeFilterPickerProps extends Omit, 'size'>, BasePropsWithoutChildren, @@ -95,16 +104,16 @@ export interface CubeFilterPickerProps */ renderSummary?: | ((args: { - selectedLabels: string[]; - selectedKeys: 'all' | (string | number)[]; + selectedLabels?: string[]; + selectedKeys?: 'all' | (string | number)[]; selectedLabel?: string; selectedKey?: string | number | null; - selectionMode: 'single' | 'multiple'; + selectionMode?: 'single' | 'multiple'; }) => ReactNode) | false; /** Ref to access internal ListBox state (from FilterListBox) */ - listStateRef?: MutableRefObject; + listStateRef?: MutableRefObject>; /** Additional modifiers for styling the FilterPicker */ mods?: Record; } @@ -130,7 +139,7 @@ export const FilterPicker = forwardRef(function FilterPicker( props = useFormProps(props); props = useFieldProps(props, { valuePropsMapper: ({ value, onChange }) => { - const fieldProps: any = {}; + const fieldProps: Record = {}; if (props.selectionMode === 'multiple') { fieldProps.selectedKeys = value || []; @@ -138,7 +147,7 @@ export const FilterPicker = forwardRef(function FilterPicker( fieldProps.selectedKey = value ?? null; } - fieldProps.onSelectionChange = (key: any) => { + fieldProps.onSelectionChange = (key: Key | null | 'all' | Key[]) => { if (props.selectionMode === 'multiple') { // Handle "all" selection and array selections if (key === 'all') { @@ -225,6 +234,8 @@ export const FilterPicker = forwardRef(function FilterPicker( // Track popover open/close and capture children order for session const [isPopoverOpen, setIsPopoverOpen] = useState(false); const cachedChildrenOrder = useRef(null); + // Cache for sorted items array when using `items` prop + const cachedItemsOrder = useRef(null); const triggerRef = useRef(null); const isControlledSingle = selectedKey !== undefined; @@ -239,7 +250,7 @@ export const FilterPicker = forwardRef(function FilterPicker( // Utility: remove React's ".$" / "." prefixes from element keys so that we // can compare them with user-provided keys. - const normalizeKeyValue = (key: any): string => { + const normalizeKeyValue = (key: Key): string => { if (key == null) return ''; const str = String(key); return str.startsWith('.$') @@ -258,24 +269,25 @@ export const FilterPicker = forwardRef(function FilterPicker( // --------------------------------------------------------------------------- const findReactKey = useCallback( - (lookup: any): any => { + (lookup: Key): Key => { if (lookup == null) return lookup; const normalizedLookup = normalizeKeyValue(lookup); - let foundKey: any = lookup; + let foundKey: Key = lookup; const traverse = (nodes: ReactNode): void => { - Children.forEach(nodes, (child: any) => { + Children.forEach(nodes, (child: ReactNode) => { if (!child || typeof child !== 'object') return; + const element = child as ReactElement; - if (child.key != null) { - if (normalizeKeyValue(child.key) === normalizedLookup) { - foundKey = child.key; + if (element.key != null) { + if (normalizeKeyValue(element.key) === normalizedLookup) { + foundKey = element.key; } } - if (child.props?.children) { - traverse(child.props.children); + if (element.props?.children) { + traverse(element.props.children); } }); }; @@ -289,23 +301,23 @@ export const FilterPicker = forwardRef(function FilterPicker( const mappedSelectedKey = useMemo(() => { if (selectionMode !== 'single') return null; - return findReactKey(effectiveSelectedKey); + return effectiveSelectedKey ? findReactKey(effectiveSelectedKey) : null; }, [selectionMode, effectiveSelectedKey, findReactKey]); const mappedSelectedKeys = useMemo(() => { - if (selectionMode !== 'multiple') return undefined as any; + if (selectionMode !== 'multiple') return undefined; if (effectiveSelectedKeys === 'all') return 'all' as const; if (Array.isArray(effectiveSelectedKeys)) { - return (effectiveSelectedKeys as any[]).map((k) => findReactKey(k)); + return (effectiveSelectedKeys as Key[]).map((k) => findReactKey(k)); } - return effectiveSelectedKeys as any; + return effectiveSelectedKeys; }, [selectionMode, effectiveSelectedKeys, findReactKey]); // Given an iterable of keys (array or Set) toggle membership for duplicates - const processSelectionArray = (iterable: Iterable): string[] => { + const processSelectionArray = (iterable: Iterable): string[] => { const resultSet = new Set(); for (const key of iterable) { const nKey = String(key); @@ -320,33 +332,74 @@ export const FilterPicker = forwardRef(function FilterPicker( // Helper to get selected item labels for display 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; + // Extract from items prop if available + if (items) { + const extractFromItems = (itemsArray: unknown[]): void => { + itemsArray.forEach((item) => { + if (item && typeof item === 'object') { + const itemObj = item as ItemWithKey; + if (Array.isArray(itemObj.children)) { + // Section-like object + extractFromItems(itemObj.children); + } else { + // Regular item - extract label + const label = + itemObj.textValue || + (itemObj as any).label || + (typeof (itemObj as any).children === 'string' + ? (itemObj as any).children + : '') || + String( + (itemObj as any).children || + itemObj.key || + itemObj.id || + item, + ); + allLabels.push(label); + } + } + }); + }; - if (child.type === Item) { - const label = - child.props.textValue || - (typeof child.props.children === 'string' - ? child.props.children - : '') || - String(child.props.children || ''); - allLabels.push(label); - } + const itemsArray = Array.isArray(items) + ? items + : Array.from(items as Iterable); + extractFromItems(itemsArray); + return allLabels; + } - if (child.props?.children) { - extractAllLabels(child.props.children); - } - }); - }; + // Extract from children if available + if (children) { + const extractAllLabels = (nodes: ReactNode): void => { + if (!nodes) return; + Children.forEach(nodes, (child: ReactNode) => { + if (!child || typeof child !== 'object') return; + const element = child as ReactElement; + + if (element.type === Item) { + const label = + element.props.textValue || + (typeof element.props.children === 'string' + ? element.props.children + : '') || + String(element.props.children || ''); + allLabels.push(label); + } + + if (element.props?.children) { + extractAllLabels(element.props.children); + } + }); + }; + + extractAllLabels(children as ReactNode); + return allLabels; + } - extractAllLabels(children as ReactNode); return allLabels; } @@ -359,59 +412,77 @@ export const FilterPicker = forwardRef(function FilterPicker( ); const labels: string[] = []; + const processedKeys = new Set(); - const extractLabels = (nodes: ReactNode): void => { - Children.forEach(nodes, (child: any) => { - if (!child || typeof child !== 'object') return; - - if (child.type === Item) { - if (selectedSet.has(normalizeKeyValue(child.key))) { - const label = - child.props.textValue || - (typeof child.props.children === 'string' - ? child.props.children - : '') || - String(child.props.children || ''); - labels.push(label); + // Extract from items prop if available + if (items) { + const extractFromItems = (itemsArray: unknown[]): void => { + itemsArray.forEach((item) => { + if (item && typeof item === 'object') { + const itemObj = item as ItemWithKey; + if (Array.isArray(itemObj.children)) { + // Section-like object + extractFromItems(itemObj.children); + } else { + // Regular item - check if selected + const itemKey = itemObj.key || itemObj.id; + if ( + itemKey != null && + selectedSet.has(normalizeKeyValue(itemKey)) + ) { + const label = + itemObj.textValue || + (itemObj as any).label || + (typeof (itemObj as any).children === 'string' + ? (itemObj as any).children + : '') || + String((itemObj as any).children || itemKey); + labels.push(label); + processedKeys.add(normalizeKeyValue(itemKey)); + } + } } - } - - if (child.props?.children) { - extractLabels(child.props.children); - } - }); - }; + }); + }; - const processedKeys = new Set(); + const itemsArray = Array.isArray(items) + ? items + : Array.from(items as Iterable); + extractFromItems(itemsArray); + } - // Modified extractLabels to track which keys we've processed - const extractLabelsWithTracking = (nodes: ReactNode): void => { - 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); + // Extract from children if available (for mixed mode or fallback) + if (children) { + const extractLabelsWithTracking = (nodes: ReactNode): void => { + if (!nodes) return; + Children.forEach(nodes, (child: ReactNode) => { + if (!child || typeof child !== 'object') return; + const element = child as ReactElement; + + if (element.type === Item) { + const childKey = String(element.key); + if (selectedSet.has(normalizeKeyValue(childKey))) { + const label = + element.props.textValue || + (typeof element.props.children === 'string' + ? element.props.children + : '') || + String(element.props.children || ''); + labels.push(label); + processedKeys.add(normalizeKeyValue(childKey)); + } } - } - if (child.props?.children) { - extractLabelsWithTracking(child.props.children); - } - }); - }; + if (element.props?.children) { + extractLabelsWithTracking(element.props.children); + } + }); + }; - extractLabelsWithTracking(children as ReactNode); + extractLabelsWithTracking(children as ReactNode); + } - // Handle custom values that don't have corresponding children + // Handle custom values that don't have corresponding items/children const selectedKeysArr = selectionMode === 'multiple' && effectiveSelectedKeys !== 'all' ? (effectiveSelectedKeys || []).map(String) @@ -468,7 +539,8 @@ export const FilterPicker = forwardRef(function FilterPicker( // Function to sort children with selected items on top const getSortedChildren = useCallback(() => { - if (!children) return children; + // If children is not provided or is a render function, return it as-is + if (!children || typeof children === 'function') return 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), @@ -519,7 +591,7 @@ export const FilterPicker = forwardRef(function FilterPicker( } // Helper function to check if an item is selected - const isItemSelected = (child: any): boolean => { + const isItemSelected = (child: ReactElement): boolean => { return ( child?.key != null && selectedSet.has(normalizeKeyValue(child.key)) ); @@ -527,45 +599,50 @@ export const FilterPicker = forwardRef(function FilterPicker( // Helper function to sort children array const sortChildrenArray = (childrenArray: ReactNode[]): ReactNode[] => { - const cloneWithNormalizedKey = (item: any) => + const cloneWithNormalizedKey = (item: ReactElement) => cloneElement(item, { - key: normalizeKeyValue(item.key), + key: item.key ? normalizeKeyValue(item.key) : undefined, }); const selected: ReactNode[] = []; const unselected: ReactNode[] = []; - childrenArray.forEach((child: any) => { + childrenArray.forEach((child: ReactNode) => { if (!child || typeof child !== 'object') { unselected.push(child); return; } + const element = child as ReactElement; + // Handle sections - sort items within each section if ( - child.type === BaseSection || - child.type?.displayName === 'Section' + element.type === BaseSection || + (element.type as any)?.displayName === 'Section' ) { - const sectionChildren = Array.isArray(child.props.children) - ? child.props.children - : [child.props.children]; + const sectionChildren = Array.isArray(element.props.children) + ? element.props.children + : [element.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); + sectionChildren.forEach((sectionChild: ReactNode) => { + if (sectionChild && typeof sectionChild === 'object') { + const sectionElement = sectionChild as ReactElement; + if ( + sectionElement.type === Item || + (sectionElement.type as any)?.displayName === 'Item' + ) { + const clonedItem = cloneWithNormalizedKey(sectionElement); + + if (isItemSelected(sectionElement)) { + selectedItems.push(clonedItem); + } else { + unselectedItems.push(clonedItem); + } } else { - unselectedItems.push(clonedItem); + unselectedItems.push(sectionChild); } } else { unselectedItems.push(sectionChild); @@ -574,17 +651,17 @@ export const FilterPicker = forwardRef(function FilterPicker( // Create new section with sorted children, preserving React element properly unselected.push( - cloneElement(child, { - ...child.props, + cloneElement(element, { + ...element.props, children: [...selectedItems, ...unselectedItems], }), ); } // Handle non-section elements (items, dividers, etc.) else { - const clonedItem = cloneWithNormalizedKey(child); + const clonedItem = cloneWithNormalizedKey(element); - if (isItemSelected(child)) { + if (isItemSelected(element)) { selected.push(clonedItem); } else { unselected.push(clonedItem); @@ -614,8 +691,92 @@ export const FilterPicker = forwardRef(function FilterPicker( isPopoverOpen, ]); + // Compute sorted items array when using `items` prop + const getSortedItems = useCallback(() => { + if (!items) return items; + + // Reuse cached order when popover is closed to avoid needless re-renders + if (!isPopoverOpen && cachedItemsOrder.current) { + return cachedItemsOrder.current; + } + + const selectedSet = new Set(); + + const addSelected = (key: Key) => { + if (key != null) selectedSet.add(String(key)); + }; + + if (selectionMode === 'multiple') { + if (selectionsWhenClosed.current.multiple === 'all') { + // Do not sort when all selected – keep original order + return items; + } + (selectionsWhenClosed.current.multiple as string[]).forEach(addSelected); + } else { + if (selectionsWhenClosed.current.single != null) { + addSelected(selectionsWhenClosed.current.single); + } + } + + if (selectedSet.size === 0) { + return items; + } + + // Helpers to extract key from item object + const getItemKey = (obj: unknown): string | undefined => { + if (obj == null || typeof obj !== 'object') return undefined; + + const item = obj as ItemWithKey; + if (item.key != null) return String(item.key); + if (item.id != null) return String(item.id); + return undefined; + }; + + const sortArray = (arr: unknown[]): unknown[] => { + const selectedArr: unknown[] = []; + const unselectedArr: unknown[] = []; + + arr.forEach((obj) => { + const item = obj as ItemWithKey; + if (obj && Array.isArray(item.children)) { + // Section-like object – keep order, but sort its children + const sortedChildren = sortArray(item.children); + unselectedArr.push({ ...item, children: sortedChildren }); + } else { + const key = getItemKey(obj); + if (key && selectedSet.has(key)) { + selectedArr.push(obj); + } else { + unselectedArr.push(obj); + } + } + }); + + return [...selectedArr, ...unselectedArr]; + }; + + const itemsArray = Array.isArray(items) + ? items + : Array.from(items as Iterable); + const sorted = sortArray(itemsArray) as T[]; + + if (isPopoverOpen || !cachedItemsOrder.current) { + cachedItemsOrder.current = sorted; + } + + return sorted; + }, [ + items, + selectionMode, + isPopoverOpen, + selectionsWhenClosed.current.multiple, + selectionsWhenClosed.current.single, + ]); + + const finalItems = getSortedItems(); + // FilterListBox handles custom values internally when allowsCustomValue={true} - // We only provide the sorted original children + // We provide sorted children (if any) and sorted items const finalChildren = getSortedChildren(); const renderTriggerContent = () => { @@ -626,14 +787,14 @@ export const FilterPicker = forwardRef(function FilterPicker( selectedLabel: selectedLabels[0], selectedKey: effectiveSelectedKey ?? null, selectedLabels, - selectedKeys: effectiveSelectedKeys as any, + selectedKeys: effectiveSelectedKeys, selectionMode: 'single', }); } return renderSummary({ selectedLabels, - selectedKeys: effectiveSelectedKeys as any, + selectedKeys: effectiveSelectedKeys, selectionMode: 'multiple', }); } else if (hasSelection && renderSummary === false) { @@ -749,7 +910,7 @@ export const FilterPicker = forwardRef(function FilterPicker( ( // Update internal state if uncontrolled if (selectionMode === 'single') { if (!isControlledSingle) { - setInternalSelectedKey(selection as any); + setInternalSelectedKey(selection as Key | null); } } else { if (!isControlledMultiple) { - let normalized: any = selection; + let normalized: 'all' | Key[] = selection as + | 'all' + | Key[]; if (selection === 'all') { normalized = 'all'; @@ -810,16 +973,19 @@ export const FilterPicker = forwardRef(function FilterPicker( typeof selection === 'object' && (selection as any) instanceof Set ) { - normalized = processSelectionArray(selection as any); + normalized = processSelectionArray( + selection as Set, + ); } - setInternalSelectedKeys(normalized as any); + setInternalSelectedKeys(normalized); } } // Update latest selection ref synchronously if (selectionMode === 'single') { - latestSelectionRef.current.single = selection as any; + latestSelectionRef.current.single = + selection != null ? String(selection) : null; } else { if (selection === 'all') { latestSelectionRef.current.multiple = 'all'; @@ -833,21 +999,30 @@ export const FilterPicker = forwardRef(function FilterPicker( (selection as any) instanceof Set ) { latestSelectionRef.current.multiple = Array.from( - new Set(processSelectionArray(selection as any)), + new Set(processSelectionArray(selection as Set)), ); } else { - latestSelectionRef.current.multiple = selection as any; + latestSelectionRef.current.multiple = + selection === 'all' + ? 'all' + : Array.isArray(selection) + ? selection.map(String) + : []; } } - onSelectionChange?.(selection as any); + onSelectionChange?.(selection); if (selectionMode === 'single') { close(); } }} > - {finalChildren as CollectionChildren} + { + (children + ? (finalChildren as CollectionChildren) + : undefined) as CollectionChildren + }