Compound Headless Tabs and Component State Boundaries
Practical 06 - Compound Headless Tabs and Component State Boundaries
Related: Chapter 6 · Lecture slides
Objective
Build a resilient, headless compound tabs system that decouples interaction state machine logic, keyboard navigation, and WAI-ARIA semantics from presentation and styling.
You will implement:
- A compound component hierarchy (
Tabs,TabsList,Tab,TabPanel) sharing state without prop drilling. - A dual controlled and uncontrolled state contract that prevents ambiguous state ownership.
- A robust WAI-ARIA accessible keyboard contract featuring roving
tabindex, automatic/manual activation modes, and bidirectional panel associations. - Independent verification confirming that consumer styling can change freely without breaking behavioral guarantees.
Prerequisites and Workspace Setup
You need Node.js (v18+) and a modern front-end build environment (such as Vite with TypeScript and React or Vue 3).
Initialize your practical workspace:
Ensure your TypeScript configuration enforces strict type checks ("strict": true).
Stage 1 - Decompose Compound Responsibilities
Deconstruct the tabs widget into four distinct component boundaries. Each component must own a single, cohesive responsibility:
Component Contract Definitions
In src/types.ts, define your public contracts:
Stage 2 - Controlled vs. Uncontrolled State Machine
A component must never exist in an ambiguous half-controlled state. Implement a unified state hook useTabsState that honors the state ownership contract:
- Uncontrolled Mode: If
props.valueisundefined, internal state is initialized toprops.defaultValue(or the first registered tab) and managed locally. - Controlled Mode: If
props.valueis defined, the component derives its active selection strictly fromprops.value. When user interactions trigger a selection, the component delegates the update viaprops.onValueChange(newValue).
Verify that passing both value and defaultValue does not trigger uncontrolled state overwrites, and that controlled updates propagate without lag or double-render cycles.
Stage 3 - Implement the WAI-ARIA and Keyboard Contract
The WAI-ARIA Tabs pattern requires strict accessibility attributes and precise keyboard behavior.
1. ARIA Relationship Wiring
For every tab and panel pair, establish explicit cross-linking IDs:
- The
Tabelement must renderid={tab-${value}}andaria-controls={panel-${value}}. - The
TabPanelelement must renderid={panel-${value}}andaria-labelledby={tab-${value}}. - When active, the
Tabsetsaria-selected="true". Inactive tabs setaria-selected="false". - Inactive
TabPanelcontainers must have the HTMLhiddenattribute applied.
2. Roving tabindex Focus Management
Tabs must not all be focusable with the Tab key:
- The currently selected tab has
tabIndex={0}. - All unselected tabs have
tabIndex={-1}. - When a keyboard user presses
Tab, focus lands only on the active tab. PressingTabagain moves focus completely out of the tablist (into the active panel or the next document control).
3. Arrow Key Navigation
Implement keyboard handling in TabsList:
- Horizontal Orientation:
ArrowRightfocuses the next enabled tab;ArrowLeftfocuses the previous enabled tab. - Vertical Orientation:
ArrowDownfocuses the next enabled tab;ArrowUpfocuses the previous enabled tab. - Navigation Extremes:
Homefocuses the first tab;Endfocuses the last tab. - Wrapping: Moving past the end wraps focus to the beginning (and vice versa).
- Activation Mode:
- In
automaticmode, focusing a tab via Arrow keys immediately selects it and displays its panel. - In
manualmode, moving focus with Arrow keys does not change the active panel until the user pressesEnterorSpace.
- In
Stage 4 - Verification Matrix and Optional Extensions
Verification Matrix
Execute the following test cases to confirm architectural integrity:
| # | Action | Expected Observable Result | Status |
|---|---|---|---|
| V1 | Press Tab from preceding document control | Focus lands on the currently active tab only (tabIndex="0"). All other tabs report tabIndex="-1". | |
| V2 | Press ArrowRight (in horizontal automatic mode) | Focus shifts to the next tab, aria-selected="true" moves to it, and its associated TabPanel becomes visible while the previous panel receives hidden. | |
| V3 | Press End key | Focus jumps directly to the final tab in the list. | |
| V4 | Switch to controlled mode (value="tab2") | The second tab is active. Calling external setter changes selection without internal state desync. | |
| V5 | Strip all visual CSS classes | The component functions completely identically: keyboard traversal, ARIA announcements, and panel switching remain intact. |
Optional Extensions
- Disabled Tabs: Add a
disabledboolean prop toTab. Ensure disabled tabs receivearia-disabled="true", cannot be activated via click/Enter, and are gracefully skipped during Arrow key traversal. - Lazy Panel Loading: Enhance
TabPanelwith alazyprop. Whentrue, panel contents are not mounted in the DOM until the tab is selected for the first time.
Evaluation Rubric
| Criterion | Exemplary (4) | Proficient (3) | Developing (2) | Inadequate (1) |
|---|---|---|---|---|
| Decomposition & API Design | Clean compound components sharing context; consumer has full markup and styling freedom; zero boolean prop clutter. | Compound hierarchy used, but leaks presentation details into root props. | Flat component requiring large configuration object or array of tabs. | Single monolithic component with hard-coded markup. |
| State Ownership Contract | Pure controlled and uncontrolled modes supported seamlessly without conflicting state updates. | Supports both modes but shows brief flicker or console warnings on switch. | Only supports one mode (controlled or uncontrolled). | State is entangled and out of sync with external props. |
| WAI-ARIA & Keyboard Semantics | Flawless roving tabindex, correct ARIA cross-linking (aria-controls, aria-labelledby), Arrow, Home/End, and mode handling. | Keyboard navigation works, but missing aria-controls or Home/End keys. | Uses standard Tab key to focus every single tab button; missing roving tabindex. | No ARIA roles or keyboard handlers implemented. |
| Headless Robustness | Behavioral logic is completely decoupled from visual CSS; works across different themes and layouts. | Headless logic works but assumes specific layout or wrapper tags. | Visual styles are hard-coded into behavioral components. | Breaking styles breaks component interaction. |