@charcuterie/ui 3.30.0 → 3.31.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 (43) hide show
  1. package/dist/Tabs/PanelTabs.d.ts +56 -0
  2. package/dist/Tabs/PanelTabs.d.ts.map +1 -0
  3. package/dist/Tabs/PanelTabs.js +163 -0
  4. package/dist/Tabs/PanelTabs.js.map +1 -0
  5. package/dist/Tabs/RoutedTabs.d.ts +19 -0
  6. package/dist/Tabs/RoutedTabs.d.ts.map +1 -0
  7. package/dist/Tabs/RoutedTabs.js +28 -0
  8. package/dist/Tabs/RoutedTabs.js.map +1 -0
  9. package/dist/Tabs/TabLink.d.ts +34 -0
  10. package/dist/Tabs/TabLink.d.ts.map +1 -0
  11. package/dist/Tabs/TabLink.js +66 -0
  12. package/dist/Tabs/TabLink.js.map +1 -0
  13. package/dist/Tabs/TabTrigger.d.ts +1 -1
  14. package/dist/Tabs/TabTrigger.d.ts.map +1 -1
  15. package/dist/Tabs/TabTrigger.js +6 -30
  16. package/dist/Tabs/TabTrigger.js.map +1 -1
  17. package/dist/Tabs/Tabs.d.ts +80 -64
  18. package/dist/Tabs/Tabs.d.ts.map +1 -1
  19. package/dist/Tabs/Tabs.js +53 -182
  20. package/dist/Tabs/Tabs.js.map +1 -1
  21. package/dist/Tabs/tabItems.d.ts +36 -0
  22. package/dist/Tabs/tabItems.d.ts.map +1 -0
  23. package/dist/Tabs/tabItems.js +2 -0
  24. package/dist/Tabs/tabItems.js.map +1 -0
  25. package/dist/Tabs/tabStyles.d.ts +41 -0
  26. package/dist/Tabs/tabStyles.d.ts.map +1 -0
  27. package/dist/Tabs/tabStyles.js +79 -0
  28. package/dist/Tabs/tabStyles.js.map +1 -0
  29. package/dist/index.d.ts +2 -1
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js.map +1 -1
  32. package/package.json +1 -1
  33. package/src/Tabs/PanelTabs.tsx +260 -0
  34. package/src/Tabs/RoutedTabs.tsx +58 -0
  35. package/src/Tabs/TabLink.tsx +132 -0
  36. package/src/Tabs/TabTrigger.tsx +10 -39
  37. package/src/Tabs/Tabs.mdx +70 -0
  38. package/src/Tabs/Tabs.stories.tsx +133 -1
  39. package/src/Tabs/Tabs.test.tsx +169 -0
  40. package/src/Tabs/Tabs.tsx +94 -296
  41. package/src/Tabs/tabItems.ts +40 -0
  42. package/src/Tabs/tabStyles.ts +107 -0
  43. package/src/index.ts +8 -3
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
@@ -8,8 +8,8 @@ import {
8
8
  StoryGrid,
9
9
  } from "../board.storyHelpers.tsx"
10
10
  import { ProgressBar } from "../ProgressBar/ProgressBar.tsx"
11
- import type { TabItem } from "./Tabs.tsx"
12
11
  import { Tabs } from "./Tabs.tsx"
12
+ import type { TabItem, TabLinkItem } from "./tabItems.ts"
13
13
 
