Start a project

Navigation

Navigation

Getting between screens: one rail for destinations, tabs for views of the same thing, crumbs for depth.

AdsTabs

Filtered views of one dataset, where the columns stay put — not navigation between screens, which is the rail's job. The underline is the accent's only appearance in a nav context. Arrow keys move between tabs.

vue
<AdsTabs v-model="tab" label="Links" controls="links-table" :tabs="[
  { label: 'Internal', value: 'internal' },
  { label: 'External', value: 'external' },
]" />
AdsTabs props
PropTypeDefaultDescription
modelValuestring | number—
tabs{ label, value }[]—
labelstring— (required)Accessible name of the tab row.
controlsstring—id of the table the tabs filter (aria-controls).
Source · ads/app/components/AdsTabs.vue
vue
<script setup lang="ts">
// Tabs: filtered views of one dataset, where the columns stay put — not
// navigation between screens, which is the rail's job. A hairline with the
// active tab underlined by the accent, the accent's only appearance in a nav
// context. One tab stop for the row; arrow keys move between tabs, as the
// tablist pattern expects. `controls` is the id of the view the tabs filter.
export type Tab = { label: string; value: string | number }

const props = withDefaults(defineProps<{ modelValue?: string | number; tabs: Tab[]; label: string; controls?: string }>(), {
  modelValue: undefined,
  controls: undefined,
})
const emit = defineEmits<{ (e: 'update:modelValue', v: string | number): void }>()
const root = ref<HTMLElement>()

// With nothing selected yet, the first tab keeps the row reachable.
const stop = computed(() => (props.tabs.some((t) => t.value === props.modelValue) ? props.modelValue : props.tabs[0]?.value))

function onKeydown(e: KeyboardEvent) {
  const at = props.tabs.findIndex((t) => t.value === stop.value)
  const last = props.tabs.length - 1
  const next = { ArrowRight: at + 1, ArrowLeft: at - 1, Home: 0, End: last }[e.key]
  if (next === undefined || !props.tabs.length) return
  e.preventDefault()
  const i = (next + props.tabs.length) % props.tabs.length
  emit('update:modelValue', props.tabs[i]!.value)
  nextTick(() => root.value?.querySelectorAll<HTMLElement>('[role="tab"]')[i]?.focus())
}
</script>

<template>
  <div ref="root" class="ads-tabs" role="tablist" :aria-label="label" @keydown="onKeydown">
    <button
      v-for="t in tabs"
      :key="t.value"
      type="button"
      role="tab"
      :aria-selected="props.modelValue === t.value"
      :aria-controls="controls"
      :tabindex="t.value === stop ? 0 : -1"
      :class="{ 'is-on': props.modelValue === t.value }"
      @click="emit('update:modelValue', t.value)"
    >
      {{ t.label }}
    </button>
  </div>
</template>

AdsCrumbs

Where you are, once a screen is three levels deep or more. The last crumb is the current page.

vue
<AdsCrumbs :items="[{ label: 'Projects', to: '/' }, { label: 'outerbridgelaw.com', to: '/p/1' }, { label: 'Response codes' }]" />
AdsCrumbs props
PropTypeDefaultDescription
items{ label, to? }[]—The last item is marked aria-current=page.
Source · ads/app/components/AdsCrumbs.vue
vue
<script setup lang="ts">
// Breadcrumb trail: where you are, once a screen is three levels deep or
// more. Only the last crumb is the current page; a middle crumb without a
// link is plain text, not "current".
export type Crumb = { label: string; to?: string }

defineProps<{ items: Crumb[] }>()
</script>

<template>
  <nav class="ads-crumbs" aria-label="Breadcrumb">
    <template v-for="(c, i) in items" :key="i">
      <span v-if="i > 0" class="sep" aria-hidden="true">/</span>
      <span v-if="i === items.length - 1" aria-current="page">{{ c.label }}</span>
      <NuxtLink v-else-if="c.to" :to="c.to">{{ c.label }}</NuxtLink>
      <span v-else>{{ c.label }}</span>
    </template>
  </nav>
</template>

AdsPager

Which slice you are looking at. Page numbers are figures, so they are monospaced. Past seven pages it keeps the ends and the current page's neighbours, so every page stays reachable.

vue
<AdsPager v-model:page="page" :pages="24" />
AdsPager props
PropTypeDefaultDescription
pagenumber (v-model:page)1
pagesnumber1
@change(page: number)—Also emitted as update:page.
Source · ads/app/components/AdsPager.vue
vue
<script setup lang="ts">
// Pagination: which slice of the table you are looking at. Page numbers are
// figures, so they are monospaced. Past seven pages the row shows the ends
// and the neighbours of the current page with a gap between — every page
// stays two clicks away, and "page 400" is still reachable, which infinite
// scroll would not allow.
const props = withDefaults(defineProps<{ page?: number; pages?: number }>(), { page: 1, pages: 1 })
const emit = defineEmits<{ (e: 'change', page: number): void; (e: 'update:page', page: number): void }>()

const items = computed<(number | 'gap')[]>(() => {
  const { page, pages } = props
  if (pages <= 7) return Array.from({ length: pages }, (_, i) => i + 1)
  const keep = new Set([1, pages, page - 1, page, page + 1].filter((n) => n >= 1 && n <= pages))
  const out: (number | 'gap')[] = []
  ;[...keep].sort((a, b) => a - b).forEach((n, i, all) => {
    if (i && n - all[i - 1]! > 1) out.push('gap')
    out.push(n)
  })
  return out
})

function go(n: number) {
  if (n < 1 || n > props.pages || n === props.page) return
  emit('update:page', n)
  emit('change', n)
}
</script>

<template>
  <nav class="ads-pager" aria-label="Pagination">
    <button type="button" aria-label="Previous page" :disabled="page <= 1" @click="go(page - 1)">‹</button>
    <template v-for="(n, i) in items" :key="n === 'gap' ? `gap${i}` : n">
      <span v-if="n === 'gap'" class="ads-pager__gap" aria-hidden="true">…</span>
      <button
        v-else
        type="button"
        :class="{ 'is-on': n === page }"
        :aria-label="`Page ${n}`"
        :aria-current="n === page ? 'page' : undefined"
        @click="go(n)"
      >
        {{ n }}
      </button>
    </template>
    <button type="button" aria-label="Next page" :disabled="page >= pages" @click="go(page + 1)">›</button>
  </nav>
</template>

<style scoped>
.ads-pager__gap {
  min-width: var(--sp-5);
  text-align: center;
  font-family: var(--mono);
  font-size: 0.78rem;
  color: var(--subdued);
}
</style>