@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
@@ -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
+ })