Skip to content
Composables

useGdTabList

The half of the WAI-ARIA tabs pattern everyone leaves out. Claiming role="tablist" is a promise about the KEYBOARD, not about the paint — and markup that announces the role while delivering none of it is worse than plain buttons.

Anatomy

GdTabs is the horizontal skin and GdChoiceList the vertical one — both live, both this composable.

Two skins, one contract. Focus a tab and use the arrow keys, then Home and End; Tab leaves the group rather than walking through it.
Code
const tabs = useGdTabList(items, model)

<div role="tablist" @keydown="tabs.onKeydown">
  <button v-for="(t, i) in items" :key="t.id" :ref="el => tabs.refs[i] = el" v-bind="tabs.bind(t.id)">
    {{ t.label }}
  </button>
</div>

Why a composable

Two skins over one contract: a horizontal underline strip of one-line labels, and a vertical stack of rich option cards. They differ entirely in how they look and not at all in how they behave — and the behaviour is the subtle part. Two copies of it would agree on the day they were written and not much longer.

  • Roving tabindex is the point. Only the active tab is in the tab order, so Tab moves past the group. A tablist where Tab walks every item is the most common way the pattern is got wrong.
  • Disabled items keep their place. They are still rendered and still announced — the arrows just never strand you on one.
  • Arrows wrap. Past the last tab is the first, which is what the pattern specifies and what people expect once they have tried it.

API

Arguments

Props

PropTypeDefault
itemsrequiredRef<T[]> | (() => T[])—

The tabs. Each needs an `id`; `disabled` is optional and is skipped by the keyboard while keeping its place in the list.

modelrequiredRef<string>—

The selected id. The composable writes to it.

orientation"horizontal" | "vertical""horizontal"

Which arrow keys move between tabs. A vertical list answers Up/Down.

Returns

Props

PropTypeDefault
select(id: string) => void—

Select by id, skipping disabled.

move(delta: number) => void—

Move by n, wrapping at the ends and stepping over disabled items.

jump(to: "first" | "last") => void—

Home and End.

onKeydown(e: KeyboardEvent) => void—

The whole keyboard contract. Bind it to the tablist.

tabindex(id: string) => 0 | -1—

Roving tabindex: 0 for the active tab, -1 for the rest, so Tab moves PAST the group rather than through every item.

bind(id: string) => object—

The per-tab ARIA: `role`, `aria-selected`, `tabindex`, `id`, `aria-controls`.

refsRef<HTMLElement[]>—

One element per index, so `refs[i]` lines up with your `v-for` and the composable can move focus.

Navigate

Esc