Skip to content

Commit 6d0ff42

Browse files
committed
feat(CommandMenu): sort menu items on top when opened
1 parent 7eaf393 commit 6d0ff42

File tree

4 files changed

+401
-50
lines changed

4 files changed

+401
-50
lines changed

.changeset/new-snails-raise.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cube-dev/ui-kit": patch
3+
---
4+
5+
Sort selected menu items on top when CommandMenu is opened.

src/components/actions/CommandMenu/CommandMenu.stories.tsx

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -367,27 +367,37 @@ WithSections.args = {
367367
autoFocus: true,
368368
};
369369

370-
export const WithMenuTrigger: StoryFn<CubeCommandMenuProps<any>> = (args) => (
371-
<CommandMenu.Trigger>
372-
<Button>Open Command Palette</Button>
373-
<CommandMenu {...args}>
374-
{basicCommands.map((command) => (
375-
<CommandMenu.Item
376-
key={command.key}
377-
description={command.description}
378-
hotkeys={command.hotkeys}
379-
icon={command.icon}
380-
>
381-
{command.label}
382-
</CommandMenu.Item>
383-
))}
384-
</CommandMenu>
385-
</CommandMenu.Trigger>
386-
);
370+
export const WithMenuTrigger: StoryFn<CubeCommandMenuProps<any>> = (args) => {
371+
const [selectedKeys, setSelectedKeys] = useState<string[]>(['undo']);
372+
373+
return (
374+
<CommandMenu.Trigger>
375+
<Button>Open Command Palette</Button>
376+
<CommandMenu
377+
{...args}
378+
selectedKeys={selectedKeys}
379+
onSelectionChange={setSelectedKeys}
380+
>
381+
{basicCommands.map((command) => (
382+
<CommandMenu.Item
383+
key={command.key}
384+
description={command.description}
385+
hotkeys={command.hotkeys}
386+
icon={command.icon}
387+
>
388+
{command.label}
389+
</CommandMenu.Item>
390+
))}
391+
</CommandMenu>
392+
</CommandMenu.Trigger>
393+
);
394+
};
387395

388396
WithMenuTrigger.args = {
389397
searchPlaceholder: 'Search commands...',
390398
autoFocus: true,
399+
selectionMode: 'multiple',
400+
selectionIcon: 'checkbox',
391401
};
392402

