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.
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
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
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.