@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
package/src/Tabs/Tabs.tsx CHANGED
@@ -1,33 +1,22 @@
1
- import {
2
- selectTabIndex,
3
- useRovingFocus,
4
- useSinglePicker,
5
- useUniqueId,
6
- } from "@charcuterie/logic"
7
1
  import type { ReactNode } from "react"
8
- import { useCallback, useEffect, useRef } from "react"
9
2
 
10
- import { toClassName } from "../toClassName.ts"
11
- import { TabTrigger } from "./TabTrigger.tsx"
12
-
13
- export type TabsActivation = "automatic" | "manual"
14
-
15
- export type TabsOrientation = "horizontal" | "vertical"
3
+ import { PanelTabs } from "./PanelTabs.tsx"
4
+ import { RoutedTabs } from "./RoutedTabs.tsx"
5
+ import type {
6
+ TabItem,
7
+ TabLinkItem,
8
+ TabsActivation,
9
+ TabsOrientation,
10
+ } from "./tabItems.ts"
16
11
 
17
- export type TabItem = {
18
- content: ReactNode
19
- /**
20
- * A disabled tab is simply **not registered** with the roving
21
- * group. Registration is membership, so the arrow keys skip it
22
- * without any command in `RovingFocus` having to know what
23
- * "disabled" means.
24
- */
25
- isDisabled?: boolean
26
- key: string
27
- label: ReactNode
12
+ type TabsCommonProps = {
13
+ className?: string
14
+ /** The tab bar's accessible name. Required. */
15
+ label: string
16
+ orientation?: TabsOrientation
28
17
  }
29
18
 
30
- export type TabsProps = {
19
+ export type TabsPanelProps = TabsCommonProps & {
31
20
  /** **Initial** only. Charcuterie owns it from then on. */
32
21
  activeKey?: string
33
22
  /**
@@ -42,292 +31,101 @@ export type TabsProps = {
42
31
  * composed rather than one kind doing both jobs badly.
43
32
  */
44
33
  activation?: TabsActivation
45
- className?: string
46
- /** The tablist's accessible name. Required. */
47
- label: string
48
34
  onChange?: (activeKey: string | null) => void
49
- orientation?: TabsOrientation
50
- tabs: TabItem[]
35
+ tabs: readonly TabItem[]
51
36
  }
52
37
 
53
- const ORIENTATION_KEYS: Record<
54
- TabsOrientation,
55
- { next: string; previous: string }
56
- > = {
57
- horizontal: {
58
- next: "ArrowRight",
59
- previous: "ArrowLeft",
60
- },
61
- vertical: { next: "ArrowDown", previous: "ArrowUp" },
38
+ export type TabsLinkProps = TabsCommonProps & {
39
+ /**
40
+ * The address the app is at — usually `location.pathname`. The tab
41
+ * whose `href` matches wears `aria-current="page"`.
42
+ *
43
+ * **Required, and it is the discriminant.** `Nav` leaves the
44
+ * equivalent optional because a top-level bar is honest about
45
+ * naming nothing on a screen it does not contain. A routed tab bar
46
+ * is drawn *inside* the section it divides, so "none of these" is
47
+ * not a state it has; an app that wants it passes a path that
48
+ * matches no tab.
49
+ */
50
+ activeHref: string
51
+ tabs: readonly TabLinkItem[]
62
52
  }
63
53
 
64
54
  /**
65
- * Kept at P0 as the **state layer's falsification test**, not on
66
- * duplication grounds — the fifteen ad-hoc tab bars that used to
67
- * justify it were in the withdrawn evidence. What it has to prove
68
- * is that two kinds compose: one says which tab is **chosen**, the
69
- * other says which tab is **tabbable**, and a tab bar needs both
70
- * at once *and* needs them to disagree on purpose while a user
71
- * arrows around in `manual` mode.
72
- *
73
- * The composition is two hooks and one decision:
55
+ * The union splits on `activeHref`, which only the routed member
56
+ * has. `tabs` alone could not carry the split — TypeScript does not
57
+ * narrow a union by the shape of an array's elements, so a mixed bar
58
+ * would type-check and then render half a navigation.
59
+ */
60
+ export type TabsProps = TabsLinkProps | TabsPanelProps
61
+
62
+ const getIsRoutedProps = (
63
+ props: TabsProps,
64
+ ): props is TabsLinkProps => "activeHref" in props
65
+
66
+ /**
67
+ * One choice out of a handful, all of them on screen at once — and
68
+ * the choice is either a **panel** this component reveals or a
69
+ * **place** the router goes to.
74
70
  *
75
- * ```ts
76
- * const selection = useSinglePicker({ selectedValue: initialKey })
77
- * const focus = useRovingFocus({ activeValue: initialKey })
71
+ * ```tsx
72
+ * // A section of this screen. Nothing outside knows about it.
73
+ * <Tabs label="Bay 3" tabs={[{ key: "log", label: "Log", content: … }]} />
78
74
  *
79
- * // `automatic` is this line, and `manual` is its absence.
80
- * if (activation === "automatic") selection.select(focus.activeValue)
75
+ * // A section that has an address.
76
+ * <Tabs
77
+ * activeHref={pathname}
78
+ * label="Bay 3 sections"
79
+ * tabs={[{ href: "/projects/1/tasks", label: "Tasks" }]}
80
+ * />
81
+ * <Outlet />
81
82
  * ```
82
83
  *
83
- * ### Selection, not visibility — and the difference is not cosmetic
84
+ * ## Which one, and the question is not "how does it look"
84
85
  *
85
- * M4 built this on `VisibilityGroup`, and the panels do end up
86
- * one-at-a-time either way, because the two cores are the same
87
- * shape. But the thing that **registers** here is the tab, not the
88
- * panel, and what a tab bar is asking is *which one did you pick*.
89
- * `SinglePicker` is the kind that answers that; the panel's
90
- * `hidden` is derived from the answer.
86
+ * **Does this section have an address?** If reloading the page, or
87
+ * opening it in a second tab, or pressing Back has to land the
88
+ * reader back on the same section, it is routed and every tab is an
89
+ * `<a href>`. If the section is a disclosure inside one screen —
90
+ * Settings' groups, a card's detail — it is a panel and every tab is
91
+ * a `<button>`.
91
92
  *
92
- * Reading it the other way round cost something concrete: it made
93
- * `aria-selected` a report about a *panel's* visibility, so a
94
- * consumer wanting a tab bar with no panels at all — a segmented
95
- * filter, a view switcher that swaps a route — had to model its
96
- * choice as a group of visibilities it did not have. `Alert`'s
97
- * sibling `SegmentedControl` is exactly that consumer, and it now
98
- * shares this component's core rather than hand-rolling a fourth
99
- * one.
100
- * [Decision](../../../../docs/decisions/2026-07-30-tab-selection-is-a-single-picker.md).
93
+ * The two are drawn identically on purpose, through
94
+ * `toTabTriggerClass`. Docket had one of each on adjacent screens
95
+ * wearing different paint, because the routed one had to be built
96
+ * out of `Nav` and `Nav`'s current item is a filled pill
97
+ * ([decision](../../../../docs/decisions/2026-08-31-a-routed-tab-is-a-tab-with-an-href.md)).
101
98
  *
102
- * ### What it does not use, and why that is the right answer
99
+ * ## `Nav` is still the app's destinations
103
100
  *
104
- * The plan expected `createLinkedIds` here as well. It does not
105
- * fit, and forcing it would have been the wrong call: that kind
106
- * exists for a **dynamic** trigger↔target pair — a `Popover` whose
107
- * panel mounts and unmounts, where the multiset is what stops
108
- * `aria-controls` pointing at a node React has already removed. A
109
- * tab bar's pairing is static and known at render, so the ids come
110
- * from one `useUniqueId` and cannot get out of step at all.
101
+ * This is **sub**-navigation: the sections of the screen you are
102
+ * already on. The app's own destinations — the header row, the side
103
+ * rail, the narrow view's menu — are `Nav` + `useNavLayout`, and
104
+ * that has not moved
105
+ * ([decision](../../../../docs/decisions/2026-08-27-nav-is-the-one-navigation-component-and-navbar-is-deprecated.md)).
106
+ * A routed `Tabs` renders no rail, no fold and no menu, because a
107
+ * bar dividing one screen has nowhere to fold to.
111
108
  *
112
- * ### Focus is moved deliberately, and only then
109
+ * ## The routed bar is not a `tablist`, and that is deliberate
113
110
  *
114
- * `RovingFocus` is state; something still has to call `.focus()`.
115
- * That happens only while the tablist already contains the focused
116
- * element — otherwise mounting a tab bar, or any parent re-render
117
- * that moved `activeValue`, yanks the caret out of whatever the
118
- * user was actually doing.
111
+ * `role="tab"` on an anchor overrides the link role, so a screen
112
+ * reader announces a disclosure and the address changes instead. See
113
+ * `TabLink`. The routed bar is a `<nav>` of links with
114
+ * `aria-current="page"`; the panel bar below keeps the full APG
115
+ * pattern, `aria-controls` and all.
116
+ *
117
+ * ## The panel bar: kept at P0 as the state layer's falsification test
118
+ *
119
+ * Not on duplication grounds — the fifteen ad-hoc tab bars that used
120
+ * to justify it were in the withdrawn evidence. What it has to prove
121
+ * is that two kinds compose: one says which tab is **chosen**, the
122
+ * other says which tab is **tabbable**, and a tab bar needs both at
123
+ * once *and* needs them to disagree on purpose while a user arrows
124
+ * around in `manual` mode. `PanelTabs` is where that lives.
119
125
  */
120
- export const Tabs = ({
121
- activation = "automatic",
122
- activeKey,
123
- className,
124
- label,
125
- onChange,
126
- orientation = "horizontal",
127
- tabs,
128
- }: TabsProps): ReactNode => {
129
- const baseId = useUniqueId()
130
-
131
- const tablistRef = useRef<HTMLDivElement>(null)
132
-
133
- const tabElements = useRef(
134
- new Map<string, HTMLButtonElement>(),
135
- )
136
-
137
- const initialKey =
138
- activeKey ??
139
- tabs.find((one) => !one.isDisabled)?.key ??
140
- null
141
-
142
- const selection = useSinglePicker<string>({
143
- onChange,
144
- selectedValue: initialKey,
145
- })
146
-
147
- const focus = useRovingFocus<string>({
148
- activeValue: initialKey,
149
- })
150
-
151
- const { select } = selection
152
-
153
- const { activeValue, setActiveValue } = focus
154
-
155
- /**
156
- * Selected **or** pending, and the fallback is load-bearing.
157
- *
158
- * Members register from an effect, so on the first paint
159
- * `selectedValue` is still null and the intent lives in
160
- * `pendingValue` — a tab bar reading only the former renders with
161
- * no tab selected and no panel shown, then corrects itself a
162
- * frame later. It is a flash on a fast machine and a visible
163
- * blank on a kiosk Pi.
164
- *
165
- * The core distinguishes the two on purpose, which is why this
166
- * belongs here rather than in the selector — unlike
167
- * `RovingFocus`, where the equivalent first-paint hole was in
168
- * `selectTabIndex` itself and got fixed there.
169
- */
170
- const shownKey =
171
- selection.selectedValue ?? selection.pendingValue
172
-
173
- const selectTab = useCallback(
174
- (key: string) => {
175
- // Both, because a pointer press moves focus *and* chooses.
176
- // Only selecting would leave `automatic`'s effect below to
177
- // put the old panel straight back.
178
- setActiveValue(key)
179
-
180
- select(key)
181
- },
182
- [select, setActiveValue],
126
+ export const Tabs = (props: TabsProps): ReactNode =>
127
+ getIsRoutedProps(props) ? (
128
+ <RoutedTabs {...props} />
129
+ ) : (
130
+ <PanelTabs {...props} />
183
131
  )
184
-
185
- useEffect(() => {
186
- // Automatic activation, and the only behaviour that differs
187
- // between the two modes. `select` is idempotent, so this is a
188
- // no-op on every render where focus did not actually move.
189
- if (
190
- activation === "automatic" &&
191
- activeValue !== null
192
- ) {
193
- select(activeValue)
194
- }
195
- }, [activation, activeValue, select])
196
-
197
- useEffect(() => {
198
- const tablist = tablistRef.current
199
-
200
- if (
201
- !tablist ||
202
- activeValue === null ||
203
- !tablist.contains(document.activeElement)
204
- ) {
205
- return
206
- }
207
-
208
- tabElements.current.get(activeValue)?.focus()
209
- }, [activeValue])
210
-
211
- const keys = ORIENTATION_KEYS[orientation]
212
-
213
- return (
214
- <div
215
- className={toClassName(
216
- "flex flex-col gap-3",
217
- className,
218
- )}
219
- >
220
- <div
221
- aria-label={label}
222
- aria-orientation={orientation}
223
- className={toClassName(
224
- "flex gap-1 border-border-subtle",
225
- // A tab bar **scrolls**; it does not wrap and it does not
226
- // paint outside its container. Without an `overflow`, a
227
- // bar narrower than its tabs simply spilled across
228
- // whatever sat beside it — visible in the `Responsive`
229
- // board since M4, and invisible to a test asserting only
230
- // that `scrollWidth > clientWidth`, which is true of a
231
- // *spilling* bar too.
232
- //
233
- // The scrollbar is hidden rather than thin: a classic
234
- // scrollbar takes layout space inside the scroll
235
- // container, so the narrow bar would grow ~15px taller
236
- // than the wide one and the underline would stop lining
237
- // up across a board. Keyboard users never need it —
238
- // `RovingFocus` calls `.focus()`, and the browser scrolls
239
- // a focused tab into view.
240
- "[scrollbar-width:none]",
241
- // An `overflow` other than `visible` clips its
242
- // descendants' **outlines**, and this component's focus
243
- // ring is an outline sitting 2px *outside* the tab. The
244
- // tab fills the bar's content box, so the ring would be
245
- // clipped away on exactly the axis that now scrolls.
246
- // Re-pointing the shared token inward keeps one ring
247
- // definition — `FOCUS_RING_CLASS` already reads this
248
- // variable — rather than appending a competing
249
- // `outline-offset` utility and letting Tailwind's
250
- // ordering decide the winner.
251
- "[--focus-ring-offset:calc(var(--focus-ring-width)*-1)]",
252
- orientation === "horizontal"
253
- ? "flex-row overflow-x-auto border-b"
254
- : "flex-col items-stretch overflow-y-auto border-e",
255
- )}
256
- onKeyDown={(keyEvent) => {
257
- const commands: Record<string, () => void> = {
258
- [keys.next]: focus.next,
259
- [keys.previous]: focus.previous,
260
- End: focus.last,
261
- Home: focus.first,
262
- }
263
-
264
- const command = commands[keyEvent.key]
265
-
266
- if (command) {
267
- // Arrow keys scroll the page by default, and a tab bar
268
- // that scrolls the window while it moves focus is the
269
- // hand-rolled version of this component.
270
- keyEvent.preventDefault()
271
-
272
- command()
273
-
274
- return
275
- }
276
-
277
- // `manual`'s commit. In `automatic` the panel is showing
278
- // already, so this is the no-op that makes Enter behave
279
- // identically in both modes.
280
- if (
281
- (keyEvent.key === "Enter" ||
282
- keyEvent.key === " ") &&
283
- activeValue !== null
284
- ) {
285
- keyEvent.preventDefault()
286
-
287
- select(activeValue)
288
- }
289
- }}
290
- ref={tablistRef}
291
- role="tablist"
292
- >
293
- {tabs.map((tab, index) => (
294
- <TabTrigger
295
- id={`${baseId}-tab-${index}`}
296
- isSelected={shownKey === tab.key}
297
- key={tab.key}
298
- onSelect={selectTab}
299
- panelId={`${baseId}-panel-${index}`}
300
- registerFocus={focus.register}
301
- orientation={orientation}
302
- registerSelection={selection.register}
303
- tab={tab}
304
- // The roving-tabindex rule read from the core rather
305
- // than restated here: exactly one member is in the tab
306
- // order, so Tab enters and leaves the bar once while
307
- // the arrow keys move inside it.
308
- tabIndex={selectTabIndex(focus, tab.key)}
309
- trackElement={(key, element) => {
310
- if (element) {
311
- tabElements.current.set(key, element)
312
- } else {
313
- tabElements.current.delete(key)
314
- }
315
- }}
316
- />
317
- ))}
318
- </div>
319
-
320
- {tabs.map((tab, index) => (
321
- <div
322
- aria-labelledby={`${baseId}-tab-${index}`}
323
- hidden={shownKey !== tab.key}
324
- id={`${baseId}-panel-${index}`}
325
- key={tab.key}
326
- role="tabpanel"
327
- >
328
- {tab.content}
329
- </div>
330
- ))}
331
- </div>
332
- )
333
- }
@@ -0,0 +1,40 @@
1
+ import type { ReactNode } from "react"
2
+
3
+ import type { NavItem } from "../Nav/navItems.ts"
4
+
5
+ export type TabsActivation = "automatic" | "manual"
6
+
7
+ export type TabsOrientation = "horizontal" | "vertical"
8
+
9
+ /**
10
+ * A tab that reveals a panel this component renders. The section
11
+ * lives in memory, so leaving the screen and coming back starts over
12
+ * — which is correct for a disclosure and wrong for a place.
13
+ */
14
+ export type TabItem = {
15
+ content: ReactNode
16
+ /**
17
+ * A disabled tab is simply **not registered** with the roving
18
+ * group. Registration is membership, so the arrow keys skip it
19
+ * without any command in `RovingFocus` having to know what
20
+ * "disabled" means.
21
+ */
22
+ isDisabled?: boolean
23
+ key: string
24
+ label: ReactNode
25
+ }
26
+
27
+ /**
28
+ * A tab that is a **place** — it has an address, and choosing it
29
+ * changes the address.
30
+ *
31
+ * It is a `NavItem` with `isDisabled` added, and that is not
32
+ * convenience: it means `resolveActiveKey` decides which routed tab
33
+ * is current with the exact rule `Nav` uses, rather than a second
34
+ * implementation of segment matching that drifts from it. `label`
35
+ * narrows to `string` for the same reason it does on `NavItem` — it
36
+ * is the accessible name of a link.
37
+ */
38
+ export type TabLinkItem = NavItem & {
39
+ isDisabled?: boolean
40
+ }
@@ -0,0 +1,107 @@
1
+ import { FOCUS_RING_CLASS } from "../intentStyles.ts"
2
+ import { toClassName } from "../toClassName.ts"
3
+ import type { TabsOrientation } from "./tabItems.ts"
4
+
5
+ /**
6
+ * The selected marker runs along the edge the tab list itself
7
+ * sits on — under a horizontal bar, down the inline-end of a
8
+ * vertical rail — and the negative margin pulls it over the
9
+ * list's own border so the two read as one line rather than two.
10
+ *
11
+ * Logical properties throughout, which is the house rule and also
12
+ * the only way a vertical rail lands on the correct side in RTL.
13
+ */
14
+ const ORIENTATION_EDGE_CLASS: Record<
15
+ TabsOrientation,
16
+ { base: string; selected: string }
17
+ > = {
18
+ horizontal: {
19
+ base: "-mb-px border-b-2",
20
+ selected: "border-b-intent-accent-solid",
21
+ },
22
+ vertical: {
23
+ base: "-me-px border-e-2 text-start",
24
+ selected: "border-e-intent-accent-solid",
25
+ },
26
+ }
27
+
28
+ /**
29
+ * One tab's paint, shared by the `<button>` a panel tab renders and
30
+ * the `<a href>` a routed tab renders.
31
+ *
32
+ * **This file is the reason the two cannot drift.** Docket's project
33
+ * bar and Docket's Settings bar are the same shape doing the same
34
+ * job, and before the routed mode existed the first was a `Nav`
35
+ * wearing a filled pill while the second was a `Tabs` wearing this
36
+ * underline — two answers to one question, in one app, on adjacent
37
+ * screens
38
+ * ([decision](../../../../docs/decisions/2026-08-31-a-routed-tab-is-a-tab-with-an-href.md)).
39
+ *
40
+ * The caller adds the one class that is genuinely its own: a button
41
+ * needs `cursor-pointer`, an anchor needs `no-underline`.
42
+ */
43
+ export const toTabTriggerClass = ({
44
+ isDisabled,
45
+ isSelected,
46
+ orientation,
47
+ }: {
48
+ isDisabled: boolean
49
+ isSelected: boolean
50
+ orientation: TabsOrientation
51
+ }): string => {
52
+ const edge = ORIENTATION_EDGE_CLASS[orientation]
53
+
54
+ return toClassName(
55
+ "border-transparent px-3 py-2 font-medium text-sm whitespace-nowrap transition-colors duration-(--duration-fast) ease-standard",
56
+ edge.base,
57
+ // Two entries rather than one interpolated string:
58
+ // `tailwindCandidates.test.ts` rejects a template literal
59
+ // in a className outright, because Tailwind's scanner
60
+ // cannot see a class that only exists at runtime.
61
+ isSelected && edge.selected,
62
+ isSelected
63
+ ? "text-content-primary"
64
+ : "text-content-secondary hover:text-content-primary",
65
+ isDisabled &&
66
+ "cursor-not-allowed text-content-disabled hover:text-content-disabled",
67
+ FOCUS_RING_CLASS,
68
+ )
69
+ }
70
+
71
+ /**
72
+ * The list's own box — the border the selected marker sits on, and
73
+ * the scroll rule.
74
+ *
75
+ * A tab bar **scrolls**; it does not wrap and it does not paint
76
+ * outside its container. Without an `overflow`, a bar narrower than
77
+ * its tabs simply spilled across whatever sat beside it — visible in
78
+ * the `Responsive` board since M4, and invisible to a test asserting
79
+ * only that `scrollWidth > clientWidth`, which is true of a
80
+ * *spilling* bar too.
81
+ *
82
+ * The scrollbar is hidden rather than thin: a classic scrollbar takes
83
+ * layout space inside the scroll container, so the narrow bar would
84
+ * grow ~15px taller than the wide one and the underline would stop
85
+ * lining up across a board. Keyboard users never need it — a panel
86
+ * bar's `RovingFocus` calls `.focus()`, a routed bar's links are in
87
+ * the tab order, and the browser scrolls a focused element into view.
88
+ */
89
+ export const toTabListClass = (
90
+ orientation: TabsOrientation,
91
+ ): string =>
92
+ toClassName(
93
+ "flex gap-1 border-border-subtle",
94
+ "[scrollbar-width:none]",
95
+ // An `overflow` other than `visible` clips its descendants'
96
+ // **outlines**, and this component's focus ring is an outline
97
+ // sitting 2px *outside* the tab. The tab fills the bar's content
98
+ // box, so the ring would be clipped away on exactly the axis that
99
+ // now scrolls. Re-pointing the shared token inward keeps one ring
100
+ // definition — `FOCUS_RING_CLASS` already reads this variable —
101
+ // rather than appending a competing `outline-offset` utility and
102
+ // letting Tailwind's ordering decide the winner.
103
+ "[--focus-ring-offset:calc(var(--focus-ring-width)*-1)]",
104
+ orientation === "horizontal"
105
+ ? "flex-row overflow-x-auto border-b"
106
+ : "flex-col items-stretch overflow-y-auto border-e",
107
+ )
package/src/index.ts CHANGED
@@ -37,6 +37,11 @@ export type {
37
37
  AccordionProps,
38
38
  } from "./Accordion/Accordion.tsx"
39
39
  export { Accordion } from "./Accordion/Accordion.tsx"
40
+ export type {
41
+ ActionTileItem,
42
+ ActionTilesProps,
43
+ } from "./ActionTiles/ActionTiles.tsx"
44
+ export { ActionTiles } from "./ActionTiles/ActionTiles.tsx"
40
45
  export type { AdaptiveGridProps } from "./AdaptiveGrid/AdaptiveGrid.tsx"
41
46
  export { AdaptiveGrid } from "./AdaptiveGrid/AdaptiveGrid.tsx"
42
47
  export type { ColumnChoice } from "./AdaptiveGrid/chooseColumns.ts"
@@ -427,12 +432,17 @@ export {
427
432
  getIsConnectionBusy,
428
433
  } from "./statusIntent.ts"
429
434
  export type {
430
- TabItem,
431
- TabsActivation,
432
- TabsOrientation,
435
+ TabsLinkProps,
436
+ TabsPanelProps,
433
437
  TabsProps,
434
438
  } from "./Tabs/Tabs.tsx"
435
439
  export { Tabs } from "./Tabs/Tabs.tsx"
440
+ export type {
441
+ TabItem,
442
+ TabLinkItem,
443
+ TabsActivation,
444
+ TabsOrientation,
445
+ } from "./Tabs/tabItems.ts"
436
446
  export type {
437
447
  TextLinkAppearance,
438
448
  TextLinkProps,
@@ -537,7 +537,15 @@ test("the barrel is the only place components are re-exported", async () => {
537
537
  // active-path rule an app's tests want to assert directly, and
538
538
  // `useNavLayout.ts`, because the app has to read the width rule to
539
539
  // know which corner to render into.
540
- expect(componentNames.length).toBe(61)
540
+ //
541
+ // `ActionTiles` is +1 -> 62. It is `RadioGroup itemShape="tile"`
542
+ // with the radio taken away: the box is shared through
543
+ // `tileStyles.ts`, and nothing else is. The 2026-08-25 record that
544
+ // made the choice tile a `RadioGroup` shape named this case under
545
+ // what it deliberately does not cover — a tile that navigates is
546
+ // not a radio, and giving one `aria-checked` would be worse than
547
+ // the paint it replaced.
548
+ expect(componentNames.length).toBe(62)
541
549
 
542
550
  for (const name of componentNames) {
543
551
  expect(barrel).toContain(`export { ${name} }`)