From 48e380a40d8471e2338eb41ebef1a231e8465c92 Mon Sep 17 00:00:00 2001 From: shogun444 Date: Sat, 25 Jul 2026 20:51:02 +0530 Subject: [PATCH] feat: add command palette for navigation --- src/app/__tests__/layout.test.tsx | 1 + src/app/layout.tsx | 2 + src/components/CommandPalette.tsx | 155 ++++++++++++++ .../__tests__/CommandPalette.test.tsx | 199 ++++++++++++++++++ 4 files changed, 357 insertions(+) create mode 100644 src/components/CommandPalette.tsx create mode 100644 src/components/__tests__/CommandPalette.test.tsx diff --git a/src/app/__tests__/layout.test.tsx b/src/app/__tests__/layout.test.tsx index 81149a11..65929ff0 100644 --- a/src/app/__tests__/layout.test.tsx +++ b/src/app/__tests__/layout.test.tsx @@ -7,6 +7,7 @@ import RootLayout from '../layout'; // Mock next/navigation for RouteAnnouncer's usePathname call. jest.mock('next/navigation', () => ({ usePathname: jest.fn().mockReturnValue('/'), + useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }), })); function renderLayout() { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 72e5788d..15594774 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -50,6 +50,7 @@ import { PreferencesProvider } from '@/lib/preferences'; import { SettingsTrigger } from '@/components/settings/SettingsTrigger'; import { WalletProvider } from '@/contexts/WalletContext'; import RouteAnnouncer from '@/components/RouteAnnouncer'; +import { CommandPalette } from '@/components/CommandPalette'; import Navbar from '@/components/Navbar'; import HeaderActions from '@/components/HeaderActions'; @@ -87,6 +88,7 @@ export default function RootLayout({ {children} + diff --git a/src/components/CommandPalette.tsx b/src/components/CommandPalette.tsx new file mode 100644 index 00000000..077becfb --- /dev/null +++ b/src/components/CommandPalette.tsx @@ -0,0 +1,155 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useDialogFocusTrap } from '@/hooks/useDialogFocusTrap'; +import { useMediaQuery } from '@/hooks/useMediaQuery'; + +type PaletteEntry = { + id: string; + label: string; + href: string; + keywords: string[]; +}; + +const NAV_ENTRIES: PaletteEntry[] = [ + { id: 'home', label: 'Home', href: '/', keywords: ['home', 'dashboard', 'start'] }, + { id: 'contracts', label: 'Contracts', href: '/contracts', keywords: ['contracts', 'agreements'] }, + { id: 'milestones', label: 'Milestones', href: '/milestones', keywords: ['milestones', 'checkpoints', 'goals'] }, + { id: 'reputation', label: 'Reputation', href: '/reputation', keywords: ['reputation', 'trust', 'score', 'history'] }, +]; + +function matchEntry(query: string, label: string, keywords: string[]): boolean { + const q = query.toLowerCase(); + if (label.toLowerCase().includes(q)) return true; + return keywords.some((k) => k.toLowerCase().includes(q)); +} + +export function CommandPalette() { + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(''); + const [selectedIndex, setSelectedIndex] = useState(0); + const dialogRef = useRef(null); + const inputRef = useRef(null); + const router = useRouter(); + const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)'); + + const close = useCallback(() => { + setIsOpen(false); + setQuery(''); + setSelectedIndex(0); + }, []); + + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + const target = e.target as HTMLElement; + if (typeof target.closest === 'function' && target.closest('input, textarea, select, [contenteditable="true"]')) return; + e.preventDefault(); + if (!isOpen) setIsOpen(true); + } + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [isOpen]); + + useDialogFocusTrap({ + isOpen, + dialogRef, + initialFocusRef: inputRef, + onEscape: close, + restoreFocus: true, + }); + + const filtered = query === '' + ? NAV_ENTRIES + : NAV_ENTRIES.filter((e) => matchEntry(query, e.label, e.keywords)); + + const selectedEntry = filtered.length > 0 ? filtered[selectedIndex] : null; + + const navigate = useCallback( + (entry: PaletteEntry) => { + router.push(entry.href); + close(); + }, + [router, close], + ); + + const handleListKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (filtered.length === 0) return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + setSelectedIndex((prev) => (prev + 1) % filtered.length); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setSelectedIndex((prev) => (prev - 1 + filtered.length) % filtered.length); + } else if (e.key === 'Enter' && selectedEntry) { + e.preventDefault(); + navigate(selectedEntry); + } + }, + [filtered.length, selectedEntry, navigate], + ); + + if (!isOpen) return null; + + return ( +
+
+
+
+ 0 && selectedEntry ? `cp-opt-${selectedEntry.id}` : undefined + } + placeholder="Search pages..." + value={query} + onChange={(e) => { + setQuery(e.target.value); + setSelectedIndex(0); + }} + className="w-full bg-transparent py-4 text-sm text-[var(--foreground)] outline-none placeholder:text-[var(--muted-foreground)]" + /> +
+
    + {filtered.length === 0 ? ( +
  • No results
  • + ) : ( + filtered.map((entry, i) => ( +
  • navigate(entry)} + className={`flex cursor-pointer items-center justify-between rounded-lg px-3 py-2.5 text-sm transition-colors ${ + i === selectedIndex + ? 'bg-[var(--accent)] text-[var(--foreground)]' + : 'text-[var(--muted-foreground)] hover:bg-[var(--accent)] hover:text-[var(--foreground)]' + }`} + > + {entry.label} + {entry.href} +
  • + )) + )} +
