Files
vue-journal-layer/app/components/Journal/SearchBox.vue
Jakub Zych 102da43f76 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.
2026-10-30 18:14:05 +01:00

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>