@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
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tileStyles.d.ts","sourceRoot":"","sources":["../src/tileStyles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAA;AAEtD;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,kBAAkB,EAAE,MAAM,CACrC,WAAW,EACX,MAAM,CAKP,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,cAAc,6DACiC,CAAA;AAE5D,eAAO,MAAM,oBAAoB,EAAE,MAAM,CACvC,WAAW,EACX,MAAM,CAKP,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,oBAAoB,EAAE,MAAM,CACvC,WAAW,EACX,MAAM,CAKP,CAAA;AAED;;;;GAIG;AACH,eAAO,MAAM,cAAc,EAAE,MAAM,CAAC,WAAW,EAAE,MAAM,CAItD,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,eAAO,MAAM,kBAAkB,gGACgE,CAAA;AAE/F,8DAA8D;AAC9D,eAAO,MAAM,6BAA6B,uCACJ,CAAA"}
@@ -0,0 +1,98 @@
1
+ /**
2
+ * The tile shape, shared by the two components that draw it.
3
+ *
4
+ * A tile is a bordered card carrying a name and a line of help, laid
5
+ * out in a grid that gains columns with its CONTAINER. `RadioGroup`
6
+ * draws one per option when `itemShape="tile"`; `ActionTiles` draws
7
+ * one per action. The two must be the **same box** — a page that puts
8
+ * "which activity" beside "what would you like to do" would otherwise
9
+ * show two cards with different padding, different corners and
10
+ * different gaps, which is the drift this file exists to make
11
+ * impossible.
12
+ *
13
+ * Same argument as `CONTROL_BASE_CLASS`, which `Button` and
14
+ * `ButtonLink` share for the same reason: "identical" spelled as two
15
+ * copies of one string is a promise that survives exactly one edit.
16
+ *
17
+ * These were `RadioGroup`'s private constants until `ActionTiles`
18
+ * shipped. Nothing about their values changed on the way out.
19
+ */
20
+ /**
21
+ * A tile is a card, so its padding is a card's rather than a
22
+ * control's — enough that the border reads as a box around content
23
+ * and not as an outline on a line of text.
24
+ *
25
+ * This is also the whole reason `ActionTiles` exists rather than a
26
+ * `Button` with a `className`: a `Button` is sized by
27
+ * `h-(--control-height-md)` and carries **no block padding at all**,
28
+ * so an app that reaches for `height: auto` to let one wrap gets a
29
+ * two-line card with `padding: 0` down its block axis and no error
30
+ * anywhere. QueuePilot's queue-type chooser shipped exactly that.
31
+ */
32
+ export const TILE_PADDING_CLASS = {
33
+ sm: "px-3 py-2.5",
34
+ md: "px-3.5 py-3",
35
+ lg: "px-4 py-3.5",
36
+ };
37
+ /**
38
+ * The box, and only the box. Every state layered over it is a
39
+ * **border and a surface**, never colour alone — the same reason
40
+ * `Stepper` refuses to say "done" with a hue.
41
+ */
42
+ export const TILE_BOX_CLASS = "rounded-lg border border-border-subtle bg-surface-raised";
43
+ export const TILE_TEXT_SIZE_CLASS = {
44
+ sm: "text-sm",
45
+ md: "text-md",
46
+ lg: "text-lg",
47
+ };
48
+ /**
49
+ * One step down from the tile's own text, and never below `xs`: a
50
+ * hint that keeps shrinking with the density axis is the 11px
51
+ * caption the 2026-08-10 ramp rebuild exists to have removed.
52
+ */
53
+ export const TILE_HINT_TEXT_CLASS = {
54
+ sm: "text-xs",
55
+ md: "text-xs",
56
+ lg: "text-sm",
57
+ };
58
+ /**
59
+ * Wider than a list's row gaps: rows are separated by their leading,
60
+ * and tiles by nothing but the gap, so the same 6px that reads as a
61
+ * list reads as a seam between two cards.
62
+ */
63
+ export const TILE_GAP_CLASS = {
64
+ sm: "gap-1.5",
65
+ md: "gap-2",
66
+ lg: "gap-3",
67
+ };
68
+ /**
69
+ * `auto-fill`, and deliberately not `auto-fit`.
70
+ *
71
+ * `auto-fit` collapses the empty tracks and lets the ones that
72
+ * remain share the whole row, so six tiles in a 2560px container
73
+ * become six 420px slabs — the full-width-row shape in a new
74
+ * costume. `auto-fill` keeps the empty tracks, so a tile stays a
75
+ * tile and the grid simply has room to spare.
76
+ *
77
+ * `min(…, 100%)` is what stops the floor overflowing a container
78
+ * narrower than one tile, which is the Narrow View and is otherwise
79
+ * a horizontal scrollbar on a phone.
80
+ *
81
+ * The floor arrives as a custom property rather than an interpolated
82
+ * class because Tailwind scans source *text* for complete class
83
+ * strings: `` `grid-cols-[…${n}px…]` `` generates nothing, paints
84
+ * nothing and reports nothing. One written-out literal reading
85
+ * `var(--charcuterie-tile-min-inline-size)` covers every width an
86
+ * app can ask for — the same reason `Card`'s accent edge goes
87
+ * through one property.
88
+ *
89
+ * This is **not** `useAdaptiveColumns`, and the difference is the
90
+ * question being asked. That hook buys a column with height, for an
91
+ * unbounded gallery that will scroll; a tile set is a bounded group
92
+ * inside a form section or a first step, where the only question is
93
+ * how many fit across the box it was given.
94
+ */
95
+ export const TILE_COLUMNS_CLASS = "grid-cols-[repeat(auto-fill,minmax(min(var(--charcuterie-tile-min-inline-size),100%),1fr))]";
96
+ /** The property `TILE_COLUMNS_CLASS` reads its floor from. */
97
+ export const TILE_MIN_INLINE_SIZE_PROPERTY = "--charcuterie-tile-min-inline-size";
98
+ //# sourceMappingURL=tileStyles.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tileStyles.js","sourceRoot":"","sources":["../src/tileStyles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;GAkBG;AAIH;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAG3B;IACF,EAAE,EAAE,aAAa;IACjB,EAAE,EAAE,aAAa;IACjB,EAAE,EAAE,aAAa;CAClB,CAAA;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,cAAc,GACzB,0DAA0D,CAAA;AAE5D,MAAM,CAAC,MAAM,oBAAoB,GAG7B;IACF,EAAE,EAAE,SAAS;IACb,EAAE,EAAE,SAAS;IACb,EAAE,EAAE,SAAS;CACd,CAAA;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,oBAAoB,GAG7B;IACF,EAAE,EAAE,SAAS;IACb,EAAE,EAAE,SAAS;IACb,EAAE,EAAE,SAAS;CACd,CAAA;AAED;;;;GAIG;AACH,MAAM,CAAC,MAAM,cAAc,GAAgC;IACzD,EAAE,EAAE,SAAS;IACb,EAAE,EAAE,OAAO;IACX,EAAE,EAAE,OAAO;CACZ,CAAA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAC7B,6FAA6F,CAAA;AAE/F,8DAA8D;AAC9D,MAAM,CAAC,MAAM,6BAA6B,GACxC,oCAAoC,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@charcuterie/ui",
3
- "version": "3.30.0",
3
+ "version": "3.32.0",
4
4
  "description": "The Charcuterie components. Tokens for look, @charcuterie/logic for state, ARIA that agents can actually drive.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,131 @@
