@charcuterie/ui 3.31.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.
- package/README.md +14 -0
- package/dist/ActionTiles/ActionTiles.d.ts +115 -0
- package/dist/ActionTiles/ActionTiles.d.ts.map +1 -0
- package/dist/ActionTiles/ActionTiles.js +115 -0
- package/dist/ActionTiles/ActionTiles.js.map +1 -0
- package/dist/RadioGroup/RadioGroup.d.ts +10 -0
- package/dist/RadioGroup/RadioGroup.d.ts.map +1 -1
- package/dist/RadioGroup/RadioGroup.js +12 -39
- package/dist/RadioGroup/RadioGroup.js.map +1 -1
- package/dist/RadioGroup/RadioGroupOption.d.ts.map +1 -1
- package/dist/RadioGroup/RadioGroupOption.js +3 -33
- package/dist/RadioGroup/RadioGroupOption.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/tileStyles.d.ts +83 -0
- package/dist/tileStyles.d.ts.map +1 -0
- package/dist/tileStyles.js +98 -0
- package/dist/tileStyles.js.map +1 -0
- package/package.json +1 -1
- package/src/ActionTiles/ActionTiles.mdx +131 -0
- package/src/ActionTiles/ActionTiles.stories.tsx +311 -0
- package/src/ActionTiles/ActionTiles.test.tsx +292 -0
- package/src/ActionTiles/ActionTiles.tsx +292 -0
- package/src/RadioGroup/RadioGroup.mdx +7 -0
- package/src/RadioGroup/RadioGroup.tsx +16 -42
- package/src/RadioGroup/RadioGroupOption.tsx +8 -38
- package/src/index.ts +5 -0
- package/src/sourceRules.test.ts +9 -1
- 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
|
-
|
|
269
|
+
[TILE_MIN_INLINE_SIZE_PROPERTY]: `${minTileInlineSize}px`,
|
|
296
270
|
} as CSSProperties)
|
|
297
271
|
: undefined
|
|
298
272
|
}
|