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

+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)
})
})