diff --git a/.changeset/new-snails-raise.md b/.changeset/new-snails-raise.md new file mode 100644 index 00000000..2add49f7 --- /dev/null +++ b/.changeset/new-snails-raise.md @@ -0,0 +1,5 @@ +--- +"@cube-dev/ui-kit": patch +--- + +Sort selected menu items on top when CommandMenu is opened. diff --git a/src/components/actions/CommandMenu/CommandMenu.stories.tsx b/src/components/actions/CommandMenu/CommandMenu.stories.tsx index 520829f5..349b8068 100644 --- a/src/components/actions/CommandMenu/CommandMenu.stories.tsx +++ b/src/components/actions/CommandMenu/CommandMenu.stories.tsx @@ -367,27 +367,37 @@ WithSections.args = { autoFocus: true, }; -export const WithMenuTrigger: StoryFn> = (args) => ( - - - - {basicCommands.map((command) => ( - - {command.label} - - ))} - - -); +export const WithMenuTrigger: StoryFn> = (args) => { + const [selectedKeys, setSelectedKeys] = useState(['undo']); + + return ( + + + + {basicCommands.map((command) => ( + + {command.label} + + ))} + + + ); +}; WithMenuTrigger.args = { searchPlaceholder: 'Search commands...', autoFocus: true, + selectionMode: 'multiple', + selectionIcon: 'checkbox', }; WithMenuTrigger.play = async ({ canvasElement, viewMode }) => { diff --git a/src/components/actions/CommandMenu/CommandMenu.test.tsx b/src/components/actions/CommandMenu/CommandMenu.test.tsx index db874013..7b9da8ab 100644 --- a/src/components/actions/CommandMenu/CommandMenu.test.tsx +++ b/src/components/actions/CommandMenu/CommandMenu.test.tsx @@ -1,6 +1,6 @@ import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import React from 'react'; +import React, { useState } from 'react'; import { CommandMenu } from './CommandMenu'; @@ -845,6 +845,246 @@ describe('CommandMenu', () => { expect(selectedDisplay).toHaveTextContent(/Selected:.*1.*2/); }); + it('sorts selected items to the top in multiple selection mode', async () => { + const user = userEvent.setup(); + const items = [ + { id: '1', textValue: 'First Item' }, + { id: '2', textValue: 'Second Item' }, + { id: '3', textValue: 'Third Item' }, + { id: '4', textValue: 'Fourth Item' }, + ]; + + const TestComponent = () => { + const [selectedKeys, setSelectedKeys] = useState(['2', '4']); + + const handleSelectionChange = (keys: string[]) => { + setSelectedKeys(keys); + }; + + return ( + + {items.map((item) => ( + + {item.textValue} + + ))} + + ); + }; + + render(); + + // Get all menu items (in multiple selection mode, they have role "menuitemcheckbox") + const menuItems = screen.getAllByRole('menuitemcheckbox'); + + // The selected items (2 and 4) should appear first + // So the order should be: Second Item, Fourth Item, First Item, Third Item + expect(menuItems[0]).toHaveTextContent('Second Item'); + expect(menuItems[1]).toHaveTextContent('Fourth Item'); + expect(menuItems[2]).toHaveTextContent('First Item'); + expect(menuItems[3]).toHaveTextContent('Third Item'); + }); + + it('sorts selected items to the top in multiple selection mode even with search filtering', async () => { + const user = userEvent.setup(); + const items = [ + { id: '1', textValue: 'Apple' }, + { id: '2', textValue: 'Banana' }, + { id: '3', textValue: 'Apricot' }, + { id: '4', textValue: 'Berry' }, + ]; + + const TestComponent = () => { + const [selectedKeys, setSelectedKeys] = useState(['3', '4']); // Apricot and Berry are selected + + const handleSelectionChange = (keys: string[]) => { + setSelectedKeys(keys); + }; + + return ( + + {items.map((item) => ( + + {item.textValue} + + ))} + + ); + }; + + render(); + + // Search for "Ap" - should match "Apple" and "Apricot" + const searchInput = screen.getByPlaceholderText('Search commands...'); + await user.type(searchInput, 'Ap'); + + // Get all filtered menu items + const menuItems = screen.getAllByRole('menuitemcheckbox'); + + // Only "Apple" and "Apricot" should be visible + // "Apricot" should appear first because it's selected + expect(menuItems).toHaveLength(2); + expect(menuItems[0]).toHaveTextContent('Apricot'); // Selected item first + expect(menuItems[1]).toHaveTextContent('Apple'); // Unselected item second + }); + + it('sorts selected items to the top in multiple selection mode with sections', async () => { + const TestComponent = () => { + const [selectedKeys, setSelectedKeys] = useState([ + 'item2', + 'item4', + ]); + + const handleSelectionChange = (keys: string[]) => { + setSelectedKeys(keys); + }; + + return ( + + + + Create File + + + Open File + + + + + Cut + + + Copy + + + + ); + }; + + render(); + + // Get all menu items + const menuItems = screen.getAllByRole('menuitemcheckbox'); + + // Within each section, selected items should appear first + // Section 1: "Open File" (selected) should come before "Create File" (unselected) + // Section 2: "Copy" (selected) should come before "Cut" (unselected) + expect(menuItems[0]).toHaveTextContent('Open File'); // Selected item in section 1 + expect(menuItems[1]).toHaveTextContent('Create File'); // Unselected item in section 1 + expect(menuItems[2]).toHaveTextContent('Copy'); // Selected item in section 2 + expect(menuItems[3]).toHaveTextContent('Cut'); // Unselected item in section 2 + }); + + it('does not re-sort items during user interaction to prevent content shifting', async () => { + const user = userEvent.setup(); + const items = [ + { id: '1', textValue: 'First Item' }, + { id: '2', textValue: 'Second Item' }, + { id: '3', textValue: 'Third Item' }, + { id: '4', textValue: 'Fourth Item' }, + ]; + + const TestComponent = () => { + const [selectedKeys, setSelectedKeys] = useState(['2', '4']); // Initially select items 2 and 4 + + const handleSelectionChange = (keys: string[]) => { + setSelectedKeys(keys); + }; + + return ( + + {items.map((item) => ( + + {item.textValue} + + ))} + + ); + }; + + render(); + + // Initially, items 2 and 4 should be sorted to the top + let menuItems = screen.getAllByRole('menuitemcheckbox'); + expect(menuItems[0]).toHaveTextContent('Second Item'); // Selected + expect(menuItems[1]).toHaveTextContent('Fourth Item'); // Selected + expect(menuItems[2]).toHaveTextContent('First Item'); // Unselected + expect(menuItems[3]).toHaveTextContent('Third Item'); // Unselected + + // Click on "First Item" to select it - the order should NOT change (no content shifting) + await user.click(menuItems[2]); // Click "First Item" + + // Get menu items again after the selection change + menuItems = screen.getAllByRole('menuitemcheckbox'); + + // The order should remain the same - no content shifting + expect(menuItems[0]).toHaveTextContent('Second Item'); // Still first + expect(menuItems[1]).toHaveTextContent('Fourth Item'); // Still second + expect(menuItems[2]).toHaveTextContent('First Item'); // Still third (now selected) + expect(menuItems[3]).toHaveTextContent('Third Item'); // Still fourth + + // Verify that "First Item" is now selected but didn't move + expect(menuItems[2]).toHaveAttribute('aria-checked', 'true'); + }); + + it('sorts selected items to the top with defaultSelectedKeys', async () => { + const items = [ + { id: '1', textValue: 'First Item' }, + { id: '2', textValue: 'Second Item' }, + { id: '3', textValue: 'Third Item' }, + { id: '4', textValue: 'Fourth Item' }, + ]; + + const TestComponent = () => { + return ( + + {items.map((item) => ( + + {item.textValue} + + ))} + + ); + }; + + render(); + + // Get all menu items + const menuItems = screen.getAllByRole('menuitemcheckbox'); + + // The default selected items (3 and 1) should appear first + // The order follows the original array order for selected items: First Item (1), Third Item (3) + expect(menuItems[0]).toHaveTextContent('First Item'); // Selected (appears first in original array) + expect(menuItems[1]).toHaveTextContent('Third Item'); // Selected (appears third in original array) + expect(menuItems[2]).toHaveTextContent('Second Item'); // Unselected + expect(menuItems[3]).toHaveTextContent('Fourth Item'); // Unselected + + // Verify the selected items are indeed selected + expect(menuItems[0]).toHaveAttribute('aria-checked', 'true'); + expect(menuItems[1]).toHaveAttribute('aria-checked', 'true'); + expect(menuItems[2]).toHaveAttribute('aria-checked', 'false'); + expect(menuItems[3]).toHaveAttribute('aria-checked', 'false'); + }); + describe('CommandMenu mods', () => { it('should apply popover mod when used with MenuTrigger', () => { const { MenuContext } = require('../Menu/context'); diff --git a/src/components/actions/CommandMenu/CommandMenu.tsx b/src/components/actions/CommandMenu/CommandMenu.tsx index b5cd621d..2f70602e 100644 --- a/src/components/actions/CommandMenu/CommandMenu.tsx +++ b/src/components/actions/CommandMenu/CommandMenu.tsx @@ -290,49 +290,145 @@ function CommandMenuBase( const [focusedKey, setFocusedKey] = React.useState(null); const focusedKeyRef = useRef(null); + // Store the initial selected keys to avoid content shifting during interaction + const initialSelectedKeysRef = useRef | undefined>(undefined); + const hasInitializedRef = useRef(false); + + // Initialize initial selected keys only once on mount + // This captures the initial state and prevents sorting changes during user interaction + React.useMemo(() => { + // Only set initial keys once, on the first render + if (!hasInitializedRef.current) { + hasInitializedRef.current = true; + // Use selectedKeys if provided, otherwise fall back to defaultSelectedKeys + const initialKeys = ariaSelectedKeys || ariaDefaultSelectedKeys; + if (initialKeys !== undefined) { + initialSelectedKeysRef.current = new Set(initialKeys); + } else { + initialSelectedKeysRef.current = undefined; + } + } + }, [selectedKeys, defaultSelectedKeys]); // Depend on both props for initial setup + // Apply filtering to collection items for rendering and empty state checks const filteredCollectionItems = useMemo(() => { + // Helper to sort items with selected ones first (for multiple selection mode) + // Uses initial selected keys to prevent content shifting during interaction + const sortWithSelectedFirst = (items: any[]): any[] => { + if ( + treeState?.selectionManager?.selectionMode !== 'multiple' || + !initialSelectedKeysRef.current || + initialSelectedKeysRef.current.size === 0 + ) { + return items; + } + + const selectedItems: any[] = []; + const unselectedItems: any[] = []; + + items.forEach((item) => { + if (initialSelectedKeysRef.current!.has(item.key)) { + selectedItems.push(item); + } else { + unselectedItems.push(item); + } + }); + + return [...selectedItems, ...unselectedItems]; + }; + const term = searchValue.trim(); - if (!term) { - return collectionItems; - } - // Split search term into words for multi-word filtering - const searchWords = term - .toLowerCase() - .split(/\s+/) - .filter((word) => word.length > 0); + let resultItems = collectionItems; - // If no valid search words, return all items - if (searchWords.length === 0) { - return collectionItems; - } + // Apply filtering if there's a search term + if (term) { + // Split search term into words for multi-word filtering + const searchWords = term + .toLowerCase() + .split(/\s+/) + .filter((word) => word.length > 0); - const filterNodes = (items: any[]): any[] => { - const result: any[] = []; + // If no valid search words, use all items + if (searchWords.length > 0) { + const filterNodes = (items: any[]): any[] => { + const result: any[] = []; + + [...items].forEach((item) => { + if (item.type === 'section') { + const filteredChildren = filterNodes(item.childNodes); + if (filteredChildren.length) { + // Sort section children with selected items first + const sortedChildren = sortWithSelectedFirst(filteredChildren); + result.push({ + ...item, + childNodes: sortedChildren, + }); + } + } else { + const text = item.textValue ?? String(item.rendered ?? ''); + if (enhancedFilter(text, term, item.props)) { + result.push(item); + } + } + }); + + return result; + }; + + resultItems = filterNodes(collectionItems); + } + } else { + // No search term - apply sorting by sections and items separately + const processNodes = (nodeList: any[]): any[] => { + const sections: any[] = []; + const items: any[] = []; - [...items].forEach((item) => { - if (item.type === 'section') { - const filteredChildren = filterNodes(item.childNodes); - if (filteredChildren.length) { - result.push({ - ...item, - childNodes: filteredChildren, + nodeList.forEach((node) => { + if (node.type === 'section') { + // Process section children recursively + const sortedChildren = sortWithSelectedFirst( + Array.from(node.childNodes), + ); + sections.push({ + ...node, + childNodes: sortedChildren, }); + } else { + items.push(node); } - } else { - const text = item.textValue ?? String(item.rendered ?? ''); - if (enhancedFilter(text, term, item.props)) { - result.push(item); - } - } - }); + }); - return result; - }; + // Sort items and combine with sections + const sortedItems = sortWithSelectedFirst(items); + return [...sections, ...sortedItems]; + }; - return filterNodes(collectionItems); - }, [collectionItems, searchValue, enhancedFilter]); + resultItems = processNodes(collectionItems); + } + + // Sort items at the root level with selected ones first (for items not in sections) + const sections: any[] = []; + const items: any[] = []; + + resultItems.forEach((item) => { + if (item.type === 'section') { + sections.push(item); + } else { + items.push(item); + } + }); + + const sortedItems = sortWithSelectedFirst(items); + return [...sections, ...sortedItems]; + }, [ + collectionItems, + searchValue, + enhancedFilter, + treeState?.selectionManager?.selectionMode, + selectedKeys, + defaultSelectedKeys, + ]); const hasFilteredItems = filteredCollectionItems.length > 0; const viewHasSections = filteredCollectionItems.some(