From 00b127d7ca3e15cae0475cfc758213e85a17f8a6 Mon Sep 17 00:00:00 2001 From: Brad Groux <3053586+BradGroux@users.noreply.github.com> Date: Wed, 2 Sep 2026 18:43:48 -0500 Subject: [PATCH] feat: improve command palette navigation (#1318) --- .../command-dialogs-mantine.test.tsx | 78 ++++- web/src/components/layout/CommandPalette.tsx | 273 +++++++++++++----- 2 files changed, 280 insertions(+), 71 deletions(-) diff --git a/web/src/__tests__/command-dialogs-mantine.test.tsx b/web/src/__tests__/command-dialogs-mantine.test.tsx index 92983484..07b892ec 100644 --- a/web/src/__tests__/command-dialogs-mantine.test.tsx +++ b/web/src/__tests__/command-dialogs-mantine.test.tsx @@ -1,5 +1,5 @@ -import { afterEach, describe, expect, it } from 'vitest'; -import { cleanup, fireEvent, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'; import { ViewProvider } from '@/contexts/ViewContext'; import { KeyboardProvider } from '@/hooks/useKeyboard'; @@ -35,6 +35,80 @@ describe('command and shortcut dialogs Mantine migration', () => { expect(baseElement.querySelector('[data-slot="dialog-title"]')).toBeNull(); }); + it('filters commands and exposes disabled reasons without a pointer tooltip', async () => { + renderCommandSurface(); + fireEvent.keyDown(window, { key: 'k', metaKey: true }); + + const input = await screen.findByRole('textbox', { name: 'Search commands' }); + fireEvent.change(input, { target: { value: 'restart local' } }); + + const disabledCommand = screen.getByRole('button', { name: /Restart Local Server/ }); + expect(disabledCommand.getAttribute('aria-disabled')).toBe('true'); + expect(disabledCommand.getAttribute('title')).toBeNull(); + expect(disabledCommand.getAttribute('aria-describedby')).toBe( + 'command-disabled-restart-local-server' + ); + expect( + screen.getByText('The desktop bridge does not expose server restart from the web app yet.') + ).toBeDefined(); + + disabledCommand.focus(); + expect(document.activeElement).toBe(disabledCommand); + }); + + it('skips unavailable commands and Enter runs the visibly selected command', async () => { + const onDiagnostics = vi.fn(); + window.addEventListener('veritas:open-diagnostics', onDiagnostics, { once: true }); + renderCommandSurface(); + fireEvent.keyDown(window, { key: 'k', metaKey: true }); + + const input = await screen.findByRole('textbox', { name: 'Search commands' }); + fireEvent.change(input, { target: { value: 'diagnostics' } }); + + const selected = screen.getByRole('button', { name: 'Open Logs and Diagnostics' }); + expect(selected.getAttribute('data-selected')).toBe('true'); + fireEvent.keyDown(input, { key: 'ArrowDown' }); + expect(selected.getAttribute('data-selected')).toBe('true'); + + fireEvent.keyDown(input, { key: 'Enter' }); + await waitFor(() => expect(onDiagnostics).toHaveBeenCalledOnce()); + }); + + it('focuses search, restores trigger focus, and provides viewport scroll cues', async () => { + renderCommandSurface( + <> + + + + ); + const trigger = screen.getByRole('button', { name: 'Palette trigger' }); + trigger.focus(); + fireEvent.keyDown(window, { key: 'k', metaKey: true }); + + const input = await screen.findByRole('textbox', { name: 'Search commands' }); + await waitFor(() => expect(document.activeElement).toBe(input)); + expect(screen.getByTestId('command-palette-surface').className).toContain('100dvh'); + + const viewport = screen.getByLabelText('Available commands'); + Object.defineProperties(viewport, { + clientHeight: { configurable: true, value: 300 }, + scrollHeight: { configurable: true, value: 900 }, + scrollTop: { configurable: true, writable: true, value: 0 }, + }); + fireEvent.scroll(viewport); + expect(screen.getByText('More commands below')).toBeDefined(); + + viewport.scrollTop = 600; + fireEvent.scroll(viewport); + expect(screen.getByText('More commands above')).toBeDefined(); + + fireEvent.keyDown(input, { key: 'Escape' }); + await waitFor(() => + expect(screen.queryByRole('dialog', { name: 'Command palette' })).toBeNull() + ); + await waitFor(() => expect(document.activeElement).toBe(trigger)); + }); + it('opens keyboard shortcuts through direct Mantine modal and key badges', async () => { const { baseElement } = renderCommandSurface(); diff --git a/web/src/components/layout/CommandPalette.tsx b/web/src/components/layout/CommandPalette.tsx index ca16b0fa..c2c7011d 100644 --- a/web/src/components/layout/CommandPalette.tsx +++ b/web/src/components/layout/CommandPalette.tsx @@ -92,12 +92,34 @@ interface CommandItem extends CommandDescriptor { iconNode: ReactNode; } +function firstEnabledIndex(commands: readonly CommandItem[]): number { + return commands.findIndex((command) => !command.disabledReason); +} + +function nextEnabledIndex( + commands: readonly CommandItem[], + selectedIndex: number, + direction: 1 | -1 +): number { + if (commands.length === 0) return -1; + + let index = selectedIndex; + for (let visited = 0; visited < commands.length; visited++) { + index = (index + direction + commands.length) % commands.length; + if (!commands[index]?.disabledReason) return index; + } + + return -1; +} + export function CommandPalette() { const [open, setOpen] = useState(false); const [searchOpen, setSearchOpen] = useState(false); const [searchMounted, setSearchMounted] = useState(false); const [query, setQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); + const [canScrollUp, setCanScrollUp] = useState(false); + const [canScrollDown, setCanScrollDown] = useState(false); const inputRef = useRef(null); const listRef = useRef(null); @@ -179,6 +201,9 @@ export function CommandPalette() { return groups; }, [filtered]); + const orderedCommands = useMemo(() => grouped.flatMap((group) => group.items), [grouped]); + const unavailableCount = filtered.filter((command) => command.disabledReason).length; + // Reset on open/close useEffect(() => { if (open) { @@ -188,12 +213,16 @@ export function CommandPalette() { } }, [open]); - // Clamp selected index + // Keep selection on an executable command after filtering or registry changes. useEffect(() => { - if (selectedIndex >= filtered.length) { - setSelectedIndex(Math.max(0, filtered.length - 1)); + if ( + selectedIndex < 0 || + selectedIndex >= orderedCommands.length || + orderedCommands[selectedIndex]?.disabledReason + ) { + setSelectedIndex(firstEnabledIndex(orderedCommands)); } - }, [filtered.length, selectedIndex]); + }, [orderedCommands, selectedIndex]); // ⌘K listener useEffect(() => { @@ -220,21 +249,41 @@ export function CommandPalette() { const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'ArrowDown') { e.preventDefault(); - setSelectedIndex((i) => (i < filtered.length - 1 ? i + 1 : 0)); + setSelectedIndex((index) => nextEnabledIndex(orderedCommands, index, 1)); } else if (e.key === 'ArrowUp') { e.preventDefault(); - setSelectedIndex((i) => (i > 0 ? i - 1 : filtered.length - 1)); - } else if (e.key === 'Enter' && filtered[selectedIndex]) { + setSelectedIndex((index) => nextEnabledIndex(orderedCommands, index, -1)); + } else if (e.key === 'Enter' && orderedCommands[selectedIndex]) { e.preventDefault(); - runCommand(filtered[selectedIndex]); + runCommand(orderedCommands[selectedIndex]); } }; + const updateScrollCues = useCallback(() => { + const viewport = listRef.current; + if (!viewport) return; + + const remaining = viewport.scrollHeight - viewport.clientHeight - viewport.scrollTop; + setCanScrollUp(viewport.scrollTop > 1); + setCanScrollDown(remaining > 1); + }, []); + + useEffect(() => { + if (!open) return; + const frame = window.requestAnimationFrame(updateScrollCues); + window.addEventListener('resize', updateScrollCues); + return () => { + window.cancelAnimationFrame(frame); + window.removeEventListener('resize', updateScrollCues); + }; + }, [open, orderedCommands, updateScrollCues]); + // Scroll selected item into view useEffect(() => { const el = listRef.current?.querySelector(`[data-index="${selectedIndex}"]`); el?.scrollIntoView({ block: 'nearest' }); - }, [selectedIndex]); + updateScrollCues(); + }, [selectedIndex, updateScrollCues]); // Don't show if help dialog is open if (isHelpOpen) return null; @@ -246,13 +295,17 @@ export function CommandPalette() { setOpen(false)} - size={520} + size={600} padding={0} title={Command palette} withCloseButton={false} classNames={{ content: 'overflow-hidden', header: 'sr-only', body: 'p-0' }} > - + Search and run board actions, navigation commands, and shortcuts. @@ -267,6 +320,11 @@ export function CommandPalette() { }} placeholder="Type a command or search..." aria-label="Search commands" + aria-activedescendant={ + orderedCommands[selectedIndex] + ? `command-${orderedCommands[selectedIndex].id}` + : undefined + } variant="unstyled" leftSection={ + )) + )} + + {canScrollUp && ( +