Problem
Keyboard navigation in the @webjsdev/ui tabs component is broken. Pressing
ArrowRight / ArrowLeft (or ArrowDown / ArrowUp in vertical orientation), Home,
or End on a focused tab trigger changes the selected tab but does not move
keyboard focus to the newly selected trigger. This violates the WAI-ARIA APG
Tabs pattern (focus must follow the roving tab stop) and leaves keyboard users
with the focus ring stranded on the previously focused trigger while a different
panel is shown.
Root cause (packages/ui/packages/registry/components/tabs.ts, UiTabsTrigger._onKeyDown).
The handler ends with target.focus(), where target is the
<ui-tabs-trigger> host custom element. The actually-focusable element is
the inner <button role="tab"> that the trigger renders. The host itself has no
tabindex, so HTMLElement.focus() on it is a no-op and focus stays put. The
roving tabindex (0 on active, -1 on inactive) is correctly applied to the
inner button, so Tab-into the tablist works. Only arrow/Home/End focus movement
is broken.
The gap is isolated to tabs. toggle-group works because its item host is the
role="button" element (item.focus() focuses a genuinely focusable host).
dropdown-menu works because it queries and focuses the inner [role="menuitem"]
elements. tabs is the one Tier-2 keyboard component that focuses the wrong node.
There are currently no keyboard-navigation browser tests for tabs at all.
test/components/browser/ui-stateful.test.js covers rendering / click / hidden
only. test/components/browser/ui-a11y.test.js covers tabs ARIA wiring but not
keyboard focus (the only keyboard-nav test in the suite is for toggle-group).
That coverage hole is why this regressed silently.
Design / approach
Focus the inner focusable control, not the host. In _onKeyDown, resolve the
target trigger's inner button before focusing, for example
(target.querySelector('[role="tab"]') as HTMLElement | null)?.focus().
Keep the existing APG "automatic activation" behaviour (arrow both selects and
moves focus). That is the correct pattern for tabs and matches shadcn/Radix.
Verify no double-render focus loss. Setting value triggers a microtask
re-broadcast (requestUpdate() on the triggers), so confirm lit-html reuses the
inner <button> so programmatic focus survives the re-render (it should, since
the template structure is stable). If focus is lost across the re-render, move
the focus call into the post-update path or re-query after the update settles.
While here, audit the other Tier-2 keyboard components for the same
"focus-the-host-instead-of-the-inner-control" class of bug and confirm each
focuses a genuinely focusable node (toggle-group and dropdown-menu already do,
they just lack full coverage).
Implementation notes (for the implementing agent)
- Where to edit:
packages/ui/packages/registry/components/tabs.ts,
UiTabsTrigger._onKeyDown (around L240 to L262). The one-line fix is the final
target.focus(), changed to focus target.querySelector('[role="tab"]').
- Website copy regenerates automatically:
packages/ui/packages/website/components/
is gitignored and rebuilt from packages/registry/ by scripts/copy-registry.js.
Do NOT hand-edit packages/website/components/tabs.ts (see packages/ui/AGENTS.md
ui-website footgun).
- Landmines:
- The host
<ui-tabs-trigger> is NOT focusable (light DOM, no tabindex on the
host). Only the inner <button role="tab"> is. This is the whole bug.
- Value change fires
UiTabs.updated() on a queueMicrotask and calls
_broadcast(), which runs requestUpdate() on every trigger/panel. Any
focus call must land on the final button element and survive that re-render.
- Do not regress SSR.
render() runs server-side, so keep the typeof closest
guards. Keyboard handlers are client-only (@keydown), which is fine.
- Invariants: Tier-2 components own their ARIA (packages/ui/AGENTS.md
"Accessibility"). Light DOM plus Tailwind, no shadow root.
- Tests + docs surfaces:
- Add browser keyboard-nav tests for tabs in
packages/ui/test/components/browser/ui-a11y.test.js (the ui-tabs a11y
suite) mirroring the existing toggle-group tests. ArrowRight moves
document.activeElement to the next trigger's [role="tab"] AND updates
data-state/value. ArrowLeft wraps. Home/End jump. Vertical orientation
uses ArrowUp/Down. Include a counterfactual (a test that fails against the
current target.focus(), asserting document.activeElement is the new inner
button, which fails today).
- Add matching keyboard-nav coverage for dropdown-menu (ArrowUp/Down moves
focus, Escape closes) to close the "no coverage" half of this report, even
though it currently passes.
- Run via
npm run test:browser.
- No public API change, so AGENTS.md needs only a note if behaviour docs
reference keyboard nav. The tabs JSDoc keyboard block is already correct (it
documents the intended behaviour that was not being met).
Acceptance criteria
Problem
Keyboard navigation in the
@webjsdev/uitabs component is broken. PressingArrowRight / ArrowLeft (or ArrowDown / ArrowUp in vertical orientation), Home,
or End on a focused tab trigger changes the selected tab but does not move
keyboard focus to the newly selected trigger. This violates the WAI-ARIA APG
Tabs pattern (focus must follow the roving tab stop) and leaves keyboard users
with the focus ring stranded on the previously focused trigger while a different
panel is shown.
Root cause (
packages/ui/packages/registry/components/tabs.ts,UiTabsTrigger._onKeyDown).The handler ends with
target.focus(), wheretargetis the<ui-tabs-trigger>host custom element. The actually-focusable element isthe inner
<button role="tab">that the trigger renders. The host itself has notabindex, soHTMLElement.focus()on it is a no-op and focus stays put. Theroving
tabindex(0 on active, -1 on inactive) is correctly applied to theinner button, so Tab-into the tablist works. Only arrow/Home/End focus movement
is broken.
The gap is isolated to tabs.
toggle-groupworks because its item host is therole="button"element (item.focus()focuses a genuinely focusable host).dropdown-menuworks because it queries and focuses the inner[role="menuitem"]elements. tabs is the one Tier-2 keyboard component that focuses the wrong node.
There are currently no keyboard-navigation browser tests for tabs at all.
test/components/browser/ui-stateful.test.jscovers rendering / click / hiddenonly.
test/components/browser/ui-a11y.test.jscovers tabs ARIA wiring but notkeyboard focus (the only keyboard-nav test in the suite is for toggle-group).
That coverage hole is why this regressed silently.
Design / approach
Focus the inner focusable control, not the host. In
_onKeyDown, resolve thetarget trigger's inner button before focusing, for example
(target.querySelector('[role="tab"]') as HTMLElement | null)?.focus().Keep the existing APG "automatic activation" behaviour (arrow both selects and
moves focus). That is the correct pattern for tabs and matches shadcn/Radix.
Verify no double-render focus loss. Setting
valuetriggers a microtaskre-broadcast (
requestUpdate()on the triggers), so confirm lit-html reuses theinner
<button>so programmatic focus survives the re-render (it should, sincethe template structure is stable). If focus is lost across the re-render, move
the focus call into the post-update path or re-query after the update settles.
While here, audit the other Tier-2 keyboard components for the same
"focus-the-host-instead-of-the-inner-control" class of bug and confirm each
focuses a genuinely focusable node (toggle-group and dropdown-menu already do,
they just lack full coverage).
Implementation notes (for the implementing agent)
packages/ui/packages/registry/components/tabs.ts,UiTabsTrigger._onKeyDown(around L240 to L262). The one-line fix is the finaltarget.focus(), changed to focustarget.querySelector('[role="tab"]').packages/ui/packages/website/components/is gitignored and rebuilt from
packages/registry/byscripts/copy-registry.js.Do NOT hand-edit
packages/website/components/tabs.ts(see packages/ui/AGENTS.mdui-website footgun).
<ui-tabs-trigger>is NOT focusable (light DOM, no tabindex on thehost). Only the inner
<button role="tab">is. This is the whole bug.UiTabs.updated()on aqueueMicrotaskand calls_broadcast(), which runsrequestUpdate()on every trigger/panel. Anyfocus call must land on the final button element and survive that re-render.
render()runs server-side, so keep thetypeof closestguards. Keyboard handlers are client-only (
@keydown), which is fine."Accessibility"). Light DOM plus Tailwind, no shadow root.
packages/ui/test/components/browser/ui-a11y.test.js(theui-tabs a11ysuite) mirroring the existing toggle-group tests. ArrowRight moves
document.activeElementto the next trigger's[role="tab"]AND updatesdata-state/value. ArrowLeft wraps. Home/End jump. Vertical orientationuses ArrowUp/Down. Include a counterfactual (a test that fails against the
current
target.focus(), assertingdocument.activeElementis the new innerbutton, which fails today).
focus, Escape closes) to close the "no coverage" half of this report, even
though it currently passes.
npm run test:browser.reference keyboard nav. The tabs JSDoc keyboard block is already correct (it
documents the intended behaviour that was not being met).
Acceptance criteria
document.activeElementto the newly selected trigger's inner[role="tab"].value/data-state="active"and the focused button stay insync after keyboard nav (no stranded focus ring).
target.focus()and passesafter the fix.
dropdown-menu.
genuinely focusable node.