From 664568152c064726a460d2ccb9968f0f5b5e5b2f Mon Sep 17 00:00:00 2001 From: HURRAEY Date: Wed, 1 Jul 2026 14:46:25 +0900 Subject: [PATCH 1/2] fix(search): focus cmdk input on first modal open The search modal looked for input[type=search], but the cmdk search field renders as a text input with cmdk-input. Focus now targets the correct input on open, preloads the modal chunk during idle time, and adds regression tests for the selector helper. --- src/components/SearchModal.tsx | 29 ++++++----- src/contexts/SearchContext.tsx | 26 +++++++++- src/utils/searchFocus.ts | 26 ++++++++++ tests/search-focus.test.ts | 90 ++++++++++++++++++++++++++++++++++ 4 files changed, 158 insertions(+), 13 deletions(-) create mode 100644 src/utils/searchFocus.ts create mode 100644 tests/search-focus.test.ts diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx index 48302cbe2..9e9662efd 100644 --- a/src/components/SearchModal.tsx +++ b/src/components/SearchModal.tsx @@ -54,6 +54,7 @@ import { CodeBlock } from '~/components/markdown/CodeBlock' import { InlineCode } from '~/ui/InlineCode' import { env } from '~/utils/env' import { getRoutableInternalLinkTarget, isSafeHref } from '~/utils/url-boundary' +import { focusSearchInputInContainer } from '~/utils/searchFocus' /** * Safely decode HTML entities without using innerHTML. @@ -3354,6 +3355,14 @@ function isSearchModalPortalTarget(target: EventTarget | null) { const searchModalTransitionMs = 140 +function scheduleSearchInputFocus(container: HTMLElement | null) { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + focusSearchInputInContainer(container) + }) + }) +} + export function SearchModal() { const { isOpen, closeSearch } = useSearchContext() const contentRef = React.useRef(null) @@ -3393,13 +3402,9 @@ export function SearchModal() { return } - const frame = requestAnimationFrame(() => { - contentRef.current - ?.querySelector('input[type="search"]') - ?.focus({ preventScroll: true }) - }) + scheduleSearchInputFocus(contentRef.current) - return () => cancelAnimationFrame(frame) + return undefined }, [isOpen]) React.useEffect(() => { @@ -3459,6 +3464,10 @@ export function SearchModal() { 'search-modal-content fixed z-[1000] inset-0 sm:inset-auto sm:top-4 sm:left-1/2 sm:-translate-x-1/2 sm:w-[96%] xl:w-full sm:max-w-4xl text-left outline-none', isFullHeight && 'sm:bottom-4', )} + onOpenAutoFocus={(event) => { + event.preventDefault() + scheduleSearchInputFocus(contentRef.current) + }} onInteractOutside={(event) => { if (isSearchModalPortalTarget(event.target)) { event.preventDefault() @@ -3562,13 +3571,9 @@ export function AiDock() { return } - const frame = requestAnimationFrame(() => { - contentRef.current - ?.querySelector('input[type="search"]') - ?.focus({ preventScroll: true }) - }) + scheduleSearchInputFocus(contentRef.current) - return () => cancelAnimationFrame(frame) + return undefined }, [isAiDockOpen, isDockVisible]) const toggleDockMaximized = React.useCallback(() => { diff --git a/src/contexts/SearchContext.tsx b/src/contexts/SearchContext.tsx index bced4b1a0..1cc243f17 100644 --- a/src/contexts/SearchContext.tsx +++ b/src/contexts/SearchContext.tsx @@ -1,7 +1,9 @@ import * as React from 'react' +const searchModalModule = () => import('~/components/SearchModal') + const LazySearchModal = React.lazy(() => - import('~/components/SearchModal').then((m) => ({ default: m.SearchModal })), + searchModalModule().then((m) => ({ default: m.SearchModal })), ) interface SearchContextType { @@ -144,6 +146,28 @@ export function SearchProvider({ children }: { children: React.ReactNode }) { ], ) + React.useEffect(() => { + const preloadSearchModal = () => { + void searchModalModule() + } + + if (typeof window === 'undefined') { + return + } + + if ('requestIdleCallback' in window) { + const idleId = window.requestIdleCallback(preloadSearchModal) + return () => { + window.cancelIdleCallback(idleId) + } + } + + const timeoutId = globalThis.setTimeout(preloadSearchModal, 1500) + return () => { + globalThis.clearTimeout(timeoutId) + } + }, []) + React.useEffect(() => { const handleDocumentClick = (event: MouseEvent) => { if (!(event.target instanceof Element)) return diff --git a/src/utils/searchFocus.ts b/src/utils/searchFocus.ts new file mode 100644 index 000000000..11c9f769a --- /dev/null +++ b/src/utils/searchFocus.ts @@ -0,0 +1,26 @@ +export type SearchInputContainer = { + querySelector: ParentNode['querySelector'] +} + +export function getSearchInputFromContainer( + container: SearchInputContainer | null | undefined, +): HTMLInputElement | null { + if (!container) { + return null + } + + return ( + container.querySelector('[cmdk-input]') ?? + container.querySelector( + 'input[aria-label="Search TanStack"]', + ) ?? + container.querySelector('input[aria-label="Search"]') ?? + container.querySelector('input[type="search"]') + ) +} + +export function focusSearchInputInContainer( + container: SearchInputContainer | null | undefined, +): void { + getSearchInputFromContainer(container)?.focus({ preventScroll: true }) +} diff --git a/tests/search-focus.test.ts b/tests/search-focus.test.ts new file mode 100644 index 000000000..9c377cda4 --- /dev/null +++ b/tests/search-focus.test.ts @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict' +import { + focusSearchInputInContainer, + getSearchInputFromContainer, +} from '../src/utils/searchFocus' + +type MockInput = { + type: string + getAttribute: (name: string) => string | null + focus: (options?: FocusOptions) => void +} + +function createMockInput({ + type, + cmdkInput = false, +}: { + type: string + cmdkInput?: boolean +}): MockInput { + return { + type, + getAttribute: (name: string) => { + if (name === 'cmdk-input' && cmdkInput) { + return '' + } + return null + }, + focus: () => {}, + } +} + +function createMockContainer( + inputsBySelector: Record, +) { + return { + querySelector(selector: string) { + return (inputsBySelector[selector] ?? null) as T + }, + } +} + +const cmdkInput = createMockInput({ + type: 'text', + cmdkInput: true, +}) + +const cmdkContainer = createMockContainer({ + '[cmdk-input]': cmdkInput, + 'input[aria-label="Search TanStack"]': cmdkInput, + 'input[aria-label="Search"]': null, + 'input[type="search"]': createMockInput({ type: 'search' }), +}) + +assert.equal( + getSearchInputFromContainer(cmdkContainer), + cmdkInput, + 'prefers cmdk search input over type=search', +) + +const searchTypeInput = createMockInput({ type: 'search' }) +const searchTypeContainer = createMockContainer({ + '[cmdk-input]': null, + 'input[aria-label="Search TanStack"]': null, + 'input[aria-label="Search"]': searchTypeInput, + 'input[type="search"]': searchTypeInput, +}) + +assert.equal( + getSearchInputFromContainer(searchTypeContainer), + searchTypeInput, + 'falls back to search input selectors', +) + +let focusedInput: MockInput | null = null +const focusableInput = createMockInput({ type: 'text', cmdkInput: true }) +focusableInput.focus = () => { + focusedInput = focusableInput +} + +const focusContainer = createMockContainer({ + '[cmdk-input]': focusableInput, + 'input[aria-label="Search TanStack"]': focusableInput, + 'input[aria-label="Search"]': null, + 'input[type="search"]': null, +}) + +focusSearchInputInContainer(focusContainer) +assert.equal(focusedInput, focusableInput, 'focus helper targets search input') + +console.log('search-focus tests passed') From bfab1a9345880b7c3cb6a128061f123b3e3bc70b Mon Sep 17 00:00:00 2001 From: HURRAEY Date: Wed, 1 Jul 2026 17:41:02 +0900 Subject: [PATCH 2/2] fix(search): cancel scheduled search focus --- src/components/SearchModal.tsx | 24 ++++++++++-------- tests/search-focus.test.ts | 46 +++++++++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx index 9e9662efd..eb355111a 100644 --- a/src/components/SearchModal.tsx +++ b/src/components/SearchModal.tsx @@ -3355,12 +3355,21 @@ function isSearchModalPortalTarget(target: EventTarget | null) { const searchModalTransitionMs = 140 -function scheduleSearchInputFocus(container: HTMLElement | null) { - requestAnimationFrame(() => { - requestAnimationFrame(() => { +function scheduleSearchInputFocus(container: HTMLElement | null): () => void { + let secondFrame: number | null = null + const firstFrame = requestAnimationFrame(() => { + secondFrame = requestAnimationFrame(() => { focusSearchInputInContainer(container) }) }) + + return () => { + cancelAnimationFrame(firstFrame) + + if (secondFrame !== null) { + cancelAnimationFrame(secondFrame) + } + } } export function SearchModal() { @@ -3402,9 +3411,7 @@ export function SearchModal() { return } - scheduleSearchInputFocus(contentRef.current) - - return undefined + return scheduleSearchInputFocus(contentRef.current) }, [isOpen]) React.useEffect(() => { @@ -3466,7 +3473,6 @@ export function SearchModal() { )} onOpenAutoFocus={(event) => { event.preventDefault() - scheduleSearchInputFocus(contentRef.current) }} onInteractOutside={(event) => { if (isSearchModalPortalTarget(event.target)) { @@ -3571,9 +3577,7 @@ export function AiDock() { return } - scheduleSearchInputFocus(contentRef.current) - - return undefined + return scheduleSearchInputFocus(contentRef.current) }, [isAiDockOpen, isDockVisible]) const toggleDockMaximized = React.useCallback(() => { diff --git a/tests/search-focus.test.ts b/tests/search-focus.test.ts index 9c377cda4..d17b61dab 100644 --- a/tests/search-focus.test.ts +++ b/tests/search-focus.test.ts @@ -46,7 +46,7 @@ const cmdkInput = createMockInput({ const cmdkContainer = createMockContainer({ '[cmdk-input]': cmdkInput, - 'input[aria-label="Search TanStack"]': cmdkInput, + 'input[aria-label="Search TanStack"]': createMockInput({ type: 'text' }), 'input[aria-label="Search"]': null, 'input[type="search"]': createMockInput({ type: 'search' }), }) @@ -57,18 +57,58 @@ assert.equal( 'prefers cmdk search input over type=search', ) +const tanStackLabelInput = createMockInput({ type: 'text' }) +const tanStackLabelContainer = createMockContainer({ + '[cmdk-input]': null, + 'input[aria-label="Search TanStack"]': tanStackLabelInput, + 'input[aria-label="Search"]': createMockInput({ type: 'text' }), + 'input[type="search"]': createMockInput({ type: 'search' }), +}) + +assert.equal( + getSearchInputFromContainer(tanStackLabelContainer), + tanStackLabelInput, + 'falls back to TanStack search label input', +) + +const searchLabelInput = createMockInput({ type: 'text' }) +const searchLabelContainer = createMockContainer({ + '[cmdk-input]': null, + 'input[aria-label="Search TanStack"]': null, + 'input[aria-label="Search"]': searchLabelInput, + 'input[type="search"]': createMockInput({ type: 'search' }), +}) + +assert.equal( + getSearchInputFromContainer(searchLabelContainer), + searchLabelInput, + 'falls back to generic search label input', +) + const searchTypeInput = createMockInput({ type: 'search' }) const searchTypeContainer = createMockContainer({ '[cmdk-input]': null, 'input[aria-label="Search TanStack"]': null, - 'input[aria-label="Search"]': searchTypeInput, + 'input[aria-label="Search"]': null, 'input[type="search"]': searchTypeInput, }) assert.equal( getSearchInputFromContainer(searchTypeContainer), searchTypeInput, - 'falls back to search input selectors', + 'falls back to search input type', +) + +assert.equal( + getSearchInputFromContainer(null), + null, + 'returns null without a container', +) + +assert.equal( + getSearchInputFromContainer(createMockContainer({})), + null, + 'returns null when no search input matches', ) let focusedInput: MockInput | null = null