- 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.
229 lines
7.9 KiB
Vue
229 lines
7.9 KiB
Vue
<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>
|