feat: integration search bar

This commit is contained in:
Florian Sylvain committed 2026-10-25 21:57:43 +01:00
1 parent a8fea8d900
commit 25d9c30b8a
22 files changed
+2558 -41

No files matched your search

+55
View File
@@ -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>
+250
View File
@@ -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>
+132
View File
@@ -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>
+41 -2
View File
@@ -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>
+20
View File
@@ -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>
+17 -3
View File
@@ -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>
+23
View File
@@ -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
+225
View File
@@ -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 }])
})
})
+2
View File
@@ -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`,
+267
View File
@@ -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()) }))
}
+204 -27
View File
@@ -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)
})
})
+15
View File
@@ -230,6 +230,21 @@ const extraSources: Record<string, Source[]> = {
},
],
integration: [
{
board: officeBoard,
group: 'Integration',
states: [
'8a. Search: trigger focused',
'8b. Search: open, empty',
'8c. Search: typing, results',
'8d. Search: keyboard-selected, status code',
'8e. Search: body field match',
'8f. Search: session-only results',
'8g. Search: no results',
'8h. Search: after selecting an endpoint',
],
alias: {},
},
{
board: officeBoard,
group: 'Integration: images',
+60
View File
@@ -1246,6 +1246,45 @@ function integrationState(overrides: Partial<VisualState> = {}): VisualState {
}
}
const integrationSearchLabel = 'Search the API reference'
const desktopMinWidth = 1024
async function focusIntegrationTrigger(page: Page) {
const width = page.viewportSize()?.width ?? 0
if (width >= desktopMinWidth) {
await page.getByRole('button', { name: integrationSearchLabel, exact: true }).focus()
await page.keyboard.press('Shift+Tab')
await page.keyboard.press('Tab')
}
}
async function openIntegrationSearch(page: Page) {
await page.getByRole('button', { name: integrationSearchLabel, exact: true }).click()
await page.getByRole('combobox').waitFor()
await page.mouse.move(0, 0)
}
function searchingIntegration(query: string, arrowDowns = 0): VisualState['steps'] {
return async (page) => {
await openIntegrationSearch(page)
await page.getByRole('combobox').fill(query)
for (let press = 0; press < arrowDowns; press++) {
await page.keyboard.press('ArrowDown')
}
}
}
async function selectIntegrationMerge(page: Page) {
const flash = page.locator('.endpoint-flash, .endpoint-flash-static')
await page.addStyleTag({ content: '.endpoint-flash { animation: none !important; }' })
await openIntegrationSearch(page)
await page.getByRole('combobox').fill('keep_id')
await page.keyboard.press('Enter')
await flash.waitFor()
await flash.waitFor({ state: 'detached' })
await page.mouse.move(0, 0)
}
function integrationImagesState(
list: ApiMock | null,
overrides: Partial<VisualState> & { session?: ApiMock } = {},
@@ -8678,6 +8717,27 @@ export const visualStates: Record<string, Record<string, VisualState>> = {
approvedBy: 'fsylvain',
},
}),
'8a. Search: trigger focused': integrationState({ steps: focusIntegrationTrigger }),
'8b. Search: open, empty': integrationState({ steps: searchingIntegration('') }),
'8c. Search: typing, results': integrationState({ steps: searchingIntegration('media') }),
'8d. Search: keyboard-selected, status code': integrationState({
steps: searchingIntegration('404', 2),
}),
'8e. Search: body field match': integrationState({
steps: searchingIntegration('keep_id'),
}),
'8f. Search: session-only results': integrationState({
steps: searchingIntegration('login'),
}),
'8g. Search: no results': integrationState({ steps: searchingIntegration('xyz') }),
'8h. Search: after selecting an endpoint': integrationState({
steps: selectIntegrationMerge,
exception: {
maxPercent: 50,
reason: 'Expanded card body predates the search slice: Query parameters block and four-column field table differ from the board, and the real catalogue lists more media routes',
approvedBy: 'Florian Sylvain',
},
}),
'I1 · Many variants': integrationImagesState(integrationManyVariants),
'I2 · One variant': integrationImagesState(
integrationVariantsList(integrationVariantRows.slice(0, 1)),