14
14
  const BAY_TABS: TabItem[] = [
15
15
  {
@@ -185,3 +185,135 @@ export const Manual: Story = {
185
185
  tabs: BAY_TABS,
186
186
  },
187
187
  }
188
+
189
+ const BAY_ROUTE_TABS: TabLinkItem[] = [
190
+ {
191
+ href: "/bays/3",
192
+ label: "Overview",
193
+ },
194
+ {
195
+ href: "/bays/3/titles",
196
+ label: "Titles",
197
+ },
198
+ {
199
+ href: "/bays/3/tracks",
200
+ label: "Tracks",
201
+ },
202
+ {
203
+ href: "/bays/3/log",
204
+ label: "Log",
205
+ },
206
+ ]
207
+
208
+ /**
209
+ * A tab that is a **place**. Every trigger is a real `<a href>`, so
210
+ * middle-click, ctrl-click, "open in a new tab" and "copy link
211
+ * address" all work — and reloading the page comes back to the same
212
+ * section, which is the whole reason to route one.
213
+ *
214
+ * The bar is a named `<nav>`, not a `tablist`: `role="tab"` on an
215
+ * anchor overrides the link role, so a screen reader would announce
216
+ * a disclosure and then the address would change instead. The
217
+ * *paint* is shared with the panel bar above through
218
+ * `toTabTriggerClass`, which is what this mode was added to fix.
219
+ *
220
+ * The sections themselves are rendered by the router — an
221
+ * `<Outlet />` under the bar — so this component draws no panels
222
+ * here at all.
223
+ */
224
+ export const Routed: Story = {
225
+ args: { label: "Bay 3", tabs: BAY_TABS },
226
+ render: () => (
227
+ <Tabs
228
+ activeHref="/bays/3/log"
229
+ label="Bay 3 sections"
230
+ tabs={BAY_ROUTE_TABS}
231
+ />
232
+ ),
233
+ }
234
+
235
+ /**
236
+ * Routed and panel bars side by side — the comparison this mode
237
+ * exists for. They are one shape doing one job, and before `href`
238
+ * existed the routed one had to be built out of `Nav`, whose current
239
+ * item is a filled pill.
240
+ *
241
+ * The third cell is the matching rule, which is `Nav`'s
242
+ * `resolveActiveKey` rather than a second implementation: the
243
+ * deepest matching path wins, so `/tasks` stands down while the
244
+ * reader is on `/tasks` *and* nothing lights up twice.
245
+ */
246
+ export const RoutedAllVariants: Story = {
247
+ args: { label: "Bay 3", tabs: BAY_TABS },
248
+ render: () => (
249
+ <StoryGrid columns={2}>
250
+ <StoryCell label="routed · horizontal">
251
+ <Tabs
252
+ activeHref="/bays/3/titles"
253
+ label="Routed horizontal"
254
+ tabs={BAY_ROUTE_TABS}
255
+ />
256
+ </StoryCell>
257
+
258
+ <StoryCell label="panel · horizontal">
259
+ <Tabs label="Panel horizontal" tabs={BAY_TABS} />
260
+ </StoryCell>
261
+
262
+ <StoryCell label="routed · vertical">
263
+ <Tabs
264
+ activeHref="/bays/3/tracks"
265
+ label="Routed vertical"
266
+ orientation="vertical"
267
+ tabs={BAY_ROUTE_TABS}
268
+ />
269
+ </StoryCell>
270
+
271
+ <StoryCell label="routed · a child route is still the section">
272
+ <Tabs
273
+ activeHref="/bays/3/titles/4"
274
+ label="Routed on a child route"
275
+ tabs={BAY_ROUTE_TABS}
276
+ />
277
+ </StoryCell>
278
+ </StoryGrid>
279
+ ),
280
+ }
281
+
282
+ const ROUTED_STATE_TABS: TabLinkItem[] = [
283
+ ...BAY_ROUTE_TABS,
284
+ {
285
+ href: "/bays/3/reports",
286
+ isDisabled: true,
287
+ label: "Reports",
288
+ },
289
+ {
290
+ href: "https://mkdocs.octen.dev/workspace/docket/",
291
+ isExternal: true,
292
+ label: "Handbook",
293
+ },
294
+ ]
295
+
296
+ /**
297
+ * A disabled routed tab is **not a link at all** — a `<span>` with
298
+ * `aria-disabled`, because an anchor has no `disabled` attribute and
299
+ * `aria-disabled` alone still navigates on Enter. That is the
300
+ * version of this that looks right and is not.
301
+ *
302
+ * An external tab is a plain anchor with `target="_blank"` and
303
+ * `rel="noopener noreferrer"`, and it is **never current** — no
304
+ * address inside this app can be a place outside it.
305
+ *
306
+ * `activeHref` here is a screen the bar does not contain, which is
307
+ * the third state: **nothing** is current. A bar that guessed the
308
+ * closest tab instead would be lying on every modal route.
309
+ */
310
+ export const RoutedAllStates: Story = {
311
+ args: { label: "Bay 3", tabs: BAY_TABS },
312
+ render: () => (
313
+ <Tabs
314
+ activeHref="/settings"
315
+ label="Bay 3 states"
316
+ tabs={ROUTED_STATE_TABS}
317
+ />
318
+ ),
319
+ }
@@ -13,6 +13,9 @@ const {
13
13
  Default,
14
14
  Manual,
15
15
  Responsive,
16
+ Routed,
17
+ RoutedAllStates,
18
+ RoutedAllVariants,
16
19
  } = composeStories(stories)
17
20
 
18
21
  test("selecting a tab wires the panel back to it", async () => {
@@ -281,3 +284,169 @@ test("automatic activation selects as focus moves, and wraps", async () => {
281
284
  expect(tabs[0]).toHaveAttribute("aria-selected", "true")
282
285
  })
283
286
  })
287
+
288
+ /**
289
+ * The routed bar is a **navigation**, not a tablist, and this is the
290
+ * assertion that says so. `role="tab"` on an anchor overrides the
291
+ * link role: a screen reader announces a disclosure, the reader
292
+ * waits for a panel, and the address changes under them instead.
293
+ *
294
+ * A screenshot cannot tell the two apart — the paint is deliberately
295
+ * identical — so nothing but this catches a regression to it.
296
+ */
297
+ test("a routed tab is a link in a nav, never a tab in a tablist", async () => {
298
+ const { canvas, canvasElement } = await mountStory(Routed)
299
+
300
+ const nav = expectAgentDrivable(canvas, {
301
+ name: "Bay 3 sections",
302
+ role: "navigation",
303
+ })
304
+
305
+ await expect(
306
+ canvas.queryByRole("tablist"),
307
+ ).not.toBeInTheDocument()
308
+
309
+ await expect(
310
+ canvas.queryByRole("tab"),
311
+ ).not.toBeInTheDocument()
312
+
313
+ // No panels either: the sections are the router's, rendered
314
+ // through an `<Outlet />` this component cannot see.
315
+ await expect(
316
+ canvas.queryByRole("tabpanel"),
317
+ ).not.toBeInTheDocument()
318
+
319
+ const links = Array.from(
320
+ nav.querySelectorAll<HTMLAnchorElement>("a[href]"),
321
+ )
322
+
323
+ await expect(links).toHaveLength(4)
324
+
325
+ await expectNoAxeViolations(canvasElement)
326
+ })
327
+
328
+ test("exactly one routed tab is current, and it is the deepest match", async () => {
329
+ const { canvas } = await mountStory(RoutedAllVariants)
330
+
331
+ // `/tasks` and the project root both match `/…/tasks`, and only
332
+ // the deeper one may light up — a bar with two current items has
333
+ // stopped saying anything. The rule is `resolveActiveKey`, shared
334
+ // with `Nav` rather than reimplemented here.
335
+ const nav = expectAgentDrivable(canvas, {
336
+ name: "Routed horizontal",
337
+ role: "navigation",
338
+ })
339
+
340
+ const current = nav.querySelectorAll(
341
+ '[aria-current="page"]',
342
+ )
343
+
344
+ await expect(current).toHaveLength(1)
345
+
346
+ await expect(current[0]).toHaveTextContent("Titles")
347
+
348
+ // A child route is still inside the section it belongs to.
349
+ const child = expectAgentDrivable(canvas, {
350
+ name: "Routed on a child route",
351
+ role: "navigation",
352
+ })
353
+
354
+ const childCurrent = child.querySelectorAll(
355
+ '[aria-current="page"]',
356
+ )
357
+
358
+ await expect(childCurrent).toHaveLength(1)
359
+
360
+ await expect(childCurrent[0]).toHaveTextContent("Titles")
361
+ })
362
+
363
+ /**
364
+ * A disabled anchor is the defect this guards. `<a>` has no
365
+ * `disabled` attribute, so the tempting version — a real `href` plus
366
+ * `aria-disabled` — greys out and still navigates on Enter and on a
367
+ * middle click.
368
+ */
369
+ test("a disabled routed tab has no href, and an external one is never current", async () => {
370
+ const { canvas, canvasElement } =
371
+ await mountStory(RoutedAllStates)
372
+
373
+ const nav = expectAgentDrivable(canvas, {
374
+ name: "Bay 3 states",
375
+ role: "navigation",
376
+ })
377
+
378
+ const reports = canvas.getByText("Reports")
379
+
380
+ await expect(reports).toHaveAttribute(
381
+ "aria-disabled",
382
+ "true",
383
+ )
384
+
385
+ await expect(reports.closest("a")).toBeNull()
386
+
387
+ const handbook = expectAgentDrivable(canvas, {
388
+ name: /Handbook/u,
389
+ role: "link",
390
+ })
391
+
392
+ await expect(handbook).toHaveAttribute("target", "_blank")
393
+
394
+ await expect(handbook).toHaveAttribute(
395
+ "rel",
396
+ "noopener noreferrer",
397
+ )
398
+
399
+ // Never current: no address inside this app can be a place
400
+ // outside it, and a plain string compare would match one.
401
+ await expect(handbook).not.toHaveAttribute("aria-current")
402
+
403
+ // `activeHref` points at `/overview`, which no tab owns — so the
404
+ // bar honestly marks nothing rather than guessing the closest.
405
+ await expect(
406
+ nav.querySelectorAll('[aria-current="page"]'),
407
+ ).toHaveLength(0)
408
+
409
+ await expectNoAxeViolations(canvasElement)
410
+ })
411
+
412
+ /**
413
+ * The reason both modes live in one component: a routed tab and a
414
+ * panel tab are one shape doing one job, and Docket shipped them on
415
+ * adjacent screens wearing different paint because the routed one
416
+ * had to be built out of `Nav`.
417
+ */
418
+ test("a routed tab and a panel tab are painted identically", async () => {
419
+ const { canvas } = await mountStory(RoutedAllVariants)
420
+
421
+ const routedCurrent = expectAgentDrivable(canvas, {
422
+ name: "Routed horizontal",
423
+ role: "navigation",
424
+ }).querySelector('[aria-current="page"]')
425
+
426
+ const panelSelected = expectAgentDrivable(canvas, {
427
+ name: "Panel horizontal",
428
+ role: "tablist",
429
+ }).querySelector('[aria-selected="true"]')
430
+
431
+ const routedStyle = globalThis.getComputedStyle(
432
+ routedCurrent as HTMLElement,
433
+ )
434
+
435
+ const panelStyle = globalThis.getComputedStyle(
436
+ panelSelected as HTMLElement,
437
+ )
438
+
439
+ for (const property of [
440
+ "borderBottomColor",
441
+ "borderBottomWidth",
442
+ "color",
443
+ "fontSize",
444
+ "fontWeight",
445
+ "paddingInlineStart",
446
+ "paddingBlockStart",
447
+ ] as const) {
448
+ await expect(routedStyle[property]).toBe(
449
+ panelStyle[property],
450
+ )
451
+ }
452
+ })