diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx index 48302cbe2..eb355111a 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,23 @@ function isSearchModalPortalTarget(target: EventTarget | null) { const searchModalTransitionMs = 140 +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() { const { isOpen, closeSearch } = useSearchContext() const contentRef = React.useRef(null) @@ -3393,13 +3411,7 @@ export function SearchModal() { return } - const frame = requestAnimationFrame(() => { - contentRef.current - ?.querySelector('input[type="search"]') - ?.focus({ preventScroll: true }) - }) - - return () => cancelAnimationFrame(frame) + return scheduleSearchInputFocus(contentRef.current) }, [isOpen]) React.useEffect(() => { @@ -3459,6 +3471,9 @@ 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() + }} onInteractOutside={(event) => { if (isSearchModalPortalTarget(event.target)) { event.preventDefault() @@ -3562,13 +3577,7 @@ export function AiDock() { return } - const frame = requestAnimationFrame(() => { - contentRef.current - ?.querySelector('input[type="search"]') - ?.focus({ preventScroll: true }) - }) - - return () => cancelAnimationFrame(frame) + return scheduleSearchInputFocus(contentRef.current) }, [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..d17b61dab --- /dev/null +++ b/tests/search-focus.test.ts @@ -0,0 +1,130 @@ +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"]': createMockInput({ type: 'text' }), + 'input[aria-label="Search"]': null, + 'input[type="search"]': createMockInput({ type: 'search' }), +}) + +assert.equal( + getSearchInputFromContainer(cmdkContainer), + cmdkInput, + '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"]': null, + 'input[type="search"]': searchTypeInput, +}) + +assert.equal( + getSearchInputFromContainer(searchTypeContainer), + searchTypeInput, + '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 +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')