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
10 changes: 2 additions & 8 deletions app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ if (import.meta.server) {
}

const keyboardShortcuts = useKeyboardShortcuts()
const focusSearchInput = provideSearchInputFocus().focus
const { settings } = useSettings()

initKeyShortcuts()
Expand All @@ -64,14 +65,7 @@ onKeyDown(
if (!keyboardShortcuts.value || isEditableElement(e.target)) return
e.preventDefault()

const searchInput = document.querySelector<HTMLInputElement>(
'input[type="search"], input[name="q"]',
)

if (searchInput) {
searchInput.focus()
return
}
if (focusSearchInput()) return

router.push({ name: 'search' })
},
Expand Down
6 changes: 5 additions & 1 deletion app/components/Header/SearchBox.vue
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,15 @@ function handleBlur() {
emit('blur')
}
function focus() {
inputRef.value?.focus()
const input = inputRef.value
if (!input) return false
input.focus()
return true
}
function blur() {
inputRef.value?.blur()
}
useSearchInputFocusTarget(focus)
defineExpose({ focus, blur })
</script>
<template>
Expand Down
54 changes: 54 additions & 0 deletions app/composables/useSearchInputFocus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import type { InjectionKey } from 'vue'

/** Focuses a mounted search input. Returns `false` when the input is not rendered. */
type SearchInputFocusTarget = () => boolean

interface SearchInputFocusContext {
/** Registers a search input for the lifetime of the current component scope. */
register: (target: SearchInputFocusTarget) => void
/** Focuses the first search input that is currently rendered. */
focus: () => boolean
}

const SEARCH_INPUT_FOCUS_KEY: InjectionKey<SearchInputFocusContext> = Symbol('search-input-focus')

/**
* Provides the search input focus context. Call once from the app root so both
* the header search box and the homepage search input can register themselves.
*/
export function provideSearchInputFocus(): SearchInputFocusContext {
const targets = new Set<SearchInputFocusTarget>()

const context: SearchInputFocusContext = {
register(target) {
targets.add(target)
onScopeDispose(() => targets.delete(target))
},
focus() {
for (const target of targets) {
if (target()) return true
}
return false
},
}

provide(SEARCH_INPUT_FOCUS_KEY, context)
return context
}

/**
* Registers the calling component's search input so keyboard shortcuts can focus it
* without querying the DOM. The target should return `false` while its input is not rendered.
*/
export function useSearchInputFocusTarget(target: SearchInputFocusTarget) {
inject(SEARCH_INPUT_FOCUS_KEY, null)?.register(target)
}

/**
* Returns a function that focuses the currently rendered search input.
* The returned function resolves to `false` when no search input is on the page.
*/
export function useSearchInputFocus(): () => boolean {
const context = inject(SEARCH_INPUT_FOCUS_KEY, null)
return () => context?.focus() ?? false
}
9 changes: 9 additions & 0 deletions app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@ import { SHOWCASED_FRAMEWORKS } from '~/utils/frameworks'

const { model: searchQuery, startSearch } = useGlobalSearch()
const isSearchFocused = shallowRef(false)
const searchInputRef = useTemplateRef('searchInputRef')

useSearchInputFocusTarget(() => {
const input = searchInputRef.value
if (!input) return false
input.focus()
return true
})

async function search() {
startSearch()
Expand Down Expand Up @@ -56,6 +64,7 @@ defineOgImage('Splash.takumi', {}, { alt: () => $t('seo.home.description') })

<InputBase
id="home-search"
ref="searchInputRef"
v-model="searchQuery"
type="search"
name="q"
Expand Down
10 changes: 1 addition & 9 deletions app/pages/search.vue
Original file line number Diff line number Diff line change
Expand Up @@ -477,15 +477,7 @@ watch(displayResults, newResults => {
}
})

/**
* Focus the header search input
*/
function focusSearchInput() {
const searchInput = document.querySelector<HTMLInputElement>(
'input[type="search"], input[name="q"]',
)
searchInput?.focus()
}
const focusSearchInput = useSearchInputFocus()

const keyboardShortcuts = useKeyboardShortcuts()

Expand Down
14 changes: 14 additions & 0 deletions test/e2e/interactions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,20 @@ test.describe('Search Pages', () => {
await expect(page.locator('input[type="search"]')).toBeFocused()
})

test('/ (homepage) → "/" focuses the homepage search input', async ({ page, goto }) => {
await goto('/', { waitUntil: 'hydration' })

const homeSearchInput = page.locator('#home-search')
await expect(homeSearchInput).toBeVisible()

// Move focus away from the autofocused input, then press the shortcut
await page.locator('body').click({ position: { x: 5, y: 5 } })
await expect(homeSearchInput).not.toBeFocused()

await page.keyboard.press('/')
await expect(homeSearchInput).toBeFocused()
})

