Skip to content

Commit 7eaf393

Browse files
authored
fix(ComboBox): navigation with filtered items (#722)
1 parent edc895f commit 7eaf393

File tree

3 files changed

+178
-66
lines changed

3 files changed

+178
-66
lines changed

.changeset/selfish-emus-pay.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+
Fix a bug when CommandMenu is unable to be navigated via keys when the search input is filled with any value.

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

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,101 @@ describe('CommandMenu', () => {
107107
);
108108
});
109109

110+
it('navigates through filtered items with arrow keys when search input has value', async () => {
111+
const user = userEvent.setup();
112+
113+
render(
114+
<CommandMenu qa="test-menu">
115+
{items.map((item) => (
116+
<CommandMenu.Item key={item.id} id={item.id}>
117+
{item.textValue}
118+
</CommandMenu.Item>
119+
))}
120+
</CommandMenu>,
121+
);
122+
123+
const searchInput = screen.getByPlaceholderText('Search commands...');
124+
125+
// Focus and type to filter items - "Create" should only match "Create file" (id: '1')
126+
await user.click(searchInput);
127+
await user.type(searchInput, 'Create');
128+
129+
// Verify filtered items are visible
130+
expect(screen.getByText('Create file')).toBeInTheDocument();
131+
expect(screen.queryByText('Open folder')).not.toBeInTheDocument();
132+
expect(screen.queryByText('Save document')).not.toBeInTheDocument();
133+
134+
// Press arrow down - should move virtual focus to the only filtered item ('Create file')
135+
await user.keyboard('{ArrowDown}');
136+
137+
// Search input should still have focus
138+
expect(searchInput).toHaveFocus();
139+
140+
// The filtered item should be virtually focused
141+
expect(searchInput).toHaveAttribute(
142+
'aria-activedescendant',
143+
'test-menu-menu-option-1',
144+
);
145+
146+
// Clear and type "Save" to test navigation with different filter
147+
await user.clear(searchInput);
148+
await user.type(searchInput, 'Save');
149+
150+
// Verify filtered items are visible
151+
expect(screen.getByText('Save document')).toBeInTheDocument();
152+
expect(screen.queryByText('Create file')).not.toBeInTheDocument();
153+
expect(screen.queryByText('Open folder')).not.toBeInTheDocument();
154+
155+
// Press arrow down - should move virtual focus to the only filtered item ('Save document')
156+
await user.keyboard('{ArrowDown}');
157+
158+
// Search input should still have focus
159+
expect(searchInput).toHaveFocus();
160+
161+
// The filtered item should be virtually focused
162+
expect(searchInput).toHaveAttribute(
163+
'aria-activedescendant',
164+
'test-menu-menu-option-3',
165+
);
166+
167+
// Clear and type "a" to match multiple items ("Create file" and "Save document")
168+
await user.clear(searchInput);
169+
await user.type(searchInput, 'a');
170+
171+
// Verify filtered items are visible
172+
expect(screen.getByText('Create file')).toBeInTheDocument();
173+
expect(screen.getByText('Save document')).toBeInTheDocument();
174+
expect(screen.queryByText('Open folder')).not.toBeInTheDocument();
175+
176+
// Press arrow down - should move to second filtered item ('Save document')
177+
// since auto-focus already focused the first item when typing
178+
await user.keyboard('{ArrowDown}');
179+
180+
// Second filtered item should be virtually focused
181+
expect(searchInput).toHaveAttribute(
182+
'aria-activedescendant',
183+
'test-menu-menu-option-3',
184+
);
185+
186+
// Press arrow down again - should wrap to first filtered item ('Create file')
187+
await user.keyboard('{ArrowDown}');
188+
189+
// First filtered item should be virtually focused
190+
expect(searchInput).toHaveAttribute(
191+
'aria-activedescendant',
192+
'test-menu-menu-option-1',
193+
);
194+
195+
// Press arrow up - should move to last filtered item ('Save document')
196+
await user.keyboard('{ArrowUp}');
197+
198+
// Last filtered item should be virtually focused
199+
expect(searchInput).toHaveAttribute(
200+
'aria-activedescendant',
201+
'test-menu-menu-option-3',
202+
);
203+
});
204+
110205
it('triggers action with Enter key', async () => {
111206
const user = userEvent.setup();
112207
const onAction = jest.fn();

src/components/actions/CommandMenu/CommandMenu.tsx

Lines changed: 78 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useSyncRef } from '@react-aria/utils';
22
import { useDOMRef } from '@react-spectrum/utils';
33
import { DOMRef, FocusStrategy } from '@react-types/shared';
44
import React, {
5+
Key,
56
ReactElement,
67
ReactNode,
78
useCallback,
@@ -133,7 +134,7 @@ function CommandMenuBase<T extends object>(
133134
: undefined;
134135

135136
const handleSelectionChange = onSelectionChange
136-
? (keys: any) => {
137+
? (keys: 'all' | Set<Key>) => {
137138
if (keys === 'all') {
138139
// Handle 'all' selection case - collect all available keys
139140
const allKeys = Array.from(treeState.collection.getKeys()).map(
@@ -287,6 +288,7 @@ function CommandMenuBase<T extends object>(
287288

288289
// Track focused key for aria-activedescendant
289290
const [focusedKey, setFocusedKey] = React.useState<React.Key | null>(null);
291+
const focusedKeyRef = useRef<React.Key | null>(null);
290292

291293
// Apply filtering to collection items for rendering and empty state checks
292294
const filteredCollectionItems = useMemo(() => {
@@ -482,28 +484,34 @@ function CommandMenuBase<T extends object>(
482484
}
483485
}, [autoFocus, contextProps.autoFocus]);
484486

487+
// Track the previous search value to only run auto-focus when search actually changes
488+
const prevSearchValueRef = useRef<string>('');
489+
485490
// Auto-focus first item when search value changes (but not on initial render)
486491
React.useEffect(() => {
487-
// Only auto-focus when search value changes, not on initial mount
488-
if (searchValue.trim() !== '') {
492+
const currentSearchValue = searchValue.trim();
493+
const prevSearchValue = prevSearchValueRef.current;
494+
495+
// Only auto-focus when search value actually changes
496+
if (currentSearchValue !== prevSearchValue && currentSearchValue !== '') {
489497
const firstSelectableKey = findFirstSelectableItem();
490498

491499
if (firstSelectableKey && hasFilteredItems) {
492500
// Focus the first item in the selection manager
493501
treeState.selectionManager.setFocusedKey(firstSelectableKey);
494502
setFocusedKey(firstSelectableKey);
503+
focusedKeyRef.current = firstSelectableKey;
495504
} else {
496505
// Clear focus if no items are available
497506
treeState.selectionManager.setFocusedKey(null);
498507
setFocusedKey(null);
508+
focusedKeyRef.current = null;
499509
}
500510
}
501-
}, [
502-
searchValue,
503-
findFirstSelectableItem,
504-
hasFilteredItems,
505-
treeState.selectionManager,
506-
]);
511+
512+
// Update the previous search value
513+
prevSearchValueRef.current = currentSearchValue;
514+
}, [searchValue, findFirstSelectableItem, hasFilteredItems]);
507515

508516
// Extract styles
509517
const extractedStyles = useMemo(
@@ -580,50 +588,44 @@ function CommandMenuBase<T extends object>(
580588
e.preventDefault();
581589

582590
const isArrowDown = e.key === 'ArrowDown';
583-
const { selectionManager, collection } = treeState;
584-
const currentKey = selectionManager.focusedKey;
591+
const { selectionManager } = treeState;
592+
// Use the ref to get the current focused key synchronously
593+
const currentKey =
594+
focusedKeyRef.current || selectionManager.focusedKey;
595+
596+
// Helper function to get all visible item keys by applying filter to tree state collection
597+
const getVisibleItemKeys = (): Key[] => {
598+
const keys: Key[] = [];
599+
const term = searchValue.trim();
600+
601+
// Use the tree state's collection and apply filter manually
602+
for (const item of treeState.collection) {
603+
if (item.type === 'item') {
604+
const text = item.textValue ?? String(item.rendered ?? '');
605+
if (enhancedFilter(text, term, item.props)) {
606+
keys.push(item.key);
607+
}
608+
}
609+
}
610+
611+
return keys;
612+
};
585613

586614
// Helper function to find next selectable key in a direction
587615
const findNextSelectableKey = (
588-
startKey: any,
616+
currentIndex: number,
589617
direction: 'forward' | 'backward',
618+
visibleKeys: Key[],
590619
) => {
591-
if (startKey == null) {
592-
return null;
593-
}
620+
const increment = direction === 'forward' ? 1 : -1;
594621

595-
// First check if the startKey itself is selectable
596-
const startNode = collection.getItem(startKey);
597-
if (
598-
startNode &&
599-
startNode.type === 'item' &&
600-
!selectionManager.isDisabled(startKey)
622+
for (
623+
let i = currentIndex + increment;
624+
i >= 0 && i < visibleKeys.length;
625+
i += increment
601626
) {
602-
return startKey;
603-
}
604-
605-
// If startKey is not selectable, find the next selectable key
606-
let keys = [...collection.getKeys()];
607-
608-
if (direction === 'backward') {
609-
keys = keys.reverse();
610-
}
611-
612-
let startIndex = keys.indexOf(startKey);
613-
614-
if (startIndex === -1) {
615-
return null;
616-
}
617-
618-
for (let i = startIndex + 1; i < keys.length; i++) {
619-
const key = keys[i];
620-
const node = collection.getItem(key);
621-
622-
if (
623-
node &&
624-
node.type === 'item' &&
625-
!selectionManager.isDisabled(key)
626-
) {
627+
const key = visibleKeys[i];
628+
if (!selectionManager.isDisabled(key)) {
627629
return key;
628630
}
629631
}
@@ -634,50 +636,60 @@ function CommandMenuBase<T extends object>(
634636
// Helper function to find first or last selectable key
635637
const findFirstLastSelectableKey = (
636638
direction: 'forward' | 'backward',
639+
visibleKeys: Key[],
637640
) => {
638-
const keys = [...collection.getKeys()];
639641
const keysToCheck =
640-
direction === 'forward' ? keys : keys.reverse();
642+
direction === 'forward'
643+
? visibleKeys
644+
: [...visibleKeys].reverse();
641645

642646
for (const key of keysToCheck) {
643-
const node = collection.getItem(key);
644-
645-
if (
646-
node &&
647-
node.type === 'item' &&
648-
!selectionManager.isDisabled(key)
649-
) {
647+
if (!selectionManager.isDisabled(key)) {
650648
return key;
651649
}
652650
}
653651

654652
return null;
655653
};
656654

655+
const visibleKeys = getVisibleItemKeys();
656+
657+
if (visibleKeys.length === 0) {
658+
return; // No visible items to navigate
659+
}
660+
657661
let nextKey;
658662
const direction = isArrowDown ? 'forward' : 'backward';
659663

660664
if (currentKey == null) {
661665
// No current focus, start from the first/last item
662-
nextKey = findFirstLastSelectableKey(direction);
666+
nextKey = findFirstLastSelectableKey(direction, visibleKeys);
663667
} else {
664-
// Find next selectable item from current position
665-
const candidateKey =
666-
direction === 'forward'
667-
? collection.getKeyAfter(currentKey)
668-
: collection.getKeyBefore(currentKey);
668+
// Find current position in visible keys
669+
const currentIndex = visibleKeys.indexOf(currentKey);
669670

670-
nextKey = findNextSelectableKey(candidateKey, direction);
671-
672-
// If no next key found and focus wrapping is enabled, wrap to first/last selectable item
673-
if (nextKey == null) {
674-
nextKey = findFirstLastSelectableKey(direction);
671+
if (currentIndex === -1) {
672+
// Current key not in visible items, start from beginning/end
673+
nextKey = findFirstLastSelectableKey(direction, visibleKeys);
674+
} else {
675+
// Find next selectable item from current position
676+
nextKey = findNextSelectableKey(
677+
currentIndex,
678+
direction,
679+
visibleKeys,
680+
);
681+
682+
// If no next key found, wrap to first/last selectable item
683+
if (nextKey == null) {
684+
nextKey = findFirstLastSelectableKey(direction, visibleKeys);
685+
}
675686
}
676687
}
677688

678689
if (nextKey != null) {
679690
selectionManager.setFocusedKey(nextKey);
680691
setFocusedKey(nextKey);
692+
focusedKeyRef.current = nextKey; // Update ref immediately
681693
}
682694
} else if (
683695
e.key === 'Enter' ||

0 commit comments

Comments
 (0)