<script setup lang="ts">
// Progressive disclosure on <details>, so it works before JS arrives and is
// announced correctly without a single ARIA attribute.
export type AccordionItem = { title: string; body: string }
defineProps<{ items: AccordionItem[] }>()
</script>
<template>
<div class="ads-accordion">
<details v-for="(item, i) in items" :key="i" :open="i === 0 ? true : undefined">
<summary>{{ item.title }}</summary>
<div class="panel">{{ item.body }}</div>
</details>
</div>
</template>
AdsDrawer
Row detail: a right-hand panel where the selected row explains itself, so the table never leaves the screen. Row detail never opens in a modal. Given @close, it carries a close button and Escape closes it; focus moves into the panel on open and back on close.
Shown at the top of the panel and used as its accessible name.
open
boolean
true
Closed, the main slot takes the full width.
@close
() => void
—
Adds the close button and Escape.
#panel
slot
—
The detail — usually AdsDl.
Source · ads/app/components/AdsDrawer.vue
vue
<script setup lang="ts">
// A right-hand detail panel. In a dashboard this is where a selected row
// explains itself, so the table never has to leave the screen — which is why
// row detail opens here and never in a modal: a modal hides the very rows the
// reader is comparing against.
//
// The panel is a labelled landmark. Given a `@close` listener it carries a
// close button and Escape closes it; when it opens, focus moves into it, and
// when it closes, focus goes back to whatever opened it (usually the row).
const props = withDefaults(defineProps<{ title?: string; open?: boolean }>(), { title: undefined, open: true })
const emit = defineEmits<{ close: [] }>()
// Only offer a close control when someone is listening for it; a button that
// does nothing is worse than no button.
const closable = !!getCurrentInstance()?.vnode.props?.onClose
const panel = ref<HTMLElement | null>(null)
let opener: HTMLElement | null = null
watch(
() => props.open,
async (open) => {
if (!false) return
if (open) {
opener = document.activeElement as HTMLElement | null
await nextTick()
panel.value?.focus()
} else {
opener?.focus?.()
opener = null
}
},
)
function onKey(e: KeyboardEvent) {
if (e.key !== 'Escape' || !closable) return
e.stopPropagation()
emit('close')
}
</script>
<template>
<div class="ads-drawer" :class="{ 'is-closed': !open }">
<div class="ads-drawer__main"><slot /></div>
<aside
v-if="open"
ref="panel"
class="panel ads-drawer__panel"
tabindex="-1"
:aria-label="title ?? 'Detail'"
@keydown="onKey"
>
<div v-if="title || closable" class="ads-drawer__head">
<span v-if="title" class="ads-label">{{ title }}</span>
<button v-if="closable" type="button" class="ads-icon-button ads-drawer__close" aria-label="Close detail" @click="emit('close')">
<Icon name="plus" :size="14" style="transform: rotate(45deg)" />
</button>
</div>
<slot name="panel" />
</aside>
</div>
</template>
<style scoped>
.ads-drawer.is-closed {
grid-template-columns: minmax(0, 1fr);
}
.ads-drawer__panel:focus {
outline: none;
}
.ads-drawer__panel:focus-visible {
box-shadow: inset 0 0 0 2px var(--accent);
}
.ads-drawer__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-2);
min-height: 28px;
}
.ads-drawer__close {
width: 28px;
height: 28px;
margin-left: auto;
}
</style>
AdsPopover
Supplementary context only, anchored to what prompted it. More than a tooltip, less than a drawer — and nothing needed to finish the task may live only in here. Row detail goes in the drawer. With trigger-label it brings its own button; Escape or a click outside closes it and focus returns.
Link scoreA 0–100 measure of how much of the site's internal link value reaches this page.How it is calculated
Link scoreA 0–100 measure of how much of the site's internal link value reaches this page.How it is calculated
vue
<AdsPopover
trigger-label="Explain link score"
label="Link score"
body="A 0–100 measure of internal link value."
:link="{ label: 'How it is calculated' }" />
AdsPopover props
Prop
Type
Default
Description
triggerLabel
string
—
Renders a button that opens the popover. Without it, a static card.
label
string
body
string
link
{ label, to? }
—
Source · ads/app/components/AdsPopover.vue
vue
<script setup lang="ts">
// A small amount of extra context, anchored to what prompted it. More than a
// tooltip, less than a drawer — supplementary by definition. Nothing the
// reader needs to finish the task may live only in here: if it matters, it
// belongs on the page; if it is a row's detail, it belongs in the drawer.
//
// Without `triggerLabel` it renders as a static card, for layouts that place
// it themselves. With one, it brings its own button: the button reports
// `aria-expanded`, Escape or a click outside closes the panel, and focus goes
// back to the button.
const props = withDefaults(
defineProps<{ label?: string; body?: string; link?: { label: string; to?: string }; triggerLabel?: string }>(),
{ label: '', body: '', link: undefined, triggerLabel: undefined },
)
const id = `ads-popover-${useId().replace(/[^\w-]/g, '-')}`
const open = ref(false)
const root = ref<HTMLElement | null>(null)
const trigger = ref<HTMLElement | null>(null)
function close(refocus = true) {
if (!open.value) return
open.value = false
if (refocus) trigger.value?.focus()
}
function onOutside(e: PointerEvent) {
if (root.value && !root.value.contains(e.target as Node)) close(false)
}
function onKey(e: KeyboardEvent) {
if (e.key !== 'Escape' || !open.value) return
e.stopPropagation()
close()
}
watch(open, (o) => {
if (!false) return
if (o) document.addEventListener('pointerdown', onOutside)
else document.removeEventListener('pointerdown', onOutside)
})
onBeforeUnmount(() => {
if (false) document.removeEventListener('pointerdown', onOutside)
})
</script>
<template>
<span v-if="triggerLabel" ref="root" class="ads-popover__anchor" @keydown="onKey">
<button
ref="trigger"
type="button"
class="btn btn-quiet btn-sm"
:aria-expanded="open"
:aria-controls="id"
@click="open = !open"
>
{{ triggerLabel }}
</button>
<Transition name="ads-popover__fade">
<div v-show="open" :id="id" class="ads-popover is-anchored" role="dialog" :aria-label="label || triggerLabel">
<span v-if="label" class="ads-label">{{ label }}</span>
<span v-if="body" class="ads-popover__body">{{ body }}</span>
<slot />
<NuxtLink v-if="link" class="ads-link" :to="link.to ?? '#'">{{ link.label }}</NuxtLink>
</div>
</Transition>
</span>
<div v-else class="ads-popover">
<span v-if="label" class="ads-label">{{ label }}</span>
<span v-if="body" class="ads-popover__body">{{ body }}</span>
<slot />
<NuxtLink v-if="link" class="ads-link" :to="link.to ?? '#'">{{ link.label }}</NuxtLink>
</div>
</template>
<style scoped>
.ads-popover__body {
font-size: 0.84rem;
color: var(--text-soft);
}
.ads-popover__anchor {
position: relative;
display: inline-block;
}
.ads-popover.is-anchored {
position: absolute;
top: calc(100% + var(--sp-2));
left: 0;
z-index: 20;
width: max-content;
}
.ads-popover__fade-enter-active,
.ads-popover__fade-leave-active {
transition:
opacity var(--dur-control) var(--ease),
transform var(--dur-control) var(--ease);
}
.ads-popover__fade-enter-from,
.ads-popover__fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>
AdsTree
Real nesting — site structure, a category hierarchy, a file system. Built on nested lists.
<script setup lang="ts">
// A tree is the right shape for anything with real nesting. Built on nested
// lists so the nesting is in the markup, not only in the indentation. Counts
// are figures, so they are set in tabular mono and right-aligned on the row.
export type TreeNode = { label: string; count?: number; children?: TreeNode[] }
defineProps<{ nodes: TreeNode[] }>()
</script>
<template>
<ul class="ads-tree">
<li v-for="(n, i) in nodes" :key="i">
<span>
{{ n.label }}
<span v-if="n.count !== undefined" class="count num">{{ n.count.toLocaleString('en-GB') }}</span>
</span>
<AdsTree v-if="n.children?.length" :nodes="n.children" />
</li>
</ul>
</template>
AdsSteps
Where you are in a sequence with a known end. Counts discrete stages, not percentage. The current step is marked aria-current, and every state is spoken as well as drawn.
<script setup lang="ts">
// Where you are in a sequence with a known end. Distinct from a progress bar:
// this counts discrete stages, not percentage.
//
// The state is never the pip's fill alone: a done step swaps its number for a
// tick, the current one is marked `aria-current="step"`, and both are spoken.
export type Step = { label: string; state?: 'done' | 'now' | 'todo' }
defineProps<{ steps: Step[] }>()
</script>
<template>
<ol class="ads-steps">
<li
v-for="(s, i) in steps"
:key="i"
:class="{ 'is-done': s.state === 'done', 'is-now': s.state === 'now' }"
:aria-current="s.state === 'now' ? 'step' : undefined"
>
<span class="pip num" aria-hidden="true">{{ s.state === 'done' ? '✓' : i + 1 }}</span>
{{ s.label }}
<span v-if="s.state === 'done'" class="sr-only">(done)</span>
<span v-else-if="s.state === 'now'" class="sr-only">(current)</span>
</li>
</ol>
</template>