feat: integration search bar
This commit is contained in:
1 parent
a8fea8d900
commit
25d9c30b8a
22 files changed
+2558
-41
No files matched your search
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import ApiSearchPanel from '@/components/ApiSearchPanel.vue'
|
||||
import type { SearchEntry, SearchIndex } from '@/lib/apiSearch'
|
||||
|
||||
defineProps<{
|
||||
index: SearchIndex
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ select: [entry: SearchEntry] }>()
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 64rem)'
|
||||
|
||||
const open = defineModel<boolean>('open', { required: true })
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
|
||||
const contentClass = computed(() =>
|
||||
isDesktop.value
|
||||
? 'divide-y-0 rounded-none border border-default shadow-(--shadow-modal) ring-0 sm:top-24 sm:max-w-180 sm:translate-y-0'
|
||||
: 'divide-y-0 rounded-none',
|
||||
)
|
||||
|
||||
function keepFocusWithCaller(event: Event) {
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
function onSelect(entry: SearchEntry) {
|
||||
open.value = false
|
||||
emit('select', entry)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UModal
|
||||
v-model:open="open"
|
||||
:title="t('Search the API reference')"
|
||||
:description="t('Search the API reference…')"
|
||||
:fullscreen="!isDesktop"
|
||||
:close="false"
|
||||
:content="{ onCloseAutoFocus: keepFocusWithCaller }"
|
||||
:ui="{ content: contentClass }"
|
||||
>
|
||||
<template #content>
|
||||
<ApiSearchPanel
|
||||
:index="index"
|
||||
:mobile="!isDesktop"
|
||||
@select="onSelect"
|
||||
@close="open = false"
|
||||
/>
|
||||
</template>
|
||||
</UModal>
|
||||
</template>
|
||||
@@ -0,0 +1,250 @@
|
||||
<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>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import HighlightedText from '@/components/HighlightedText.vue'
|
||||
import type { SearchHit } from '@/lib/apiSearch'
|
||||
import { CHIP_CLASS, METHOD_CLASS, SESSION_METHOD_CLASS } from '@/lib/apiReference'
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
hit: SearchHit
|
||||
terms: string[]
|
||||
selected: boolean
|
||||
mobile: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ choose: [] }>()
|
||||
|
||||
const endpoint = computed(() => (props.hit.entry.kind === 'endpoint' ? props.hit.entry : null))
|
||||
const section = computed(() => (props.hit.entry.kind === 'section' ? props.hit.entry : null))
|
||||
|
||||
const optionLabel = computed(() =>
|
||||
endpoint.value
|
||||
? `${endpoint.value.method} ${endpoint.value.path}. ${endpoint.value.description}. ${endpoint.value.access}`
|
||||
: t('Section {0}. {1}', [section.value!.title, section.value!.description]),
|
||||
)
|
||||
|
||||
const viaLabel = computed(() => {
|
||||
const kind = props.hit.via?.kind
|
||||
if (kind === 'query') {
|
||||
return t('Query parameter')
|
||||
}
|
||||
return kind === 'body' ? t('Body field') : t('Error')
|
||||
})
|
||||
|
||||
const descriptionClass = computed(() => (endpoint.value?.session ? 'text-muted' : 'text-toned'))
|
||||
const pathClass = computed(() =>
|
||||
endpoint.value?.session ? 'font-normal text-toned' : 'font-medium text-highlighted',
|
||||
)
|
||||
const chipClass = computed(() => (endpoint.value?.session ? CHIP_CLASS.dashed : CHIP_CLASS.neutral))
|
||||
const methodClass = computed(() =>
|
||||
endpoint.value?.session ? SESSION_METHOD_CLASS : METHOD_CLASS[endpoint.value!.method],
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:id="id"
|
||||
role="option"
|
||||
:aria-selected="selected"
|
||||
:aria-label="optionLabel"
|
||||
class="grid cursor-pointer items-start gap-3 px-4 hover:bg-elevated"
|
||||
:class="[
|
||||
mobile
|
||||
? 'min-h-14 grid-cols-[3.5rem_minmax(0,1fr)] py-2.5'
|
||||
: 'min-h-10 grid-cols-[3.5rem_minmax(0,1fr)_auto] py-2',
|
||||
selected ? 'search-row-selected bg-elevated' : '',
|
||||
]"
|
||||
@click="emit('choose')"
|
||||
>
|
||||
<UBadge
|
||||
v-if="endpoint"
|
||||
:label="endpoint.method"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:class="methodClass"
|
||||
/>
|
||||
<span v-else class="flex h-5.5 w-14 items-center justify-center text-muted">
|
||||
<UIcon :name="section!.locked ? 'i-lucide-lock' : 'i-lucide-hash'" class="size-4" />
|
||||
</span>
|
||||
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<div v-if="endpoint" class="flex min-h-5.5 min-w-0 items-center gap-3">
|
||||
<span
|
||||
class="min-w-0 truncate font-mono text-sm"
|
||||
:class="[pathClass, mobile ? '' : 'shrink-0']"
|
||||
><HighlightedText :text="endpoint.path" :terms="terms"
|
||||
/></span>
|
||||
<span v-if="!mobile" class="min-w-0 flex-1 truncate" :class="descriptionClass"
|
||||
><HighlightedText :text="endpoint.description" :terms="terms"
|
||||
/></span>
|
||||
</div>
|
||||
<div v-else class="flex min-h-5.5 min-w-0 items-center gap-3">
|
||||
<span class="shrink-0 font-medium text-highlighted"
|
||||
><HighlightedText :text="section!.title" :terms="terms"
|
||||
/></span>
|
||||
<span v-if="!mobile" class="min-w-0 flex-1 truncate text-muted"
|
||||
><HighlightedText :text="section!.description" :terms="terms"
|
||||
/></span>
|
||||
</div>
|
||||
<div v-if="mobile" class="flex min-w-0 items-center gap-2">
|
||||
<UBadge
|
||||
v-if="endpoint"
|
||||
:label="endpoint.access"
|
||||
:icon="endpoint.session ? 'i-lucide-lock' : undefined"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:class="chipClass"
|
||||
:ui="{ leadingIcon: 'size-3' }"
|
||||
/>
|
||||
<span class="min-w-0 flex-1 truncate" :class="descriptionClass"
|
||||
><HighlightedText
|
||||
:text="endpoint ? endpoint.description : section!.description"
|
||||
:terms="terms"
|
||||
/></span>
|
||||
</div>
|
||||
<div v-if="hit.via" class="flex min-w-0 items-baseline gap-2 text-xs/4.5 text-muted">
|
||||
<span class="shrink-0">{{ viaLabel }}</span>
|
||||
<span class="min-w-0 truncate font-mono text-toned"
|
||||
><HighlightedText :text="hit.via.text" :terms="terms"
|
||||
/></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UBadge
|
||||
v-if="endpoint && !mobile"
|
||||
:label="endpoint.access"
|
||||
:icon="endpoint.session ? 'i-lucide-lock' : undefined"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="mt-px"
|
||||
:class="chipClass"
|
||||
:ui="{ leadingIcon: 'size-3' }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.search-row-selected {
|
||||
box-shadow: inset 2px 0 0 var(--ui-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { t, translate } from '@/i18n'
|
||||
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import ApiFieldTable from '@/components/ApiFieldTable.vue'
|
||||
import CodeSnippet from '@/components/CodeSnippet.vue'
|
||||
import { RATE_LIMIT_SENTENCE, endpointExamples, type ApiEndpoint } from '@/data/apiCatalogue'
|
||||
@@ -10,9 +10,19 @@ import { CHIP_CLASS, METHOD_CLASS, endpointAnchor } from '@/lib/apiReference'
|
||||
const props = defineProps<{
|
||||
endpoint: ApiEndpoint
|
||||
origin: string
|
||||
flash?: 'animated' | 'static' | null
|
||||
}>()
|
||||
|
||||
const open = ref(false)
|
||||
const rowButton = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
async function reveal() {
|
||||
open.value = true
|
||||
await nextTick()
|
||||
rowButton.value?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
defineExpose({ reveal })
|
||||
const activeTab = ref('fetch')
|
||||
|
||||
const examples = computed(() => endpointExamples(props.endpoint, props.origin))
|
||||
@@ -42,10 +52,15 @@ const snippetTabs = computed(() => [
|
||||
:ui="{ content: 'border-t border-muted' }"
|
||||
>
|
||||
<button
|
||||
ref="rowButton"
|
||||
type="button"
|
||||
:aria-label="rowLabel"
|
||||
class="flex min-h-11 w-full cursor-pointer flex-col items-stretch gap-1.5 px-3 py-3 text-left focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary lg:grid lg:grid-cols-[300px_minmax(0,1fr)_auto_16px] lg:items-center lg:gap-4 lg:px-4 lg:py-0"
|
||||
:class="open ? 'bg-muted' : ''"
|
||||
:class="[
|
||||
open ? 'bg-muted' : '',
|
||||
flash === 'animated' ? 'endpoint-flash' : '',
|
||||
flash === 'static' ? 'endpoint-flash-static' : '',
|
||||
]"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-2.5">
|
||||
<UBadge
|
||||
@@ -134,3 +149,27 @@ const snippetTabs = computed(() => [
|
||||
</template>
|
||||
</UCollapsible>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.endpoint-flash,
|
||||
.endpoint-flash-static {
|
||||
background: var(--rc-sand);
|
||||
box-shadow: inset 0 0 0 2px var(--ui-primary);
|
||||
}
|
||||
|
||||
.endpoint-flash {
|
||||
animation: endpoint-flash-fade 1.5s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes endpoint-flash-fade {
|
||||
0%,
|
||||
60% {
|
||||
background: var(--rc-sand);
|
||||
box-shadow: inset 0 0 0 2px var(--ui-primary);
|
||||
}
|
||||
100% {
|
||||
background: var(--ui-bg-muted);
|
||||
box-shadow: inset 0 0 0 2px transparent;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { highlightSegments } from '@/lib/apiSearch'
|
||||
|
||||
const props = defineProps<{
|
||||
text: string
|
||||
terms: string[]
|
||||
}>()
|
||||
|
||||
const segments = computed(() => highlightSegments(props.text, props.terms))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<template v-for="(segment, position) in segments" :key="position">
|
||||
<mark v-if="segment.marked" class="bg-(--rc-sand) text-(--rc-on-sand)">{{
|
||||
segment.text
|
||||
}}</mark>
|
||||
<template v-else>{{ segment.text }}</template>
|
||||
</template>
|
||||
</template>
|
||||
@@ -13,7 +13,7 @@ const props = defineProps<{
|
||||
active: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ select: [id: string] }>()
|
||||
const emit = defineEmits<{ select: [id: string]; search: [trigger: HTMLElement | null] }>()
|
||||
|
||||
const activeLabel = computed(
|
||||
() => props.sections.find((section) => section.id === props.active)?.label ?? '',
|
||||
@@ -22,6 +22,10 @@ const activeLabel = computed(
|
||||
function onJump(id: string) {
|
||||
emit('select', id)
|
||||
}
|
||||
|
||||
function onSearch(event: MouseEvent) {
|
||||
emit('search', event.currentTarget as HTMLElement | null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -51,7 +55,7 @@ function onJump(id: string) {
|
||||
</nav>
|
||||
|
||||
<div
|
||||
class="sticky -top-4 z-5 order-first -mx-4 -mt-4 mb-4 border-b border-default bg-default px-4 py-2 lg:hidden"
|
||||
class="sticky -top-4 z-5 order-first -mx-4 -mt-4 mb-4 flex items-center gap-2 border-b border-default bg-default px-4 py-2 lg:hidden"
|
||||
>
|
||||
<USelectMenu
|
||||
:model-value="active"
|
||||
@@ -63,7 +67,7 @@ function onJump(id: string) {
|
||||
:aria-label="t('Jump to section')"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
class="min-w-0 flex-1"
|
||||
:content="{ sideOffset: 4 }"
|
||||
:ui="{
|
||||
base: 'h-11 rounded-none border border-accented bg-default px-3 text-base ring-0 hover:bg-default',
|
||||
@@ -80,5 +84,15 @@ function onJump(id: string) {
|
||||
}}</span>
|
||||
</span>
|
||||
</USelectMenu>
|
||||
<UButton
|
||||
icon="i-lucide-search"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
:aria-label="t('Search the API reference')"
|
||||
aria-haspopup="dialog"
|
||||
class="size-11 shrink-0 justify-center rounded-none border border-accented bg-default text-highlighted ring-0 hover:bg-default"
|
||||
:ui="{ leadingIcon: 'size-4.5' }"
|
||||
@click="onSearch"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2718,6 +2718,29 @@ export const messages = {
|
||||
'How are they connected?': 'Comment sont-ils liés ?',
|
||||
'Each {0} has': 'Chaque {0} a',
|
||||
'shown on {0} as the field "{1}"': 'affiché sur {0} comme le champ « {1} »',
|
||||
'Search the API reference': 'Rechercher dans la référence API',
|
||||
'Search the API reference…': 'Rechercher dans la référence API…',
|
||||
'Search results': 'Résultats de la recherche',
|
||||
Endpoints: 'Routes',
|
||||
Sections: 'Sections',
|
||||
Move: 'Naviguer',
|
||||
'{0} result': '{0} résultat',
|
||||
'{0} results, showing {1}': '{0} résultats, {1} affichés',
|
||||
'{0} more endpoint. Keep typing to narrow the list.':
|
||||
'{0} route de plus. Continuez à écrire pour affiner la liste.',
|
||||
'{0} more endpoints. Keep typing to narrow the list.':
|
||||
'{0} routes de plus. Continuez à écrire pour affiner la liste.',
|
||||
'No match for "{0}". Try a path, a method or a status code like 404.':
|
||||
'Aucun résultat pour « {0} ». Essayez un chemin, une méthode ou un code d’état comme 404.',
|
||||
'Body field': 'Champ du corps',
|
||||
'Query parameter': 'Paramètre d’URL',
|
||||
Error: 'Erreur',
|
||||
'Section {0}. {1}': 'Section {0}. {1}',
|
||||
'Public access and API keys': 'Accès public et clés API',
|
||||
'The ?v= parameter': 'Le paramètre ?v=',
|
||||
'Status codes and messages': 'Codes d’état et messages',
|
||||
'Images, CORS and ?format=': 'Images, CORS et ?format=',
|
||||
'{0} routes, not available to API keys': '{0} routes, indisponibles avec une clé API',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -0,0 +1,225 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildSearchIndex,
|
||||
firstSentence,
|
||||
highlightSegments,
|
||||
queryTerms,
|
||||
searchIndex,
|
||||
suggestedHits,
|
||||
type EndpointEntry,
|
||||
type SearchHit,
|
||||
} from '../apiSearch'
|
||||
|
||||
const index = buildSearchIndex()
|
||||
|
||||
function routes(hits: SearchHit[]) {
|
||||
return hits.map((hit) =>
|
||||
hit.entry.kind === 'endpoint'
|
||||
? `${hit.entry.method} ${hit.entry.path}`
|
||||
: `section ${hit.entry.title}`,
|
||||
)
|
||||
}
|
||||
|
||||
function search(query: string) {
|
||||
return searchIndex(index, query)
|
||||
}
|
||||
|
||||
describe('firstSentence', () => {
|
||||
it.each([
|
||||
['Get one article. Drafts answer 404 to anonymous callers.', 'Get one article.'],
|
||||
['Rename an image', 'Rename an image'],
|
||||
['Upload an image (multipart).', 'Upload an image (multipart).'],
|
||||
])('cuts %s', (text, expected) => {
|
||||
expect(firstSentence(text)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('queryTerms', () => {
|
||||
it('splits on spaces, trims and lowercases', () => {
|
||||
expect(queryTerms(' POST Media ')).toEqual(['post', 'media'])
|
||||
expect(queryTerms(' ')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('index', () => {
|
||||
it('covers documented, session only and public auth routes', () => {
|
||||
const keys = index.endpoints.map((entry) => entry.key)
|
||||
expect(keys).toContain('ep:med-merge')
|
||||
expect(keys).toContain('session:POST /v1/auth/login')
|
||||
expect(keys).toContain('public:GET /healthz')
|
||||
expect(index.sections.map((section) => section.sectionId)).toEqual([
|
||||
'auth',
|
||||
'articles',
|
||||
'seo',
|
||||
'media',
|
||||
'users',
|
||||
'settings',
|
||||
'sites',
|
||||
'authors',
|
||||
'images',
|
||||
'session',
|
||||
'errors',
|
||||
'notes',
|
||||
])
|
||||
})
|
||||
|
||||
it('never indexes code samples or response examples', () => {
|
||||
expect(search('rcms_kf3x').endpoints.map((hit) => hit.entry.key)).toEqual([])
|
||||
expect(search('"total"').endpoints).toEqual([])
|
||||
expect(search('curl').endpoints).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('matching', () => {
|
||||
it('matches terms case-insensitively as substrings', () => {
|
||||
expect(routes(search('MEDIA').endpoints)).toContain('POST /v1/media/merge')
|
||||
expect(routes(search('edia').endpoints)).toContain('POST /v1/media/merge')
|
||||
})
|
||||
|
||||
it('does not match a misspelled term', () => {
|
||||
const result = search('mdia')
|
||||
expect(result.endpoints).toEqual([])
|
||||
expect(result.sections).toEqual([])
|
||||
})
|
||||
|
||||
it('requires every term in the same endpoint', () => {
|
||||
const result = routes(search('post media').endpoints)
|
||||
expect(result).toContain('POST /v1/media/')
|
||||
expect(result.every((route) => !route.startsWith('GET'))).toBe(true)
|
||||
expect(routes(search('patch user').endpoints)).toContain('PATCH /v1/user/me')
|
||||
})
|
||||
|
||||
it('ignores extra spaces', () => {
|
||||
expect(routes(search(' post media ').endpoints)).toEqual(
|
||||
routes(search('post media').endpoints),
|
||||
)
|
||||
})
|
||||
|
||||
it('returns nothing for an empty query', () => {
|
||||
expect(search(' ')).toEqual({ endpoints: [], sections: [] })
|
||||
})
|
||||
|
||||
it('matches the access chip', () => {
|
||||
const owners = search('OWNER ONLY').endpoints.map((hit) => hit.entry as EndpointEntry)
|
||||
expect(owners.map((entry) => entry.path)).toEqual(
|
||||
expect.arrayContaining(['/v1/user/invite', '/v1/user/invite/{id}']),
|
||||
)
|
||||
expect(
|
||||
owners.every((entry) => entry.access === 'Owner only' || entry.path.length > 0),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('matches a body field and reports it as the second line', () => {
|
||||
const hits = search('keep_id').endpoints
|
||||
expect(routes(hits)).toEqual(['POST /v1/media/merge'])
|
||||
expect(hits[0]!.via).toEqual({ kind: 'body', text: 'keep_id' })
|
||||
expect(search('keep_id').sections).toEqual([])
|
||||
})
|
||||
|
||||
it('matches a query parameter and labels it', () => {
|
||||
const hit = search('hash').endpoints.find(
|
||||
(item) => (item.entry as EndpointEntry).path === '/v1/media/',
|
||||
)
|
||||
expect(hit!.via).toEqual({ kind: 'query', text: 'hash' })
|
||||
})
|
||||
|
||||
it('matches an error code or message and reports the first error', () => {
|
||||
const hits = search('404').endpoints
|
||||
expect(hits.length).toBeGreaterThan(6)
|
||||
const withError = hits.find((hit) => hit.via?.kind === 'error')!
|
||||
expect(withError.via!.text.startsWith('404 ')).toBe(true)
|
||||
expect(routes(search('404').sections)).toEqual(['section Errors'])
|
||||
})
|
||||
|
||||
it('shows no second line for a path, description, method or chip match', () => {
|
||||
for (const query of ['merge', 'identical copies', 'patch', 'verified user']) {
|
||||
expect(search(query).endpoints.some((hit) => hit.via === null)).toBe(true)
|
||||
}
|
||||
expect(search('merge').endpoints[0]!.via).toBeNull()
|
||||
})
|
||||
|
||||
it('lists path and description matches first, then catalogue order', () => {
|
||||
const hits = search('token').endpoints
|
||||
const firstIndirect = hits.findIndex((hit) => hit.via !== null)
|
||||
if (firstIndirect !== -1) {
|
||||
expect(hits.slice(0, firstIndirect).every((hit) => hit.via === null)).toBe(true)
|
||||
expect(hits.slice(firstIndirect).every((hit) => hit.via !== null)).toBe(true)
|
||||
}
|
||||
const keys = index.endpoints.map((entry) => entry.key)
|
||||
const media = search('media').endpoints.filter((hit) => hit.via === null)
|
||||
const positions = media.map((hit) => keys.indexOf(hit.entry.key))
|
||||
expect(positions).toEqual([...positions].sort((a, b) => a - b))
|
||||
})
|
||||
|
||||
it('puts a field-only match after the path matches', () => {
|
||||
const hits = search('keep_id media').endpoints
|
||||
expect(routes(hits)).toEqual(['POST /v1/media/merge'])
|
||||
const mixed = search('title').endpoints
|
||||
const firstIndirect = mixed.findIndex((hit) => hit.via !== null)
|
||||
expect(firstIndirect === -1 || mixed.slice(firstIndirect).every((hit) => hit.via)).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('finds sections by title, description and keywords', () => {
|
||||
expect(routes(search('media').sections)).toEqual(['section Media'])
|
||||
expect(search('media').sections[0]!.entry).toMatchObject({ description: '11 routes' })
|
||||
const login = search('login')
|
||||
expect(routes(login.endpoints)).toEqual(['POST /v1/auth/login'])
|
||||
expect(routes(login.sections)).toEqual(['section Session only'])
|
||||
expect(login.sections[0]!.entry).toMatchObject({
|
||||
locked: true,
|
||||
description: '25 routes, not available to API keys',
|
||||
})
|
||||
})
|
||||
|
||||
it('flags session-only routes and sends them to the Session only section', () => {
|
||||
const entry = search('login').endpoints[0]!.entry as EndpointEntry
|
||||
expect(entry).toMatchObject({
|
||||
session: true,
|
||||
access: 'Session only',
|
||||
description: 'Log in',
|
||||
cardId: null,
|
||||
sectionId: 'session',
|
||||
})
|
||||
})
|
||||
|
||||
it('finds nothing for xyz', () => {
|
||||
expect(search('xyz')).toEqual({ endpoints: [], sections: [] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestions', () => {
|
||||
it('lists the five suggested rows in order', () => {
|
||||
expect(routes(suggestedHits(index))).toEqual([
|
||||
'section Authentication',
|
||||
'POST /v1/media/',
|
||||
'section Errors',
|
||||
'GET /v1/article/',
|
||||
'PATCH /v1/user/me',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('highlightSegments', () => {
|
||||
it('marks every occurrence of every term', () => {
|
||||
expect(highlightSegments('POST /v1/media/media', ['media'])).toEqual([
|
||||
{ text: 'POST /v1/', marked: false },
|
||||
{ text: 'media', marked: true },
|
||||
{ text: '/', marked: false },
|
||||
{ text: 'media', marked: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('is case-insensitive and escapes regex characters', () => {
|
||||
expect(highlightSegments('Get {id}', ['get', '{id}'])).toEqual([
|
||||
{ text: 'Get', marked: true },
|
||||
{ text: ' ', marked: false },
|
||||
{ text: '{id}', marked: true },
|
||||
])
|
||||
})
|
||||
|
||||
it('returns the text unchanged without terms', () => {
|
||||
expect(highlightSegments('Hello', [])).toEqual([{ text: 'Hello', marked: false }])
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,8 @@ export const CHIP_CLASS = {
|
||||
const METHOD_BASE =
|
||||
'h-5.5 w-14 shrink-0 justify-center rounded-none px-0 py-0 font-mono text-xs font-medium ring-0 border'
|
||||
|
||||
export const SESSION_METHOD_CLASS = `${METHOD_BASE} border-dashed border-accented bg-transparent text-muted`
|
||||
|
||||
export const METHOD_CLASS: Record<HttpMethod, string> = {
|
||||
GET: `${METHOD_BASE} border-inverted bg-inverted text-inverted`,
|
||||
POST: `${METHOD_BASE} border-success bg-(--rc-success-soft) text-success`,
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { t, translate } from '@/i18n'
|
||||
import {
|
||||
API_GROUPS,
|
||||
ERROR_ROWS,
|
||||
LOGIN_FAMILY_CAPTION,
|
||||
PUBLIC_AUTH_ROWS,
|
||||
SESSION_GROUPS,
|
||||
type HttpMethod,
|
||||
} from '@/data/apiCatalogue'
|
||||
|
||||
export const ENDPOINT_CAP = 6
|
||||
|
||||
export type MatchKind = 'query' | 'body' | 'error'
|
||||
|
||||
export interface SearchMatch {
|
||||
kind: MatchKind
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface EndpointEntry {
|
||||
kind: 'endpoint'
|
||||
key: string
|
||||
method: HttpMethod
|
||||
path: string
|
||||
description: string
|
||||
access: string
|
||||
session: boolean
|
||||
cardId: string | null
|
||||
sectionId: string
|
||||
fields: SearchMatch[]
|
||||
errors: SearchMatch[]
|
||||
}
|
||||
|
||||
export interface SectionEntry {
|
||||
kind: 'section'
|
||||
key: string
|
||||
sectionId: string
|
||||
title: string
|
||||
description: string
|
||||
locked: boolean
|
||||
keywords: string
|
||||
}
|
||||
|
||||
export type SearchEntry = EndpointEntry | SectionEntry
|
||||
|
||||
export interface SearchHit {
|
||||
entry: SearchEntry
|
||||
via: SearchMatch | null
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
endpoints: SearchHit[]
|
||||
sections: SearchHit[]
|
||||
}
|
||||
|
||||
export interface HighlightSegment {
|
||||
text: string
|
||||
marked: boolean
|
||||
}
|
||||
|
||||
export interface SearchIndex {
|
||||
endpoints: EndpointEntry[]
|
||||
sections: SectionEntry[]
|
||||
}
|
||||
|
||||
const SUGGESTED_KEYS = ['section:auth', 'ep:med-up', 'section:errors', 'ep:art-list', 'ep:usr-me']
|
||||
|
||||
export function firstSentence(text: string) {
|
||||
const end = text.search(/\.(\s|$)/)
|
||||
return end === -1 ? text : text.slice(0, end + 1)
|
||||
}
|
||||
|
||||
export function queryTerms(query: string) {
|
||||
return query.toLowerCase().split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
function endpointEntries(): EndpointEntry[] {
|
||||
const documented = API_GROUPS.flatMap((group) =>
|
||||
group.endpoints.map<EndpointEntry>((endpoint) => ({
|
||||
kind: 'endpoint',
|
||||
key: `ep:${endpoint.id}`,
|
||||
method: endpoint.method,
|
||||
path: endpoint.path,
|
||||
description: firstSentence(translate(endpoint.description)),
|
||||
access: translate(endpoint.access),
|
||||
session: false,
|
||||
cardId: endpoint.id,
|
||||
sectionId: group.key,
|
||||
fields: [
|
||||
...endpoint.query.map<SearchMatch>((field) => ({
|
||||
kind: 'query',
|
||||
text: field.name,
|
||||
})),
|
||||
...endpoint.body.map<SearchMatch>((field) => ({ kind: 'body', text: field.name })),
|
||||
],
|
||||
errors: endpoint.errors.map<SearchMatch>((error) => ({
|
||||
kind: 'error',
|
||||
text: `${error.status} ${translate(error.caption)}`,
|
||||
})),
|
||||
})),
|
||||
)
|
||||
const sessionOnly = SESSION_GROUPS.flatMap((group) =>
|
||||
group.rows.map<EndpointEntry>((row) => ({
|
||||
kind: 'endpoint',
|
||||
key: `session:${row.method} ${row.path}`,
|
||||
method: row.method,
|
||||
path: row.path,
|
||||
description: firstSentence(
|
||||
translate(row.description.split(`. ${LOGIN_FAMILY_CAPTION}`)[0]!),
|
||||
),
|
||||
access: t('Session only'),
|
||||
session: true,
|
||||
cardId: null,
|
||||
sectionId: 'session',
|
||||
fields: [],
|
||||
errors: [],
|
||||
})),
|
||||
)
|
||||
const publicAuth = PUBLIC_AUTH_ROWS.map<EndpointEntry>((row) => ({
|
||||
kind: 'endpoint',
|
||||
key: `public:${row.method} ${row.path}`,
|
||||
method: row.method,
|
||||
path: row.path,
|
||||
description: firstSentence(translate(row.description)),
|
||||
access: t('Public'),
|
||||
session: false,
|
||||
cardId: null,
|
||||
sectionId: 'session',
|
||||
fields: [],
|
||||
errors: [],
|
||||
}))
|
||||
return [...documented, ...sessionOnly, ...publicAuth]
|
||||
}
|
||||
|
||||
function routeCount(count: number) {
|
||||
return count === 1 ? t('{0} route', [count]) : t('{0} routes', [count])
|
||||
}
|
||||
|
||||
function sectionEntries(): SectionEntry[] {
|
||||
const sessionRoutes = SESSION_GROUPS.reduce((total, group) => total + group.rows.length, 0)
|
||||
const errorWords = ERROR_ROWS.map((row) => `${row.status} ${translate(row.meaning)}`).join(' ')
|
||||
const section = (
|
||||
id: string,
|
||||
title: string,
|
||||
description: string,
|
||||
keywords = '',
|
||||
locked = false,
|
||||
): SectionEntry => ({
|
||||
kind: 'section',
|
||||
key: `section:${id}`,
|
||||
sectionId: id,
|
||||
title,
|
||||
description,
|
||||
locked,
|
||||
keywords,
|
||||
})
|
||||
return [
|
||||
section(
|
||||
'auth',
|
||||
t('Authentication'),
|
||||
t('Public access and API keys'),
|
||||
'bearer api key rcms rate limit 429 base url public',
|
||||
),
|
||||
...API_GROUPS.map((group) =>
|
||||
section(group.key, translate(group.title), routeCount(group.endpoints.length)),
|
||||
),
|
||||
section(
|
||||
'images',
|
||||
t('Images and variants'),
|
||||
t('The ?v= parameter'),
|
||||
'variant image width height format quality',
|
||||
),
|
||||
section(
|
||||
'session',
|
||||
t('Session only'),
|
||||
t('{0} routes, not available to API keys', [sessionRoutes]),
|
||||
'login log in register password real session',
|
||||
true,
|
||||
),
|
||||
section(
|
||||
'errors',
|
||||
t('Errors'),
|
||||
t('Status codes and messages'),
|
||||
`${errorWords} status error plain text`,
|
||||
),
|
||||
section(
|
||||
'notes',
|
||||
t('Notes'),
|
||||
t('Images, CORS and ?format='),
|
||||
'cors origins format markdown html json body_format',
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
export function buildSearchIndex(): SearchIndex {
|
||||
return { endpoints: endpointEntries(), sections: sectionEntries() }
|
||||
}
|
||||
|
||||
function primaryText(entry: EndpointEntry) {
|
||||
return `${entry.method} ${entry.path} ${entry.description} ${entry.access}`.toLowerCase()
|
||||
}
|
||||
|
||||
function viaMatch(entry: EndpointEntry, term: string): SearchMatch | null | undefined {
|
||||
if (primaryText(entry).includes(term)) {
|
||||
return null
|
||||
}
|
||||
const field = entry.fields.find((item) => item.text.toLowerCase().includes(term))
|
||||
if (field) {
|
||||
return field
|
||||
}
|
||||
return entry.errors.find((item) => item.text.toLowerCase().includes(term))
|
||||
}
|
||||
|
||||
function matchEndpoint(entry: EndpointEntry, terms: string[]): SearchHit | null {
|
||||
let via: SearchMatch | null = null
|
||||
for (const term of terms) {
|
||||
const found = viaMatch(entry, term)
|
||||
if (found === undefined) {
|
||||
return null
|
||||
}
|
||||
via = via ?? found
|
||||
}
|
||||
return { entry, via }
|
||||
}
|
||||
|
||||
function matchSection(entry: SectionEntry, terms: string[]): SearchHit | null {
|
||||
const text = `${entry.title} ${entry.description} ${entry.keywords}`.toLowerCase()
|
||||
return terms.every((term) => text.includes(term)) ? { entry, via: null } : null
|
||||
}
|
||||
|
||||
export function suggestedHits(index: SearchIndex): SearchHit[] {
|
||||
const entries: SearchEntry[] = [...index.endpoints, ...index.sections]
|
||||
return SUGGESTED_KEYS.flatMap((key) => {
|
||||
const entry = entries.find((item) => item.key === key)
|
||||
return entry ? [{ entry, via: null }] : []
|
||||
})
|
||||
}
|
||||
|
||||
export function searchIndex(index: SearchIndex, query: string): SearchResult {
|
||||
const terms = queryTerms(query)
|
||||
if (terms.length === 0) {
|
||||
return { endpoints: [], sections: [] }
|
||||
}
|
||||
const hits = index.endpoints.flatMap((entry) => matchEndpoint(entry, terms) ?? [])
|
||||
const direct = hits.filter((hit) => hit.via === null)
|
||||
const indirect = hits.filter((hit) => hit.via !== null)
|
||||
return {
|
||||
endpoints: [...direct, ...indirect],
|
||||
sections: index.sections.flatMap((entry) => matchSection(entry, terms) ?? []),
|
||||
}
|
||||
}
|
||||
|
||||
function escapePattern(term: string) {
|
||||
return term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
}
|
||||
|
||||
export function highlightSegments(text: string, terms: string[]): HighlightSegment[] {
|
||||
if (terms.length === 0) {
|
||||
return [{ text, marked: false }]
|
||||
}
|
||||
const pattern = new RegExp(`(${terms.map(escapePattern).join('|')})`, 'gi')
|
||||
const lowered = new Set(terms)
|
||||
return text
|
||||
.split(pattern)
|
||||
.filter((part) => part !== '')
|
||||
.map((part) => ({ text: part, marked: lowered.has(part.toLowerCase()) }))
|
||||
}
|
||||
@@ -1,8 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { t, translate } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import {
|
||||
computed,
|
||||
nextTick,
|
||||
onBeforeUnmount,
|
||||
onMounted,
|
||||
ref,
|
||||
watch,
|
||||
type ComponentPublicInstance,
|
||||
} from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import ApiSearchPalette from '@/components/ApiSearchPalette.vue'
|
||||
import CodeSnippet from '@/components/CodeSnippet.vue'
|
||||
import CodeText from '@/components/CodeText.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
@@ -18,7 +27,8 @@ import {
|
||||
SESSION_GROUPS,
|
||||
authExamples,
|
||||
} from '@/data/apiCatalogue'
|
||||
import { SECTION_TITLE } from '@/lib/apiReference'
|
||||
import { SECTION_TITLE, endpointAnchor } from '@/lib/apiReference'
|
||||
import { buildSearchIndex, type SearchEntry } from '@/lib/apiSearch'
|
||||
|
||||
const SPY_OFFSET_PX = 120
|
||||
|
||||
@@ -124,9 +134,7 @@ function sectionElement(id: string) {
|
||||
return root.value?.querySelector<HTMLElement>(`[data-anchor="${id}"]`) ?? null
|
||||
}
|
||||
|
||||
function goTo(id: string) {
|
||||
activeSection.value = id
|
||||
const element = sectionElement(id)
|
||||
function scrollToElement(element: HTMLElement | null) {
|
||||
const parent = element ? scrollParent(element) : null
|
||||
if (!element || !parent) {
|
||||
element?.scrollIntoView?.({ block: 'start' })
|
||||
@@ -137,6 +145,11 @@ function goTo(id: string) {
|
||||
parent.scrollTo({ top: parent.scrollTop + offset - margin })
|
||||
}
|
||||
|
||||
function goTo(id: string) {
|
||||
activeSection.value = id
|
||||
scrollToElement(sectionElement(id))
|
||||
}
|
||||
|
||||
function updateActive() {
|
||||
const element = root.value
|
||||
const parent = element ? scrollParent(element) : null
|
||||
@@ -156,18 +169,150 @@ function updateActive() {
|
||||
activeSection.value = current
|
||||
}
|
||||
|
||||
interface RevealableCard {
|
||||
reveal: () => Promise<void>
|
||||
}
|
||||
|
||||
const FLASH_MS = 1500
|
||||
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'
|
||||
|
||||
const searchOpen = ref(false)
|
||||
const searchData = computed(() => buildSearchIndex())
|
||||
const flashedCard = ref<string | null>(null)
|
||||
const flashMode = ref<'animated' | 'static'>('animated')
|
||||
const cards = new Map<string, RevealableCard>()
|
||||
let searchOpener: HTMLElement | null = null
|
||||
let skipFocusRestore = false
|
||||
let flashTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function setCard(id: string, card: Element | ComponentPublicInstance | null) {
|
||||
if (card) {
|
||||
cards.set(id, card as unknown as RevealableCard)
|
||||
} else {
|
||||
cards.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
function openSearch(trigger?: HTMLElement | null) {
|
||||
const active = document.activeElement
|
||||
searchOpener = trigger ?? (active instanceof HTMLElement ? active : null)
|
||||
searchOpen.value = true
|
||||
}
|
||||
|
||||
watch(searchOpen, async (value) => {
|
||||
if (value) {
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
if (skipFocusRestore) {
|
||||
skipFocusRestore = false
|
||||
return
|
||||
}
|
||||
searchOpener?.focus()
|
||||
})
|
||||
|
||||
function isEditable(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
const field = ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
|
||||
return (
|
||||
field ||
|
||||
target.isContentEditable === true ||
|
||||
target.closest('[contenteditable=""], [contenteditable="true"]') !== null
|
||||
)
|
||||
}
|
||||
|
||||
function onShortcut(event: KeyboardEvent) {
|
||||
if (searchOpen.value || event.defaultPrevented || event.isComposing) {
|
||||
return
|
||||
}
|
||||
const withoutAlt = !event.altKey
|
||||
const chord =
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
withoutAlt &&
|
||||
!event.shiftKey &&
|
||||
event.key.toLowerCase() === 'k'
|
||||
const slash =
|
||||
event.key === '/' &&
|
||||
withoutAlt &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!isEditable(event.target)
|
||||
if (chord || slash) {
|
||||
event.preventDefault()
|
||||
openSearch()
|
||||
}
|
||||
}
|
||||
|
||||
function writeHash(anchor: string) {
|
||||
const { pathname, search } = window.location
|
||||
window.history.replaceState(window.history.state, '', `${pathname}${search}#${anchor}`)
|
||||
}
|
||||
|
||||
function startFlash(id: string) {
|
||||
clearTimeout(flashTimer)
|
||||
flashMode.value = window.matchMedia?.(REDUCED_MOTION_QUERY).matches ? 'static' : 'animated'
|
||||
flashedCard.value = id
|
||||
flashTimer = setTimeout(() => {
|
||||
flashedCard.value = null
|
||||
}, FLASH_MS)
|
||||
}
|
||||
|
||||
function revealCard(id: string, sectionId: string) {
|
||||
activeSection.value = sectionId
|
||||
void cards.get(id)?.reveal()
|
||||
scrollToElement(sectionElement(endpointAnchor(id)))
|
||||
writeHash(endpointAnchor(id))
|
||||
startFlash(id)
|
||||
}
|
||||
|
||||
function focusSection(id: string) {
|
||||
goTo(id)
|
||||
writeHash(id)
|
||||
const heading = sectionElement(id)?.querySelector<HTMLElement>('h2')
|
||||
heading?.setAttribute('tabindex', '-1')
|
||||
heading?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
async function onSearchSelect(entry: SearchEntry) {
|
||||
skipFocusRestore = true
|
||||
await nextTick()
|
||||
if (entry.kind === 'endpoint' && entry.cardId) {
|
||||
revealCard(entry.cardId, entry.sectionId)
|
||||
return
|
||||
}
|
||||
focusSection(entry.sectionId)
|
||||
}
|
||||
|
||||
function revealSharedCard(anchor: string) {
|
||||
const group = API_GROUPS.find((item) =>
|
||||
item.endpoints.some((endpoint) => endpointAnchor(endpoint.id) === anchor),
|
||||
)
|
||||
if (group) {
|
||||
revealCard(anchor.slice('ep-'.length), group.key)
|
||||
}
|
||||
}
|
||||
|
||||
let scroller: HTMLElement | null = null
|
||||
|
||||
onMounted(() => {
|
||||
scroller = root.value ? scrollParent(root.value) : null
|
||||
scroller?.addEventListener('scroll', updateActive, { passive: true })
|
||||
document.addEventListener('keydown', onShortcut)
|
||||
const target = route.hash.slice(1)
|
||||
if (sections.value.some((section) => section.id === target)) {
|
||||
nextTick(() => goTo(target))
|
||||
} else if (target.startsWith('ep-')) {
|
||||
nextTick(() => revealSharedCard(target))
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => scroller?.removeEventListener('scroll', updateActive))
|
||||
onBeforeUnmount(() => {
|
||||
scroller?.removeEventListener('scroll', updateActive)
|
||||
document.removeEventListener('keydown', onShortcut)
|
||||
clearTimeout(flashTimer)
|
||||
})
|
||||
|
||||
const SECTION_CLASS = 'flex scroll-mt-16 flex-col gap-2.5 lg:scroll-mt-8'
|
||||
const NOTE_ICON = 'mt-0.75 size-4 shrink-0 text-info'
|
||||
@@ -179,28 +324,48 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
|
||||
<DashboardShell compact-padding :mobile-title="t('Integration')">
|
||||
<div ref="root" class="flex flex-col lg:flex-row lg:items-start lg:gap-10">
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-7 lg:gap-8">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<UBreadcrumb
|
||||
:items="[
|
||||
{
|
||||
get label() {
|
||||
return t('Integration')
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex min-w-0 flex-col gap-1.5">
|
||||
<UBreadcrumb
|
||||
:items="[
|
||||
{
|
||||
get label() {
|
||||
return t('Integration')
|
||||
},
|
||||
},
|
||||
},
|
||||
]"
|
||||
]"
|
||||
color="neutral"
|
||||
:ui="{
|
||||
link: 'text-sm font-normal text-muted',
|
||||
linkLabel: 'text-muted',
|
||||
list: 'gap-1',
|
||||
}"
|
||||
/>
|
||||
<h1 class="text-xl font-semibold text-highlighted">
|
||||
{{ t('API reference') }}
|
||||
</h1>
|
||||
<p class="text-base text-muted">
|
||||
{{ t('Everything a developer needs to build on RenewCMS.') }}
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
color="neutral"
|
||||
:ui="{
|
||||
link: 'text-sm font-normal text-muted',
|
||||
linkLabel: 'text-muted',
|
||||
list: 'gap-1',
|
||||
}"
|
||||
/>
|
||||
<h1 class="text-xl font-semibold text-highlighted">
|
||||
{{ t('API reference') }}
|
||||
</h1>
|
||||
<p class="text-base text-muted">
|
||||
{{ t('Everything a developer needs to build on RenewCMS.') }}
|
||||
</p>
|
||||
variant="outline"
|
||||
:aria-label="t('Search the API reference')"
|
||||
aria-keyshortcuts="/ Control+K Meta+K"
|
||||
aria-haspopup="dialog"
|
||||
class="hidden h-9 w-80 shrink-0 justify-start gap-2 rounded-none border border-accented bg-default py-0 pr-2 pl-3 text-base font-normal text-muted ring-0 hover:border-muted hover:bg-default focus-visible:outline-primary lg:inline-flex"
|
||||
@click="openSearch($event.currentTarget as HTMLElement)"
|
||||
>
|
||||
<UIcon name="i-lucide-search" class="size-4 shrink-0 text-muted" />
|
||||
<span class="min-w-0 flex-1 truncate text-left">{{
|
||||
t('Search the API reference…')
|
||||
}}</span>
|
||||
<UKbd
|
||||
value="/"
|
||||
class="h-5 min-w-5 justify-center rounded-none border border-accented bg-elevated px-1.5 font-mono normal-case text-xs font-medium text-toned ring-0"
|
||||
/>
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<p class="max-w-180 text-base text-pretty text-toned">
|
||||
@@ -311,8 +476,10 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
|
||||
<EndpointCard
|
||||
v-for="endpoint in group.endpoints"
|
||||
:key="endpoint.id"
|
||||
:ref="(card) => setCard(endpoint.id, card)"
|
||||
:endpoint="endpoint"
|
||||
:origin="origin"
|
||||
:flash="flashedCard === endpoint.id ? flashMode : null"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
@@ -407,7 +574,17 @@ const CARD_TITLE = 'text-md/6 font-semibold text-highlighted'
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<SectionIndex :sections="sections" :active="activeSection" @select="goTo" />
|
||||
<SectionIndex
|
||||
:sections="sections"
|
||||
:active="activeSection"
|
||||
@select="goTo"
|
||||
@search="openSearch"
|
||||
/>
|
||||
<ApiSearchPalette
|
||||
v-model:open="searchOpen"
|
||||
:index="searchData"
|
||||
@select="onSearchSelect"
|
||||
/>
|
||||
</div>
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -0,0 +1,696 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import EndpointCard from '@/components/EndpointCard.vue'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
|
||||
import IntegrationView from '../IntegrationView.vue'
|
||||
|
||||
const SEARCH_LABEL = 'Search the API reference'
|
||||
const FLASH_MS = 1500
|
||||
|
||||
let currentWrapper: VueWrapper | null = null
|
||||
let fetchSpy: ReturnType<typeof vi.fn>
|
||||
let scrollIntoView: ReturnType<typeof vi.fn>
|
||||
|
||||
function stubReducedMotion(reduced: boolean) {
|
||||
const base = window.matchMedia
|
||||
window.matchMedia = ((query: string) => {
|
||||
const media = base(query)
|
||||
return query.includes('prefers-reduced-motion') ? { ...media, matches: reduced } : media
|
||||
}) as typeof window.matchMedia
|
||||
}
|
||||
|
||||
async function mountIntegration(width = DESKTOP_WIDTH, hash = '') {
|
||||
setViewportWidth(width)
|
||||
stubReducedMotion(false)
|
||||
fetchSpy = vi.fn()
|
||||
vi.stubGlobal('fetch', fetchSpy)
|
||||
scrollIntoView = vi.fn()
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: scrollIntoView,
|
||||
})
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.status = 'ready'
|
||||
await router.push(`/integration${hash}`)
|
||||
const wrapper = mount(IntegrationView, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
currentWrapper = wrapper
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function press(target: EventTarget, key: string, init: KeyboardEventInit = {}) {
|
||||
const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true, ...init })
|
||||
target.dispatchEvent(event)
|
||||
return event
|
||||
}
|
||||
|
||||
function searchInput() {
|
||||
return document.body.querySelector<HTMLInputElement>('input[role="combobox"]')
|
||||
}
|
||||
|
||||
function paletteOpen() {
|
||||
return searchInput() !== null
|
||||
}
|
||||
|
||||
async function openWithSlash() {
|
||||
press(document.body, '/')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function type(text: string) {
|
||||
const input = searchInput()!
|
||||
input.value = text
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
async function pressInPalette(key: string) {
|
||||
press(searchInput()!, key)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function options() {
|
||||
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="option"]'))
|
||||
}
|
||||
|
||||
function optionRoutes() {
|
||||
return options().map((option) => option.getAttribute('aria-label')!.split('. ')[0])
|
||||
}
|
||||
|
||||
function selectedIndex() {
|
||||
return options().findIndex((option) => option.getAttribute('aria-selected') === 'true')
|
||||
}
|
||||
|
||||
function liveRegion() {
|
||||
return document.body.querySelector('[role="status"]')!.textContent
|
||||
}
|
||||
|
||||
function groupTitles() {
|
||||
return Array.from(document.body.querySelectorAll('[role="group"]')).map((group) => {
|
||||
const id = group.getAttribute('aria-labelledby')!
|
||||
return document.getElementById(id)!.textContent!.trim()
|
||||
})
|
||||
}
|
||||
|
||||
function cardFor(wrapper: VueWrapper, id: string) {
|
||||
return wrapper.findAllComponents(EndpointCard).find((item) => item.props('endpoint').id === id)!
|
||||
}
|
||||
|
||||
function openCards(wrapper: VueWrapper) {
|
||||
return wrapper
|
||||
.findAll('[data-anchor^="ep-"] button[aria-expanded="true"]')
|
||||
.map((button) => button.attributes('aria-label'))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
currentWrapper?.unmount()
|
||||
currentWrapper = null
|
||||
vi.unstubAllGlobals()
|
||||
vi.useRealTimers()
|
||||
document.body.innerHTML = ''
|
||||
window.history.replaceState(null, '', '/')
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
async function settle() {
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('Desktop trigger', () => {
|
||||
it('shows the field-style button with placeholder, key hint and shortcuts', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const trigger = wrapper.get(`button[aria-label="${SEARCH_LABEL}"]`)
|
||||
expect(trigger.text()).toContain('Search the API reference…')
|
||||
expect(trigger.text()).toContain('/')
|
||||
expect(trigger.attributes('aria-keyshortcuts')).toBe('/ Control+K Meta+K')
|
||||
expect(trigger.classes()).toContain('lg:inline-flex')
|
||||
expect(trigger.classes()).toContain('hidden')
|
||||
expect(wrapper.find('.sticky button[aria-label="Search the API reference"]').exists()).toBe(
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
it('is not inside the sticky bar', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
|
||||
const desktopTriggers = wrapper
|
||||
.findAll(`button[aria-label="${SEARCH_LABEL}"]`)
|
||||
.filter((button) => button.attributes('aria-keyshortcuts'))
|
||||
expect(desktopTriggers).toHaveLength(1)
|
||||
expect(desktopTriggers[0]!.element.closest('.sticky')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Mobile trigger', () => {
|
||||
it('sits beside Jump to as an icon-only button', async () => {
|
||||
const wrapper = await mountIntegration(MOBILE_WIDTH)
|
||||
|
||||
const bar = wrapper.get('button[aria-label="Jump to section"]').element.closest('.sticky')!
|
||||
const icon = bar.querySelector(`button[aria-label="${SEARCH_LABEL}"]`)!
|
||||
expect(icon.textContent?.trim()).toBe('')
|
||||
expect(icon.classList.contains('size-11')).toBe(true)
|
||||
const desktop = wrapper
|
||||
.findAll(`button[aria-label="${SEARCH_LABEL}"]`)
|
||||
.find((button) => button.attributes('aria-keyshortcuts'))!
|
||||
expect(desktop.classes()).toContain('hidden')
|
||||
expect(desktop.classes()).toContain('lg:inline-flex')
|
||||
})
|
||||
|
||||
it('opens the full-screen sheet with a Cancel button that returns focus to the trigger', async () => {
|
||||
const wrapper = await mountIntegration(MOBILE_WIDTH)
|
||||
const bar = wrapper.get('button[aria-label="Jump to section"]').element.closest('.sticky')!
|
||||
const icon = bar.querySelector<HTMLButtonElement>(`button[aria-label="${SEARCH_LABEL}"]`)!
|
||||
icon.focus()
|
||||
|
||||
icon.click()
|
||||
await settle()
|
||||
expect(paletteOpen()).toBe(true)
|
||||
const cancel = Array.from(document.body.querySelectorAll('button')).find(
|
||||
(button) => button.textContent?.trim() === 'Cancel',
|
||||
)
|
||||
expect(cancel).toBeInstanceOf(HTMLButtonElement)
|
||||
|
||||
cancel!.click()
|
||||
await settle()
|
||||
expect(paletteOpen()).toBe(false)
|
||||
expect(document.activeElement).toBe(icon)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Opening the palette with a shortcut', () => {
|
||||
it.each([
|
||||
['/ on the page body', () => document.body, '/', {}],
|
||||
['Ctrl+K on the page body', () => document.body, 'k', { ctrlKey: true }],
|
||||
['Cmd+K on the page body', () => document.body, 'k', { metaKey: true }],
|
||||
])('%s opens the palette', async (_name, target, key, init) => {
|
||||
await mountIntegration()
|
||||
|
||||
const event = press(target(), key, init)
|
||||
await settle()
|
||||
|
||||
expect(paletteOpen()).toBe(true)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('opens with / from a button and does not type the slash', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const button = wrapper.get('button[aria-expanded]').element as HTMLButtonElement
|
||||
button.focus()
|
||||
|
||||
const event = press(button, '/')
|
||||
await settle()
|
||||
|
||||
expect(paletteOpen()).toBe(true)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
expect(searchInput()!.value).toBe('')
|
||||
})
|
||||
|
||||
it('ignores / in a text input, a textarea and a contenteditable', async () => {
|
||||
await mountIntegration()
|
||||
const input = document.createElement('input')
|
||||
const textarea = document.createElement('textarea')
|
||||
const editable = document.createElement('div')
|
||||
editable.setAttribute('contenteditable', 'true')
|
||||
document.body.append(input, textarea, editable)
|
||||
|
||||
for (const field of [input, textarea, editable]) {
|
||||
const event = press(field, '/')
|
||||
await settle()
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
expect(paletteOpen()).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('opens with Ctrl+K from inside a text input', async () => {
|
||||
await mountIntegration()
|
||||
const input = document.createElement('input')
|
||||
document.body.append(input)
|
||||
|
||||
const event = press(input, 'k', { ctrlKey: true })
|
||||
await settle()
|
||||
|
||||
expect(paletteOpen()).toBe(true)
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('removes the shortcuts when the user leaves the page', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
wrapper.unmount()
|
||||
currentWrapper = null
|
||||
|
||||
const slash = press(document.body, '/')
|
||||
const chord = press(document.body, 'k', { ctrlKey: true })
|
||||
await settle()
|
||||
|
||||
expect(paletteOpen()).toBe(false)
|
||||
expect(slash.defaultPrevented).toBe(false)
|
||||
expect(chord.defaultPrevented).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves Ctrl+S alone', async () => {
|
||||
await mountIntegration()
|
||||
|
||||
const event = press(document.body, 's', { ctrlKey: true })
|
||||
await settle()
|
||||
|
||||
expect(event.defaultPrevented).toBe(false)
|
||||
expect(paletteOpen()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Opening focuses the input and closing restores focus', () => {
|
||||
it('focuses the input and returns focus to the desktop trigger on Escape', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const trigger = wrapper.get(`button[aria-keyshortcuts]`).element as HTMLButtonElement
|
||||
trigger.focus()
|
||||
|
||||
trigger.click()
|
||||
await settle()
|
||||
expect(document.activeElement).toBe(searchInput())
|
||||
|
||||
await pressInPalette('Escape')
|
||||
expect(paletteOpen()).toBe(false)
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
})
|
||||
|
||||
it('returns focus to the element that had it when opened with the keyboard', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const other = wrapper.get('button[aria-expanded]').element as HTMLButtonElement
|
||||
other.focus()
|
||||
|
||||
press(other, 'k', { ctrlKey: true })
|
||||
await settle()
|
||||
await pressInPalette('Escape')
|
||||
|
||||
expect(document.activeElement).toBe(other)
|
||||
})
|
||||
})
|
||||
|
||||
describe('8b Open, empty query', () => {
|
||||
it('shows the five suggested rows, the first selected, and 5 suggestions', async () => {
|
||||
await mountIntegration()
|
||||
await openWithSlash()
|
||||
|
||||
expect(searchInput()!.getAttribute('placeholder')).toBe('Search the API reference…')
|
||||
expect(groupTitles()).toEqual(['Suggested'])
|
||||
expect(options().map((option) => option.getAttribute('aria-label'))).toEqual([
|
||||
expect.stringMatching(/^Section Authentication\. Public access and API keys$/),
|
||||
expect.stringMatching(
|
||||
/^POST \/v1\/media\/\. Upload an image \(multipart\)\.\. Verified user$/,
|
||||
),
|
||||
expect.stringMatching(/^Section Errors\. Status codes and messages$/),
|
||||
expect.stringMatching(/^GET \/v1\/article\/\. .*\. Public$/),
|
||||
expect.stringMatching(/^PATCH \/v1\/user\/me\. Update my profile\.\. Verified user$/),
|
||||
])
|
||||
expect(selectedIndex()).toBe(0)
|
||||
expect(document.body.textContent).toContain('5 suggestions')
|
||||
expect(liveRegion()).toBe('5 suggestions')
|
||||
expect(document.body.textContent).toContain('Move')
|
||||
expect(document.body.textContent).toContain('Open')
|
||||
expect(document.body.textContent).toContain('Close')
|
||||
})
|
||||
|
||||
it('stores nothing as a recent search', async () => {
|
||||
await mountIntegration()
|
||||
const stored = JSON.stringify({ ...localStorage })
|
||||
await openWithSlash()
|
||||
await type('media')
|
||||
|
||||
expect(JSON.stringify({ ...localStorage })).toBe(stored)
|
||||
expect(sessionStorage.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('8c Typing media', () => {
|
||||
it('lists endpoints then the Media section, with highlights and the count', async () => {
|
||||
await mountIntegration()
|
||||
await openWithSlash()
|
||||
await type('media')
|
||||
|
||||
expect(groupTitles()).toEqual(['Endpoints', 'Sections'])
|
||||
const rows = optionRoutes()
|
||||
expect(rows.slice(0, 6).every((route) => route!.includes('/v1/media'))).toBe(true)
|
||||
expect(rows.at(-1)).toBe('Section Media')
|
||||
expect(options().at(-1)!.textContent).toContain('11 routes')
|
||||
expect(document.body.textContent).toContain('12 results')
|
||||
expect(liveRegion()).toBe('12 results, showing 7')
|
||||
expect(document.body.textContent).toContain(
|
||||
'5 more endpoints. Keep typing to narrow the list.',
|
||||
)
|
||||
const marks = Array.from(document.body.querySelectorAll('[role="option"] mark'))
|
||||
expect(marks.length).toBeGreaterThan(0)
|
||||
expect(marks.every((mark) => mark.textContent!.toLowerCase() === 'media')).toBe(true)
|
||||
expect(marks[0]!.className).toContain('--rc-sand')
|
||||
expect(marks[0]!.className).toContain('--rc-on-sand')
|
||||
})
|
||||
})
|
||||
|
||||
describe('8d More endpoints than the cap', () => {
|
||||
it('lists six rows with an Error line, the Errors section and the cap line', async () => {
|
||||
await mountIntegration()
|
||||
await openWithSlash()
|
||||
await type('404')
|
||||
|
||||
expect(groupTitles()).toEqual(['Endpoints', 'Sections'])
|
||||
const endpointRows = options().slice(0, -1)
|
||||
expect(endpointRows).toHaveLength(6)
|
||||
expect(endpointRows.every((row) => row.textContent!.includes('Error'))).toBe(true)
|
||||
expect(endpointRows[0]!.textContent).toMatch(/Error\s*404 /)
|
||||
expect(options().at(-1)!.getAttribute('aria-label')).toBe(
|
||||
'Section Errors. Status codes and messages',
|
||||
)
|
||||
const total = Number(/(\d+) results/.exec(document.body.textContent!)![1])
|
||||
expect(total).toBeGreaterThan(7)
|
||||
expect(document.body.textContent).toContain(
|
||||
`${total - 7} more endpoints. Keep typing to narrow the list.`,
|
||||
)
|
||||
expect(liveRegion()).toBe(`${total} results, showing 7`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('8e Match on a body field', () => {
|
||||
it('lists only POST /v1/media/merge with the body field line', async () => {
|
||||
await mountIntegration()
|
||||
await openWithSlash()
|
||||
await type('keep_id')
|
||||
|
||||
expect(optionRoutes()).toEqual(['POST /v1/media/merge'])
|
||||
const row = options()[0]!
|
||||
expect(row.textContent).toContain('Merge identical copies into one image.')
|
||||
expect(row.textContent).toContain('Verified user')
|
||||
expect(row.textContent).toContain('Body field')
|
||||
expect(row.querySelector('mark')!.textContent).toBe('keep_id')
|
||||
expect(document.body.textContent).toContain('1 result')
|
||||
expect(liveRegion()).toBe('1 result')
|
||||
})
|
||||
|
||||
it('labels a query parameter', async () => {
|
||||
await mountIntegration()
|
||||
await openWithSlash()
|
||||
await type('hash')
|
||||
|
||||
const row = options().find((item) =>
|
||||
item.getAttribute('aria-label')!.startsWith('GET /v1/media/. '),
|
||||
)!
|
||||
expect(row.textContent).toContain('Query parameter')
|
||||
})
|
||||
|
||||
it('shows no second line for a path match', async () => {
|
||||
await mountIntegration()
|
||||
await openWithSlash()
|
||||
await type('merge')
|
||||
|
||||
expect(options()[0]!.textContent).not.toContain('Body field')
|
||||
})
|
||||
})
|
||||
|
||||
describe('8f Session-only route', () => {
|
||||
it('styles the route as session only and lists the Session only section', async () => {
|
||||
await mountIntegration()
|
||||
await openWithSlash()
|
||||
await type('login')
|
||||
|
||||
expect(groupTitles()).toEqual(['Endpoints', 'Sections'])
|
||||
const [route, section] = options()
|
||||
expect(route!.getAttribute('aria-label')).toBe('POST /v1/auth/login. Log in. Session only')
|
||||
expect(route!.textContent).toContain('Session only')
|
||||
expect(route!.querySelector('[data-slot="leadingIcon"]')).not.toBeNull()
|
||||
const path = route!.querySelector('mark')!.parentElement!
|
||||
expect(path.classList.contains('font-normal')).toBe(true)
|
||||
expect(route!.innerHTML).toContain('border-dashed')
|
||||
expect(section!.getAttribute('aria-label')).toBe(
|
||||
'Section Session only. 25 routes, not available to API keys',
|
||||
)
|
||||
expect(section!.querySelector('svg')).not.toBeNull()
|
||||
expect(document.body.textContent).toContain('2 results')
|
||||
})
|
||||
})
|
||||
|
||||
describe('8g No results', () => {
|
||||
it('shows the empty copy, 0 results and No results', async () => {
|
||||
await mountIntegration()
|
||||
await openWithSlash()
|
||||
await type('xyz')
|
||||
|
||||
expect(options()).toHaveLength(0)
|
||||
expect(document.body.textContent).toContain(
|
||||
'No match for "xyz". Try a path, a method or a status code like 404.',
|
||||
)
|
||||
expect(document.body.textContent).toContain('0 results')
|
||||
expect(liveRegion()).toBe('No results')
|
||||
})
|
||||
|
||||
it('does not match a misspelled term', async () => {
|
||||
await mountIntegration()
|
||||
await openWithSlash()
|
||||
await type('mdia')
|
||||
|
||||
expect(options()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Keyboard navigation', () => {
|
||||
async function showSeven() {
|
||||
await mountIntegration()
|
||||
await openWithSlash()
|
||||
await type('media')
|
||||
return options().length
|
||||
}
|
||||
|
||||
it('moves the selection with the arrows and wraps', async () => {
|
||||
const count = await showSeven()
|
||||
expect(count).toBe(7)
|
||||
|
||||
await pressInPalette('ArrowDown')
|
||||
expect(selectedIndex()).toBe(1)
|
||||
expect(document.activeElement).toBe(searchInput())
|
||||
|
||||
await pressInPalette('ArrowUp')
|
||||
await pressInPalette('ArrowUp')
|
||||
expect(selectedIndex()).toBe(6)
|
||||
|
||||
await pressInPalette('ArrowDown')
|
||||
expect(selectedIndex()).toBe(0)
|
||||
})
|
||||
|
||||
it('jumps to the first and last row with Home and End', async () => {
|
||||
await showSeven()
|
||||
await pressInPalette('ArrowDown')
|
||||
await pressInPalette('ArrowDown')
|
||||
|
||||
await pressInPalette('End')
|
||||
expect(selectedIndex()).toBe(6)
|
||||
await pressInPalette('Home')
|
||||
expect(selectedIndex()).toBe(0)
|
||||
})
|
||||
|
||||
it('resets the selection to the first row when typing', async () => {
|
||||
await showSeven()
|
||||
await pressInPalette('ArrowDown')
|
||||
await pressInPalette('ArrowDown')
|
||||
expect(selectedIndex()).toBe(2)
|
||||
|
||||
await type('medi')
|
||||
|
||||
expect(selectedIndex()).toBe(0)
|
||||
})
|
||||
|
||||
it('follows the selection with aria-activedescendant', async () => {
|
||||
await showSeven()
|
||||
await pressInPalette('ArrowDown')
|
||||
|
||||
expect(searchInput()!.getAttribute('aria-activedescendant')).toBe(options()[1]!.id)
|
||||
})
|
||||
|
||||
it('closes on Escape without scrolling or changing the hash', async () => {
|
||||
await showSeven()
|
||||
window.history.replaceState(null, '', '/integration#media')
|
||||
|
||||
await pressInPalette('Escape')
|
||||
|
||||
expect(paletteOpen()).toBe(false)
|
||||
expect(scrollIntoView).not.toHaveBeenCalled()
|
||||
expect(window.location.hash).toBe('#media')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Roles and live region', () => {
|
||||
it('exposes the combobox, listbox, groups and options', async () => {
|
||||
await mountIntegration()
|
||||
await openWithSlash()
|
||||
await type('media')
|
||||
|
||||
const input = searchInput()!
|
||||
const list = document.body.querySelector('[role="listbox"]')!
|
||||
expect(input.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(input.getAttribute('aria-controls')).toBe(list.id)
|
||||
expect(input.getAttribute('aria-autocomplete')).toBe('list')
|
||||
expect(input.getAttribute('aria-activedescendant')).toBe(options()[0]!.id)
|
||||
expect(document.body.querySelectorAll('[role="group"]')).toHaveLength(2)
|
||||
expect(options()[0]!.getAttribute('aria-selected')).toBe('true')
|
||||
expect(options()[1]!.getAttribute('aria-selected')).toBe('false')
|
||||
expect(options()[0]!.getAttribute('aria-label')).toMatch(/^[A-Z]+ \/v1\/.*\. .*\. .+$/)
|
||||
expect(document.body.querySelector('[role="status"]')!.getAttribute('aria-live')).toBe(
|
||||
'polite',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('8h Selecting an endpoint', () => {
|
||||
async function selectMerge(wrapper: VueWrapper, how: 'enter' | 'click') {
|
||||
await openWithSlash()
|
||||
await type('keep_id')
|
||||
if (how === 'enter') {
|
||||
await pressInPalette('Enter')
|
||||
} else {
|
||||
options()[0]!.click()
|
||||
await settle()
|
||||
}
|
||||
await wrapper.vm.$nextTick()
|
||||
await settle()
|
||||
}
|
||||
|
||||
it.each(['enter', 'click'] as const)(
|
||||
'closes, scrolls, expands, focuses, flashes and writes the hash with %s',
|
||||
async (how) => {
|
||||
const wrapper = await mountIntegration()
|
||||
const historyLength = window.history.length
|
||||
const card = cardFor(wrapper, 'med-merge')
|
||||
const replaceState = vi.spyOn(window.history, 'replaceState')
|
||||
|
||||
await selectMerge(wrapper, how)
|
||||
|
||||
expect(paletteOpen()).toBe(false)
|
||||
expect(scrollIntoView.mock.contexts).toContain(
|
||||
wrapper.get('[data-anchor="ep-med-merge"]').element,
|
||||
)
|
||||
const button = card.get('button[aria-expanded]')
|
||||
expect(button.attributes('aria-expanded')).toBe('true')
|
||||
expect(document.activeElement).toBe(button.element)
|
||||
expect(button.classes()).toContain('endpoint-flash')
|
||||
expect(window.location.hash).toBe('#ep-med-merge')
|
||||
expect(replaceState).toHaveBeenCalled()
|
||||
expect(window.history.length).toBe(historyLength)
|
||||
expect(openCards(wrapper)).toHaveLength(1)
|
||||
const index = wrapper.get('nav[aria-label="On this page"]')
|
||||
expect(index.get('button[aria-current="true"]').text()).toBe('Media')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(FLASH_MS)
|
||||
await flushPromises()
|
||||
|
||||
expect(button.classes()).not.toContain('endpoint-flash')
|
||||
expect(button.attributes('aria-expanded')).toBe('true')
|
||||
},
|
||||
)
|
||||
|
||||
it('keeps the other cards as they were', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
await wrapper.get('button[aria-label^="GET /v1/media/. "]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
await selectMerge(wrapper, 'enter')
|
||||
|
||||
expect(openCards(wrapper)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('uses a static ring with reduced motion', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
stubReducedMotion(true)
|
||||
|
||||
await selectMerge(wrapper, 'enter')
|
||||
|
||||
const button = cardFor(wrapper, 'med-merge').get('button[aria-expanded]')
|
||||
expect(button.classes()).toContain('endpoint-flash-static')
|
||||
expect(button.classes()).not.toContain('endpoint-flash')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selecting a section', () => {
|
||||
it('focuses the heading, writes the hash and expands nothing', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
await openWithSlash()
|
||||
await type('errors')
|
||||
await pressInPalette('Enter')
|
||||
await settle()
|
||||
|
||||
expect(paletteOpen()).toBe(false)
|
||||
expect(scrollIntoView.mock.contexts).toContain(
|
||||
wrapper.get('[data-anchor="errors"]').element,
|
||||
)
|
||||
expect(document.activeElement).toBe(wrapper.get('#h-errors').element)
|
||||
expect(window.location.hash).toBe('#errors')
|
||||
expect(openCards(wrapper)).toHaveLength(0)
|
||||
expect(wrapper.find('.endpoint-flash').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Selecting a session-only route', () => {
|
||||
it('goes to the Session only heading without expanding a card', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
await openWithSlash()
|
||||
await type('login')
|
||||
await pressInPalette('Enter')
|
||||
await settle()
|
||||
|
||||
expect(document.activeElement).toBe(wrapper.get('#h-session').element)
|
||||
expect(openCards(wrapper)).toHaveLength(0)
|
||||
expect(wrapper.find('.endpoint-flash').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Shared link', () => {
|
||||
it('scrolls to, expands, focuses and flashes the card without opening the palette', async () => {
|
||||
const wrapper = await mountIntegration(DESKTOP_WIDTH, '#ep-med-merge')
|
||||
await settle()
|
||||
|
||||
const button = cardFor(wrapper, 'med-merge').get('button[aria-expanded]')
|
||||
expect(paletteOpen()).toBe(false)
|
||||
expect(button.attributes('aria-expanded')).toBe('true')
|
||||
expect(document.activeElement).toBe(button.element)
|
||||
expect(button.classes()).toContain('endpoint-flash')
|
||||
expect(scrollIntoView.mock.contexts).toContain(
|
||||
wrapper.get('[data-anchor="ep-med-merge"]').element,
|
||||
)
|
||||
})
|
||||
|
||||
it('does nothing for an unknown id', async () => {
|
||||
const wrapper = await mountIntegration(DESKTOP_WIDTH, '#ep-nope')
|
||||
await settle()
|
||||
|
||||
expect(openCards(wrapper)).toHaveLength(0)
|
||||
expect(paletteOpen()).toBe(false)
|
||||
expect(wrapper.find('.endpoint-flash').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The page is never filtered', () => {
|
||||
it('keeps every section and card in the page while a query is typed', async () => {
|
||||
const wrapper = await mountIntegration()
|
||||
const sections = wrapper.findAll('[data-anchor]:not([data-anchor^="ep-"])').length
|
||||
const cards = wrapper.findAllComponents(EndpointCard).length
|
||||
|
||||
const calls = fetchSpy.mock.calls.length
|
||||
|
||||
await openWithSlash()
|
||||
await type('keep_id')
|
||||
|
||||
expect(wrapper.findAll('[data-anchor]:not([data-anchor^="ep-"])')).toHaveLength(sections)
|
||||
expect(wrapper.findAllComponents(EndpointCard)).toHaveLength(cards)
|
||||
expect(fetchSpy.mock.calls.length).toBe(calls)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user