From 389c16b74fdcc5c901698ad402877e611fd75a11 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 30 Jul 2025 15:02:18 +0200 Subject: [PATCH 1/8] fix(FilterPicker): full items prop support --- .changeset/metal-pumpkins-appear.md | 5 + .../FilterListBox/FilterListBox.stories.tsx | 4 +- .../FilterPicker/FilterPicker.stories.tsx | 13 +- .../fields/FilterPicker/FilterPicker.tsx | 252 +++++++++++++----- 4 files changed, 197 insertions(+), 77 deletions(-) create mode 100644 .changeset/metal-pumpkins-appear.md 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/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.tsx b/src/components/fields/FilterPicker/FilterPicker.tsx index d8c9f2aa0..1963bd16f 100644 --- a/src/components/fields/FilterPicker/FilterPicker.tsx +++ b/src/components/fields/FilterPicker/FilterPicker.tsx @@ -225,6 +225,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; @@ -320,33 +322,64 @@ 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: any[]): void => { + itemsArray.forEach((item) => { + if (item && typeof item === 'object') { + if (Array.isArray(item.children)) { + // Section-like object + extractFromItems(item.children); + } else { + // Regular item - extract label + const label = + item.textValue || + item.label || + item.name || + String(item.key || item.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 any); + extractFromItems(itemsArray); + return allLabels; + } - if (child.props?.children) { - extractAllLabels(child.props.children); - } - }); - }; + // Extract from children if available + if (children) { + 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; + } - extractAllLabels(children as ReactNode); return allLabels; } @@ -359,59 +392,66 @@ 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: any[]): void => { + itemsArray.forEach((item) => { + if (item && typeof item === 'object') { + if (Array.isArray(item.children)) { + // Section-like object + extractFromItems(item.children); + } else { + // Regular item - check if selected + const itemKey = item.key || item.id; + if (itemKey != null && selectedSet.has(String(itemKey))) { + const label = + item.textValue || item.label || item.name || String(itemKey); + labels.push(label); + processedKeys.add(String(itemKey)); + } + } } - } + }); + }; - if (child.props?.children) { - extractLabels(child.props.children); - } - }); - }; + const itemsArray = Array.isArray(items) + ? items + : Array.from(items as any); + extractFromItems(itemsArray); + } - const processedKeys = new Set(); + // Extract from children if available (for mixed mode or fallback) + if (children) { + const extractLabelsWithTracking = (nodes: ReactNode): void => { + Children.forEach(nodes, (child: any) => { + if (!child || typeof child !== 'object') return; - // 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); + if (child.type === Item) { + const childKey = String(child.key); + if (selectedSet.has(normalizeKeyValue(childKey))) { + const label = + child.props.textValue || + (typeof child.props.children === 'string' + ? child.props.children + : '') || + String(child.props.children || ''); + labels.push(label); + processedKeys.add(normalizeKeyValue(childKey)); + } } - } - if (child.props?.children) { - extractLabelsWithTracking(child.props.children); - } - }); - }; + if (child.props?.children) { + extractLabelsWithTracking(child.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 +508,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), @@ -614,8 +655,79 @@ 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: any) => { + 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 { + addSelected(selectionsWhenClosed.current.single); + } + + if (selectedSet.size === 0) { + return items; + } + + // Helpers to extract key from item object + const getItemKey = (obj: any): string | undefined => { + if (obj == null || typeof obj !== 'object') return undefined; + if (obj.key != null) return String(obj.key); + if (obj.id != null) return String(obj.id); + return undefined; + }; + + const sortArray = (arr: any[]): any[] => { + const selectedArr: any[] = []; + const unselectedArr: any[] = []; + + arr.forEach((obj) => { + if (obj && Array.isArray(obj.children)) { + // Section-like object – keep order, but sort its children + const sortedChildren = sortArray(obj.children); + unselectedArr.push({ ...obj, 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 any); + const sorted = sortArray(itemsArray); + + if (isPopoverOpen || !cachedItemsOrder.current) { + cachedItemsOrder.current = sorted; + } + + return sorted; + }, [items, selectionMode, isPopoverOpen, selectionsWhenClosed.current]); + + 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 = () => { @@ -749,7 +861,7 @@ export const FilterPicker = forwardRef(function FilterPicker( ( } }} > - {finalChildren as CollectionChildren} + { + (children + ? (finalChildren as CollectionChildren) + : undefined) as CollectionChildren + } From 8184104b630aaa0ff9b1009ee7bc4c0e6a04ab51 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 30 Jul 2025 15:16:12 +0200 Subject: [PATCH 2/8] fix(FilterPicker): normalization --- src/components/fields/FilterPicker/FilterPicker.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/fields/FilterPicker/FilterPicker.tsx b/src/components/fields/FilterPicker/FilterPicker.tsx index 1963bd16f..265d0fbad 100644 --- a/src/components/fields/FilterPicker/FilterPicker.tsx +++ b/src/components/fields/FilterPicker/FilterPicker.tsx @@ -405,11 +405,14 @@ export const FilterPicker = forwardRef(function FilterPicker( } else { // Regular item - check if selected const itemKey = item.key || item.id; - if (itemKey != null && selectedSet.has(String(itemKey))) { + if ( + itemKey != null && + selectedSet.has(normalizeKeyValue(itemKey)) + ) { const label = item.textValue || item.label || item.name || String(itemKey); labels.push(label); - processedKeys.add(String(itemKey)); + processedKeys.add(normalizeKeyValue(itemKey)); } } } @@ -861,7 +864,7 @@ export const FilterPicker = forwardRef(function FilterPicker( Date: Wed, 30 Jul 2025 15:39:59 +0200 Subject: [PATCH 3/8] chore(FilterPicker): types * 2 --- src/components/actions/Button/Button.tsx | 3 +- .../fields/FilterPicker/FilterPicker.tsx | 240 +++++++++++------- 2 files changed, 145 insertions(+), 98 deletions(-) diff --git a/src/components/actions/Button/Button.tsx b/src/components/actions/Button/Button.tsx index fc889854c..3932cd960 100644 --- a/src/components/actions/Button/Button.tsx +++ b/src/components/actions/Button/Button.tsx @@ -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/FilterPicker/FilterPicker.tsx b/src/components/fields/FilterPicker/FilterPicker.tsx index 265d0fbad..86a30b2a6 100644 --- a/src/components/fields/FilterPicker/FilterPicker.tsx +++ b/src/components/fields/FilterPicker/FilterPicker.tsx @@ -44,6 +44,14 @@ 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; + children?: ItemWithKey[]; + [key: string]: unknown; +} + export interface CubeFilterPickerProps extends Omit, 'size'>, BasePropsWithoutChildren, @@ -104,7 +112,7 @@ export interface CubeFilterPickerProps | false; /** Ref to access internal ListBox state (from FilterListBox) */ - listStateRef?: MutableRefObject; + listStateRef?: MutableRefObject; /** Additional modifiers for styling the FilterPicker */ mods?: Record; } @@ -130,7 +138,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 +146,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') { @@ -226,7 +234,7 @@ export const FilterPicker = forwardRef(function FilterPicker( const [isPopoverOpen, setIsPopoverOpen] = useState(false); const cachedChildrenOrder = useRef(null); // Cache for sorted items array when using `items` prop - const cachedItemsOrder = useRef(null); + const cachedItemsOrder = useRef(null); const triggerRef = useRef(null); const isControlledSingle = selectedKey !== undefined; @@ -241,7 +249,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('.$') @@ -260,24 +268,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); } }); }; @@ -291,23 +300,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); @@ -328,19 +337,24 @@ export const FilterPicker = forwardRef(function FilterPicker( // Extract from items prop if available if (items) { - const extractFromItems = (itemsArray: any[]): void => { + const extractFromItems = (itemsArray: unknown[]): void => { itemsArray.forEach((item) => { if (item && typeof item === 'object') { - if (Array.isArray(item.children)) { + const itemObj = item as ItemWithKey & { + textValue?: string; + label?: string; + name?: string; + }; + if (Array.isArray(itemObj.children)) { // Section-like object - extractFromItems(item.children); + extractFromItems(itemObj.children); } else { // Regular item - extract label const label = - item.textValue || - item.label || - item.name || - String(item.key || item.id || item); + itemObj.textValue || + itemObj.label || + itemObj.name || + String(itemObj.key || itemObj.id || item); allLabels.push(label); } } @@ -349,7 +363,7 @@ export const FilterPicker = forwardRef(function FilterPicker( const itemsArray = Array.isArray(items) ? items - : Array.from(items as any); + : Array.from(items as Iterable); extractFromItems(itemsArray); return allLabels; } @@ -357,21 +371,23 @@ export const FilterPicker = forwardRef(function FilterPicker( // Extract from children if available if (children) { const extractAllLabels = (nodes: ReactNode): void => { - Children.forEach(nodes, (child: any) => { + if (!nodes) return; + Children.forEach(nodes, (child: ReactNode) => { if (!child || typeof child !== 'object') return; + const element = child as ReactElement; - if (child.type === Item) { + if (element.type === Item) { const label = - child.props.textValue || - (typeof child.props.children === 'string' - ? child.props.children + element.props.textValue || + (typeof element.props.children === 'string' + ? element.props.children : '') || - String(child.props.children || ''); + String(element.props.children || ''); allLabels.push(label); } - if (child.props?.children) { - extractAllLabels(child.props.children); + if (element.props?.children) { + extractAllLabels(element.props.children); } }); }; @@ -396,21 +412,29 @@ export const FilterPicker = forwardRef(function FilterPicker( // Extract from items prop if available if (items) { - const extractFromItems = (itemsArray: any[]): void => { + const extractFromItems = (itemsArray: unknown[]): void => { itemsArray.forEach((item) => { if (item && typeof item === 'object') { - if (Array.isArray(item.children)) { + const itemObj = item as ItemWithKey & { + textValue?: string; + label?: string; + name?: string; + }; + if (Array.isArray(itemObj.children)) { // Section-like object - extractFromItems(item.children); + extractFromItems(itemObj.children); } else { // Regular item - check if selected - const itemKey = item.key || item.id; + const itemKey = itemObj.key || itemObj.id; if ( itemKey != null && selectedSet.has(normalizeKeyValue(itemKey)) ) { const label = - item.textValue || item.label || item.name || String(itemKey); + itemObj.textValue || + itemObj.label || + itemObj.name || + String(itemKey); labels.push(label); processedKeys.add(normalizeKeyValue(itemKey)); } @@ -421,32 +445,34 @@ export const FilterPicker = forwardRef(function FilterPicker( const itemsArray = Array.isArray(items) ? items - : Array.from(items as any); + : Array.from(items as Iterable); extractFromItems(itemsArray); } // Extract from children if available (for mixed mode or fallback) if (children) { const extractLabelsWithTracking = (nodes: ReactNode): void => { - Children.forEach(nodes, (child: any) => { + if (!nodes) return; + Children.forEach(nodes, (child: ReactNode) => { if (!child || typeof child !== 'object') return; + const element = child as ReactElement; - if (child.type === Item) { - const childKey = String(child.key); + if (element.type === Item) { + const childKey = String(element.key); if (selectedSet.has(normalizeKeyValue(childKey))) { const label = - child.props.textValue || - (typeof child.props.children === 'string' - ? child.props.children + element.props.textValue || + (typeof element.props.children === 'string' + ? element.props.children : '') || - String(child.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); } }); }; @@ -563,7 +589,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)) ); @@ -571,45 +597,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); @@ -618,17 +649,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); @@ -669,7 +700,7 @@ export const FilterPicker = forwardRef(function FilterPicker( const selectedSet = new Set(); - const addSelected = (key: any) => { + const addSelected = (key: Key) => { if (key != null) selectedSet.add(String(key)); }; @@ -680,7 +711,9 @@ export const FilterPicker = forwardRef(function FilterPicker( } (selectionsWhenClosed.current.multiple as string[]).forEach(addSelected); } else { - addSelected(selectionsWhenClosed.current.single); + if (selectionsWhenClosed.current.single != null) { + addSelected(selectionsWhenClosed.current.single); + } } if (selectedSet.size === 0) { @@ -688,22 +721,25 @@ export const FilterPicker = forwardRef(function FilterPicker( } // Helpers to extract key from item object - const getItemKey = (obj: any): string | undefined => { + const getItemKey = (obj: unknown): string | undefined => { if (obj == null || typeof obj !== 'object') return undefined; - if (obj.key != null) return String(obj.key); - if (obj.id != null) return String(obj.id); + + 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: any[]): any[] => { - const selectedArr: any[] = []; - const unselectedArr: any[] = []; + const sortArray = (arr: unknown[]): unknown[] => { + const selectedArr: unknown[] = []; + const unselectedArr: unknown[] = []; arr.forEach((obj) => { - if (obj && Array.isArray(obj.children)) { + const item = obj as ItemWithKey; + if (obj && Array.isArray(item.children)) { // Section-like object – keep order, but sort its children - const sortedChildren = sortArray(obj.children); - unselectedArr.push({ ...obj, children: sortedChildren }); + const sortedChildren = sortArray(item.children); + unselectedArr.push({ ...item, children: sortedChildren }); } else { const key = getItemKey(obj); if (key && selectedSet.has(key)) { @@ -717,7 +753,9 @@ export const FilterPicker = forwardRef(function FilterPicker( return [...selectedArr, ...unselectedArr]; }; - const itemsArray = Array.isArray(items) ? items : Array.from(items as any); + const itemsArray = Array.isArray(items) + ? items + : Array.from(items as Iterable); const sorted = sortArray(itemsArray); if (isPopoverOpen || !cachedItemsOrder.current) { @@ -741,14 +779,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) { @@ -910,11 +948,13 @@ 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'; @@ -925,16 +965,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'; @@ -948,14 +991,19 @@ 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(); From bb5470a8c06efd36b01ea2d3fbaba52f603eb997 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 30 Jul 2025 15:47:22 +0200 Subject: [PATCH 4/8] chore(FilterPicker): types * 3 --- .../fields/FilterPicker/FilterPicker.tsx | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/components/fields/FilterPicker/FilterPicker.tsx b/src/components/fields/FilterPicker/FilterPicker.tsx index 86a30b2a6..2d32d0ba9 100644 --- a/src/components/fields/FilterPicker/FilterPicker.tsx +++ b/src/components/fields/FilterPicker/FilterPicker.tsx @@ -48,6 +48,9 @@ import type { FieldBaseProps } from '../../../shared'; interface ItemWithKey { key?: string | number; id?: string | number; + textValue?: string; + label?: string; + name?: string; children?: ItemWithKey[]; [key: string]: unknown; } @@ -234,7 +237,7 @@ export const FilterPicker = forwardRef(function FilterPicker( const [isPopoverOpen, setIsPopoverOpen] = useState(false); const cachedChildrenOrder = useRef(null); // Cache for sorted items array when using `items` prop - const cachedItemsOrder = useRef(null); + const cachedItemsOrder = useRef(null); const triggerRef = useRef(null); const isControlledSingle = selectedKey !== undefined; @@ -340,11 +343,7 @@ export const FilterPicker = forwardRef(function FilterPicker( const extractFromItems = (itemsArray: unknown[]): void => { itemsArray.forEach((item) => { if (item && typeof item === 'object') { - const itemObj = item as ItemWithKey & { - textValue?: string; - label?: string; - name?: string; - }; + const itemObj = item as ItemWithKey; if (Array.isArray(itemObj.children)) { // Section-like object extractFromItems(itemObj.children); @@ -756,14 +755,20 @@ export const FilterPicker = forwardRef(function FilterPicker( const itemsArray = Array.isArray(items) ? items : Array.from(items as Iterable); - const sorted = sortArray(itemsArray); + const sorted = sortArray(itemsArray) as T[]; if (isPopoverOpen || !cachedItemsOrder.current) { cachedItemsOrder.current = sorted; } return sorted; - }, [items, selectionMode, isPopoverOpen, selectionsWhenClosed.current]); + }, [ + items, + selectionMode, + isPopoverOpen, + selectionsWhenClosed.current.multiple, + selectionsWhenClosed.current.single, + ]); const finalItems = getSortedItems(); From 0d524d177207d4b3c551bfbbf7762fb3e86664a0 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 30 Jul 2025 15:48:41 +0200 Subject: [PATCH 5/8] chore(FilterPicker): types * 4 --- .../fields/FilterPicker/FilterPicker.tsx | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/src/components/fields/FilterPicker/FilterPicker.tsx b/src/components/fields/FilterPicker/FilterPicker.tsx index 2d32d0ba9..4f31a627b 100644 --- a/src/components/fields/FilterPicker/FilterPicker.tsx +++ b/src/components/fields/FilterPicker/FilterPicker.tsx @@ -49,8 +49,6 @@ interface ItemWithKey { key?: string | number; id?: string | number; textValue?: string; - label?: string; - name?: string; children?: ItemWithKey[]; [key: string]: unknown; } @@ -351,8 +349,6 @@ export const FilterPicker = forwardRef(function FilterPicker( // Regular item - extract label const label = itemObj.textValue || - itemObj.label || - itemObj.name || String(itemObj.key || itemObj.id || item); allLabels.push(label); } @@ -414,11 +410,7 @@ export const FilterPicker = forwardRef(function FilterPicker( const extractFromItems = (itemsArray: unknown[]): void => { itemsArray.forEach((item) => { if (item && typeof item === 'object') { - const itemObj = item as ItemWithKey & { - textValue?: string; - label?: string; - name?: string; - }; + const itemObj = item as ItemWithKey; if (Array.isArray(itemObj.children)) { // Section-like object extractFromItems(itemObj.children); @@ -429,11 +421,7 @@ export const FilterPicker = forwardRef(function FilterPicker( itemKey != null && selectedSet.has(normalizeKeyValue(itemKey)) ) { - const label = - itemObj.textValue || - itemObj.label || - itemObj.name || - String(itemKey); + const label = itemObj.textValue || String(itemKey); labels.push(label); processedKeys.add(normalizeKeyValue(itemKey)); } From 6c6d5b7d05b3eeca48472b782519acb5ec43a4a9 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 30 Jul 2025 16:07:03 +0200 Subject: [PATCH 6/8] fix(Button): icon size --- src/components/actions/Button/Button.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/actions/Button/Button.tsx b/src/components/actions/Button/Button.tsx index 3932cd960..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)': { From 38ff5d5d749f724767bcb3dcf81771c6ce3017b7 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 30 Jul 2025 16:11:10 +0200 Subject: [PATCH 7/8] chore(FilterPicker): types * 5 --- src/components/fields/FilterPicker/FilterPicker.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/fields/FilterPicker/FilterPicker.tsx b/src/components/fields/FilterPicker/FilterPicker.tsx index 4f31a627b..8c3c4e0c2 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'; @@ -113,7 +113,7 @@ export interface CubeFilterPickerProps | false; /** Ref to access internal ListBox state (from FilterListBox) */ - listStateRef?: MutableRefObject; + listStateRef?: MutableRefObject>; /** Additional modifiers for styling the FilterPicker */ mods?: Record; } From fd6cd1e9a1d3ac8b9cc9934d36de47ed07058ec9 Mon Sep 17 00:00:00 2001 From: Andrey Yamanov Date: Wed, 30 Jul 2025 16:23:22 +0200 Subject: [PATCH 8/8] fix(FilterPicker): selected label retrival --- .../fields/FilterPicker/FilterPicker.test.tsx | 71 +++++++++++++++++++ .../fields/FilterPicker/FilterPicker.tsx | 25 +++++-- 2 files changed, 91 insertions(+), 5 deletions(-) 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 8c3c4e0c2..9004bfa6f 100644 --- a/src/components/fields/FilterPicker/FilterPicker.tsx +++ b/src/components/fields/FilterPicker/FilterPicker.tsx @@ -104,11 +104,11 @@ 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; @@ -349,7 +349,16 @@ export const FilterPicker = forwardRef(function FilterPicker( // Regular item - extract label const label = itemObj.textValue || - String(itemObj.key || itemObj.id || item); + (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); } } @@ -421,7 +430,13 @@ export const FilterPicker = forwardRef(function FilterPicker( itemKey != null && selectedSet.has(normalizeKeyValue(itemKey)) ) { - const label = itemObj.textValue || String(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)); }