393403
WithMenuTrigger.play = async ({ canvasElement, viewMode }) => {

src/components/actions/CommandMenu/CommandMenu.test.tsx

Lines changed: 241 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { render, screen, waitFor } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
3-
import React from 'react';
3+
import React, { useState } from 'react';
44

55
import { CommandMenu } from './CommandMenu';
66

@@ -845,6 +845,246 @@ describe('CommandMenu', () => {
845845
expect(selectedDisplay).toHaveTextContent(/Selected:.*1.*2/);
846846
});
847847

848+
it('sorts selected items to the top in multiple selection mode', async () => {
849+
const user = userEvent.setup();
850+
const items = [
851+
{ id: '1', textValue: 'First Item' },
852+
{ id: '2', textValue: 'Second Item' },
853+
{ id: '3', textValue: 'Third Item' },
854+
{ id: '4', textValue: 'Fourth Item' },
855+
];
856+
857+
const TestComponent = () => {
858+
const [selectedKeys, setSelectedKeys] = useState<string[]>(['2', '4']);
859+
860+
const handleSelectionChange = (keys: string[]) => {
861+
setSelectedKeys(keys);
862+
};
863+
864+
return (
865+
<CommandMenu
866+
selectionMode="multiple"
867+
selectedKeys={selectedKeys}
868+
onSelectionChange={handleSelectionChange}
869+
>
870+
{items.map((item) => (
871+
<CommandMenu.Item key={item.id} id={item.id}>
872+
{item.textValue}
873+
</CommandMenu.Item>
874+
))}
875+
</CommandMenu>
876+
);
877+
};
878+
879+
render(<TestComponent />);
880+
881+
// Get all menu items (in multiple selection mode, they have role "menuitemcheckbox")
882+
const menuItems = screen.getAllByRole('menuitemcheckbox');
883+
884+
// The selected items (2 and 4) should appear first
885+
// So the order should be: Second Item, Fourth Item, First Item, Third Item
886+
expect(menuItems[0]).toHaveTextContent('Second Item');
887+
expect(menuItems[1]).toHaveTextContent('Fourth Item');
888+
expect(menuItems[2]).toHaveTextContent('First Item');
889+
expect(menuItems[3]).toHaveTextContent('Third Item');
890+
});
891+
892+
it('sorts selected items to the top in multiple selection mode even with search filtering', async () => {
893+
const user = userEvent.setup();
894+
const items = [
895+
{ id: '1', textValue: 'Apple' },
896+
{ id: '2', textValue: 'Banana' },
897+
{ id: '3', textValue: 'Apricot' },
898+
{ id: '4', textValue: 'Berry' },
899+
];
900+
901+
const TestComponent = () => {
902+
const [selectedKeys, setSelectedKeys] = useState<string[]>(['3', '4']); // Apricot and Berry are selected
903+
904+
const handleSelectionChange = (keys: string[]) => {
905+
setSelectedKeys(keys);
906+
};
907+
908+
return (
909+
<CommandMenu
910+
selectionMode="multiple"
911+
selectedKeys={selectedKeys}
912+
onSelectionChange={handleSelectionChange}
913+
>
914+
{items.map((item) => (
915+
<CommandMenu.Item key={item.id} id={item.id}>
916+
{item.textValue}
917+
</CommandMenu.Item>
918+
))}
919+
</CommandMenu>
920+
);
921+
};
922+
923+
render(<TestComponent />);
924+
925+
// Search for "Ap" - should match "Apple" and "Apricot"
926+
const searchInput = screen.getByPlaceholderText('Search commands...');
927+
await user.type(searchInput, 'Ap');
928+
929+
// Get all filtered menu items
930+
const menuItems = screen.getAllByRole('menuitemcheckbox');
931+
932+
// Only "Apple" and "Apricot" should be visible
933+
// "Apricot" should appear first because it's selected
934+
expect(menuItems).toHaveLength(2);
935+
expect(menuItems[0]).toHaveTextContent('Apricot'); // Selected item first
936+
expect(menuItems[1]).toHaveTextContent('Apple'); // Unselected item second
937+
});
938+
939+
it('sorts selected items to the top in multiple selection mode with sections', async () => {
940+
const TestComponent = () => {
941+
const [selectedKeys, setSelectedKeys] = useState<string[]>([
942+
'item2',
943+
'item4',
944+
]);
945+
946+
const handleSelectionChange = (keys: string[]) => {
947+
setSelectedKeys(keys);
948+
};
949+
950+
return (
951+
<CommandMenu
952+
selectionMode="multiple"
953+
selectedKeys={selectedKeys}
954+
onSelectionChange={handleSelectionChange}
955+
>
956+
<CommandMenu.Section key="section1" aria-label="Files">
957+
<CommandMenu.Item key="item1" id="item1">
958+
Create File
959+
</CommandMenu.Item>
960+
<CommandMenu.Item key="item2" id="item2">
961+
Open File
962+
</CommandMenu.Item>
963+
</CommandMenu.Section>
964+
<CommandMenu.Section key="section2" aria-label="Edit">
965+
<CommandMenu.Item key="item3" id="item3">
966+
Cut
967+
</CommandMenu.Item>
968+
<CommandMenu.Item key="item4" id="item4">
969+
Copy
970+
</CommandMenu.Item>
971+
</CommandMenu.Section>
972+
</CommandMenu>
973+
);
974+
};
975+
976+
render(<TestComponent />);
977+
978+
// Get all menu items
979+
const menuItems = screen.getAllByRole('menuitemcheckbox');
980+
981+
// Within each section, selected items should appear first
982+
// Section 1: "Open File" (selected) should come before "Create File" (unselected)
983+
// Section 2: "Copy" (selected) should come before "Cut" (unselected)
984+
expect(menuItems[0]).toHaveTextContent('Open File'); // Selected item in section 1
985+
expect(menuItems[1]).toHaveTextContent('Create File'); // Unselected item in section 1
986+
expect(menuItems[2]).toHaveTextContent('Copy'); // Selected item in section 2
987+
expect(menuItems[3]).toHaveTextContent('Cut'); // Unselected item in section 2
988+
});
989+
990+
it('does not re-sort items during user interaction to prevent content shifting', async () => {
991+
const user = userEvent.setup();
992+
const items = [
993+
{ id: '1', textValue: 'First Item' },
994+
{ id: '2', textValue: 'Second Item' },
995+
{ id: '3', textValue: 'Third Item' },
996+
{ id: '4', textValue: 'Fourth Item' },
997+
];
998+
999+
const TestComponent = () => {
1000+
const [selectedKeys, setSelectedKeys] = useState<string[]>(['2', '4']); // Initially select items 2 and 4
1001+
1002+
const handleSelectionChange = (keys: string[]) => {
1003+
setSelectedKeys(keys);
1004+
};
1005+
1006+
return (
1007+
<CommandMenu
1008+
selectionMode="multiple"
1009+
selectedKeys={selectedKeys}
1010+
onSelectionChange={handleSelectionChange}
1011+
>
1012+
{items.map((item) => (
1013+
<CommandMenu.Item key={item.id} id={item.id}>
1014+
{item.textValue}
1015+
</CommandMenu.Item>
1016+
))}
1017+
</CommandMenu>
1018+
);
1019+
};
1020+
1021+
render(<TestComponent />);
1022+
1023+
// Initially, items 2 and 4 should be sorted to the top
1024+
let menuItems = screen.getAllByRole('menuitemcheckbox');
1025+
expect(menuItems[0]).toHaveTextContent('Second Item'); // Selected
1026+
expect(menuItems[1]).toHaveTextContent('Fourth Item'); // Selected
1027+
expect(menuItems[2]).toHaveTextContent('First Item'); // Unselected
1028+
expect(menuItems[3]).toHaveTextContent('Third Item'); // Unselected
1029+
1030+
// Click on "First Item" to select it - the order should NOT change (no content shifting)
1031+
await user.click(menuItems[2]); // Click "First Item"
1032+
1033+
// Get menu items again after the selection change
1034+
menuItems = screen.getAllByRole('menuitemcheckbox');
1035+
1036+
// The order should remain the same - no content shifting
1037+
expect(menuItems[0]).toHaveTextContent('Second Item'); // Still first
1038+
expect(menuItems[1]).toHaveTextContent('Fourth Item'); // Still second
1039+
expect(menuItems[2]).toHaveTextContent('First Item'); // Still third (now selected)
1040+
expect(menuItems[3]).toHaveTextContent('Third Item'); // Still fourth
1041+
1042+
// Verify that "First Item" is now selected but didn't move
1043+
expect(menuItems[2]).toHaveAttribute('aria-checked', 'true');
1044+
});
1045+
1046+
it('sorts selected items to the top with defaultSelectedKeys', async () => {
1047+
const items = [
1048+
{ id: '1', textValue: 'First Item' },
1049+
{ id: '2', textValue: 'Second Item' },
1050+
{ id: '3', textValue: 'Third Item' },
1051+
{ id: '4', textValue: 'Fourth Item' },
1052+
];
1053+
1054+
const TestComponent = () => {
1055+
return (
1056+
<CommandMenu
1057+
selectionMode="multiple"
1058+
defaultSelectedKeys={['3', '1']} // Default select Third and First items
1059+
>
1060+
{items.map((item) => (
1061+
<CommandMenu.Item key={item.id} id={item.id}>
1062+
{item.textValue}
1063+
</CommandMenu.Item>
1064+
))}
1065+
</CommandMenu>
1066+
);
1067+
};
1068+
1069+
render(<TestComponent />);
1070+
1071+
// Get all menu items
1072+
const menuItems = screen.getAllByRole('menuitemcheckbox');
1073+
1074+
// The default selected items (3 and 1) should appear first
1075+
// The order follows the original array order for selected items: First Item (1), Third Item (3)
1076+
expect(menuItems[0]).toHaveTextContent('First Item'); // Selected (appears first in original array)
1077+
expect(menuItems[1]).toHaveTextContent('Third Item'); // Selected (appears third in original array)
1078+
expect(menuItems[2]).toHaveTextContent('Second Item'); // Unselected
1079+
expect(menuItems[3]).toHaveTextContent('Fourth Item'); // Unselected
1080+
1081+
// Verify the selected items are indeed selected
1082+
expect(menuItems[0]).toHaveAttribute('aria-checked', 'true');
1083+
expect(menuItems[1]).toHaveAttribute('aria-checked', 'true');
1084+
expect(menuItems[2]).toHaveAttribute('aria-checked', 'false');
1085+
expect(menuItems[3]).toHaveAttribute('aria-checked', 'false');
1086+
});
1087+
8481088
describe('CommandMenu mods', () => {
8491089
it('should apply popover mod when used with MenuTrigger', () => {
8501090
const { MenuContext } = require('../Menu/context');

0 commit comments

Comments
 (0)