← All stories

COMPONENT · ui-tabs

ui-tabs

Component Tier 1 (primitive) Used by account overview, event dashboard tabs, guest list status tabs, reports center

Tabs that switch which sub-section is visible. Sounds simple. The contracts that hide the bugs: keyboard navigation per WAI-ARIA Authoring Practices, URL state per the SPA mandate, badge counts that don't lie about content load state, and overflow behavior that doesn't bury tabs under "More".

Component contract

Renders a tab list + active panel. URL-synced when linkBuilder is provided; otherwise local state only.

  • tabs: TabDef[] — id, label, badge?, icon?, disabled?: boolean.
  • activeTabId: string — controlled.
  • onChange: (nextTabId) => void
  • linkBuilder?: (tabId) => string — when provided, tabs are rendered as <a> with hrefs; clicks navigate. Mandatory in production for refresh-safe state.
  • renderPanel: (tabId) => ReactNode — only the active tab's panel is rendered. Inactive panels are unmounted.
  • orientation?: "horizontal" | "vertical" — defaults to horizontal.
  • overflowMode?: "wrap" | "scroll" — what happens when tabs don't fit. Defaults to scroll. NEVER "more-menu" — burying tabs is a Ratchet violation.

Interaction surface

  1. Tab list with active indicator.

    Each tab is a button (or anchor with linkBuilder). Active tab has visible underline (or vertical bar for vertical orientation) + bold weight + aria-selected=true.

  2. Click switches tab.

    Click fires onChange with the clicked tab's id. With linkBuilder, the click is also a link navigation; the parent's router updates the URL and the activeTabId.

  3. Keyboard navigation per WAI-ARIA APG.

    Tab focuses the active tab. Inside the tab list, ArrowLeft/ArrowRight (or Up/Down for vertical) move focus + auto-activate the focused tab. Home/End jump to first/last. Tab again moves focus to the active panel content. Disabled tabs are skipped during arrow nav.

  4. Badge counts on tabs.

    A tab with a badge renders the count to the right of the label. Badge=0 hides the badge entirely. Badge is from the parent — the tabs component does not fetch or compute counts.

  5. Overflow handling.

    scroll mode: tab list horizontally scrolls; arrow buttons appear when overflow is present. wrap mode: tabs wrap to multiple rows. Never a hidden "More" menu.

Failure modes

Arrow keys move focus but don't activate the tab

Trigger: ArrowRight moves focus; user has to press Enter to activate. WAI-ARIA APG calls for auto-activation.

Tabs auto-activate on focus change via arrow keys. (This is the default per APG — manual activation is also valid but less common; component picks auto.) Harness: focus first tab, ArrowRight, assert second tab activated AND onChange called.

Disabled tab activated via arrow nav

Trigger: tabs[1] is disabled but ArrowRight from tabs[0] focuses + activates it.

Disabled tabs are skipped during arrow nav. ArrowRight from tabs[0] when tabs[1].disabled lands on tabs[2]. Harness: middle tab disabled, arrow over it, focus skips.

Inactive panels stay mounted, leaking state

Trigger: all panels are rendered with display:none; parent state across tabs accumulates and the page slows.

Only the active panel is mounted. Switching tabs unmounts the previous panel's React tree. Parent state persistence is the parent's responsibility. Harness: switch tabs A→B, the React tree for A is unmounted (not just hidden).

Badge=0 still shown

Trigger: tab badges render whenever badge prop exists, even when it's 0.

Badge=0 hides the badge. Visual: tab without badge. Harness: tab with badge=0, assert no badge element visible. Tab with badge=5, badge visible.

Overflow uses a More menu

Trigger: overflowMode is implemented as a hidden "More" dropdown that hides tabs the user doesn't see.

Tabs are always visible — scroll or wrap, never hidden. The "More" menu pattern is explicitly forbidden in the contract. Harness: render with 20 tabs in narrow viewport, assert no "More" button, assert tab list has overflow-x: auto OR wraps.

URL doesn't update when tab changes

Trigger: linkBuilder is provided but tab clicks call onChange without navigating.

With linkBuilder, tabs render as <a href>. Clicks let the parent's router intercept and navigate; activeTabId updates from the URL on next render. Harness: linkBuilder provided, click tab B, URL contains the linkBuilder('b') value.

Refresh on a tabbed page lands on the wrong tab

Trigger: linkBuilder provided but state is hydrated from local-storage instead of URL.

activeTabId is derived from URL (parent's job, but the contract requires it). Harness: navigate to URL for tab B, refresh, on render activeTabId=b. (Verified at branch level for the integrated parent + tabs combo.)

Vertical orientation uses left/right arrows

Trigger: orientation=vertical but ArrowRight/ArrowLeft still nav between tabs.

orientation=vertical uses Up/Down arrows; left/right are no-ops (or switch to next/prev panel content if relevant). Harness: orientation=vertical, ArrowDown advances tab, ArrowRight is no-op.

Accessibility

  • Tab list has role="tablist" + aria-orientation.
  • Each tab has role="tab", aria-selected, aria-controls pointing at the panel id, and tabindex management (active tab tabindex=0; others tabindex=-1).
  • Active panel has role="tabpanel" with aria-labelledby pointing at the tab.
  • Disabled tabs have aria-disabled="true".
  • Color contrast: tab label ≥ 4.5:1 in every state, active indicator ≥ 3:1 against background.
  • axe-clean at severity ≥ serious.

Stable test attributes

data-testWherePurpose
ui-tabsOuter wrapperdata-active-tab attr
ui-tabs-listTab listrole=tablist
ui-tabs-tabEach tabdata-tab-id, data-state (active/inactive/disabled)
ui-tabs-tab-badgeBadge inside tabVisible only when badge > 0
ui-tabs-panelActive panel regionrole=tabpanel
ui-tabs-overflow-prevScroll-prev arrowVisible only when scroll-overflow + scrollable left
ui-tabs-overflow-nextScroll-next arrowVisible only when scroll-overflow + scrollable right

Agent test plan

Standalone probes against /admin-test/ui-tabs-fixture with variants: 3-tabs simple, 5-tabs with badges, with-disabled, vertical, with-linkBuilder, narrow-viewport-overflow.

Probe list
- click-fires-onChange: click tab B, onChange called with "b"
- aria-selected-on-active: active tab has aria-selected=true, others=false
- aria-controls-points-to-panel: active tab aria-controls matches panel id
- arrow-right-auto-activates: focus first, ArrowRight, second tab active AND focused
- arrow-skips-disabled: middle tab disabled, ArrowRight from first lands on third
- home-end-jumps-to-first-last: focus middle, Home → first; End → last
- enter-activates-explicit: also support Enter on focused tab (for manual-activation pattern)
- inactive-panels-unmounted: switch from A to B, A's panel React tree gone
- badge-zero-hidden: badge=0 → ui-tabs-tab-badge not visible
- badge-positive-shown: badge=5 → ui-tabs-tab-badge visible with text "5"
- overflow-no-more-menu: 20 tabs narrow viewport → no "More" button, scroll arrows visible
- overflow-scroll-arrows: scrollable list → ui-tabs-overflow-prev/next visible at boundaries
- linkBuilder-renders-anchors: linkBuilder provided → each tab is 
- linkBuilder-click-navigates: click tab → URL contains linkBuilder result
- vertical-up-down-only: orientation=vertical, ArrowDown advances, ArrowRight no-op
- color-contrast-tab-label: ≥ 4.5:1 in every state
- color-contrast-active-indicator: ≥ 3:1
- axe-clean-serious: no serious violations