From cdb6f2db254ac4d7968df125be92f299acd82266 Mon Sep 17 00:00:00 2001 From: Patrick Bacon-Blaber Date: Fri, 6 Mar 2026 08:22:16 -0500 Subject: [PATCH] feat: add cmd+k hotkey to focus search bar with kbd badge indicator --- package-lock.json | 75 +++++++++++++++++++ package.json | 1 + src/App.css | 28 +++++++ src/main.tsx | 5 +- .../HomeScreen/components/SearchBar.test.tsx | 71 +++++++++++++++++- .../HomeScreen/components/SearchBar.tsx | 22 +++++- 6 files changed, 199 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9b9ffeb..415a752 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "newtab", "version": "0.8.0", "dependencies": { + "@tanstack/react-hotkeys": "^0.3.1", "react": "^19.2.0", "react-dom": "^19.2.0" }, @@ -1607,6 +1608,71 @@ "dev": true, "license": "MIT" }, + "node_modules/@tanstack/hotkeys": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@tanstack/hotkeys/-/hotkeys-0.3.1.tgz", + "integrity": "sha512-G+v+PUac8ff/jg752yhhfPqw7/0xr8RYKL69Jb4i7L1JCPKwwoO4aLqVSmI17WSDN6sh1+nNf8QzUOphuPLAuw==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "^0.9.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-hotkeys": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-hotkeys/-/react-hotkeys-0.3.1.tgz", + "integrity": "sha512-QXTIwVy4mdLZD0Fz501hzw85aNEI9522kLnxF85Bgyr6iKSHYq5L6ChBsBVx28d4zbyEiDQ6HHf96/qzIcNtOg==", + "license": "MIT", + "dependencies": { + "@tanstack/hotkeys": "0.3.1", + "@tanstack/react-store": "^0.9.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/react-store": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.1.tgz", + "integrity": "sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.1", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/store": { + "version": "0.9.1", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.1.tgz", + "integrity": "sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -4130,6 +4196,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index 6bb11aa..fdba2ff 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "preview": "vite preview" }, "dependencies": { + "@tanstack/react-hotkeys": "^0.3.1", "react": "^19.2.0", "react-dom": "^19.2.0" }, diff --git a/src/App.css b/src/App.css index 146ac4e..dad1933 100644 --- a/src/App.css +++ b/src/App.css @@ -153,6 +153,34 @@ border-color: rgba(var(--fg), 0.2); } +.search-bar--has-kbd { + padding-right: 72px; +} + +.search-bar-kbd { + position: absolute; + right: 14px; + top: 50%; + transform: translateY(-50%); + padding: 3px 7px; + font-size: 0.72rem; + font-family: inherit; + color: rgba(var(--fg), 0.45); + background: rgba(var(--fg), 0.08); + border: 1px solid rgba(var(--fg), 0.18); + border-bottom-width: 2px; + border-radius: 6px; + pointer-events: none; + opacity: 1; + transition: opacity 150ms; + white-space: nowrap; + user-select: none; +} + +.search-bar-kbd--hidden { + opacity: 0; +} + /* Search Dropdown */ .search-dropdown { position: absolute; diff --git a/src/main.tsx b/src/main.tsx index bef5202..004d073 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { HotkeysProvider } from '@tanstack/react-hotkeys' import './index.css' import App from './App.tsx' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/src/screens/HomeScreen/components/SearchBar.test.tsx b/src/screens/HomeScreen/components/SearchBar.test.tsx index 3ff66e0..cec7891 100644 --- a/src/screens/HomeScreen/components/SearchBar.test.tsx +++ b/src/screens/HomeScreen/components/SearchBar.test.tsx @@ -1,10 +1,15 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { HotkeysProvider } from '@tanstack/react-hotkeys'; import { SearchBar } from './SearchBar.tsx'; import { scoreMatch, scoreLinkMatch } from './searchScoring.ts'; import { mockModule, mockModuleWithHiddenLinks } from '../../../test/fixtures.ts'; import type { ModuleConfig } from '../../../types/config.ts'; +function renderWithHotkeys(ui: React.ReactElement) { + return render({ui}); +} + const modules = [mockModule, mockModuleWithHiddenLinks]; describe('scoreMatch', () => { @@ -251,4 +256,68 @@ describe('SearchBar', () => { expect(screen.queryByText('GitHub')).not.toBeInTheDocument(); expect(input).toHaveValue(''); }); + + it('renders the kbd badge when enabled', () => { + renderWithHotkeys( + , + ); + // kbd is aria-hidden, so query by element tag + const kbd = document.querySelector('kbd'); + expect(kbd).toBeInTheDocument(); + }); + + it('kbd badge has hidden class when input is focused', () => { + renderWithHotkeys( + , + ); + const input = screen.getByPlaceholderText('Filter...'); + const kbd = document.querySelector('kbd')!; + + // Initially the input has autoFocus, so it starts focused — badge is hidden + fireEvent.focus(input); + expect(kbd).toHaveClass('search-bar-kbd--hidden'); + }); + + it('kbd badge has hidden class when query is non-empty', async () => { + const user = userEvent.setup(); + renderWithHotkeys( + , + ); + const input = screen.getByPlaceholderText('Filter...'); + const kbd = document.querySelector('kbd')!; + + fireEvent.blur(input); + await user.type(input, 'g'); + expect(kbd).toHaveClass('search-bar-kbd--hidden'); + }); + + it('kbd badge does not have hidden class when input is blurred and query is empty', () => { + renderWithHotkeys( + , + ); + const input = screen.getByPlaceholderText('Filter...'); + const kbd = document.querySelector('kbd')!; + + fireEvent.blur(input); + expect(kbd).not.toHaveClass('search-bar-kbd--hidden'); + }); + + it('Mod+K focuses the search input', async () => { + const user = userEvent.setup(); + renderWithHotkeys( + <> + + + , + ); + const input = screen.getByPlaceholderText('Filter...'); + + // Move focus to the other button so the input is not focused + await user.click(screen.getByRole('button', { name: 'other' })); + expect(document.activeElement).not.toBe(input); + + // In jsdom (Linux), Mod resolves to Control; fire Ctrl+K + await user.keyboard('{Control>}k{/Control}'); + expect(document.activeElement).toBe(input); + }); }); diff --git a/src/screens/HomeScreen/components/SearchBar.tsx b/src/screens/HomeScreen/components/SearchBar.tsx index 8460a1b..e4210ad 100644 --- a/src/screens/HomeScreen/components/SearchBar.tsx +++ b/src/screens/HomeScreen/components/SearchBar.tsx @@ -1,9 +1,14 @@ import { useState, useEffect, useRef, useMemo } from 'react'; +import { useHotkey } from '@tanstack/react-hotkeys'; import type { LinkConfig, ModuleConfig } from '../../../types/config.ts'; import { scoreLinkMatch } from './searchScoring.ts'; const MAX_RESULTS = 5; +function isMac(): boolean { + return /Mac|iPhone|iPad|iPod/.test(navigator.platform); +} + interface SearchBarProps { enabled: boolean; placeholder: string; @@ -28,8 +33,14 @@ function getFaviconUrl(url: string): string { export function SearchBar({ enabled, placeholder, modules, onNavigate }: SearchBarProps) { const [query, setQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); + const [isFocused, setIsFocused] = useState(false); + const inputRef = useRef(null); const listRef = useRef(null); + useHotkey('Mod+K', () => { + inputRef.current?.focus(); + }, { preventDefault: true }); + const matches: MatchedLink[] = useMemo(() => { if (!query) return []; @@ -62,6 +73,9 @@ export function SearchBar({ enabled, placeholder, modules, onNavigate }: SearchB if (!enabled) return null; + const kbdHidden = isFocused || query.length > 0; + const kbdLabel = isMac() ? '⌘K' : 'Ctrl K'; + function handleKeyDown(e: React.KeyboardEvent) { if (e.key === 'Escape') { setQuery(''); @@ -80,14 +94,20 @@ export function SearchBar({ enabled, placeholder, modules, onNavigate }: SearchB
{ setQuery(e.target.value); setSelectedIndex(0); }} onKeyDown={handleKeyDown} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} autoFocus /> + {matches.length > 0 && (
    {matches.map((link, i) => (