import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";

import * as TabsStories from "./Tabs.stories";

<Meta of={TabsStories} />

# Tabs

**The state layer's falsification test.** Tabs is P0 for that reason and no other — the
fifteen ad-hoc tab bars that used to justify it were in the withdrawn evidence. The plan
was explicit: if this came out ugly, stop and reconsider the state layer before building
fifteen more components.

It did not come out ugly. The composition is two hooks and one decision.

<Canvas of={TabsStories.Playground} />

## Props

<Controls />

## Two bars, and the question is not "how does it look"

**Does this section have an address?**

| | Panel tab | Routed tab |
| --- | --- | --- |
| The item carries | `content` | `href` |
| Each trigger is | `<button role="tab">` | `<a href>` |
| The bar is | `role="tablist"` | `<nav aria-label>` |
| The section is drawn by | this component | the router, through your `<Outlet />` |
| Which one is on | `Tabs` owns it | the address does, via `activeHref` |
| Reload, Back, new tab | start over | land on the same section |

```tsx
// A disclosure inside one screen. Nothing outside knows about it.
<Tabs label="Bay 3" tabs={[{ key: "log", label: "Log", content: <Log /> }]} />

// A section that has an address.
<Tabs
  activeHref={pathname}
  label="Bay 3 sections"
  tabs={[{ href: "/projects/1/tasks", label: "Tasks" }]}
/>
<Outlet />
```

`activeHref` is the discriminant, and it is required in the routed mode. `tabs` alone
could not carry the split: TypeScript does not narrow a union by the shape of an array's
elements, so a bar mixing the two would type-check and then render half a navigation.

<Canvas of={TabsStories.Routed} />

### The paint is shared, and that is the point

Docket had one of each on adjacent screens wearing **different paint** — a filled pill on
the project bar, this underline on Settings — because a routed section switcher had to be
built out of `Nav`, and `Nav`'s current item is a pill. `toTabTriggerClass` is now the one
definition both triggers read, and `Tabs.test.tsx` compares the two computed styles
property by property, because a screenshot cannot tell them apart on purpose.
[Decision](../../../docs/decisions/2026-08-31-a-routed-tab-is-a-tab-with-an-href.md).

<Canvas of={TabsStories.RoutedAllVariants} />

### The routed bar is not a `tablist`

`role="tab"` on an anchor **overrides** the link role. A screen reader announces a
disclosure, the reader waits for a panel to appear beside it, and instead the address
changes and the page under them is replaced. `aria-controls` has nothing to point at
either — the section's content comes from the router, somewhere this component cannot see.

So the routed bar is what it actually is: a named `<nav>` of real links with
`aria-current="page"` on the one you are on. Which one that is comes from `Nav`'s
`resolveActiveKey`, imported rather than restated — whole-segment matching, deepest match
wins, the root is exact, an external item is never current, and the query string is not
part of the answer.

<Canvas of={TabsStories.RoutedAllStates} />

A **disabled** routed tab is not a link at all. `<a>` has no `disabled` attribute, so the
tempting version — a real `href` plus `aria-disabled` — greys out and still navigates on
Enter and on a middle click; it renders as a `<span aria-disabled>` instead.

### This is sub-navigation. `Nav` is still the app's destinations

The header row, the side rail and the Narrow View's menu are `Nav` + `useNavLayout`, and
that has not moved.
[Decision](../../../docs/decisions/2026-08-27-nav-is-the-one-navigation-component-and-navbar-is-deprecated.md).
A routed `Tabs` divides **one screen** into sections, so it renders no rail, no fold and
no menu — a bar inside a screen has nowhere to fold to.

## The composition

```ts
const selection = useSinglePicker({ selectedValue: initialKey })  // which tab is chosen
const focus = useRovingFocus({ activeValue: initialKey })         // which tab is tabbable

// `automatic` is this line. `manual` is its absence.
if (activation === "automatic") selection.select(focus.activeValue)
```

That one line is the whole difference between the two ARIA activation modes, and it is
only that small because **focus and selection are separate kinds**. In `manual`, focus
lands on a tab that is *not* chosen — a state the original three-kind model could not
represent at all, because it had one notion of "the current one" doing both jobs. The
`Interactive` story asserts both halves against the same component.

## Selection, not visibility

