@charcuterie/ui 3.30.0 → 3.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +14 -0
  2. package/dist/ActionTiles/ActionTiles.d.ts +115 -0
  3. package/dist/ActionTiles/ActionTiles.d.ts.map +1 -0
  4. package/dist/ActionTiles/ActionTiles.js +115 -0
  5. package/dist/ActionTiles/ActionTiles.js.map +1 -0
  6. package/dist/RadioGroup/RadioGroup.d.ts +10 -0
  7. package/dist/RadioGroup/RadioGroup.d.ts.map +1 -1
  8. package/dist/RadioGroup/RadioGroup.js +12 -39
  9. package/dist/RadioGroup/RadioGroup.js.map +1 -1
  10. package/dist/RadioGroup/RadioGroupOption.d.ts.map +1 -1
  11. package/dist/RadioGroup/RadioGroupOption.js +3 -33
  12. package/dist/RadioGroup/RadioGroupOption.js.map +1 -1
  13. package/dist/Tabs/PanelTabs.d.ts +56 -0
  14. package/dist/Tabs/PanelTabs.d.ts.map +1 -0
  15. package/dist/Tabs/PanelTabs.js +163 -0
  16. package/dist/Tabs/PanelTabs.js.map +1 -0
  17. package/dist/Tabs/RoutedTabs.d.ts +19 -0
  18. package/dist/Tabs/RoutedTabs.d.ts.map +1 -0
  19. package/dist/Tabs/RoutedTabs.js +28 -0
  20. package/dist/Tabs/RoutedTabs.js.map +1 -0
  21. package/dist/Tabs/TabLink.d.ts +34 -0
  22. package/dist/Tabs/TabLink.d.ts.map +1 -0
  23. package/dist/Tabs/TabLink.js +66 -0
  24. package/dist/Tabs/TabLink.js.map +1 -0
  25. package/dist/Tabs/TabTrigger.d.ts +1 -1
  26. package/dist/Tabs/TabTrigger.d.ts.map +1 -1
  27. package/dist/Tabs/TabTrigger.js +6 -30
  28. package/dist/Tabs/TabTrigger.js.map +1 -1
  29. package/dist/Tabs/Tabs.d.ts +80 -64
  30. package/dist/Tabs/Tabs.d.ts.map +1 -1
  31. package/dist/Tabs/Tabs.js +53 -182
  32. package/dist/Tabs/Tabs.js.map +1 -1
  33. package/dist/Tabs/tabItems.d.ts +36 -0
  34. package/dist/Tabs/tabItems.d.ts.map +1 -0
  35. package/dist/Tabs/tabItems.js +2 -0
  36. package/dist/Tabs/tabItems.js.map +1 -0
  37. package/dist/Tabs/tabStyles.d.ts +41 -0
  38. package/dist/Tabs/tabStyles.d.ts.map +1 -0
  39. package/dist/Tabs/tabStyles.js +79 -0
  40. package/dist/Tabs/tabStyles.js.map +1 -0
  41. package/dist/index.d.ts +4 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +1 -0
  44. package/dist/index.js.map +1 -1
  45. package/dist/tileStyles.d.ts +83 -0
  46. package/dist/tileStyles.d.ts.map +1 -0
  47. package/dist/tileStyles.js +98 -0
  48. package/dist/tileStyles.js.map +1 -0
  49. package/package.json +1 -1
  50. package/src/ActionTiles/ActionTiles.mdx +131 -0
  51. package/src/ActionTiles/ActionTiles.stories.tsx +311 -0
  52. package/src/ActionTiles/ActionTiles.test.tsx +292 -0
  53. package/src/ActionTiles/ActionTiles.tsx +292 -0
  54. package/src/RadioGroup/RadioGroup.mdx +7 -0
  55. package/src/RadioGroup/RadioGroup.tsx +16 -42
  56. package/src/RadioGroup/RadioGroupOption.tsx +8 -38
  57. package/src/Tabs/PanelTabs.tsx +260 -0
  58. package/src/Tabs/RoutedTabs.tsx +58 -0
  59. package/src/Tabs/TabLink.tsx +132 -0
  60. package/src/Tabs/TabTrigger.tsx +10 -39
  61. package/src/Tabs/Tabs.mdx +70 -0
  62. package/src/Tabs/Tabs.stories.tsx +133 -1
  63. package/src/Tabs/Tabs.test.tsx +169 -0
  64. package/src/Tabs/Tabs.tsx +94 -296
  65. package/src/Tabs/tabItems.ts +40 -0
  66. package/src/Tabs/tabStyles.ts +107 -0
  67. package/src/index.ts +13 -3
  68. package/src/sourceRules.test.ts +9 -1
  69. package/src/tileStyles.ts +118 -0