+
+
+ ); +} diff --git a/src/components/__tests__/CommandPalette.test.tsx b/src/components/__tests__/CommandPalette.test.tsx new file mode 100644 index 00000000..b1f1f4a6 --- /dev/null +++ b/src/components/__tests__/CommandPalette.test.tsx @@ -0,0 +1,199 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { CommandPalette } from '../CommandPalette'; + +const mockPush = jest.fn(); + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ push: jest.fn((...args) => mockPush(...args)), replace: jest.fn(), prefetch: jest.fn() }), +})); + +beforeEach(() => { + mockPush.mockClear(); +}); + +function openPalette() { + fireEvent.keyDown(document, { key: 'k', metaKey: true }); +} + +describe('CommandPalette', () => { + describe('shortcut', () => { + it('opens on Cmd+K', () => { + render(); + fireEvent.keyDown(document, { key: 'k', metaKey: true }); + expect(screen.getByRole('dialog', { name: 'Command palette' })).toBeInTheDocument(); + }); + + it('opens on Ctrl+K', () => { + render(); + fireEvent.keyDown(document, { key: 'k', ctrlKey: true }); + expect(screen.getByRole('dialog', { name: 'Command palette' })).toBeInTheDocument(); + }); + + it('does not open on plain K', () => { + render(); + fireEvent.keyDown(document, { key: 'k' }); + expect(screen.queryByRole('dialog', { name: 'Command palette' })).not.toBeInTheDocument(); + }); + + it('does not open while typing in an input', () => { + render( +
+ + +
, + ); + const input = screen.getByPlaceholderText('Type here'); + fireEvent.keyDown(input, { key: 'k', metaKey: true }); + expect(screen.queryByRole('dialog', { name: 'Command palette' })).not.toBeInTheDocument(); + }); + + it('does not open while typing in a contenteditable', () => { + render( +
+
+ +
, + ); + const editable = screen.getByRole('textbox'); + fireEvent.keyDown(editable, { key: 'k', metaKey: true }); + expect(screen.queryByRole('dialog', { name: 'Command palette' })).not.toBeInTheDocument(); + }); + }); + + describe('filtering', () => { + it('shows all entries when query is empty', () => { + render(); + openPalette(); + expect(screen.getByRole('option', { name: /Home/ })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /Contracts/ })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /Milestones/ })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /Reputation/ })).toBeInTheDocument(); + }); + + it('filters by label prefix', () => { + render(); + openPalette(); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'con' } }); + expect(screen.getByRole('option', { name: /Contracts/ })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: /Home/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('option', { name: /Milestones/ })).not.toBeInTheDocument(); + expect(screen.queryByRole('option', { name: /Reputation/ })).not.toBeInTheDocument(); + }); + + it('filters by label substring', () => { + render(); + openPalette(); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'put' } }); + expect(screen.getByRole('option', { name: /Reputation/ })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: /Contracts/ })).not.toBeInTheDocument(); + }); + + it('filters by keyword match', () => { + render(); + openPalette(); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'trust' } }); + expect(screen.getByRole('option', { name: /Reputation/ })).toBeInTheDocument(); + expect(screen.queryByRole('option', { name: /Contracts/ })).not.toBeInTheDocument(); + }); + + it('shows no results for an unmatched query', () => { + render(); + openPalette(); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'xyzzy' } }); + expect(screen.getByText('No results')).toBeInTheDocument(); + }); + + it('returns a single result for an overlapping query (no duplicates)', () => { + render(); + openPalette(); + fireEvent.change(screen.getByRole('combobox'), { target: { value: 'home' } }); + const options = screen.getAllByRole('option'); + expect(options).toHaveLength(1); + expect(options[0]).toHaveTextContent('Home'); + }); + }); + + describe('activation', () => { + it('navigates on Enter and closes', async () => { + const user = userEvent.setup(); + render(); + openPalette(); + await user.type(screen.getByRole('combobox'), 'mil'); + await user.keyboard('{Enter}'); + expect(mockPush).toHaveBeenCalledWith('/milestones'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('navigates on click and closes', async () => { + const user = userEvent.setup(); + render(); + openPalette(); + await user.click(screen.getByRole('option', { name: /Reputation/ })); + expect(mockPush).toHaveBeenCalledWith('/reputation'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + describe('keyboard navigation', () => { + it('closes on Escape', () => { + render(); + openPalette(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('cycles with ArrowDown and ArrowUp', () => { + render(); + openPalette(); + const combobox = screen.getByRole('combobox'); + + fireEvent.keyDown(combobox, { key: 'ArrowDown' }); + expect(screen.getByRole('option', { name: /Contracts/ })).toHaveAttribute('aria-selected', 'true'); + + fireEvent.keyDown(combobox, { key: 'ArrowDown' }); + expect(screen.getByRole('option', { name: /Milestones/ })).toHaveAttribute('aria-selected', 'true'); + + fireEvent.keyDown(combobox, { key: 'ArrowUp' }); + expect(screen.getByRole('option', { name: /Contracts/ })).toHaveAttribute('aria-selected', 'true'); + }); + + it('combined flow: open → type → ArrowDown → Enter navigates highlighted item', async () => { + const user = userEvent.setup(); + render(); + openPalette(); + + const combobox = screen.getByRole('combobox'); + await user.type(combobox, 'rep'); + + fireEvent.keyDown(combobox, { key: 'ArrowDown' }); + await user.keyboard('{Enter}'); + + expect(mockPush).toHaveBeenCalledWith('/reputation'); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + describe('focus', () => { + it('focuses the search input when opened', () => { + render(); + openPalette(); + expect(screen.getByRole('combobox')).toHaveFocus(); + }); + + it('restores focus to the trigger element after closing', () => { + render( +
+ + +
, + ); + const trigger = screen.getByRole('button', { name: 'Trigger' }); + trigger.focus(); + openPalette(); + fireEvent.keyDown(document, { key: 'Escape' }); + expect(trigger).toHaveFocus(); + }); + }); +});