feat: opt-in Journal search page and JournalSearchBox
- journal.routes.search (off by default) registers journal-search, the results page for ?q= in the API's relevance order (no sort is sent), server-rendered with a Polish-aware result count. - JournalSearchBox: an icon button and a WAI-ARIA combobox with debounced ?fields=suggest suggestions, stale answers dropped, query words marked ignoring diacritics; a plain GET form without JS. - The module keeps the search path out of the route cache and the sitemap and marks it noindex, follow. - Client: suggestPosts and the fields option; useJournalLinks.searchLink.
This commit is contained in:
228
app/components/Journal/SearchBox.vue
Normal file
228
app/components/Journal/SearchBox.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
// The header search: an icon button opens a search input, up to `limit`
|
||||
// suggestions show as the visitor types, and "see all" opens the search page.
|
||||
// The input is a WAI-ARIA combobox: arrows move through the suggestions,
|
||||
// Enter opens the active one or the search page, Escape closes and returns
|
||||
// focus to the button, a click outside closes. Without JS it is a plain GET
|
||||
// form to the search page, always open. It renders nothing when
|
||||
// journal.routes.search is false.
|
||||
//
|
||||
// Layout hooks: .journal-search--open on the root while open, and the panel
|
||||
// (.journal-search__panel) for an inline input or a drop-down; the icon is
|
||||
// the `icon` slot.
|
||||
import { JOURNAL_SEARCH_MIN_LENGTH } from '../../utils/client.ts'
|
||||
import { createSuggestController, markTerms, moveActive, searchTermState, type SuggestController, type SuggestState } from '../../utils/search.ts'
|
||||
import type { JournalPostSuggestion } from '../../utils/types.ts'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
limit?: number
|
||||
minLength?: number
|
||||
debounce?: number
|
||||
/** Overrides journal.search.placeholder. */
|
||||
placeholder?: string
|
||||
/** Overrides journal.search.button, the icon button's accessible label. */
|
||||
buttonLabel?: string
|
||||
/** Overrides journal.search.seeAll. */
|
||||
seeAllLabel?: string
|
||||
/** Overrides journal.search.empty. */
|
||||
emptyLabel?: string
|
||||
}>(),
|
||||
{ limit: 5, minLength: JOURNAL_SEARCH_MIN_LENGTH, debounce: 250 },
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
const links = useJournalLinks()
|
||||
const api = useJournalApi()
|
||||
|
||||
const action = computed(() => links.searchLink())
|
||||
const id = useId()
|
||||
const panelId = `${id}-panel`
|
||||
const listId = `${id}-list`
|
||||
const optionId = (i: number) => `${id}-option-${i}`
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const toggleEl = ref<HTMLButtonElement | null>(null)
|
||||
const inputEl = ref<HTMLInputElement | null>(null)
|
||||
const enhanced = ref(false)
|
||||
const open = ref(false)
|
||||
const term = ref('')
|
||||
const active = ref(-1)
|
||||
const state = shallowRef<SuggestState<JournalPostSuggestion>>({ term: '', status: 'idle', loading: false, rows: [], total: 0 })
|
||||
|
||||
const rows = computed(() => state.value.rows)
|
||||
const listOpen = computed(
|
||||
() => enhanced.value && open.value && searchTermState(term.value, props.minLength) === 'ready' && state.value.status !== 'idle',
|
||||
)
|
||||
const seeAll = computed(() => links.searchLink(term.value.trim()))
|
||||
|
||||
let suggest: SuggestController | null = null
|
||||
|
||||
const onOutside = (e: PointerEvent) => {
|
||||
if (open.value && root.value && !root.value.contains(e.target as Node)) close(false)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
enhanced.value = true
|
||||
suggest = createSuggestController<JournalPostSuggestion>({
|
||||
fetch: (q, signal) => api.suggestPosts(q, { perPage: props.limit, signal }),
|
||||
onChange: (s) => {
|
||||
state.value = s
|
||||
active.value = -1
|
||||
},
|
||||
debounce: props.debounce,
|
||||
minLength: props.minLength,
|
||||
})
|
||||
document.addEventListener('pointerdown', onOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
suggest?.dispose()
|
||||
document.removeEventListener('pointerdown', onOutside)
|
||||
})
|
||||
|
||||
watch(term, (value) => suggest?.input(value))
|
||||
|
||||
// Opening selects the previous term, so typing replaces it.
|
||||
function openPanel() {
|
||||
open.value = true
|
||||
nextTick(() => {
|
||||
inputEl.value?.focus()
|
||||
inputEl.value?.select()
|
||||
})
|
||||
}
|
||||
|
||||
function close(focusToggle: boolean) {
|
||||
open.value = false
|
||||
active.value = -1
|
||||
if (focusToggle) toggleEl.value?.focus()
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (open.value) close(false)
|
||||
else openPanel()
|
||||
}
|
||||
|
||||
function go(to: string | null) {
|
||||
if (!to) return
|
||||
close(false)
|
||||
return navigateTo(to)
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
close(true)
|
||||
return
|
||||
}
|
||||
if (e.key === 'Enter' && listOpen.value && active.value >= 0) {
|
||||
const row = rows.value[active.value]
|
||||
if (row) {
|
||||
e.preventDefault()
|
||||
go(links.postLink(row.slug))
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!listOpen.value) return
|
||||
const next = moveActive(active.value, rows.value.length, e.key)
|
||||
if (next !== null) {
|
||||
e.preventDefault()
|
||||
active.value = next
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit(e: Event) {
|
||||
if (!enhanced.value) return
|
||||
e.preventDefault()
|
||||
const q = term.value.trim()
|
||||
if (q) go(links.searchLink(q))
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="action"
|
||||
ref="root"
|
||||
class="journal-search"
|
||||
:class="{ 'journal-search--enhanced': enhanced, 'journal-search--open': open }"
|
||||
>
|
||||
<button
|
||||
v-if="enhanced"
|
||||
ref="toggleEl"
|
||||
type="button"
|
||||
class="journal-search__toggle"
|
||||
:aria-label="buttonLabel ?? t('journal.search.button')"
|
||||
:aria-expanded="open"
|
||||
:aria-controls="panelId"
|
||||
@click="toggle"
|
||||
>
|
||||
<slot name="icon">
|
||||
<svg class="journal-search__icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
|
||||
<circle cx="10.5" cy="10.5" r="6.5" fill="none" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M15.5 15.5 21 21" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</slot>
|
||||
</button>
|
||||
<form :id="panelId" class="journal-search__panel" role="search" method="get" :action="action" @submit="onSubmit">
|
||||
<input
|
||||
ref="inputEl"
|
||||
v-model="term"
|
||||
class="journal-search__input"
|
||||
type="search"
|
||||
name="q"
|
||||
autocomplete="off"
|
||||
:placeholder="placeholder ?? t('journal.search.placeholder')"
|
||||
:aria-label="t('journal.search.input')"
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
:aria-expanded="listOpen"
|
||||
:aria-controls="listId"
|
||||
:aria-activedescendant="listOpen && active >= 0 ? optionId(active) : undefined"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<div v-show="listOpen" class="journal-search__dropdown">
|
||||
<ul :id="listId" role="listbox" class="journal-search__list" :aria-label="t('journal.search.suggestions')">
|
||||
<li
|
||||
v-for="(row, i) in rows"
|
||||
:id="optionId(i)"
|
||||
:key="row.id"
|
||||
role="option"
|
||||
class="journal-search__option"
|
||||
:class="{ 'journal-search__option--active': i === active }"
|
||||
:aria-selected="i === active"
|
||||
@mousedown.prevent
|
||||
@mouseenter="active = i"
|
||||
>
|
||||
<NuxtLink :to="links.postLink(row.slug) ?? undefined" tabindex="-1" class="journal-search__link" @click="close(false)">
|
||||
<img v-if="row.featured_image_url" :src="row.featured_image_url" alt="" class="journal-search__thumb" width="48" height="48" loading="lazy" />
|
||||
<span class="journal-search__text">
|
||||
<span class="journal-search__title">
|
||||
<template v-for="(seg, k) in markTerms(row.title, state.term)" :key="k">
|
||||
<mark v-if="seg.mark">{{ seg.text }}</mark>
|
||||
<template v-else>{{ seg.text }}</template>
|
||||
</template>
|
||||
</span>
|
||||
<span v-if="row.excerpt" class="journal-search__excerpt">
|
||||
<template v-for="(seg, k) in markTerms(row.excerpt, state.term)" :key="k">
|
||||
<mark v-if="seg.mark">{{ seg.text }}</mark>
|
||||
<template v-else>{{ seg.text }}</template>
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="state.loading" class="journal-search__status journal-search__status--loading" role="status">
|
||||
{{ t('journal.search.loading') }}
|
||||
</p>
|
||||
<p v-else-if="state.status === 'error'" class="journal-search__status" role="status">{{ t('journal.search.error') }}</p>
|
||||
<p v-else-if="state.status === 'done' && rows.length === 0" class="journal-search__status" role="status">
|
||||
{{ emptyLabel ?? t('journal.search.empty') }}
|
||||
</p>
|
||||
<NuxtLink v-if="seeAll && state.status !== 'error'" :to="seeAll" class="journal-search__all" @mousedown.prevent @click="close(false)">
|
||||
{{ seeAllLabel ?? t('journal.search.seeAll') }}
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user