Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 23 additions & 14 deletions src/components/SearchModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<HTMLDivElement>(null)
Expand Down Expand Up @@ -3393,13 +3411,7 @@ export function SearchModal() {
return
}

const frame = requestAnimationFrame(() => {
contentRef.current
?.querySelector<HTMLInputElement>('input[type="search"]')
?.focus({ preventScroll: true })
})

return () => cancelAnimationFrame(frame)
return scheduleSearchInputFocus(contentRef.current)
}, [isOpen])

React.useEffect(() => {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -3562,13 +3577,7 @@ export function AiDock() {
return
}

const frame = requestAnimationFrame(() => {
contentRef.current
?.querySelector<HTMLInputElement>('input[type="search"]')
?.focus({ preventScroll: true })
})

return () => cancelAnimationFrame(frame)
return scheduleSearchInputFocus(contentRef.current)
}, [isAiDockOpen, isDockVisible])

const toggleDockMaximized = React.useCallback(() => {
Expand Down
26 changes: 25 additions & 1 deletion src/contexts/SearchContext.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions src/utils/searchFocus.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>('[cmdk-input]') ??
container.querySelector<HTMLInputElement>(
'input[aria-label="Search TanStack"]',
) ??
container.querySelector<HTMLInputElement>('input[aria-label="Search"]') ??
container.querySelector<HTMLInputElement>('input[type="search"]')
)
}

export function focusSearchInputInContainer(
container: SearchInputContainer | null | undefined,
): void {
getSearchInputFromContainer(container)?.focus({ preventScroll: true })
}
130 changes: 130 additions & 0 deletions tests/search-focus.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, MockInput | null>,
) {
return {
querySelector<T>(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')