1
+ import { Canvas, Controls, Meta } from "@storybook/addon-docs/blocks";
2
+
3
+ import * as ActionTilesStories from "./ActionTiles.stories";
4
+
5
+ <Meta of={ActionTilesStories} />
6
+
7
+ # ActionTiles
8
+
9
+ A set of **actions** drawn as tiles — a bordered card carrying a name and a line of help, in
10
+ a grid that gains columns with its container.
11
+
12
+ This is [`RadioGroup`](/docs/components-controls-radiogroup--docs)'s `itemShape="tile"` with
13
+ the radio taken away. The 2026-08-25 record that made the choice tile a `RadioGroup` shape
14
+ named this case under *"What this deliberately does not cover"*: mux-magic's "Pick a tool"
15
+ tiles are `<a href>` and points-market's are `<Link>`, and *"a link is not a radio, and
16
+ giving one `aria-checked` would be worse than the paint it replaced"*.
17
+
18
+ <Canvas of={ActionTilesStories.Default} />
19
+
20
+ ## Props
21
+
22
+ <Controls />
23
+
24
+ ## Which of the two you want
25
+
26
+ Ask what a press does.
27
+
28
+ - It **records a value** the user will then submit, or that filters what is below it —
29
+ `RadioGroup itemShape="tile"`. The set announces "3 of 6", the arrow keys move the choice,
30
+ and one tile stays selected.
31
+ - It **goes somewhere or starts something** — this. Nothing stays selected, because nothing
32
+ was chosen: the page moved on.
33
+
34
+ A first step that opens the next step is the second kind. QueuePilot's "Queue type" chooser
35
+ — *Picks* or *Rules*, and the editor for the one you press opens immediately — is the case
36
+ this component was built for; that same app's Tonight activity filter stays a `RadioGroup`,
37
+ because it holds a value the Go button below it reads.
38
+
39
+ <Canvas of={ActionTilesStories.BesideTheRadioTile} />
40
+
41
+ ## Why this is not a `Button` with a `className`
42
+
43
+ A `Button` is sized by `h-(--control-height-md)` and carries **no block padding at all**. It
44
+ is a one-line control by contract, which is what keeps every control on a form row the same
45
+ height at every density.
46
+
47
+ So an app that wants a wrapping two-line card reaches for `height: auto` — and that deletes
48
+ the height and leaves `padding: 0` down the block axis. The title sits flush against the top
49
+ border and the description flush against the bottom one. Nothing reports it: the class really
50
+ is in the DOM, TypeScript never reads the CSS, and unstyled-but-present markup passes axe.
51
+
52
+ That shipped in QueuePilot's queue-type chooser and is what this component exists to have
53
+ made impossible. `ActionTiles.test.tsx` reads the padding off `getComputedStyle` in a real
54
+ browser for the same reason.
55
+
56
+ <Canvas of={ActionTilesStories.AllStates} />
57
+
58
+ ## The box is shared, and asserted rather than trusted
59
+
60
+ `tileStyles.ts` holds the padding, the border, the radius, the surface, the type ramp, the
61
+ hint ramp, the gap and the grid; `RadioGroup` and `ActionTiles` both read it. Two tile sets
62
+ on one page have to be the same card, and "identical" spelled as two copies of one string is
63
+ a promise that survives exactly one edit — the same argument `CONTROL_BASE_CLASS` settles
64
+ between `Button` and `ButtonLink`.
65
+
66
+ The test compares **computed** styles of an action tile and a resting radio tile, not class
67
+ names. A class-name assertion passes while the paint is wrong.
68
+
69
+ ## `href` makes the tile a real anchor
70
+
71
+ Middle-click, ctrl-click, "open in a new tab", "copy link address" and the status bar all
72
+ come from the element rather than from the paint — the standing
73
+ [buttons are actions, links are navigation](/docs/guides-routing--docs) rule. A same-origin
74
+ destination goes through the injected `RouterLink`; an external one or a fragment falls back
75
+ to a plain `<a>`, because pushing `https://…` or `#credits` onto the history stack navigates
76
+ the SPA to a route that does not exist.
77
+
78
+ A set may mix links and buttons. `isDisabled` on a link drops the `href` entirely rather than
79
+ shipping a focusable anchor that silently ignores clicks, and keeps `role="link"` plus
80
+ `aria-disabled` so it is *announced* as unavailable rather than vanishing from the tree.
81
+
82
+ <Canvas of={ActionTilesStories.Links} />
83
+
84
+ ## `role="group"`, and no roving tabindex
85
+
86
+ These are ordinary buttons and anchors, so the platform already owns the keyboard: Tab
87
+ reaches **each** tile, Enter and Space press a button, Enter follows a link.
88
+
89
+ A radio group borrows one tab stop and moves its choice with the arrow keys because exactly
90
+ one of its options is true at a time. Nothing here is true, so imposing that model would only
91
+ cost a keyboard user the ability to Tab to the second tile.
92
+
93
+ The group takes a required `label`, which names the set — "Queue type", "Pick a tool" — the
94
+ way a `radiogroup` is named. A set of unnamed tiles is a pile of buttons.
95
+
96
+ ## A tile may lead with an icon
97
+
98
+ `icon` is `aria-hidden` by construction, because the name it sits above is inside the same
99
+ tile — an announced icon is the label read twice. The library still ships no icons; apps
100
+ bring their own.
101
+
102
+ <Canvas of={ActionTilesStories.WithIcons} />
103
+
104
+ ## The grid takes its columns from the container
105
+
106
+ `repeat(auto-fill, minmax(min(var(--charcuterie-tile-min-inline-size), 100%), 1fr))` — the
107
+ same constant `RadioGroup`'s tile shape uses, and each of its three parts is load-bearing:
108
+
109
+ - **`auto-fill`, not `auto-fit`.** `auto-fit` collapses the empty tracks and lets the
110
+ survivors share the whole row, so six tiles in a 2560px container become six 420px slabs —
111
+ the full-width-row shape in a new costume.
112
+ - **`min(…, 100%)`.** Without it the 200px floor overflows a container narrower than one
113
+ tile, which is a horizontal scrollbar in the Narrow View.
114
+ - **A custom property, not an interpolated class.** Tailwind scans source *text* for complete
115
+ class strings, so `` `grid-cols-[…${n}px…]` `` generates nothing, paints nothing and
116
+ reports nothing.
117
+
118
+ This is **not** `useAdaptiveColumns`. That hook buys a column with **height**, for an
119
+ unbounded gallery that is going to scroll; a tile set is a bounded group inside a form section
120
+ or a first step, where the only question is how many fit across the box it was given.
121
+
122
+ <Canvas of={ActionTilesStories.Responsive} />
123
+
124
+ ## What this deliberately does not do
125
+
126
+ - **Hold a selection.** That is `RadioGroup itemShape="tile"`, and giving these tiles
127
+ `aria-checked` would be the mistake the 2026-08-25 record refused.
128
+ - **Toggle per tile.** A multi-select tile is a checkbox group and a different record.
129
+ - **Carry artwork.** That is [`MediaTile`](/docs/components-data-mediatile--docs).
130
+
131
+ <Canvas of={ActionTilesStories.Interactive} />
@@ -0,0 +1,311 @@
1
+ import type { Meta, StoryObj } from "@storybook/react"
2
+
3
+ import { controlSizeArgType } from "../argTypes.storyHelpers.ts"
4
+ import {
5
+ ContainerBoard,
6
+ StoryCell,
7
+ StoryGrid,
8
+ StorySection,
9
+ } from "../board.storyHelpers.tsx"
10
+ import {
11
+ InboxIcon,
12
+ PlayIcon,
13
+ SearchIcon,
14
+ } from "../icons.storyHelpers.tsx"
15
+ import { RadioGroup } from "../RadioGroup/RadioGroup.tsx"
16
+ import type { ActionTileItem } from "./ActionTiles.tsx"
17
+ import { ActionTiles } from "./ActionTiles.tsx"
18
+
19
+ /**
20
+ * The shape this component was built for: a first step that opens
21
+ * the next step. Nothing stays selected, because nothing was chosen
22
+ * — the page moved on.
23
+ */
24
+ const QUEUE_TYPE_ITEMS: ActionTileItem[] = [
25
+ {
26
+ hint: "Choose titles yourself, then arrange them in priority and random lanes.",
27
+ label: "Picks",
28
+ value: "picks",
29
+ },
30
+ {
31
+ hint: "Set eligibility filters and let the app select matching titles.",
32
+ label: "Rules",
33
+ value: "rules",
34
+ },
35
+ ]
36
+
37
+ /** A tile may lead with an icon. The library ships none. */
38
+ const START_ITEMS: ActionTileItem[] = [
39
+ {
40
+ hint: "Everything that arrived since the last run.",
41
+ icon: <InboxIcon />,
42
+ label: "New arrivals",
43
+ value: "new",
44
+ },
45
+ {
46
+ hint: "Whatever the saved query matches today.",
47
+ icon: <SearchIcon />,
48
+ label: "Saved search",
49
+ value: "search",
50
+ },
51
+ {
52
+ hint: "Pick up where the last session stopped.",
53
+ icon: <PlayIcon />,
54
+ label: "Continue",
55
+ value: "continue",
56
+ },
57
+ ]
58
+
59
+ /**
60
+ * A tile that carries an `href` is a real `<a href>`, so
61
+ * middle-click, ctrl-click and "copy link address" all work. The
62
+ * same set may mix links and buttons.
63
+ */
64
+ const TOOL_ITEMS: ActionTileItem[] = [
65
+ {
66
+ hint: "Read a container's tracks and say what would change.",
67
+ href: "/tools/inspect",
68
+ label: "Inspect",
69
+ value: "inspect",
70
+ },
71
+ {
72
+ hint: "Rewrite the container without touching a stream.",
73
+ href: "/tools/remux",
74
+ label: "Remux",
75
+ value: "remux",
76
+ },
77
+ {
78
+ hint: "The reference, on the upstream site.",
79
+ href: "https://example.com/docs",
80
+ isExternal: true,
81
+ label: "Read the docs",
82
+ value: "docs",
83
+ },
84
+ ]
85
+
86
+ const meta = {
87
+ title: "Components/Actions/ActionTiles",
88
+ component: ActionTiles,
89
+ parameters: { layout: "padded" },
90
+ argTypes: { size: controlSizeArgType },
91
+ // The component's own defaults, restated — Storybook does not seed
92
+ // `args` from docgen, so an unstated default shows in the props
93
+ // table with nothing selected in its control.
94
+ args: {
95
+ minTileInlineSize: 200,
96
+ size: "md",
97
+ },
98
+ } satisfies Meta<typeof ActionTiles>
99
+
100
+ export default meta
101
+
102
+ type Story = StoryObj<typeof meta>
103
+
104
+ export const Default: Story = {
105
+ args: { items: QUEUE_TYPE_ITEMS, label: "Queue type" },
106
+ }
107
+
108
+ export const AllVariants: Story = {
109
+ args: { items: QUEUE_TYPE_ITEMS, label: "Queue type" },
110
+ render: (controlProps) => (
111
+ <StorySection title="Three sizes, all reading the density axis rather than hardcoding a length — the same three a RadioGroup tile has, from the same tokens.">
112
+ <StoryGrid columns={3}>
113
+ {(["sm", "md", "lg"] as const).map((size) => (
114
+ <StoryCell
115
+ align="stretch"
116
+ key={size}
117
+ label={size}
118
+ >
119
+ <ActionTiles
120
+ {...controlProps}
121
+ label={`Queue type at ${size}`}
122
+ minTileInlineSize={160}
123
+ size={size}
124
+ />
125
+ </StoryCell>
126
+ ))}
127
+ </StoryGrid>
128
+ </StorySection>
129
+ ),
130
+ }
131
+
132
+ /**
133
+ * A tile may lead with an icon. The library ships none — these are
134
+ * story-only SVGs, exactly what an app passes — and the icon is
135
+ * `aria-hidden` by construction, because the name it sits above is
136
+ * inside the same tile.
137
+ */
138
+ export const WithIcons: Story = {
139
+ args: { items: START_ITEMS, label: "Where to start" },
140
+ }
141
+
142
+ /**
143
+ * `href` makes the tile an anchor rather than a button, routed
144
+ * through the injected `RouterLink` when the destination is the
145
+ * router's. An `isExternal` tile falls back to a plain `<a
146
+ * target="_blank">` and says so to a screen reader.
147
+ */
148
+ export const Links: Story = {
149
+ args: { items: TOOL_ITEMS, label: "Pick a tool" },
150
+ }
151
+
152
+ export const AllStates: Story = {
153
+ args: { items: QUEUE_TYPE_ITEMS, label: "Queue type" },
154
+ render: (controlProps) => (
155
+ <StorySection title="What varies is what a tile does and whether it can be pressed. Nothing here is 'selected' — that state belongs to RadioGroup itemShape='tile'.">
156
+ <StoryGrid columns={2}>
157
+ <StoryCell align="stretch" label="two actions">
158
+ <ActionTiles
159
+ {...controlProps}
160
+ label="Queue type"
161
+ />
162
+ </StoryCell>
163
+
164
+ <StoryCell align="stretch" label="no hint">
165
+ <ActionTiles
166
+ {...controlProps}
167
+ items={[
168
+ { label: "Picks", value: "picks" },
169
+ { label: "Rules", value: "rules" },
170
+ ]}
171
+ label="Queue type, names only"
172
+ />
173
+ </StoryCell>
174
+
175
+ <StoryCell align="stretch" label="a disabled tile">
176
+ <ActionTiles
177
+ {...controlProps}
178
+ items={[
179
+ QUEUE_TYPE_ITEMS[0] as ActionTileItem,
180
+ {
181
+ hint: "No provider is configured to filter on.",
182
+ isDisabled: true,
183
+ label: "Rules",
184
+ value: "rules",
185
+ },
186
+ ]}
187
+ label="Queue type with Rules unavailable"
188
+ />
189
+ </StoryCell>
190
+
191
+ <StoryCell align="stretch" label="a disabled link">
192
+ <ActionTiles
193
+ {...controlProps}
194
+ items={[
195
+ {
196
+ hint: "Read a container's tracks and say what would change.",
197
+ href: "/tools/inspect",
198
+ label: "Inspect",
199
+ value: "inspect",
200
+ },
201
+ {
202
+ hint: "Nothing is loaded to remux yet.",
203
+ href: "/tools/remux",
204
+ isDisabled: true,
205
+ label: "Remux",
206
+ value: "remux",
207
+ },
208
+ ]}
209
+ label="Pick a tool with Remux unavailable"
210
+ />
211
+ </StoryCell>
212
+
213
+ <StoryCell align="stretch" label="links">
214
+ <ActionTiles
215
+ {...controlProps}
216
+ items={TOOL_ITEMS}
217
+ label="Pick a tool"
218
+ />
219
+ </StoryCell>
220
+
221
+ <StoryCell align="stretch" label="icons">
222
+ <ActionTiles
223
+ {...controlProps}
224
+ items={START_ITEMS}
225
+ label="Where to start"
226
+ />
227
+ </StoryCell>
228
+ </StoryGrid>
229
+ </StorySection>
230
+ ),
231
+ }
232
+
233
+ /**
234
+ * The two tile components, side by side, which is the comparison a
235
+ * reader choosing between them actually needs.
236
+ *
237
+ * The box is the **same box** — `tileStyles.ts` holds the padding,
238
+ * the border, the radius, the surface, the type ramp and the grid,
239
+ * and both components read it. What differs is everything the box is
240
+ * not: one announces "1 of 2" and keeps a tile selected, the other
241
+ * presses and is done.
242
+ */
243
+ export const BesideTheRadioTile: Story = {
244
+ args: { items: QUEUE_TYPE_ITEMS, label: "Queue type" },
245
+ render: (controlProps) => (
246
+ <StorySection title="One box, two controls. Only the resting radio tile matches — a checked one wears the accent edge, which is the state ActionTiles has none of.">
247
+ <StoryGrid columns={2}>
248
+ <StoryCell
249
+ align="stretch"
250
+ label="ActionTiles — presses and is done"
251
+ >
252
+ <ActionTiles {...controlProps} />
253
+ </StoryCell>
254
+
255
+ <StoryCell
256
+ align="stretch"
257
+ label="RadioGroup itemShape='tile' — holds a value"
258
+ >
259
+ <RadioGroup
260
+ itemShape="tile"
261
+ items={QUEUE_TYPE_ITEMS.map(
262
+ ({ hint, label, value }) => ({
263
+ hint,
264
+ label,
265
+ value,
266
+ }),
267
+ )}
268
+ label="Queue type, as radios"
269
+ size={controlProps.size}
270
+ />
271
+ </StoryCell>
272
+ </StoryGrid>
273
+ </StorySection>
274
+ ),
275
+ }
276
+
277
+ /**
278
+ * Column count comes from the **container**, never the window — the
279
+ * standing rule for any list of cards, and the reason this board
280
+ * varies a wrapper's width rather than the viewport's.
281
+ *
282
+ * At the 200px default floor: one track at 15rem, one at 24rem, two
283
+ * at 34rem. `auto-fill` and deliberately not `auto-fit`, so the
284
+ * tiles keep their size in a container with room to spare instead of
285
+ * stretching to fill it. Same formula as the `RadioGroup` tile, from
286
+ * the same constant.
287
+ */
288
+ export const Responsive: Story = {
289
+ args: { items: START_ITEMS, label: "Where to start" },
290
+ render: (controlProps) => (
291
+ <ContainerBoard>
292
+ {(width) => (
293
+ <ActionTiles
294
+ {...controlProps}
295
+ label={`Where to start at ${width}`}
296
+ />
297
+ )}
298
+ </ContainerBoard>
299
+ ),
300
+ }
301
+
302
+ /**
303
+ * The complete keyboard path, and it is the platform's: Tab reaches
304
+ * **each** tile, Enter and Space press a button one, Enter follows a
305
+ * link one. There is no roving tabindex here — a radio group borrows
306
+ * one tab stop because exactly one of its options is true at a time,
307
+ * and nothing here is true.
308
+ */
309
+ export const Interactive: Story = {
310
+ args: { items: QUEUE_TYPE_ITEMS, label: "Queue type" },
311
+ }