251 lines
6.7 KiB
Vue
251 lines
6.7 KiB
Vue
<script setup lang="ts">
|
|
import { t } from '@/i18n'
|
|
|
|
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
|
import ApiSearchRow from '@/components/ApiSearchRow.vue'
|
|
import {
|
|
ENDPOINT_CAP,
|
|
queryTerms,
|
|
searchIndex,
|
|
suggestedHits,
|
|
type SearchEntry,
|
|
type SearchHit,
|
|
type SearchIndex,
|
|
} from '@/lib/apiSearch'
|
|
|
|
const props = defineProps<{
|
|
index: SearchIndex
|
|
mobile: boolean
|
|
}>()
|
|
|
|
const emit = defineEmits<{ select: [entry: SearchEntry]; close: [] }>()
|
|
|
|
interface PanelRow {
|
|
id: string
|
|
hit: SearchHit
|
|
}
|
|
|
|
interface PanelGroup {
|
|
id: string
|
|
title: string
|
|
rows: PanelRow[]
|
|
}
|
|
|
|
const LIST_ID = 'rc-sr-list'
|
|
|
|
const root = ref<HTMLElement | null>(null)
|
|
const query = ref('')
|
|
const position = ref(0)
|
|
|
|
const terms = computed(() => queryTerms(query.value))
|
|
const suggesting = computed(() => terms.value.length === 0)
|
|
const result = computed(() => searchIndex(props.index, query.value))
|
|
const shownEndpoints = computed(() => result.value.endpoints.slice(0, ENDPOINT_CAP))
|
|
const hiddenEndpoints = computed(() => result.value.endpoints.length - shownEndpoints.value.length)
|
|
|
|
const hitGroups = computed(() => {
|
|
if (suggesting.value) {
|
|
return [{ title: t('Suggested'), hits: suggestedHits(props.index) }]
|
|
}
|
|
return [
|
|
{ title: t('Endpoints'), hits: shownEndpoints.value },
|
|
{ title: t('Sections'), hits: result.value.sections },
|
|
].filter((group) => group.hits.length > 0)
|
|
})
|
|
|
|
const groups = computed<PanelGroup[]>(() => {
|
|
let next = 0
|
|
return hitGroups.value.map((group, groupIndex) => ({
|
|
id: `rc-sr-g${groupIndex}`,
|
|
title: group.title,
|
|
rows: group.hits.map((hit) => ({ id: `rc-sr-${next++}`, hit })),
|
|
}))
|
|
})
|
|
|
|
const rows = computed(() => groups.value.flatMap((group) => group.rows))
|
|
const selected = computed(() => Math.min(position.value, Math.max(rows.value.length - 1, 0)))
|
|
const activeId = computed(() => rows.value[selected.value]?.id)
|
|
const empty = computed(() => !suggesting.value && rows.value.length === 0)
|
|
|
|
const total = computed(() =>
|
|
suggesting.value
|
|
? rows.value.length
|
|
: result.value.endpoints.length + result.value.sections.length,
|
|
)
|
|
|
|
const countText = computed(() => {
|
|
if (suggesting.value) {
|
|
return t('{0} suggestions', [total.value])
|
|
}
|
|
return total.value === 1 ? t('{0} result', [1]) : t('{0} results', [total.value])
|
|
})
|
|
|
|
const liveText = computed(() => {
|
|
if (empty.value) {
|
|
return t('No results')
|
|
}
|
|
return hiddenEndpoints.value > 0
|
|
? t('{0} results, showing {1}', [total.value, rows.value.length])
|
|
: countText.value
|
|
})
|
|
|
|
const moreText = computed(() =>
|
|
hiddenEndpoints.value === 1
|
|
? t('{0} more endpoint. Keep typing to narrow the list.', [1])
|
|
: t('{0} more endpoints. Keep typing to narrow the list.', [hiddenEndpoints.value]),
|
|
)
|
|
|
|
const emptyText = computed(() =>
|
|
t('No match for "{0}". Try a path, a method or a status code like 404.', [query.value.trim()]),
|
|
)
|
|
|
|
watch(query, () => {
|
|
position.value = 0
|
|
})
|
|
|
|
watch(selected, async () => {
|
|
await nextTick()
|
|
document.getElementById(activeId.value ?? '')?.scrollIntoView?.({ block: 'nearest' })
|
|
})
|
|
|
|
onMounted(async () => {
|
|
await nextTick()
|
|
root.value?.querySelector('input')?.focus()
|
|
})
|
|
|
|
function choose(row: PanelRow) {
|
|
emit('select', row.hit.entry)
|
|
}
|
|
|
|
function move(target: number) {
|
|
const count = rows.value.length
|
|
position.value = count === 0 ? 0 : (target + count) % count
|
|
}
|
|
|
|
function onKeydown(event: KeyboardEvent) {
|
|
if (event.isComposing) {
|
|
return
|
|
}
|
|
const handlers: Record<string, () => void> = {
|
|
ArrowDown: () => move(selected.value + 1),
|
|
ArrowUp: () => move(selected.value - 1),
|
|
Home: () => move(0),
|
|
End: () => move(rows.value.length - 1),
|
|
Enter: () => {
|
|
const row = rows.value[selected.value]
|
|
if (row) {
|
|
choose(row)
|
|
}
|
|
},
|
|
}
|
|
const handler = handlers[event.key]
|
|
if (handler) {
|
|
event.preventDefault()
|
|
handler()
|
|
return
|
|
}
|
|
if (event.key === 'Escape') {
|
|
emit('close')
|
|
}
|
|
}
|
|
|
|
const KBD_CLASS =
|
|
'h-5 min-w-5 justify-center rounded-none border border-accented bg-default px-1 font-mono normal-case text-xs font-medium text-toned ring-0'
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="root" class="flex h-full min-h-0 flex-col text-sm text-default">
|
|
<div
|
|
class="flex shrink-0 items-center gap-2.5 border-b border-default px-4 ring-2 ring-primary ring-inset"
|
|
:class="mobile ? 'h-14' : 'h-13'"
|
|
>
|
|
<UInput
|
|
v-model="query"
|
|
variant="none"
|
|
icon="i-lucide-search"
|
|
:placeholder="t('Search the API reference…')"
|
|
autocomplete="off"
|
|
spellcheck="false"
|
|
role="combobox"
|
|
aria-expanded="true"
|
|
:aria-controls="LIST_ID"
|
|
aria-autocomplete="list"
|
|
:aria-activedescendant="activeId"
|
|
:aria-label="t('Search the API reference')"
|
|
:ui="{
|
|
root: 'min-w-0 flex-1',
|
|
base: 'h-full px-0 ps-7 text-lg text-highlighted lg:text-base',
|
|
leading: 'ps-0',
|
|
leadingIcon: 'size-4.5 text-muted',
|
|
}"
|
|
class="h-full"
|
|
@keydown="onKeydown"
|
|
/>
|
|
<UKbd v-if="!mobile" value="Esc" :class="KBD_CLASS" />
|
|
<UButton
|
|
v-else
|
|
:label="t('Cancel')"
|
|
color="neutral"
|
|
variant="ghost"
|
|
class="-mr-2 h-11 rounded-none px-2 text-base font-medium text-highlighted"
|
|
@click="emit('close')"
|
|
/>
|
|
</div>
|
|
|
|
<span role="status" aria-live="polite" class="sr-only">{{ liveText }}</span>
|
|
|
|
<div
|
|
:id="LIST_ID"
|
|
role="listbox"
|
|
:aria-label="t('Search results')"
|
|
class="min-h-0 flex-1 overflow-y-auto py-1 lg:max-h-110"
|
|
>
|
|
<div v-if="empty" class="flex flex-col items-center gap-2.5 px-6 py-10 text-center">
|
|
<UIcon name="i-lucide-search-x" class="size-5 text-muted" />
|
|
<span class="max-w-90 text-base/5.5 text-pretty text-toned">{{ emptyText }}</span>
|
|
</div>
|
|
<div v-for="group in groups" :key="group.id" role="group" :aria-labelledby="group.id">
|
|
<div
|
|
:id="group.id"
|
|
class="px-4 pt-2 pb-1 text-xs/5 font-medium tracking-wide text-muted uppercase"
|
|
>
|
|
{{ group.title }}
|
|
</div>
|
|
<ApiSearchRow
|
|
v-for="row in group.rows"
|
|
:id="row.id"
|
|
:key="row.id"
|
|
:hit="row.hit"
|
|
:terms="terms"
|
|
:selected="row.id === activeId"
|
|
:mobile="mobile"
|
|
@choose="choose(row)"
|
|
/>
|
|
</div>
|
|
<div v-if="hiddenEndpoints > 0" class="px-4 py-2 text-muted">{{ moreText }}</div>
|
|
</div>
|
|
|
|
<div
|
|
class="flex h-10 shrink-0 items-center gap-4 border-t border-default bg-muted px-4 text-muted"
|
|
>
|
|
<template v-if="!mobile">
|
|
<span aria-hidden="true" class="inline-flex items-center gap-1.5">
|
|
<UKbd value="↑" :class="KBD_CLASS" />
|
|
<UKbd value="↓" :class="KBD_CLASS" />
|
|
{{ t('Move') }}
|
|
</span>
|
|
<span aria-hidden="true" class="inline-flex items-center gap-1.5">
|
|
<UKbd value="↵" :class="KBD_CLASS" />
|
|
{{ t('Open') }}
|
|
</span>
|
|
<span aria-hidden="true" class="inline-flex items-center gap-1.5">
|
|
<UKbd value="Esc" :class="KBD_CLASS" />
|
|
{{ t('Close') }}
|
|
</span>
|
|
</template>
|
|
<span class="flex-1" />
|
|
<span aria-hidden="true">{{ countText }}</span>
|
|
</div>
|
|
</div>
|
|
</template>
|