@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,292 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import { expect, userEvent, waitFor } from "storybook/test"
3
+ import { test } from "vitest"
4
+
5
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
6
+ import { mountStory } from "../mountStory.testHelpers.ts"
7
+ import { expectAgentDrivable } from "../testing/index.ts"
8
+ import * as stories from "./ActionTiles.stories.tsx"
9
+
10
+ const {
11
+ AllStates,
12
+ BesideTheRadioTile,
13
+ Default,
14
+ Interactive,
15
+ Links,
16
+ Responsive,
17
+ WithIcons,
18
+ } = composeStories(stories)
19
+
20
+ test("it is a named group of pressable tiles", async () => {
21
+ const { canvas, canvasElement } =
22
+ await mountStory(Default)
23
+
24
+ const group = expectAgentDrivable(canvas, {
25
+ name: "Queue type",
26
+ role: "group",
27
+ })
28
+
29
+ await expect(
30
+ group.querySelectorAll("button"),
31
+ ).toHaveLength(2)
32
+
33
+ // Nothing here is selected, and nothing claims to be. The whole
34
+ // reason this is not a `RadioGroup` in a costume.
35
+ await expect(
36
+ group.querySelector(
37
+ "[aria-checked], [aria-pressed], [role='radio']",
38
+ ),
39
+ ).toBeNull()
40
+
41
+ await expectNoAxeViolations(canvasElement)
42
+ })
43
+
44
+ test("a tile's hint is part of its accessible name", async () => {
45
+ const { canvas } = await mountStory(Default)
46
+
47
+ // The reason the hint is rendered inside the tile rather than
48
+ // beside it: a hint a screen reader never reads is a hint half the
49
+ // audience does not have. `getByRole` computes the name from
50
+ // contents, so this query only passes if it is in there.
51
+ expectAgentDrivable(canvas, {
52
+ name: "Picks Choose titles yourself, then arrange them in priority and random lanes.",
53
+ role: "button",
54
+ })
55
+ })
56
+
57
+ test("a tile icon is decoration and stays out of the name", async () => {
58
+ const { canvas, canvasElement } =
59
+ await mountStory(WithIcons)
60
+
61
+ // Named by its words alone. An icon that joined the name would
62
+ // make this query fail, which is the whole reason it is
63
+ // `aria-hidden`.
64
+ expectAgentDrivable(canvas, {
65
+ name: "New arrivals Everything that arrived since the last run.",
66
+ role: "button",
67
+ })
68
+
69
+ await expectNoAxeViolations(canvasElement)
70
+ })
71
+
72
+ test("every tile is its own tab stop, and Enter and Space press it", async () => {
73
+ const { canvas } = await mountStory(Interactive)
74
+
75
+ const group = expectAgentDrivable(canvas, {
76
+ name: "Queue type",
77
+ role: "group",
78
+ })
79
+
80
+ const tiles = Array.from(
81
+ group.querySelectorAll<HTMLButtonElement>("button"),
82
+ )
83
+
84
+ // The opposite of the roving-tabindex rule, on purpose: a radio
85
+ // group borrows one tab stop because exactly one option is true at
86
+ // a time, and nothing here is true. Every tile must be reachable
87
+ // by Tab.
88
+ await expect(
89
+ tiles.filter((one) => one.tabIndex === 0),
90
+ ).toHaveLength(2)
91
+
92
+ tiles[0]?.focus()
93
+
94
+ await userEvent.tab()
95
+
96
+ await waitFor(() => {
97
+ expect(tiles[1]).toHaveFocus()
98
+ })
99
+
100
+ // Enter and Space are the platform's, because these are real
101
+ // `<button>`s — the two things the hand-rolled `<div role=button>`
102
+ // versions in the fleet re-implement by hand.
103
+ await userEvent.keyboard("{Enter}")
104
+
105
+ await userEvent.keyboard(" ")
106
+ })
107
+
108
+ test("a disabled tile cannot be pressed", async () => {
109
+ const { canvas, canvasElement } =
110
+ await mountStory(AllStates)
111
+
112
+ const group = expectAgentDrivable(canvas, {
113
+ name: "Queue type with Rules unavailable",
114
+ role: "group",
115
+ })
116
+
117
+ const rules = Array.from(
118
+ group.querySelectorAll<HTMLButtonElement>("button"),
119
+ ).find((one) => one.textContent?.startsWith("Rules"))
120
+
121
+ await expect(rules).toBeDisabled()
122
+
123
+ await expectNoAxeViolations(canvasElement)
124
+ })
125
+
126
+ test("an href tile is a real anchor, and an external one says so", async () => {
127
+ const { canvas, canvasElement } = await mountStory(Links)
128
+
129
+ const group = expectAgentDrivable(canvas, {
130
+ name: "Pick a tool",
131
+ role: "group",
132
+ })
133
+
134
+ const inspect = expectAgentDrivable(canvas, {
135
+ name: "Inspect Read a container's tracks and say what would change.",
136
+ role: "link",
137
+ })
138
+
139
+ // A real `<a href>`, so middle-click, ctrl-click, "open in a new
140
+ // tab" and "copy link address" come from the element rather than
141
+ // from the paint.
142
+ await expect(inspect.tagName).toBe("A")
143
+
144
+ await expect(inspect).toHaveAttribute(
145
+ "href",
146
+ "/tools/inspect",
147
+ )
148
+
149
+ const external = Array.from(
150
+ group.querySelectorAll<HTMLAnchorElement>("a"),
151
+ ).find((one) => one.target === "_blank")
152
+
153
+ // `noreferrer` alongside `noopener`: the first closes the
154
+ // `window.opener` hole, the second keeps a private app's URL out of
155
+ // the destination's logs.
156
+ await expect(external).toHaveAttribute(
157
+ "rel",
158
+ "noopener noreferrer",
159
+ )
160
+
161
+ await expectNoAxeViolations(canvasElement)
162
+ })
163
+
164
+ test("a disabled link drops its href and stays announced as a link", async () => {
165
+ const { canvas } = await mountStory(AllStates)
166
+
167
+ const remux = expectAgentDrivable(canvas, {
168
+ name: "Remux Nothing is loaded to remux yet.",
169
+ role: "link",
170
+ })
171
+
172
+ // An anchor with no `href` is inert and out of the tab order by the
173
+ // platform's own rules — better than a focusable one that silently
174
+ // ignores clicks. The explicit role keeps it *announced* rather
175
+ // than vanishing from the tree.
176
+ await expect(remux).not.toHaveAttribute("href")
177
+
178
+ await expect(remux).toHaveAttribute(
179
+ "aria-disabled",
180
+ "true",
181
+ )
182
+ })
183
+
184
+ /**
185
+ * The box is shared with `RadioGroup itemShape="tile"` through
186
+ * `tileStyles.ts`, and "shared" is asserted as **computed** styles
187
+ * rather than trusted — the same discipline `ButtonLink.test.tsx`
188
+ * applies to `Button`. Two copies of one string is a promise that
189
+ * survives exactly one edit.
190
+ */
191
+ test("an action tile is the same box as a resting radio tile", async () => {
192
+ const { canvas } = await mountStory(BesideTheRadioTile)
193
+
194
+ const actionTile = expectAgentDrivable(canvas, {
195
+ name: "Picks Choose titles yourself, then arrange them in priority and random lanes.",
196
+ role: "button",
197
+ })
198
+
199
+ const radioGroup = expectAgentDrivable(canvas, {
200
+ name: "Queue type, as radios",
201
+ role: "radiogroup",
202
+ })
203
+
204
+ // The SECOND radio tile, because `RadioGroup` checks its first
205
+ // option on mount and a checked tile wears the accent edge. A
206
+ // RESTING tile is what these two must match.
207
+ const radioTile =
208
+ radioGroup.querySelectorAll<HTMLElement>(
209
+ '[role="radio"]',
210
+ )[1]
211
+
212
+ if (!radioTile) {
213
+ throw new Error("the radio tile did not render")
214
+ }
215
+
216
+ const action = getComputedStyle(actionTile)
217
+
218
+ const radio = getComputedStyle(radioTile)
219
+
220
+ await expect(action.paddingTop).toBe(radio.paddingTop)
221
+
222
+ await expect(action.paddingBottom).toBe(
223
+ radio.paddingBottom,
224
+ )
225
+
226
+ await expect(action.paddingInlineStart).toBe(
227
+ radio.paddingInlineStart,
228
+ )
229
+
230
+ await expect(action.borderTopWidth).toBe(
231
+ radio.borderTopWidth,
232
+ )
233
+
234
+ await expect(action.borderTopColor).toBe(
235
+ radio.borderTopColor,
236
+ )
237
+
238
+ await expect(action.borderTopLeftRadius).toBe(
239
+ radio.borderTopLeftRadius,
240
+ )
241
+
242
+ await expect(action.backgroundColor).toBe(
243
+ radio.backgroundColor,
244
+ )
245
+
246
+ await expect(action.fontSize).toBe(radio.fontSize)
247
+
248
+ // The padding is the whole point. A `Button` with `height: auto` —
249
+ // the shape this component replaces — computes to `0px` here, with
250
+ // the title flush against the top border.
251
+ await expect(
252
+ Number.parseFloat(action.paddingTop),
253
+ ).toBeGreaterThan(0)
254
+ })
255
+
256
+ test("the column count comes from the container, not the window", async () => {
257
+ const { canvas } = await mountStory(Responsive)
258
+
259
+ const getColumnCount = (name: string) =>
260
+ getComputedStyle(
261
+ expectAgentDrivable(canvas, { name, role: "group" }),
262
+ ).gridTemplateColumns.split(" ").length
263
+
264
+ // One viewport, three container widths, and the answer changes
265
+ // with the container. Every panel is the same window.
266
+ await expect(
267
+ getColumnCount("Where to start at 15rem"),
268
+ ).toBe(1)
269
+
270
+ await expect(
271
+ getColumnCount("Where to start at 34rem"),
272
+ ).toBe(2)
273
+ })
274
+
275
+ test("a tile grid never overflows a container narrower than one tile", async () => {
276
+ const { canvas } = await mountStory(Responsive)
277
+
278
+ const group = expectAgentDrivable(canvas, {
279
+ name: "Where to start at 15rem",
280
+ role: "group",
281
+ })
282
+
283
+ // The case that matters is the `min(…, 100%)` in the track floor,
284
+ // which is what stops a 200px floor from becoming a horizontal
285
+ // scrollbar in the Narrow View.
286
+ await expect(
287
+ group.getBoundingClientRect().width,
288
+ ).toBeLessThanOrEqual(
289
+ (group.parentElement?.getBoundingClientRect().width ??
290
+ 0) + 1,
291
+ )
292
+ })
@@ -0,0 +1,292 @@
1
+ import type { ControlSize } from "@charcuterie/tokens"
2
+ import type { CSSProperties, ReactNode } from "react"
3
+
4
+ import { FOCUS_RING_CLASS } from "../intentStyles.ts"
5
+ import { useRouterLink } from "../RouterLink/RouterLinkProvider.tsx"
6
+ import { getIsRoutedHref } from "../RouterLink/routerLink.ts"
7
+ import {
8
+ TILE_BOX_CLASS,
9
+ TILE_COLUMNS_CLASS,
10
+ TILE_GAP_CLASS,
11
+ TILE_HINT_TEXT_CLASS,
12
+ TILE_MIN_INLINE_SIZE_PROPERTY,
13
+ TILE_PADDING_CLASS,
14
+ TILE_TEXT_SIZE_CLASS,
15
+ } from "../tileStyles.ts"
16
+ import { toClassName } from "../toClassName.ts"
17
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
18
+
19
+ export type ActionTileItem = {
20
+ /**
21
+ * A line of help under the name. Drawn one step down the type ramp
22
+ * in `content-muted`, **inside** the tile — so `getByRole` computes
23
+ * it into the accessible name. A hint a screen reader never reads
24
+ * is a hint half the audience does not have.
25
+ */
26
+ hint?: ReactNode
27
+ /**
28
+ * Makes this tile a real `<a href>`: middle-click, ctrl-click,
29
+ * "open in new tab", "copy link address" and the status bar all
30
+ * come from the element rather than from the paint. Routed through
31
+ * the injected `RouterLink` when the destination is same-origin and
32
+ * not a fragment.
33
+ *
34
+ * Without one, the tile is a `<button>` that calls the set's
35
+ * `onChoose`. A set may mix the two.
36
+ */
37
+ href?: string
38
+ /**
39
+ * Leading content, above the name. A `Badge`, an app's own icon, a
40
+ * count.
41
+ *
42
+ * `aria-hidden` by construction: the name it sits above is inside
43
+ * the same tile, so an announced icon is the label read twice.
44
+ */
45
+ icon?: ReactNode
46
+ isDisabled?: boolean
47
+ /** Opens in a new tab, and says so to a screen reader. `href` only. */
48
+ isExternal?: boolean
49
+ label: ReactNode
50
+ /**
51
+ * Identity. The React key, and what `onChoose` is handed.
52
+ *
53
+ * Required even on a tile that navigates, so a set can mix the two
54
+ * without one half losing its key — the same reason `RadioItem`
55
+ * carries one.
56
+ */
57
+ value: string
58
+ }
59
+
60
+ export type ActionTilesProps = {
61
+ className?: string
62
+ items: readonly ActionTileItem[]
63
+ /** The set's accessible name. Required. */
64
+ label: string
65
+ /**
66
+ * The narrowest a tile track may be, in CSS px.
67
+ *
68
+ * It is the grid's floor and not the tile's width: tracks below it
69
+ * are not created, and the ones that are share the row evenly.
70
+ */
71
+ minTileInlineSize?: number
72
+ /** Fired by a tile that carries no `href`. */
73
+ onChoose?: (value: string) => void
74
+ /** What `isExternal` announces. Not shown. */
75
+ newTabLabel?: string
76
+ size?: ControlSize
77
+ }
78
+
79
+ /**
80
+ * A set of ACTIONS drawn as tiles — a bordered card carrying a name
81
+ * and a line of help, in a grid that gains columns with its
82
+ * container.
83
+ *
84
+ * This is `RadioGroup`'s `itemShape="tile"` with the radio taken
85
+ * away. The
86
+ * [2026-08-25 record](../../../../docs/decisions/2026-08-25-a-choice-tile-is-a-radiogroup-shape-not-a-third-component.md)
87
+ * that made the choice tile a `RadioGroup` shape named this case
88
+ * under *"What this deliberately does not cover"* — mux-magic's "Pick
89
+ * a tool" tiles are `<a href>` and points-market's are `<Link>`, and
90
+ * *"a link is not a radio, and giving one `aria-checked` would be
91
+ * worse than the paint it replaced"*. So the box is shared through
92
+ * `tileStyles.ts` and nothing else is.
93
+ *
94
+ * ### Which of the two you want
95
+ *
96
+ * Ask what a press does.
97
+ *
98
+ * - It **records a value** the user will then submit, or that
99
+ * filters what is below it — `RadioGroup itemShape="tile"`. The
100
+ * set announces "3 of 6", the arrow keys move the choice, and one
101
+ * tile stays selected.
102
+ * - It **goes somewhere or starts something** — this. Nothing stays
103
+ * selected, because nothing was chosen: the page moved on. Tab
104
+ * reaches each tile, Enter and Space press it, and a `href` tile
105
+ * is a real anchor.
106
+ *
107
+ * A first step that opens the next step is the second kind, which is
108
+ * why QueuePilot's "Queue type" chooser is here and its Tonight
109
+ * activity filter is not.
110
+ *
111
+ * ### Why this is not a `Button` with a `className`
112
+ *
113
+ * A `Button` is sized by `h-(--control-height-md)` and carries **no
114
+ * block padding at all** — one line of text, by contract. An app that
115
+ * wants a wrapping two-line card reaches for `height: auto`, which
116
+ * deletes the height and leaves `padding: 0` down the block axis:
117
+ * a title flush against the top border and a description flush
118
+ * against the bottom one, with nothing to report it. That shipped in
119
+ * QueuePilot and is what this component exists to have made
120
+ * impossible.
121
+ *
122
+ * ### `role="group"`, and no roving tabindex
123
+ *
124
+ * These are ordinary buttons and anchors, so the platform already
125
+ * owns the keyboard: Tab reaches each one, Enter and Space press it,
126
+ * and a link opens in a new tab on ctrl-click. A radio group borrows
127
+ * one tab stop and moves the choice with the arrow keys because
128
+ * exactly one of its options is true at a time; nothing here is
129
+ * true, so imposing that model would only cost a keyboard user the
130
+ * ability to Tab to the second tile.
131
+ */
132
+ export const ActionTiles = ({
133
+ className,
134
+ items,
135
+ label,
136
+ minTileInlineSize = 200,
137
+ newTabLabel = "(opens in a new tab)",
138
+ onChoose,
139
+ size = "md",
140
+ }: ActionTilesProps): ReactNode => {
141
+ const RouterLink = useRouterLink()
142
+
143
+ return (
144
+ // biome-ignore lint/a11y/useSemanticElements: the semantic element for `group` is `<fieldset>`, which is a form-control grouping — it drags `<legend>` semantics, a border and form-reset behaviour onto a set of buttons and links that is not a form. Same call `Menu`'s group, `AccordionSection` and `BoardLaneList` already made.
145
+ <div
146
+ aria-label={label}
147
+ className={toClassName(
148
+ "grid",
149
+ TILE_COLUMNS_CLASS,
150
+ TILE_GAP_CLASS[size],
151
+ className,
152
+ )}
153
+ role="group"
154
+ style={
155
+ {
156
+ [TILE_MIN_INLINE_SIZE_PROPERTY]: `${minTileInlineSize}px`,
157
+ } as CSSProperties
158
+ }
159
+ >
160
+ {items.map(
161
+ ({
162
+ hint,
163
+ href,
164
+ icon,
165
+ isDisabled = false,
166
+ isExternal = false,
167
+ label: tileLabel,
168
+ value,
169
+ }) => {
170
+ const tileClassName = toClassName(
171
+ // `flex`, not `inline-flex`: a grid item stretches to its
172
+ // track, and an inline box would sit at max-content
173
+ // inside it. `min-w-0` with `wrap-anywhere` below,
174
+ // because a grid item's automatic minimum is its
175
+ // min-content width — one unbroken token would otherwise
176
+ // set the track's floor and shove the grid out of its
177
+ // container.
178
+ "flex min-w-0 flex-col gap-0.5 text-start wrap-anywhere transition-colors duration-(--duration-fast) ease-standard",
179
+ TILE_BOX_CLASS,
180
+ TILE_PADDING_CLASS[size],
181
+ TILE_TEXT_SIZE_CLASS[size],
182
+ // Disabled turns the whole tile down with `opacity-60`,
183
+ // the same family treatment as `Checkbox`, `Switch` and
184
+ // the radio tile, rather than fading the border to a
185
+ // token that disappeared on a pale theme.
186
+ isDisabled
187
+ ? "cursor-not-allowed opacity-60"
188
+ : "cursor-pointer hover:border-border-strong",
189
+ FOCUS_RING_CLASS,
190
+ )
191
+
192
+ const content = (
193
+ <>
194
+ {icon === undefined ? null : (
195
+ <span aria-hidden className="flex">
196
+ {icon}
197
+ </span>
198
+ )}
199
+
200
+ <span className="font-semibold text-content-primary">
201
+ {tileLabel}
202
+ </span>
203
+
204
+ {hint === undefined ? null : (
205
+ <span
206
+ className={toClassName(
207
+ "font-normal text-content-muted",
208
+ TILE_HINT_TEXT_CLASS[size],
209
+ )}
210
+ >
211
+ {hint}
212
+ </span>
213
+ )}
214
+
215
+ {isExternal && href !== undefined ? (
216
+ <VisuallyHidden>
217
+ {newTabLabel}
218
+ </VisuallyHidden>
219
+ ) : null}
220
+ </>
221
+ )
222
+
223
+ if (href !== undefined && !isDisabled) {
224
+ // An external destination or a fragment is never the
225
+ // router's — pushing `https://…` or `#credits` onto the
226
+ // history stack navigates the SPA to a route that does
227
+ // not exist. Same destination test as `ButtonLink`.
228
+ const LinkElement =
229
+ isExternal || !getIsRoutedHref(href)
230
+ ? "a"
231
+ : RouterLink
232
+
233
+ return (
234
+ <LinkElement
235
+ className={tileClassName}
236
+ href={href}
237
+ key={value}
238
+ // `noreferrer` alongside `noopener` on purpose:
239
+ // `noopener` closes the `window.opener` hole, and
240
+ // `noreferrer` is what keeps a private app's URL out
241
+ // of the destination's logs.
242
+ rel={
243
+ isExternal
244
+ ? "noopener noreferrer"
245
+ : undefined
246
+ }
247
+ target={isExternal ? "_blank" : undefined}
248
+ >
249
+ {content}
250
+ </LinkElement>
251
+ )
252
+ }
253
+
254
+ if (href !== undefined) {
255
+ // `isDisabled` drops `href` entirely rather than shipping
256
+ // a focusable anchor that silently ignores clicks — an
257
+ // anchor with no `href` is inert by the platform's own
258
+ // rules, and the explicit `role`/`aria-disabled` keeps it
259
+ // *announced* as a link that is currently unavailable
260
+ // rather than vanishing from the tree. `ButtonLink`'s
261
+ // rule, unchanged.
262
+ return (
263
+ // biome-ignore lint/a11y/useValidAnchor: dropping `href` is the point. A disabled anchor that keeps one is focusable and silently ignores clicks; without one it is inert and out of the tab order by the platform's own rules, and the explicit `role`/`aria-disabled` keeps it announced rather than removed from the tree. `ButtonLink` makes the same call.
264
+ <a
265
+ aria-disabled="true"
266
+ className={tileClassName}
267
+ key={value}
268
+ role="link"
269
+ >
270
+ {content}
271
+ </a>
272
+ )
273
+ }
274
+
275
+ return (
276
+ <button
277
+ className={tileClassName}
278
+ disabled={isDisabled}
279
+ key={value}
280
+ onClick={() => {
281
+ onChoose?.(value)
282
+ }}
283
+ type="button"
284
+ >
285
+ {content}
286
+ </button>
287
+ )
288
+ },
289
+ )}
290
+ </div>
291
+ )
292
+ }
@@ -59,6 +59,13 @@ tile with its border taken away is a row.
59
59
  no radio affordance, options too short to carry a sentence. A tile is this control with a
