feat: integration search bar
This commit is contained in:
1 parent
a8fea8d900
commit
25d9c30b8a
22 files changed
+2558
-41
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user