Start a project

Disclosure

Disclosure

Summary first, detail on request — without losing the reader's place.

AdsAccordion

The simplest disclosure. Native details/summary, so it works before JavaScript and is announced correctly.

Why this page ranks eighth
371 pages share near-duplicate content, but they are 278 separate pieces of work.
What a fix would involve
Two template changes and a canonical rule. Roughly a day.
vue
<AdsAccordion :items="[
  { title: 'Why this page ranks eighth', body: '371 pages share near-duplicate content…' },
]" />
AdsAccordion props
PropTypeDefaultDescription
items{ title, body }[]—
Source · ads/app/components/AdsAccordion.vue
vue
<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.

Crawled pages
/blog/launch2001,2863,762
/pricing2006421,904
/old-docs301088
vue
<AdsDrawer :open="!!row" :title="row?.page" @close="row = null">
  <AdsDataGrid :columns="columns" :rows="rows" @select="(r) => (row = r)" />
  <template #panel><AdsDl :items="rowDetail" /></template>
</AdsDrawer>
AdsDrawer props
PropTypeDefaultDescription
titlestring—Shown at the top of the panel and used as its accessible name.
openbooleantrueClosed, the main slot takes the full width.
@close() => void—Adds the close button and Escape.
#panelslot—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
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
PropTypeDefaultDescription
triggerLabelstring—Renders a button that opens the popover. Without it, a static card.
labelstring
bodystring
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.

  • /
    • blog 842
    • docs 311
vue
<AdsTree :nodes="[{ label: '/', children: [{ label: 'blog', count: 842 }] }]" />
AdsTree props
PropTypeDefaultDescription
nodes{ label, count?, children? }[]—
Source · ads/app/components/AdsTree.vue
vue
<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.

  1. Configure (done)
  2. Crawl (done)
  3. Audit (current)
  4. Plan
vue
<AdsSteps :steps="[
  { label: 'Configure', state: 'done' },
  { label: 'Crawl', state: 'done' },
  { label: 'Audit', state: 'now' },
  { label: 'Plan', state: 'todo' },
]" />
AdsSteps props
PropTypeDefaultDescription
steps{ label, state? }[]—state: done | now | todo
Source · ads/app/components/AdsSteps.vue
vue
<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>