60
60
  border, which is a different reason.
61
61
 
62
+ [`ActionTiles`](/docs/components-actions-actiontiles--docs) is the same tile that **acts**
63
+ rather than holds a value, and it *does* earn its own file: invert every clause of the
64
+ paragraph above — no `radio` role, no roving tabindex, no selection-follows-focus, no
65
+ `isReadOnly` — and the box is all that is left in common. The two read it from one
66
+ `tileStyles.ts`. Ask what a press does: records a value something below reads, or goes
67
+ somewhere and is done.
68
+
62
69
  <Canvas of={RadioGroupStories.Tiles} />
63
70
 
64
71
  Four apps in the fleet had each hand-painted this shape — ten instances between them — and
@@ -7,6 +7,11 @@ import type { ControlSize } from "@charcuterie/tokens"
7
7
  import type { CSSProperties, ReactNode } from "react"
8
8
  import { useCallback, useEffect, useRef } from "react"
9
9
 
10
+ import {
11
+ TILE_COLUMNS_CLASS,
12
+ TILE_GAP_CLASS,
13
+ TILE_MIN_INLINE_SIZE_PROPERTY,
14
+ } from "../tileStyles.ts"
10
15
  import { toClassName } from "../toClassName.ts"
11
16
  import { RadioGroupOption } from "./RadioGroupOption.tsx"