M4 built this on `VisibilityGroup`, and the panels come out one-at-a-time either way: the
two cores are the same shape, deliberately. What settled it is **what registers**. The
thing that calls `register` here is the *tab*, not the panel, and the question a tab bar
asks is *which one did you pick* — so the kind that answers it is `SinglePicker`, and the
panel's `hidden` is derived from the answer rather than being the answer.

That is not bookkeeping. Reading it the other way made `aria-selected` a report about a
panel's visibility, so a tab bar with **no panels at all** — a segmented filter, a view
switcher that swaps a route — had to model its choice as a group of visibilities it did not
have. `SegmentedControl` is exactly that consumer, and it shares this core rather than
hand-rolling a fourth one.
[Decision](../../../docs/decisions/2026-07-30-tab-selection-is-a-single-picker.md).

## What it does not use, and why that is the answer

The plan expected `createLinkedIds` here too, alongside Visibility, VisibilityGroup, and
RovingFocus. **It does not fit, and forcing it would have been wrong.**

That kind exists for a *dynamic* trigger↔target pair — a `Popover` whose panel mounts and
unmounts, where the multiset is what stops `aria-controls` pointing at a node React has
already removed. A tab bar's pairing is static and known at render, so the ids are derived
from one `useUniqueId` and cannot get out of step at all. Three of the four kinds, and a
specific reason for the fourth.

## Registration is membership

A disabled tab joins the **choice** — it is still one of the options, still owns a panel
and an id — and stays out of the **focus** group. That is all "disabled" means here: the
arrow keys skip it because it is not a member, and nothing in `RovingFocus` has to know the
word.

`TabTrigger` is its own file for a reason that is not style: both registrations are
effects, and an effect cannot run in a loop. Any member of a registering group ends up
shaped like this.

<Canvas of={TabsStories.AllVariants} />

## `activeKey` is initial, not controlled

Read once, on the first render. A parent that needs to push a value calls the hook's own
setter; there is no controlled mode and no prop-sync effect, which is
[the M2 decision](../../../docs/decisions/2026-07-29-logic-hooks-are-uncontrolled.md) and
the thing that keeps the fleet's existing echo loops out of this library.

## Focus is moved deliberately, and only then

`RovingFocus` is state; something still has to call `.focus()`. It happens only while the
tablist already contains the focused element — otherwise mounting a tab bar, or any parent
re-render that moved `activeValue`, yanks the caret out of whatever the user was doing.

<Canvas of={TabsStories.AllStates} />

## What Tabs found in M3's own testing helper

`expectAgentDrivable` rejected every tab outright:

> Not agent-drivable: role "tab" named "Log" has a negative tabindex, so it can be clicked
> but never reached with Tab.

Correct for a standalone button, wrong for a tab. **A roving group's whole design is that
exactly one member is in the tab order** and the arrow keys reach the rest; a tab list
where every tab had `tabindex="0"` would be the bug.

So the rejection was not skipped for composite widgets — it was **replaced by the roving
rule itself**, which is the stronger assertion: inside a `tablist` / `listbox` / `menu` /
`radiogroup` / `tree` / `grid` / `toolbar`, exactly one enabled member may be in the tab
order. Zero strands the widget; several mean the pattern was never implemented.
`AllStates` proves the failing half too, with a four-line stub standing in for the canvas —
`AgentQueries` is structural precisely so that it can.

<Canvas of={TabsStories.Responsive} />

## A tab bar scrolls, it does not wrap

A wrapped tablist puts tabs on two rows, which breaks the visual row and the mental model
the arrow keys give — "right" stops meaning right. `ContainerBoard` appears here as a width
harness rather than a container-query claim: `Tabs` declares no container, because nothing
about it needs to *know* its width. It only has to survive being narrow.

## Manual activation is the falsification test

`automatic` and `manual` differ only in whether moving focus also *chooses* — and that
difference is *one line*, because focus and selection are separate kinds. Arrow through the
bar below and the underline stays put until Enter: focus has moved to a tab that is not
selected, which is a state a three-kind model cannot represent at all.

<Canvas of={TabsStories.Manual} />


## Current tabs in a narrow bar

An overflowing bar keeps its current tab in view on first render, route changes,
and reflow. It scrolls only itself, without moving focus or the surrounding page.
Manual panel activation keeps the focused tab visible even while another panel
remains selected. A reader can still scroll to other tabs.
