Skip to content

Commit 503a2e4

Browse files
committed
feat(CommandPalette): add new component * 2
1 parent 04fcdfe commit 503a2e4

File tree

2 files changed

+144
-0
lines changed

2 files changed

+144
-0
lines changed

src/components/actions/CommandPalette/CommandPalette.test.tsx

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,111 @@ describe('CommandPalette', () => {
336336
});
337337
});
338338

339+
it('auto-focuses first item when search input is focused and has content', async () => {
340+
const user = userEvent.setup();
341+
342+
render(
343+
<CommandPalette>
344+
{items.map((item) => (
345+
<CommandPalette.Item key={item.id} id={item.id}>
346+
{item.textValue}
347+
</CommandPalette.Item>
348+
))}
349+
</CommandPalette>,
350+
);
351+
352+
const searchInput = screen.getByPlaceholderText('Search commands...');
353+
354+
// Initially no item should be focused when just clicking
355+
await user.click(searchInput);
356+
expect(searchInput.getAttribute('aria-activedescendant')).toBeFalsy();
357+
358+
// Type something to trigger auto-focus
359+
await user.type(searchInput, 'C');
360+
361+
// Check that the first matching item is virtually focused
362+
await waitFor(() => {
363+
const activeDescendant = searchInput.getAttribute(
364+
'aria-activedescendant',
365+
);
366+
expect(activeDescendant).toMatch(/CommandPalette-menu-option-1/);
367+
});
368+
});
369+
370+
it('focuses first item in filtered results when search changes', async () => {
371+
const user = userEvent.setup();
372+
373+
render(
374+
<CommandPalette>
375+
{items.map((item) => (
376+
<CommandPalette.Item key={item.id} id={item.id}>
377+
{item.textValue}
378+
</CommandPalette.Item>
379+
))}
380+
</CommandPalette>,
381+
);
382+
383+
const searchInput = screen.getByPlaceholderText('Search commands...');
384+
385+
// Focus and type to filter items - "folder" should match "Open folder" (id: '2')
386+
await user.click(searchInput);
387+
await user.type(searchInput, 'folder');
388+
389+
// First verify that the item is actually filtered and visible
390+
expect(screen.getByText('Open folder')).toBeInTheDocument();
391+
expect(screen.queryByText('Create file')).not.toBeInTheDocument();
392+
393+
// Check that the correct item is virtually focused
394+
await waitFor(() => {
395+
const activeDescendant = searchInput.getAttribute(
396+
'aria-activedescendant',
397+
);
398+
expect(activeDescendant).toBeTruthy();
399+
// Since "Open folder" has id="2", it should be focused when filtered
400+
expect(activeDescendant).toContain('menu-option-2');
401+
});
402+
});
403+
404+
it('clears focus when no items match search', async () => {
405+
const user = userEvent.setup();
406+
407+
render(
408+
<CommandPalette>
409+
{items.map((item) => (
410+
<CommandPalette.Item key={item.id} id={item.id}>
411+
{item.textValue}
412+
</CommandPalette.Item>
413+
))}
414+
</CommandPalette>,
415+
);
416+
417+
const searchInput = screen.getByPlaceholderText('Search commands...');
418+
419+
// First type something that matches to establish focus
420+
await user.click(searchInput);
421+
await user.type(searchInput, 'C');
422+
423+
// Verify an item is focused first
424+
await waitFor(() => {
425+
const activeDescendant = searchInput.getAttribute(
426+
'aria-activedescendant',
427+
);
428+
expect(activeDescendant).toMatch(/CommandPalette-menu-option-1/);
429+
});
430+
431+
// Clear and type something that won't match
432+
await user.clear(searchInput);
433+
await user.type(searchInput, 'nonexistent');
434+
435+
// Check that no item is focused
436+
await waitFor(() => {
437+
const activeDescendant = searchInput.getAttribute(
438+
'aria-activedescendant',
439+
);
440+
expect(activeDescendant).toBeFalsy();
441+
});
442+
});
443+
339444
it('supports custom empty label', async () => {
340445
const user = userEvent.setup();
341446

src/components/actions/CommandPalette/CommandPalette.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,22 @@ function CommandPaletteBase<T extends object>(
247247
(item) => item.type === 'section',
248248
);
249249

250+
// Helper function to find the first selectable item from filtered results
251+
const findFirstSelectableItem = useCallback(() => {
252+
// Use the filtered collection items instead of the full tree state collection
253+
for (const item of filteredCollectionItems) {
254+
if (
255+
item &&
256+
item.type === 'item' &&
257+
!treeState.selectionManager.isDisabled(item.key)
258+
) {
259+
return item.key;
260+
}
261+
}
262+
263+
return null;
264+
}, [filteredCollectionItems, treeState.selectionManager]);
265+
250266
// Create a ref for the menu container
251267
const menuRef = useRef<HTMLUListElement>(null);
252268

@@ -369,6 +385,29 @@ function CommandPaletteBase<T extends object>(
369385
}
370386
}, [autoFocus, contextProps.autoFocus]);
371387

388+
// Auto-focus first item when search value changes (but not on initial render)
389+
React.useEffect(() => {
390+
// Only auto-focus when search value changes, not on initial mount
391+
if (searchValue.trim() !== '') {
392+
const firstSelectableKey = findFirstSelectableItem();
393+
394+
if (firstSelectableKey && hasFilteredItems) {
395+
// Focus the first item in the selection manager
396+
treeState.selectionManager.setFocusedKey(firstSelectableKey);
397+
setFocusedKey(firstSelectableKey);
398+
} else {
399+
// Clear focus if no items are available
400+
treeState.selectionManager.setFocusedKey(null);
401+
setFocusedKey(null);
402+
}
403+
}
404+
}, [
405+
searchValue,
406+
findFirstSelectableItem,
407+
hasFilteredItems,
408+
treeState.selectionManager,
409+
]);
410+
372411
// Extract styles
373412
const extractedStyles = useMemo(
374413
() => extractStyles(props, CONTAINER_STYLES),

0 commit comments

Comments
 (0)