feat(integration): add image variants section and standardize code snippets

This commit is contained in:
Florian Sylvain committed 2026-10-12 23:27:41 +02:00
1 parent 6f9ae6207a
commit e6746f2be7
28 files changed
+2256 -422

No files matched your search

+5 -25
View File
@@ -2,7 +2,7 @@
import { t } from '@/i18n'
import { computed, nextTick, ref, watch } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import CodeSnippet from '@/components/CodeSnippet.vue'
const props = defineProps<{
name: string
@@ -13,14 +13,12 @@ const open = defineModel<boolean>('open', { default: false })
const emit = defineEmits<{ done: [] }>()
const { copied, copy } = useCopyToClipboard()
const copyButton = ref<{ $el: HTMLElement } | null>(null)
const snippet = ref<{ focusCopy: () => void } | null>(null)
const description = computed(() =>
t('"{0}" is ready. Copy the key and store it somewhere safe.', [props.name]),
)
const copyLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
const copyIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
const keyTabs = computed(() => [{ k: 'key', label: t('API key'), code: props.apiKey }])
watch(
open,
@@ -30,7 +28,7 @@ watch(
}
await nextTick()
await nextTick()
copyButton.value?.$el?.focus()
snippet.value?.focusCopy()
},
{ immediate: true },
)
@@ -78,25 +76,7 @@ function keepOpenOnOutsideInteraction(event: Event) {
:title="t('You won\'t be able to see this key again.')"
:ui="{ root: 'gap-2.5 px-3 py-2.5', title: 'text-sm font-medium' }"
/>
<div class="flex flex-col border border-accented bg-elevated md:flex-row">
<span
class="min-w-0 flex-1 px-3 py-2.5 font-mono text-code/5 break-all text-highlighted"
>{{ apiKey }}</span
>
<UButton
ref="copyButton"
:icon="copyIcon"
color="neutral"
variant="ghost"
aria-live="polite"
class="h-11 justify-center gap-1.5 rounded-none border-t border-accented px-3.5 font-medium md:border-t-0 md:border-l"
:class="copied ? 'text-success' : 'text-highlighted'"
:ui="{ leadingIcon: 'size-3.5' }"
@click="copy(apiKey)"
>
{{ copyLabel }}
</UButton>
</div>
<CodeSnippet ref="snippet" :tabs="keyTabs" />
<span class="text-muted">{{
t('Closing this window discards the key from the screen. Copy it first.')
}}</span>
+41
View File
@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
items: string[]
label: string
display?: string
warning?: boolean
}>()
const model = defineModel<string>({ required: true })
const options = computed(() => props.items.map((item) => ({ label: item, value: item })))
const borderClass = computed(() => (props.warning ? 'border-warning' : 'border-accented'))
const shown = computed(() => props.display ?? model.value)
</script>
<template>
<USelectMenu
:model-value="model"
:items="options"
value-key="value"
label-key="label"
:search-input="false"
:aria-label="label"
color="neutral"
variant="outline"
class="w-full"
:ui="{
base: `h-11 rounded-none border ${borderClass} bg-default px-2.5 text-sm ring-0 data-[state=open]:border-primary data-[state=open]:ring-1 data-[state=open]:ring-primary lg:h-8`,
trailingIcon: 'size-3.5 text-muted',
content: 'min-w-(--reka-combobox-trigger-width)',
item: 'min-h-11 text-sm lg:min-h-8',
itemLeadingIcon: 'hidden',
itemTrailingIcon: 'hidden',
}"
@update:model-value="model = $event as string"
>
<span class="truncate text-highlighted">{{ shown }}</span>
</USelectMenu>
</template>
-64
View File
@@ -1,64 +0,0 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref, watchEffect } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { highlightToHtml, useShikiHighlighter } from '@/composables/useShikiHighlighter'
const props = defineProps<{
code: string
lang: string
label?: string
}>()
const { copied, copy } = useCopyToClipboard()
const buttonLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
const buttonIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
const highlightedHtml = ref<string | null>(null)
watchEffect(async () => {
const highlighter = await useShikiHighlighter()
highlightedHtml.value = highlightToHtml(highlighter, props.code, props.lang)
})
function onCopy() {
copy(props.code)
}
</script>
<template>
<div class="bg-elevated">
<div class="flex h-8 items-center justify-between border-b border-default pr-1 pl-3">
<span class="font-mono text-xs leading-tight font-medium text-muted">{{
label ?? lang
}}</span>
<UButton
:icon="buttonIcon"
color="neutral"
variant="ghost"
size="xs"
:aria-label="t('Copy {0} example', [label ?? lang])"
aria-live="polite"
class="gap-1.5 px-2 font-medium text-toned"
:ui="{ leadingIcon: 'size-3.5' }"
@click="onCopy"
>
{{ buttonLabel }}
</UButton>
</div>
<div
v-if="highlightedHtml"
tabindex="0"
class="shiki-block overflow-x-auto px-3 py-3 font-mono text-code whitespace-pre"
v-html="highlightedHtml"
/>
<pre
v-else
tabindex="0"
class="overflow-x-auto px-3 py-3 font-mono text-code whitespace-pre text-default"
>{{ code }}</pre
>
</div>
</template>
+120
View File
@@ -0,0 +1,120 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, ref } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import type { CodeSnippetTab } from '@/lib/codeSnippet'
const props = defineProps<{
tabs: CodeSnippetTab[]
}>()
const active = defineModel<string>('active')
const { copied, copy } = useCopyToClipboard()
const tabList = ref<HTMLElement | null>(null)
const copyButton = ref<{ $el: HTMLElement } | null>(null)
const current = computed(() => props.tabs.find((tab) => tab.k === active.value) ?? props.tabs[0])
const single = computed(() => props.tabs.length === 1)
const copyLabel = computed(() => (copied.value ? t('Copied') : t('Copy')))
const copyIcon = computed(() => (copied.value ? 'i-lucide-check' : 'i-lucide-copy'))
const TAB_BASE =
'flex h-8 cursor-pointer items-center px-2.5 text-sm whitespace-nowrap focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary'
const TAB_SELECTED = 'font-medium text-highlighted shadow-[inset_0_-2px_0_var(--ui-primary)]'
const TAB_IDLE = 'font-normal text-toned'
function isSelected(tab: CodeSnippetTab) {
return tab.k === current.value?.k
}
function select(tab: CodeSnippetTab) {
active.value = tab.k
}
function targetIndex(key: string, from: number) {
const last = props.tabs.length - 1
const targets: Record<string, number> = {
ArrowRight: from === last ? 0 : from + 1,
ArrowLeft: from === 0 ? last : from - 1,
Home: 0,
End: last,
}
return targets[key]
}
function onTabKeydown(event: KeyboardEvent, index: number) {
const target = targetIndex(event.key, index)
if (target === undefined) {
return
}
event.preventDefault()
select(props.tabs[target]!)
const buttons = tabList.value?.querySelectorAll<HTMLElement>('[role="tab"]')
buttons?.[target]?.focus()
}
function onCopy() {
copy(current.value?.code ?? '')
}
function focusCopy() {
copyButton.value?.$el?.focus()
}
defineExpose({ focusCopy })
</script>
<template>
<div class="flex min-w-0 flex-col border border-default bg-elevated text-default">
<div class="flex min-h-11 items-center gap-2 border-b border-default pr-1 pl-2">
<div
ref="tabList"
role="tablist"
:aria-label="t('Language')"
class="flex min-w-0 flex-1 gap-0.5 overflow-x-auto"
>
<template v-if="single">
<span class="flex h-8 items-center px-2.5 text-sm font-normal text-muted">{{
current?.label
}}</span>
</template>
<template v-else>
<button
v-for="(tab, index) in tabs"
:key="tab.k"
type="button"
role="tab"
:aria-selected="isSelected(tab)"
:tabindex="isSelected(tab) ? 0 : -1"
:class="[TAB_BASE, isSelected(tab) ? TAB_SELECTED : TAB_IDLE]"
@click="select(tab)"
@keydown="onTabKeydown($event, index)"
>
{{ tab.label }}
</button>
</template>
</div>
<UButton
ref="copyButton"
:icon="copyIcon"
color="neutral"
variant="ghost"
:aria-label="t('Copy code')"
class="h-8 min-w-11 justify-center gap-1.5 rounded-none px-2.5 text-sm font-medium focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary"
:class="copied ? 'text-success' : 'text-highlighted'"
:ui="{ leadingIcon: 'size-3.5' }"
@click="onCopy"
>
<span aria-live="polite">{{ copyLabel }}</span>
</UButton>
</div>
<pre
tabindex="0"
class="m-0 overflow-x-auto px-4 py-3 font-mono text-code whitespace-pre text-highlighted"
>{{ current?.code }}</pre
>
</div>
</template>
+8 -57
View File
@@ -3,13 +3,8 @@ import { t, translate } from '@/i18n'
import { computed, ref } from 'vue'
import ApiFieldTable from '@/components/ApiFieldTable.vue'
import CodeBlock from '@/components/CodeBlock.vue'
import {
RATE_LIMIT_SENTENCE,
NO_CONTENT_RESPONSE,
endpointExamples,
type ApiEndpoint,
} from '@/data/apiCatalogue'
import CodeSnippet from '@/components/CodeSnippet.vue'
import { RATE_LIMIT_SENTENCE, endpointExamples, type ApiEndpoint } from '@/data/apiCatalogue'
import { CHIP_CLASS, METHOD_CLASS, endpointAnchor } from '@/lib/apiReference'
const props = defineProps<{
@@ -30,23 +25,12 @@ const bodyTitle = computed(() => {
}
return props.endpoint.bodyKind === 'json' ? t('Request body · JSON') : t('Request body')
})
const responseLabel = computed(() =>
props.endpoint.response === NO_CONTENT_RESPONSE ? 'HTTP' : 'JSON',
)
const responseLang = computed(() =>
props.endpoint.response === NO_CONTENT_RESPONSE ? 'text' : 'json',
)
const tabItems = [
{ label: 'fetch', value: 'fetch' },
{ label: 'curl', value: 'curl' },
{
get label() {
return t('Response')
},
value: 'response',
},
]
const snippetTabs = computed(() => [
{ k: 'fetch', label: 'fetch', code: examples.value.fetch },
{ k: 'curl', label: 'curl', code: examples.value.curl },
{ k: 'response', label: t('Response'), code: examples.value.response },
])
</script>
<template>
@@ -119,40 +103,7 @@ const tabItems = [
<span v-else class="text-muted">{{ t('No request body.') }}</span>
</div>
<UTabs
v-model="activeTab"
:items="tabItems"
variant="link"
color="primary"
:ui="{
root: 'gap-0 border border-default',
list: 'gap-4 border-b border-default px-3 py-0 before:hidden',
trigger:
'h-9 grow-0 px-0 text-sm font-medium data-[state=inactive]:font-medium data-[state=inactive]:text-toned max-lg:h-11',
content: 'mt-0',
}"
>
<template #content="{ item }">
<CodeBlock
v-if="item.value === 'fetch'"
label="JavaScript"
lang="javascript"
:code="examples.fetch"
/>
<CodeBlock
v-else-if="item.value === 'curl'"
label="Shell"
lang="shell"
:code="examples.curl"
/>
<CodeBlock
v-else
:label="responseLabel"
:lang="responseLang"
:code="examples.response"
/>
</template>
</UTabs>
<CodeSnippet v-model:active="activeTab" :tabs="snippetTabs" />
<div v-if="endpoint.rateLimited" class="flex gap-2.5 text-toned">
<UIcon name="i-lucide-info" class="mt-0.5 size-4 shrink-0 text-info" />
+412
View File
@@ -0,0 +1,412 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import BuilderSelect from '@/components/BuilderSelect.vue'
import CodeSnippet from '@/components/CodeSnippet.vue'
import CodeText from '@/components/CodeText.vue'
import { SECTION_TITLE } from '@/lib/apiReference'
import { useCanManageVariants } from '@/composables/useCanManageVariants'
import {
SAMPLE_IMAGE,
SETTINGS_VARIANTS_PATH,
imageResponseRows,
imageSnippets,
unknownVariantBody,
variantUrl,
} from '@/lib/imageSnippets'
import { formatDimensions, formatLabel } from '@/lib/variants/variantFormat'
import { useImageVariantsStore } from '@/stores/imageVariants'
const props = defineProps<{
origin: string
}>()
const MAX_ROWS_BEFORE_SCROLL = 6
const DEFAULT_RATIO = '4 / 3'
const store = useImageVariantsStore()
const canManage = useCanManageVariants()
const chosen = ref<string | null>(null)
const tab = ref<string>()
const urlTab = ref<string>()
const enabled = computed(() => store.items.filter((item) => item.enabled))
const enabledNames = computed(() => enabled.value.map((item) => item.name))
const hasDisabled = computed(() => store.items.some((item) => !item.enabled))
const view = computed(() => {
if (!canManage.value || store.loadState === 'forbidden') {
return 'plain'
}
if (store.loadState === 'loading') {
return 'loading'
}
if (store.loadState === 'error') {
return 'error'
}
return enabled.value.length === 0 ? 'none' : 'many'
})
const showSnippets = computed(() => view.value !== 'loading')
const showBuilder = computed(() => view.value === 'many')
const showResponses = computed(() => view.value !== 'loading' && view.value !== 'error')
const snippetNames = computed(() => (view.value === 'many' ? enabledNames.value : []))
const snippets = computed(() => imageSnippets(props.origin, snippetNames.value))
const activeTab = computed(() => tab.value ?? (view.value === 'many' ? 'html' : 'curl'))
const selected = computed(() => chosen.value ?? enabledNames.value[0] ?? '')
const removed = computed(() => showBuilder.value && !enabledNames.value.includes(selected.value))
const selectedVariant = computed(() => enabled.value.find((item) => item.name === selected.value))
const variantDisplay = computed(() =>
removed.value ? t('{0} (removed)', [selected.value]) : selected.value,
)
const urlTabs = computed(() => [
{ k: 'url', label: t('URL'), code: variantUrl(props.origin, selected.value) },
])
const previewLabel = computed(() => {
const variant = selectedVariant.value
if (!variant) {
return t('No preview: unknown variant')
}
return [
variant.name,
formatDimensions(variant.width, variant.height),
formatLabel(variant.format),
].join(' · ')
})
const previewRatio = computed(() => {
const variant = selectedVariant.value
if (!variant || variant.width === null || variant.height === null) {
return DEFAULT_RATIO
}
return `${variant.width} / ${variant.height}`
})
const countText = computed(() =>
enabled.value.length === 1
? t('1 enabled variant')
: t('{0} enabled variants', [enabled.value.length]),
)
const rows = computed(() =>
enabled.value.map((item) => ({
name: item.name,
dimensions: formatDimensions(item.width, item.height),
format: formatLabel(item.format),
url: variantUrl('', item.name),
})),
)
const scrollBody = computed(() => rows.value.length > MAX_ROWS_BEFORE_SCROLL)
const responseRows = computed(() => imageResponseRows())
const removedTitle = computed(() => t('The variant "{0}" no longer exists', [selected.value]))
const removedDescription = computed(() =>
t(
'There is no fallback to the original. Requests with ?v={0} return status 400 with the body `{1}`. Remove the parameter to get the original image, or pick another variant.',
[selected.value, unknownVariantBody(selected.value)],
),
)
function loadList() {
return store.load()
}
function reloadOnFocus() {
if (!canManage.value) {
return
}
if (store.loadState === 'ready') {
return store.reloadQuietly()
}
return store.load()
}
onMounted(() => {
if (canManage.value) {
store.load()
}
window.addEventListener('focus', reloadOnFocus)
})
onBeforeUnmount(() => window.removeEventListener('focus', reloadOnFocus))
const SUB_HEADING = 'text-base/5.5 font-semibold text-highlighted'
const FIELD_LABEL = 'font-medium text-highlighted'
const TABLE_BOX = 'border border-default bg-default overflow-x-auto'
const VARIANT_COLUMNS = 'grid grid-cols-[96px_112px_72px_minmax(0,1fr)] items-center gap-3 px-3'
const RESPONSE_COLUMNS = 'grid grid-cols-[minmax(0,1.3fr)_56px_minmax(0,1fr)] gap-3 px-3'
const EMPTY_UI = {
root: 'gap-3 border border-default bg-default px-6 py-8',
header: 'max-w-none gap-3',
body: 'w-full max-w-none items-center gap-3',
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
title: 'text-md font-semibold',
description: 'max-w-105 text-pretty text-muted',
actions: 'w-full flex-col items-center justify-center gap-3',
}
</script>
<template>
<section
data-anchor="images"
aria-labelledby="h-images"
class="flex max-w-220 scroll-mt-16 flex-col gap-5 lg:scroll-mt-8 lg:gap-6"
>
<div class="flex flex-col gap-2">
<h2 id="h-images" :class="SECTION_TITLE">
{{ t('Images and variants') }}
</h2>
<p class="max-w-180 text-pretty text-toned">
<CodeText
:text="
t(
'Every image in the media library can be requested as a named variant. Add the `?v=<variant-name>` parameter to the image URL. The original file is never changed, and a variant is generated the first time it is requested. Variants are managed in Settings, Media, Variants.',
)
"
/>
</p>
</div>
<section
v-if="view === 'loading'"
:aria-label="t('Loading image variants')"
aria-busy="true"
class="flex flex-col gap-3"
>
<USkeleton class="h-35 rounded-none bg-accented" />
<USkeleton class="h-50 rounded-none bg-accented" />
<USkeleton class="h-30 rounded-none bg-accented" />
</section>
<UAlert
v-if="view === 'error'"
role="alert"
color="error"
variant="soft"
orientation="vertical"
:close="false"
icon="i-lucide-circle-x"
:title="t('Couldn\'t load image variants')"
:description="
t(
'The snippets below still work. The variant list and the URL builder need a connection.',
)
"
:ui="{ actions: 'mt-2' }"
>
<template #actions>
<UButton
color="neutral"
variant="outline"
class="h-11 gap-1.5 rounded-none border border-accented bg-default px-3 text-sm font-medium text-highlighted ring-0 lg:h-8"
@click="loadList"
>
{{ t('Try again') }}
</UButton>
</template>
</UAlert>
<div v-if="showSnippets" class="flex flex-col gap-2">
<h3 :class="SUB_HEADING">{{ t('Request an image') }}</h3>
<CodeSnippet :active="activeTab" :tabs="snippets" @update:active="tab = $event" />
</div>
<UEmpty
v-if="view === 'none'"
variant="naked"
icon="i-lucide-layers"
:title="t('No image variants yet')"
:description="
t(
'Until you add a variant, any request with the ?v= parameter returns status 400 unknown_variant. Requests without it return the original image.',
)
"
:ui="EMPTY_UI"
>
<template #actions>
<UButton :to="SETTINGS_VARIANTS_PATH" class="h-11 px-4 lg:h-8">
{{ t('Go to image variants') }}
</UButton>
</template>
</UEmpty>
<UEmpty
v-if="view === 'plain'"
variant="naked"
icon="i-lucide-layers"
:title="t('No image variants yet')"
:description="
t(
'Ask an owner or admin to add one. Until you do, any request with the ?v= parameter returns status 400 unknown_variant. Requests without it return the original image.',
)
"
:ui="EMPTY_UI"
/>
<template v-if="showBuilder">
<div class="flex flex-col gap-2">
<div class="flex flex-wrap items-baseline gap-3">
<h3 :class="SUB_HEADING">{{ t('Variants') }}</h3>
<span class="text-muted">{{ countText }}</span>
</div>
<div :class="TABLE_BOX">
<div role="table" :aria-label="t('Variants')" class="min-w-130">
<div role="rowgroup">
<div
role="row"
:class="[VARIANT_COLUMNS, 'h-9 bg-elevated font-medium text-muted']"
>
<span role="columnheader">{{ t('Name') }}</span>
<span role="columnheader">{{ t('Dimensions') }}</span>
<span role="columnheader">{{ t('Format') }}</span>
<span role="columnheader">{{ t('Example URL') }}</span>
</div>
</div>
<div
role="rowgroup"
class="overflow-y-auto"
:class="scrollBody ? 'max-h-66' : ''"
>
<div
v-for="row in rows"
:key="row.name"
role="row"
:class="[
VARIANT_COLUMNS,
'min-h-10 border-t border-default',
row.name === selected ? 'bg-elevated' : 'bg-default',
]"
>
<span
role="cell"
class="font-mono text-xs/5 font-medium text-highlighted"
>{{ row.name }}</span
>
<span role="cell" class="text-toned">{{ row.dimensions }}</span>
<span role="cell" class="text-toned">{{ row.format }}</span>
<span role="cell" class="truncate font-mono text-xs/5 text-toned">{{
row.url
}}</span>
</div>
</div>
</div>
</div>
<span v-if="hasDisabled" class="text-muted">{{
t(
'Disabled variants are not served. A request for one returns 400 "Unknown variant".',
)
}}</span>
</div>
<div class="flex flex-col gap-2">
<h3 :class="SUB_HEADING">{{ t('URL builder') }}</h3>
<div
class="grid items-start gap-4 border border-default bg-default p-4 lg:grid-cols-2 lg:p-5"
>
<div class="flex min-w-0 flex-col gap-3">
<div class="flex flex-col gap-1.5">
<span :class="FIELD_LABEL">{{ t('Image') }}</span>
<BuilderSelect
:model-value="SAMPLE_IMAGE"
:items="[SAMPLE_IMAGE]"
:label="t('Image')"
/>
</div>
<div class="flex flex-col gap-1.5">
<span :class="FIELD_LABEL">{{ t('Variant') }}</span>
<BuilderSelect
:model-value="selected"
:items="enabledNames"
:label="t('Variant')"
:display="variantDisplay"
:warning="removed"
@update:model-value="chosen = $event"
/>
</div>
<div class="flex flex-col gap-1.5">
<span :class="FIELD_LABEL">{{ t('URL') }}</span>
<CodeSnippet v-model:active="urlTab" :tabs="urlTabs" />
</div>
<UAlert
v-if="removed"
role="alert"
color="warning"
variant="soft"
:close="false"
icon="i-lucide-triangle-alert"
:title="removedTitle"
:ui="{
root: 'gap-2.5 px-3.5 py-3',
description: 'text-toned text-pretty',
}"
>
<template #description>
<CodeText :text="removedDescription" />
</template>
</UAlert>
</div>
<div class="flex min-w-0 flex-col gap-1.5">
<span :class="FIELD_LABEL">{{ t('Preview') }}</span>
<div
role="img"
:aria-label="previewLabel"
class="image-placeholder flex max-h-60 w-full items-center justify-center border border-default text-toned"
:class="removed ? 'opacity-50' : ''"
:style="{ aspectRatio: previewRatio }"
>
<span
class="border border-default bg-default px-2 py-1 font-mono text-xs font-medium"
>{{ previewLabel }}</span
>
</div>
</div>
</div>
</div>
</template>
<div v-if="showResponses" class="flex flex-col gap-2">
<h3 :class="SUB_HEADING">{{ t('Responses') }}</h3>
<div :class="TABLE_BOX">
<div role="table" :aria-label="t('Responses')" class="min-w-140">
<div role="rowgroup" class="sr-only">
<div role="row">
<span role="columnheader">{{ t('Request') }}</span>
<span role="columnheader">{{ t('Status') }}</span>
<span role="columnheader">{{ t('Response') }}</span>
</div>
</div>
<div role="rowgroup">
<div
v-for="(row, index) in responseRows"
:key="row.request"
role="row"
:class="[
RESPONSE_COLUMNS,
'min-h-10 items-center py-1.5',
index > 0 ? 'border-t border-default' : '',
]"
>
<span role="cell" class="font-mono text-xs/5 text-highlighted">{{
row.request
}}</span>
<span
role="cell"
class="font-mono text-xs/5 font-medium text-highlighted"
>{{ row.status }}</span
>
<span role="cell" class="flex min-w-0 flex-col gap-0.5 text-toned">
{{ row.response }}
<span
v-if="row.body"
class="font-mono text-xs/4.5 wrap-anywhere text-muted"
>{{ row.body }}</span
>
</span>
</div>
</div>
</div>
</div>
</div>
</section>
</template>
+2 -1
View File
@@ -51,7 +51,7 @@ function onJump(id: string) {
</nav>
<div
class="sticky -top-5 z-5 order-first -mx-4 -mt-5 mb-5 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 border-b border-default bg-default px-4 py-2 lg:hidden"
>
<USelectMenu
:model-value="active"
@@ -64,6 +64,7 @@ function onJump(id: string) {
color="neutral"
variant="outline"
class="w-full"
:content="{ sideOffset: 4 }"
:ui="{
base: 'h-11 rounded-none border border-accented bg-default px-3 text-base ring-0 hover:bg-default',
trailingIcon: 'size-4 text-muted',
@@ -0,0 +1,132 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import uiPlugin from '@nuxt/ui/vue-plugin'
import UButton from '@nuxt/ui/components/Button.vue'
import CodeSnippet from '@/components/CodeSnippet.vue'
import type { CodeSnippetTab } from '@/lib/codeSnippet'
const THREE_TABS: CodeSnippetTab[] = [
{ k: 'curl', label: 'curl', code: 'curl -O "https://cms.renew.example/a"' },
{ k: 'html', label: 'HTML', code: '<img src="a">' },
{ k: 'js', label: 'JavaScript', code: 'const res = await fetch("a")' },
]
let wrapper: VueWrapper | null = null
function stubClipboard() {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
return writeText
}
function mountSnippet(tabs: CodeSnippetTab[] = THREE_TABS) {
wrapper = mount(CodeSnippet, {
props: { tabs },
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
return wrapper
}
function copyButton(target: VueWrapper) {
return target.get('button[aria-label="Copy code"]')
}
afterEach(() => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
document.body.innerHTML = ''
})
describe('CodeSnippet', () => {
it('renders three tabs, the first selected with an underline, and a Copy code button', () => {
const target = mountSnippet()
const list = target.get('[role="tablist"]')
expect(list.attributes('aria-label')).toBe('Language')
const tabs = list.findAll('[role="tab"]')
expect(tabs.map((tab) => tab.text())).toEqual(['curl', 'HTML', 'JavaScript'])
expect(tabs[0]!.attributes('aria-selected')).toBe('true')
expect(tabs[0]!.classes().join(' ')).toContain('shadow-')
expect(tabs[1]!.attributes('aria-selected')).toBe('false')
expect(tabs[1]!.classes().join(' ')).not.toContain('shadow-')
expect(copyButton(target).text()).toBe('Copy')
})
it('switches the pre on a tab click and copies the active tab', async () => {
const writeText = stubClipboard()
const target = mountSnippet()
expect(target.get('pre').text()).toBe(THREE_TABS[0]!.code)
await target.findAll('[role="tab"]')[2]!.trigger('click')
expect(target.get('pre').text()).toBe(THREE_TABS[2]!.code)
await copyButton(target).trigger('click')
expect(writeText).toHaveBeenCalledWith(THREE_TABS[2]!.code)
})
it('moves between tabs with the arrow keys', async () => {
const target = mountSnippet()
await target.findAll('[role="tab"]')[0]!.trigger('keydown', { key: 'ArrowRight' })
expect(target.get('pre').text()).toBe(THREE_TABS[1]!.code)
await target.findAll('[role="tab"]')[1]!.trigger('keydown', { key: 'ArrowLeft' })
expect(target.get('pre').text()).toBe(THREE_TABS[0]!.code)
})
it('renders a muted label and no tab for a single tab', () => {
const target = mountSnippet([
{ k: 'base', label: 'Base URL', code: 'https://cms.renew.example' },
])
expect(target.findAll('[role="tab"]')).toHaveLength(0)
expect(target.text()).toContain('Base URL')
expect(target.get('pre').text()).toBe('https://cms.renew.example')
})
it('copies, reads Copied in a polite region for 2 s, then reverts', async () => {
const writeText = stubClipboard()
vi.useFakeTimers()
const target = mountSnippet()
await copyButton(target).trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith(THREE_TABS[0]!.code)
const region = target.get('[aria-live="polite"]')
expect(region.text()).toBe('Copied')
expect(target.findComponent(UButton).props('icon')).toBe('i-lucide-check')
await vi.advanceTimersByTimeAsync(1999)
expect(region.text()).toBe('Copied')
await vi.advanceTimersByTimeAsync(1)
expect(region.text()).toBe('Copy')
expect(target.findComponent(UButton).props('icon')).toBe('i-lucide-copy')
})
it('copies two instances independently', async () => {
stubClipboard()
const first = mountSnippet()
const second = mount(CodeSnippet, {
props: { tabs: THREE_TABS },
attachTo: document.body,
global: { plugins: [uiPlugin] },
})
await copyButton(first).trigger('click')
await flushPromises()
expect(copyButton(first).text()).toBe('Copied')
expect(copyButton(second).text()).toBe('Copy')
second.unmount()
})
it('draws a focusable pre without syntax markup', () => {
const target = mountSnippet()
const pre = target.get('pre')
expect(pre.attributes('tabindex')).toBe('0')
expect(pre.find('span').exists()).toBe(false)
})
})