@@ -3,6 +3,12 @@ import type { ReactNode } from "react"
3
3
  import { useEffect } from "react"
4
4
 
5
5
  import { FOCUS_RING_CLASS } from "../intentStyles.ts"
6
+ import {
7
+ TILE_BOX_CLASS,
8
+ TILE_HINT_TEXT_CLASS,
9
+ TILE_PADDING_CLASS,
10
+ TILE_TEXT_SIZE_CLASS,
11
+ } from "../tileStyles.ts"
6
12
  import { toClassName } from "../toClassName.ts"
7
13
  import type {
8
14
  RadioItem,
@@ -42,42 +48,6 @@ const DOT_SIZE_CLASS: Record<ControlSize, string> = {
42
48
  lg: "size-2.5",
43
49
  }
44
50
 
45
- const TEXT_SIZE_CLASS: Record<ControlSize, string> = {
46
- sm: "text-sm",
47
- md: "text-md",
48
- lg: "text-lg",
49
- }
50
-
51
- /**
52
- * One step down from the option's own text, and never below `xs`:
53
- * a hint that keeps shrinking with the density axis is the 11px
54
- * caption the 2026-08-10 ramp rebuild exists to have removed.
55
- */
56
- const HINT_TEXT_CLASS: Record<ControlSize, string> = {
57
- sm: "text-xs",
58
- md: "text-xs",
59
- lg: "text-sm",
60
- }
61
-
62
- /**
63
- * A tile is a card, so its padding is a card's rather than a
64
- * control's — enough that the border reads as a box around content
65
- * and not as an outline on a line of text.
66
- */
67
- const TILE_PADDING_CLASS: Record<ControlSize, string> = {
68
- sm: "px-3 py-2.5",
69
- md: "px-3.5 py-3",
70
- lg: "px-4 py-3.5",
71
- }
72
-
73
- /**
74
- * The box, and only the box. Every state below it is a **border and
75
- * a surface**, never colour alone — the same reason `Stepper`
76
- * refuses to say "done" with a hue.
77
- */
78
- const TILE_BOX_CLASS =
79
- "rounded-lg border border-border-subtle bg-surface-raised"
80
-
81
51
  /**
82
52
  * Its own file for the same reason `SegmentedOption` is: **both
83
53
  * registrations are effects**, and an effect cannot run in a loop.
@@ -152,7 +122,7 @@ export const RadioGroupOption = ({
152
122
  ? "items-center"
153
123
  : "items-start",
154
124
  ),
155
- TEXT_SIZE_CLASS[size],
125
+ TILE_TEXT_SIZE_CLASS[size],
156
126
  // Disabled turns the whole option down with `opacity-60`, the
157
127
  // same family treatment as `Checkbox` and `Switch`, rather than
158
128
  // fading the ring to a token that disappeared on a pale theme.
@@ -252,7 +222,7 @@ export const RadioGroupOption = ({
252
222
  <span
253
223
  className={toClassName(
254
224
  "font-normal text-content-muted",
255
- HINT_TEXT_CLASS[size],
225
+ TILE_HINT_TEXT_CLASS[size],
256
226
  )}
257
227
  >
258
228
  {hint}
@@ -0,0 +1,260 @@
1
+ import {
2
+ selectTabIndex,
3
+ useRovingFocus,
4
+ useSinglePicker,
5
+ useUniqueId,
6
+ } from "@charcuterie/logic"
7
+ import type { ReactNode } from "react"
8
+ import { useCallback, useEffect, useRef } from "react"
9
+
10
+ import { toClassName } from "../toClassName.ts"
11
+ import type { TabsPanelProps } from "./Tabs.tsx"
12
+ import { TabTrigger } from "./TabTrigger.tsx"
13
+ import type { TabsOrientation } from "./tabItems.ts"
14
+ import { toTabListClass } from "./tabStyles.ts"
15
+
16
+ const ORIENTATION_KEYS: Record<
17
+ TabsOrientation,
18
+ { next: string; previous: string }
19
+ > = {
20
+ horizontal: {
21
+ next: "ArrowRight",
22
+ previous: "ArrowLeft",
23
+ },
24
+ vertical: { next: "ArrowDown", previous: "ArrowUp" },
25
+ }
26
+
27
+ /**
28
+ * The panel bar — every tab a `<button>`, every section a
29
+ * `role="tabpanel"` this component renders and hides. See `Tabs`
30
+ * for which of the two bars a screen wants.
31
+ *
32
+ * The composition is two hooks and one decision:
33
+ *
34
+ * ```ts
35
+ * const selection = useSinglePicker({ selectedValue: initialKey })
36
+ * const focus = useRovingFocus({ activeValue: initialKey })
37
+ *
38
+ * // `automatic` is this line, and `manual` is its absence.
39
+ * if (activation === "automatic") selection.select(focus.activeValue)
40
+ * ```
41
+ *
42
+ * ### Selection, not visibility — and the difference is not cosmetic
43
+ *
44
+ * M4 built this on `VisibilityGroup`, and the panels do end up
45
+ * one-at-a-time either way, because the two cores are the same
46
+ * shape. But the thing that **registers** here is the tab, not the
47
+ * panel, and what a tab bar is asking is *which one did you pick*.
48
+ * `SinglePicker` is the kind that answers that; the panel's
49
+ * `hidden` is derived from the answer.
50
+ *
51
+ * Reading it the other way round cost something concrete: it made
52
+ * `aria-selected` a report about a *panel's* visibility, so a
53
+ * consumer wanting a tab bar with no panels at all — a segmented
54
+ * filter, a view switcher that swaps a route — had to model its
55
+ * choice as a group of visibilities it did not have. `Alert`'s
56
+ * sibling `SegmentedControl` is exactly that consumer, and it now
57
+ * shares this component's core rather than hand-rolling a fourth
58
+ * one.
59
+ * [Decision](../../../../docs/decisions/2026-07-30-tab-selection-is-a-single-picker.md).
60
+ *
61
+ * ### What it does not use, and why that is the right answer
62
+ *
63
+ * The plan expected `createLinkedIds` here as well. It does not
64
+ * fit, and forcing it would have been the wrong call: that kind
65
+ * exists for a **dynamic** trigger↔target pair — a `Popover` whose
66
+ * panel mounts and unmounts, where the multiset is what stops
67
+ * `aria-controls` pointing at a node React has already removed. A
68
+ * tab bar's pairing is static and known at render, so the ids come
69
+ * from one `useUniqueId` and cannot get out of step at all.
70
+ *
71
+ * ### Focus is moved deliberately, and only then
72
+ *
73
+ * `RovingFocus` is state; something still has to call `.focus()`.
74
+ * That happens only while the tablist already contains the focused
75
+ * element — otherwise mounting a tab bar, or any parent re-render
76
+ * that moved `activeValue`, yanks the caret out of whatever the
77
+ * user was actually doing.
78
+ */
79
+ export const PanelTabs = ({
80
+ activation = "automatic",
81
+ activeKey,
82
+ className,
83
+ label,
84
+ onChange,
85
+ orientation = "horizontal",
86
+ tabs,
87
+ }: TabsPanelProps): ReactNode => {
88
+ const baseId = useUniqueId()
89
+
90
+ const tablistRef = useRef<HTMLDivElement>(null)
91
+
92
+ const tabElements = useRef(
93
+ new Map<string, HTMLButtonElement>(),
94
+ )
95
+
96
+ const initialKey =
97
+ activeKey ??
98
+ tabs.find((one) => !one.isDisabled)?.key ??
99
+ null
100
+
101
+ const selection = useSinglePicker<string>({
102
+ onChange,
103
+ selectedValue: initialKey,
104
+ })
105
+
106
+ const focus = useRovingFocus<string>({
107
+ activeValue: initialKey,
108
+ })
109
+
110
+ const { select } = selection
111
+
112
+ const { activeValue, setActiveValue } = focus
113
+
114
+ /**
115
+ * Selected **or** pending, and the fallback is load-bearing.
116
+ *
117
+ * Members register from an effect, so on the first paint
118
+ * `selectedValue` is still null and the intent lives in
119
+ * `pendingValue` — a tab bar reading only the former renders with
120
+ * no tab selected and no panel shown, then corrects itself a
121
+ * frame later. It is a flash on a fast machine and a visible
122
+ * blank on a kiosk Pi.
123
+ *
124
+ * The core distinguishes the two on purpose, which is why this
125
+ * belongs here rather than in the selector — unlike
126
+ * `RovingFocus`, where the equivalent first-paint hole was in
127
+ * `selectTabIndex` itself and got fixed there.
128
+ */
129
+ const shownKey =
130
+ selection.selectedValue ?? selection.pendingValue
131
+
132
+ const selectTab = useCallback(
133
+ (key: string) => {
134
+ // Both, because a pointer press moves focus *and* chooses.
135
+ // Only selecting would leave `automatic`'s effect below to
136
+ // put the old panel straight back.
137
+ setActiveValue(key)
138
+
139
+ select(key)
140
+ },
141
+ [select, setActiveValue],
142
+ )
143
+
144
+ useEffect(() => {
145
+ // Automatic activation, and the only behaviour that differs
146
+ // between the two modes. `select` is idempotent, so this is a
147
+ // no-op on every render where focus did not actually move.
148
+ if (
149
+ activation === "automatic" &&
150
+ activeValue !== null
151
+ ) {
152
+ select(activeValue)
153
+ }
154
+ }, [activation, activeValue, select])
155
+
156
+ useEffect(() => {
157
+ const tablist = tablistRef.current
158
+
159
+ if (
160
+ !tablist ||
161
+ activeValue === null ||
162
+ !tablist.contains(document.activeElement)
163
+ ) {
164
+ return
165
+ }
166
+
167
+ tabElements.current.get(activeValue)?.focus()
168
+ }, [activeValue])
169
+
170
+ const keys = ORIENTATION_KEYS[orientation]
171
+
172
+ return (
173
+ <div
174
+ className={toClassName(
175
+ "flex flex-col gap-3",
176
+ className,
177
+ )}
178
+ >
179
+ <div
180
+ aria-label={label}
181
+ aria-orientation={orientation}
182
+ className={toTabListClass(orientation)}
183
+ onKeyDown={(keyEvent) => {
184
+ const commands: Record<string, () => void> = {
185
+ [keys.next]: focus.next,
186
+ [keys.previous]: focus.previous,
187
+ End: focus.last,
188
+ Home: focus.first,
189
+ }
190
+
191
+ const command = commands[keyEvent.key]
192
+
193
+ if (command) {
194
+ // Arrow keys scroll the page by default, and a tab bar
195
+ // that scrolls the window while it moves focus is the
196
+ // hand-rolled version of this component.
197
+ keyEvent.preventDefault()
198
+
199
+ command()
200
+
201
+ return
202
+ }
203
+
204
+ // `manual`'s commit. In `automatic` the panel is showing
205
+ // already, so this is the no-op that makes Enter behave
206
+ // identically in both modes.
207
+ if (
208
+ (keyEvent.key === "Enter" ||
209
+ keyEvent.key === " ") &&
210
+ activeValue !== null
211
+ ) {
212
+ keyEvent.preventDefault()
213
+
214
+ select(activeValue)
215
+ }
216
+ }}
217
+ ref={tablistRef}
218
+ role="tablist"
219
+ >
220
+ {tabs.map((tab, index) => (
221
+ <TabTrigger
222
+ id={`${baseId}-tab-${index}`}
223
+ isSelected={shownKey === tab.key}
224
+ key={tab.key}
225
+ onSelect={selectTab}
226
+ panelId={`${baseId}-panel-${index}`}
227
+ registerFocus={focus.register}
228
+ orientation={orientation}
229
+ registerSelection={selection.register}
230
+ tab={tab}
231
+ // The roving-tabindex rule read from the core rather
232
+ // than restated here: exactly one member is in the tab
233
+ // order, so Tab enters and leaves the bar once while
234
+ // the arrow keys move inside it.
235
+ tabIndex={selectTabIndex(focus, tab.key)}
236
+ trackElement={(key, element) => {
237
+ if (element) {
238
+ tabElements.current.set(key, element)
239
+ } else {
240
+ tabElements.current.delete(key)
241
+ }
242
+ }}
243
+ />
244
+ ))}
245
+ </div>
246
+
247
+ {tabs.map((tab, index) => (
248
+ <div
249
+ aria-labelledby={`${baseId}-tab-${index}`}
250
+ hidden={shownKey !== tab.key}
251
+ id={`${baseId}-panel-${index}`}
252
+ key={tab.key}
253
+ role="tabpanel"
254
+ >
255
+ {tab.content}
256
+ </div>
257
+ ))}
258
+ </div>
259
+ )
260
+ }
@@ -0,0 +1,58 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ import {
4
+ getNavItemKey,
5
+ resolveActiveKey,
6
+ } from "../Nav/navItems.ts"
7
+ import { toClassName } from "../toClassName.ts"
8
+ import { TabLink } from "./TabLink.tsx"
9
+ import type { TabsLinkProps } from "./Tabs.tsx"
10
+ import { toTabListClass } from "./tabStyles.ts"
11
+
12
+ /**
13
+ * The routed bar: a named `<nav>` of real links wearing the tab
14
+ * paint.
15
+ *
16
+ * There is **no state here at all**, and that is the difference that
17
+ * matters rather than a saving. A panel bar owns its choice because
18
+ * nothing else knows it; a routed bar's choice is the address, so
19
+ * owning a copy would give the app two answers that can disagree —
20
+ * the Back button being the first thing to prove it.
21
+ *
22
+ * `resolveActiveKey` is `Nav`'s rule, imported rather than restated:
23
+ * whole-segment matching, the deepest match wins, the root is exact,
24
+ * an external item is never current, and the query string is not
25
+ * part of the answer.
26
+ */
27
+ export const RoutedTabs = ({
28
+ activeHref,
29
+ className,
30
+ label,
31
+ orientation = "horizontal",
32
+ tabs,
33
+ }: TabsLinkProps): ReactNode => {
34
+ const activeKey = resolveActiveKey(tabs, activeHref)
35
+
36
+ return (
37
+ <nav
38
+ aria-label={label}
39
+ className={toClassName(
40
+ toTabListClass(orientation),
41
+ className,
42
+ )}
43
+ >
44
+ {tabs.map((tab) => {
45
+ const key = getNavItemKey(tab)
46
+
47
+ return (
48
+ <TabLink
49
+ isCurrent={key === activeKey}
50
+ item={tab}
51
+ key={key}
52
+ orientation={orientation}
53
+ />
54
+ )
55
+ })}
56
+ </nav>
57
+ )
58
+ }
@@ -0,0 +1,132 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ import { useRouterLink } from "../RouterLink/RouterLinkProvider.tsx"
4
+ import { getIsRoutedHref } from "../RouterLink/routerLink.ts"
5
+ import { toClassName } from "../toClassName.ts"
6
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
7
+ import type {
8
+ TabLinkItem,
9
+ TabsOrientation,
10
+ } from "./tabItems.ts"
11
+ import { toTabTriggerClass } from "./tabStyles.ts"
12
+
13
+ export type TabLinkProps = {
14
+ isCurrent: boolean
15
+ item: TabLinkItem
16
+ orientation: TabsOrientation
17
+ }
18
+
19
+ /**
20
+ * One routed tab — and it is a real `<a href>`, through the same
21
+ * `RouterLinkProvider` seam `NavLink` uses.
22
+ *
23
+ * ### It carries no `role="tab"`, and that is the whole point
24
+ *
25
+ * `role="tab"` on an anchor **overrides** the link role. A screen
26
+ * reader then announces "tab", the reader expects a panel to appear
27
+ * beside it, and instead the address changes and the page under it
28
+ * is replaced. The APG tab pattern describes a disclosure inside one
29
+ * document; it does not describe navigation, and `aria-controls`
30
+ * has nothing to point at here because the section's content comes
31
+ * from the router — an `<Outlet />` the app renders somewhere this
32
+ * component cannot see.
33
+ *
34
+ * So a routed bar is what it actually is: a list of links inside a
35
+ * named `<nav>`, with `aria-current="page"` on the one you are on.
36
+ * That is `Nav`'s answer, and reusing it rather than restating it is
37
+ * why `resolveActiveKey` decides the current item.
38
+ *
39
+ * What the tab pattern was *really* supplying here was the paint,
40
+ * and the paint is shared through `toTabTriggerClass` — so this
41
+ * looks identical to the `<button>` beside it in Storybook and is
42
+ * announced correctly.
43
+ */
44
+ export const TabLink = ({
45
+ isCurrent,
46
+ item,
47
+ orientation,
48
+ }: TabLinkProps): ReactNode => {
49
+ const RouterLink = useRouterLink()
50
+
51
+ const { href, icon, isDisabled = false, label } = item
52
+
53
+ const isExternal = item.isExternal === true
54
+
55
+ const className = toTabTriggerClass({
56
+ isDisabled,
57
+ isSelected: isCurrent,
58
+ orientation,
59
+ })
60
+
61
+ const content = (
62
+ <>
63
+ {icon === undefined ? null : (
64
+ // Decoration beside the item's own text. An icon that
65
+ // announces itself turns "Settings" into "gear Settings".
66
+ <span
67
+ aria-hidden="true"
68
+ className="flex shrink-0 text-content-secondary"
69
+ >
70
+ {icon}
71
+ </span>
72
+ )}
73
+
74
+ {label}
75
+
76
+ {isExternal ? (
77
+ <VisuallyHidden>
78
+ {" "}
79
+ (opens in a new tab)
80
+ </VisuallyHidden>
81
+ ) : null}
82
+ </>
83
+ )
84
+
85
+ if (isDisabled) {
86
+ // Not a link at all, rather than a link with a `disabled`
87
+ // attribute an anchor does not have. `aria-disabled` alone on a
88
+ // real `<a href>` still navigates on Enter, which is the version
89
+ // of this that looks right and is not.
90
+ return (
91
+ <span
92
+ aria-disabled="true"
93
+ className={toClassName(
94
+ "inline-flex shrink-0 items-center gap-2",
95
+ className,
96
+ )}
97
+ >
98
+ {content}
99
+ </span>
100
+ )
101
+ }
102
+
103
+ const LinkElement =
104
+ isExternal || !getIsRoutedHref(href) ? "a" : RouterLink
105
+
106
+ return (
107
+ <LinkElement
108
+ // The platform's own "you are here", and the reason the paint
109
+ // is never the only signal: an underline is invisible to a
110
+ // screen reader and to anyone who cannot tell the two colours
111
+ // apart.
112
+ aria-current={isCurrent ? "page" : undefined}
113
+ className={toClassName(
114
+ "inline-flex shrink-0 items-center gap-2 no-underline",
115
+ className,
116
+ )}
117
+ href={href}
118
+ // No `onClick`, deliberately — unlike `NavLink`, which has a
119
+ // panel to close behind the reader. A routed tab bar stays on
120
+ // screen across the navigation, so there is nothing to react
121
+ // to and a handler here would only be something to keep.
122
+ //
123
+ // `noreferrer` beside `noopener` on purpose: one closes the
124
+ // `window.opener` hole, the other stops this app's address
125
+ // leaking as the referrer to a product it does not own.
126
+ rel={isExternal ? "noopener noreferrer" : undefined}
127
+ target={isExternal ? "_blank" : undefined}
128
+ >
129
+ {content}
130
+ </LinkElement>
131
+ )
132
+ }
@@ -1,32 +1,12 @@
1
1
  import type { ReactNode } from "react"
2
2
  import { useEffect } from "react"
3
3
 
4
- import { FOCUS_RING_CLASS } from "../intentStyles.ts"
5
4
  import { toClassName } from "../toClassName.ts"
6
- import type { TabItem, TabsOrientation } from "./Tabs.tsx"
7
-
8
- /**
9
- * The selected marker runs along the edge the tab list itself
10
- * sits on — under a horizontal bar, down the inline-end of a
11
- * vertical rail — and the negative margin pulls it over the
12
- * list's own border so the two read as one line rather than two.
13
- *
14
- * Logical properties throughout, which is the house rule and also
15
- * the only way a vertical rail lands on the correct side in RTL.
16
- */
17
- const ORIENTATION_EDGE_CLASS: Record<
5
+ import type {
6
+ TabItem,
18
7
  TabsOrientation,
19
- { base: string; selected: string }
20
- > = {
21
- horizontal: {
22
- base: "-mb-px border-b-2",
23
- selected: "border-b-intent-accent-solid",
24
- },
25
- vertical: {
26
- base: "-me-px border-e-2 text-start",
27
- selected: "border-e-intent-accent-solid",
28
- },
29
- }
8
+ } from "./tabItems.ts"
9
+ import { toTabTriggerClass } from "./tabStyles.ts"
30
10
 
31
11
  export type TabTriggerProps = {
32
12
  id: string
@@ -74,8 +54,6 @@ export const TabTrigger = ({
74
54
  }: TabTriggerProps): ReactNode => {
75
55
  const { isDisabled = false, key, label } = tab
76
56
 
77
- const edge = ORIENTATION_EDGE_CLASS[orientation]
78
-
79
57
  useEffect(
80
58
  () => registerSelection(key),
81
59
  [key, registerSelection],
@@ -94,19 +72,12 @@ export const TabTrigger = ({
94
72
  aria-controls={panelId}
95
73
  aria-selected={isSelected}
96
74
  className={toClassName(
97
- "cursor-pointer border-transparent px-3 py-2 font-medium text-sm whitespace-nowrap transition-colors duration-(--duration-fast) ease-standard",
98
- edge.base,
99
- // Two entries rather than one interpolated string:
100
- // `tailwindCandidates.test.ts` rejects a template literal
101
- // in a className outright, because Tailwind's scanner
102
- // cannot see a class that only exists at runtime.
103
- isSelected && edge.selected,
104
- isSelected
105
- ? "text-content-primary"
106
- : "text-content-secondary hover:text-content-primary",
107
- isDisabled &&
108
- "cursor-not-allowed text-content-disabled hover:text-content-disabled",
109
- FOCUS_RING_CLASS,
75
+ "cursor-pointer",
76
+ toTabTriggerClass({
77
+ isDisabled,
78
+ isSelected,
79
+ orientation,
80
+ }),
110
81
  )}
111
82
  disabled={isDisabled}
112
83
  id={id}
package/src/Tabs/Tabs.mdx CHANGED
@@ -19,6 +19,76 @@ It did not come out ugly. The composition is two hooks and one decision.
19
19
 
20
20
  <Controls />
21
21
 
22
+ ## Two bars, and the question is not "how does it look"
23
+
24
+ **Does this section have an address?**
25
+
26
+ | | Panel tab | Routed tab |
27
+ | --- | --- | --- |
28
+ | The item carries | `content` | `href` |
29
+ | Each trigger is | `<button role="tab">` | `<a href>` |
30
+ | The bar is | `role="tablist"` | `<nav aria-label>` |
31
+ | The section is drawn by | this component | the router, through your `<Outlet />` |
32
+ | Which one is on | `Tabs` owns it | the address does, via `activeHref` |
33
+ | Reload, Back, new tab | start over | land on the same section |
34
+
35
+ ```tsx
36
+ // A disclosure inside one screen. Nothing outside knows about it.
37
+ <Tabs label="Bay 3" tabs={[{ key: "log", label: "Log", content: <Log /> }]} />
38
+
39
+ // A section that has an address.
40
+ <Tabs
41
+ activeHref={pathname}
42
+ label="Bay 3 sections"
43
+ tabs={[{ href: "/projects/1/tasks", label: "Tasks" }]}
44
+ />
45
+ <Outlet />
46
+ ```
47
+
48
+ `activeHref` is the discriminant, and it is required in the routed mode. `tabs` alone
49
+ could not carry the split: TypeScript does not narrow a union by the shape of an array's
50
+ elements, so a bar mixing the two would type-check and then render half a navigation.
51
+
52
+ <Canvas of={TabsStories.Routed} />
53
+
54
+ ### The paint is shared, and that is the point
55
+
56
+ Docket had one of each on adjacent screens wearing **different paint** — a filled pill on
57
+ the project bar, this underline on Settings — because a routed section switcher had to be
58
+ built out of `Nav`, and `Nav`'s current item is a pill. `toTabTriggerClass` is now the one
59
+ definition both triggers read, and `Tabs.test.tsx` compares the two computed styles
60
+ property by property, because a screenshot cannot tell them apart on purpose.
61
+ [Decision](../../../docs/decisions/2026-08-31-a-routed-tab-is-a-tab-with-an-href.md).
62
+
63
+ <Canvas of={TabsStories.RoutedAllVariants} />
64
+
65
+ ### The routed bar is not a `tablist`
66
+
67
+ `role="tab"` on an anchor **overrides** the link role. A screen reader announces a
68
+ disclosure, the reader waits for a panel to appear beside it, and instead the address
69
+ changes and the page under them is replaced. `aria-controls` has nothing to point at
70
+ either — the section's content comes from the router, somewhere this component cannot see.
71
+
72
+ So the routed bar is what it actually is: a named `<nav>` of real links with
73
+ `aria-current="page"` on the one you are on. Which one that is comes from `Nav`'s
74
+ `resolveActiveKey`, imported rather than restated — whole-segment matching, deepest match
75
+ wins, the root is exact, an external item is never current, and the query string is not
76
+ part of the answer.
77
+
78
+ <Canvas of={TabsStories.RoutedAllStates} />
79
+
80
+ A **disabled** routed tab is not a link at all. `<a>` has no `disabled` attribute, so the
81
+ tempting version — a real `href` plus `aria-disabled` — greys out and still navigates on
82
+ Enter and on a middle click; it renders as a `<span aria-disabled>` instead.
83
+
84
+ ### This is sub-navigation. `Nav` is still the app's destinations
85
+
86
+ The header row, the side rail and the Narrow View's menu are `Nav` + `useNavLayout`, and
87
+ that has not moved.
88
+ [Decision](../../../docs/decisions/2026-08-27-nav-is-the-one-navigation-component-and-navbar-is-deprecated.md).
89
+ A routed `Tabs` divides **one screen** into sections, so it renders no rail, no fold and
90
+ no menu — a bar inside a screen has nowhere to fold to.
91
+
22
92
  ## The composition
23
93
 
24
94
  ```ts