12
17
 
@@ -85,47 +90,6 @@ const GAP_CLASS: Record<ControlSize, string> = {
85
90
  lg: "gap-2",
86
91
  }
87
92
 
88
- /**
89
- * Wider than the row gaps: rows are separated by their leading, and
90
- * tiles by nothing but the gap, so the same 6px that reads as a list
91
- * reads as a seam between two cards.
92
- */
93
- const TILE_GAP_CLASS: Record<ControlSize, string> = {
94
- sm: "gap-1.5",
95
- md: "gap-2",
96
- lg: "gap-3",
97
- }
98
-
99
- /**
100
- * `auto-fill`, and deliberately not `auto-fit`.
101
- *
102
- * `auto-fit` collapses the empty tracks and lets the ones that
103
- * remain share the whole row, so six tiles in a 2560px container
104
- * become six 420px slabs — the full-width-row shape in a new
105
- * costume. `auto-fill` keeps the empty tracks, so a tile stays a
106
- * tile and the grid simply has room to spare.
107
- *
108
- * `min(…, 100%)` is what stops the floor overflowing a container
109
- * narrower than one tile, which is the Narrow View and is otherwise
110
- * a horizontal scrollbar on a phone.
111
- *
112
- * The floor arrives as a custom property rather than an interpolated
113
- * class because Tailwind scans source *text* for complete class
114
- * strings: `` `grid-cols-[…${n}px…]` `` generates nothing, paints
115
- * nothing and reports nothing. One written-out literal reading
116
- * `var(--charcuterie-tile-min-inline-size)` covers every width an
117
- * app can ask for — the same reason `Card`'s accent edge goes
118
- * through one property.
119
- *
120
- * This is **not** `useAdaptiveColumns`, and the difference is the
121
- * question being asked. That hook buys a column with height, for an
122
- * unbounded gallery that will scroll; a radio group is a bounded set
123
- * of options inside a form section, where the only question is how
124
- * many fit across the box it was given.
125
- */
126
- const TILE_COLUMNS_CLASS =
127
- "grid-cols-[repeat(auto-fill,minmax(min(var(--charcuterie-tile-min-inline-size),100%),1fr))]"
128
-
129
93
  /**
130
94
  * One choice out of many, each on its own row with a label the size
131
95
  * of prose — the stacked sibling of `SegmentedControl`.
@@ -157,6 +121,16 @@ const TILE_COLUMNS_CLASS =
157
121
  * and `isReadOnly` are the ones already tested here. `SegmentedControl`
158
122
  * earns its own file by being a different control — one connected
159
123
  * strip, no radio affordance; a tile is this control with a border.
124
+ *
125
+ * ### A tile that ACTS is `ActionTiles`
126
+ *
127
+ * Same box, read from `tileStyles.ts`, and nothing else in common:
128
+ * no `radio` role, no roving tabindex, no selection to follow focus,
129
+ * no read-only. Ask what a press does. It records a value something
130
+ * below reads — this. It goes somewhere or starts something, and
131
+ * nothing stays selected — `ActionTiles`. Inverting every clause of
132
+ * the paragraph above is what earned that one its own file
133
+ * (`docs/decisions/2026-09-01-a-tile-that-acts-is-its-own-component-and-shares-only-the-box.md`).
160
134
  */
161
135
  export const RadioGroup = ({
162
136
  className,
@@ -292,7 +266,7 @@ export const RadioGroup = ({
292
266
  style={
293
267
  isTile
294
268
  ? ({
295
- "--charcuterie-tile-min-inline-size": `${minTileInlineSize}px`,
269
+ [TILE_MIN_INLINE_SIZE_PROPERTY]: `${minTileInlineSize}px`,
296
270
  } as CSSProperties)
297
271
  : undefined
298
272
  }