test('/ (homepage) → search, keeps focus on search input', async ({ page, goto }) => {
await goto('/', { waitUntil: 'hydration' })

Expand Down
163 changes: 163 additions & 0 deletions test/nuxt/composables/use-search-input-focus.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { describe, expect, it, vi } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import { defineComponent, h, nextTick, shallowRef } from 'vue'
import { HeaderSearchBox } from '#components'

const notProvided = (): boolean => {
throw new Error('focus function was not captured')
}

const Target = defineComponent({
props: { id: { type: String, required: true } },
setup(props) {
const input = shallowRef<HTMLInputElement | null>(null)
useSearchInputFocusTarget(() => {
if (!input.value) return false
input.value.focus()
return true
})
return () => h('input', { id: props.id, ref: input, type: 'search' })
},
})

async function mountProvider(children: () => ReturnType<typeof h>[], route?: string) {
let focusSearchInput = notProvided
const Provider = defineComponent({
setup() {
focusSearchInput = provideSearchInputFocus().focus
return () => h('div', children())
},
})
const wrapper = await mountSuspended(Provider, { attachTo: document.body, route })
return { wrapper, focusSearchInput: () => focusSearchInput() }
}

describe('useSearchInputFocus', () => {
it('focuses a registered search input', async () => {
const { wrapper, focusSearchInput } = await mountProvider(() => [h(Target, { id: 'target' })])
try {
expect(focusSearchInput()).toBe(true)
expect(document.activeElement?.id).toBe('target')
} finally {
wrapper.unmount()
}
})

it('returns false when no search input is registered', async () => {
const { wrapper, focusSearchInput } = await mountProvider(() => [])
try {
expect(focusSearchInput()).toBe(false)
} finally {
wrapper.unmount()
}
})

it('skips targets that are not rendered and stops after the first success', async () => {
const hidden = vi.fn(() => false)
const HiddenTarget = defineComponent({
setup() {
useSearchInputFocusTarget(hidden)
return () => null
},
})
const focused = vi.fn(() => true)
const FocusedTarget = defineComponent({
setup() {
useSearchInputFocusTarget(focused)
return () => null
},
})
const spare = vi.fn(() => true)
const SpareTarget = defineComponent({
setup() {
useSearchInputFocusTarget(spare)
return () => null
},
})
const { wrapper, focusSearchInput } = await mountProvider(() => [
h(HiddenTarget),
h(FocusedTarget),
h(SpareTarget),
])
try {
expect(focusSearchInput()).toBe(true)
expect(hidden).toHaveBeenCalledTimes(1)
expect(focused).toHaveBeenCalledTimes(1)
expect(spare).not.toHaveBeenCalled()
} finally {
wrapper.unmount()
}
})

it('unregisters a target when its component unmounts', async () => {
const target = vi.fn(() => true)
const StaleTarget = defineComponent({
setup() {
useSearchInputFocusTarget(target)
return () => null
},
})
const show = shallowRef(true)
const { wrapper, focusSearchInput } = await mountProvider(() =>
show.value ? [h(StaleTarget)] : [],
)
try {
expect(focusSearchInput()).toBe(true)
show.value = false
await nextTick()
expect(focusSearchInput()).toBe(false)
expect(target).toHaveBeenCalledTimes(1)
} finally {
wrapper.unmount()
}
})

it('uses the injected focus function when one is provided', async () => {
let focusSearchInput = notProvided
const Consumer = defineComponent({
setup() {
focusSearchInput = useSearchInputFocus()
return () => null
},
})
const { wrapper } = await mountProvider(() => [h(Target, { id: 'target' }), h(Consumer)])
try {
expect(focusSearchInput()).toBe(true)
expect(document.activeElement?.id).toBe('target')
} finally {
wrapper.unmount()
}
})

it('returns false without a provider', async () => {
let focusSearchInput = notProvided
const Consumer = defineComponent({
setup() {
focusSearchInput = useSearchInputFocus()
return () => null
},
})
const wrapper = await mountSuspended(Consumer)
try {
expect(focusSearchInput()).toBe(false)
} finally {
wrapper.unmount()
}
})

it('registers the header search box and reports when its input is not rendered', async () => {
const { wrapper, focusSearchInput } = await mountProvider(() => [h(HeaderSearchBox)], '/search')
try {
expect(focusSearchInput()).toBe(true)
expect(document.activeElement?.id).toBe('header-search')

// The header search box hides its input on the homepage
await useRouter().push('/')
await nextTick()
expect(wrapper.find('#header-search').exists()).toBe(false)
expect(focusSearchInput()).toBe(false)
} finally {
wrapper.unmount()
}
})
})
Loading