@charcuterie/ui 3.15.0 → 3.17.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/dist/Combobox/Combobox.d.ts.map +1 -1
- package/dist/Combobox/Combobox.js +35 -0
- package/dist/Combobox/Combobox.js.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.d.ts +7 -1
- package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.js +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -1
- package/dist/RadioGroup/RadioGroup.d.ts +49 -1
- package/dist/RadioGroup/RadioGroup.d.ts.map +1 -1
- package/dist/RadioGroup/RadioGroup.js +58 -3
- package/dist/RadioGroup/RadioGroup.js.map +1 -1
- package/dist/RadioGroup/RadioGroupOption.d.ts +13 -2
- package/dist/RadioGroup/RadioGroupOption.d.ts.map +1 -1
- package/dist/RadioGroup/RadioGroupOption.js +62 -5
- package/dist/RadioGroup/RadioGroupOption.js.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +1 -1
- package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -1
- package/package.json +1 -1
- package/src/Combobox/Combobox.stories.tsx +16 -5
- package/src/Combobox/Combobox.test.tsx +45 -7
- package/src/Combobox/Combobox.tsx +44 -0
- package/src/MarkdownEditor/MarkdownEditor.mdx +12 -0
- package/src/MarkdownEditor/MarkdownEditor.stories.tsx +17 -0
- package/src/MarkdownEditor/MarkdownEditor.tsx +8 -0
- package/src/RadioGroup/RadioGroup.mdx +62 -0
- package/src/RadioGroup/RadioGroup.stories.tsx +164 -1
- package/src/RadioGroup/RadioGroup.test.tsx +179 -2
- package/src/RadioGroup/RadioGroup.tsx +107 -3
- package/src/RadioGroup/RadioGroupOption.tsx +122 -5
- package/src/icons.storyHelpers.tsx +108 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +16 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.stories.tsx +55 -0
- package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.tsx +1 -0
|
@@ -7,8 +7,14 @@ import { mountStory } from "../mountStory.testHelpers.ts"
|
|
|
7
7
|
import { expectAgentDrivable } from "../testing/index.ts"
|
|
8
8
|
import * as stories from "./RadioGroup.stories.tsx"
|
|
9
9
|
|
|
10
|
-
const {
|
|
11
|
-
|
|
10
|
+
const {
|
|
11
|
+
AllStates,
|
|
12
|
+
Default,
|
|
13
|
+
Interactive,
|
|
14
|
+
Responsive,
|
|
15
|
+
Tiles,
|
|
16
|
+
TilesWithIcons,
|
|
17
|
+
} = composeStories(stories)
|
|
12
18
|
|
|
13
19
|
/**
|
|
14
20
|
* `querySelectorAll` rather than `getAllByRole("radio")`: a board
|
|
@@ -230,3 +236,174 @@ test("a read-only group is announced and its choice cannot move", async () => {
|
|
|
230
236
|
|
|
231
237
|
await expectNoAxeViolations(canvasElement)
|
|
232
238
|
})
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The tile shape is the same control in a box, so what these assert
|
|
242
|
+
* is that **nothing about the control changed** — role, exclusivity,
|
|
243
|
+
* the roving tab stop and selection-follows-focus are the ones
|
|
244
|
+
* already proven above, driven through the tile story.
|
|
245
|
+
*/
|
|
246
|
+
test("a tile group is still a radiogroup of radios", async () => {
|
|
247
|
+
const { canvas, canvasElement } = await mountStory(Tiles)
|
|
248
|
+
|
|
249
|
+
const group = expectAgentDrivable(canvas, {
|
|
250
|
+
name: "On import",
|
|
251
|
+
role: "radiogroup",
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
const options = getOptions(group)
|
|
255
|
+
|
|
256
|
+
await expect(options).toHaveLength(4)
|
|
257
|
+
|
|
258
|
+
await waitFor(() => {
|
|
259
|
+
expect(
|
|
260
|
+
options.filter(
|
|
261
|
+
(one) =>
|
|
262
|
+
one.getAttribute("aria-checked") === "true",
|
|
263
|
+
),
|
|
264
|
+
).toHaveLength(1)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
// Exactly one tab stop — the roving rule survives the layout
|
|
268
|
+
// change, because the layout is all that changed.
|
|
269
|
+
await expect(
|
|
270
|
+
options.filter((one) => one.tabIndex === 0),
|
|
271
|
+
).toHaveLength(1)
|
|
272
|
+
|
|
273
|
+
await expectNoAxeViolations(canvasElement)
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test("a tile's hint is part of the option's accessible name", async () => {
|
|
277
|
+
const { canvas } = await mountStory(Tiles)
|
|
278
|
+
|
|
279
|
+
// The reason the hint is rendered inside the button rather than
|
|
280
|
+
// beside it: a hint a screen reader never reads is a hint half the
|
|
281
|
+
// audience does not have. `getByRole` computes the name from
|
|
282
|
+
// contents, so this query only passes if it is in there.
|
|
283
|
+
expectAgentDrivable(canvas, {
|
|
284
|
+
name: "Hard link One copy on disk, two names for it. Same volume only.",
|
|
285
|
+
role: "radio",
|
|
286
|
+
})
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
test("a tile icon is decoration and stays out of the name", async () => {
|
|
290
|
+
const { canvas, canvasElement } =
|
|
291
|
+
await mountStory(TilesWithIcons)
|
|
292
|
+
|
|
293
|
+
// Named by its words alone. An icon that joined the name would
|
|
294
|
+
// make this query fail, which is the whole reason it is
|
|
295
|
+
// `aria-hidden`.
|
|
296
|
+
expectAgentDrivable(canvas, {
|
|
297
|
+
name: "New arrivals Everything that arrived since the last run.",
|
|
298
|
+
role: "radio",
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
await expectNoAxeViolations(canvasElement)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test("the arrow keys still move and check inside a tile grid", async () => {
|
|
305
|
+
const { canvas } = await mountStory(Tiles)
|
|
306
|
+
|
|
307
|
+
const options = getOptions(
|
|
308
|
+
expectAgentDrivable(canvas, {
|
|
309
|
+
name: "On import",
|
|
310
|
+
role: "radiogroup",
|
|
311
|
+
}),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
options[0]?.focus()
|
|
315
|
+
|
|
316
|
+
// ArrowRight in a grid is `focus.next`, exactly as ArrowDown is in
|
|
317
|
+
// a stack. APG gives a radio group one traversal order, and a grid
|
|
318
|
+
// does not earn a second one.
|
|
319
|
+
await userEvent.keyboard("{ArrowRight}")
|
|
320
|
+
|
|
321
|
+
await waitFor(() => {
|
|
322
|
+
expect(options[1]).toHaveAttribute(
|
|
323
|
+
"aria-checked",
|
|
324
|
+
"true",
|
|
325
|
+
)
|
|
326
|
+
})
|
|
327
|
+
})
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The three things about the box that fail *silently*, so all three
|
|
331
|
+
* are read off `getComputedStyle` in a real browser rather than off
|
|
332
|
+
* a class name. A class-name assertion would have passed while the
|
|
333
|
+
* grid was one column, because the class really was in the DOM.
|
|
334
|
+
*/
|
|
335
|
+
test("a tile carries a border, and the chosen one carries the accent", async () => {
|
|
336
|
+
const { canvas } = await mountStory(Tiles)
|
|
337
|
+
|
|
338
|
+
const options = getOptions(
|
|
339
|
+
expectAgentDrivable(canvas, {
|
|
340
|
+
name: "On import",
|
|
341
|
+
role: "radiogroup",
|
|
342
|
+
}),
|
|
343
|
+
)
|
|
344
|
+
|
|
345
|
+
const [first, second] = options as [
|
|
346
|
+
HTMLButtonElement,
|
|
347
|
+
HTMLButtonElement,
|
|
348
|
+
]
|
|
349
|
+
|
|
350
|
+
await waitFor(() => {
|
|
351
|
+
expect(first).toHaveAttribute("aria-checked", "true")
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
const chosen = getComputedStyle(first)
|
|
355
|
+
|
|
356
|
+
const other = getComputedStyle(second)
|
|
357
|
+
|
|
358
|
+
await expect(
|
|
359
|
+
Number.parseFloat(chosen.borderTopWidth),
|
|
360
|
+
).toBeGreaterThan(0)
|
|
361
|
+
|
|
362
|
+
// A border and a surface, never colour alone — but the two tiles
|
|
363
|
+
// must still differ, or "chosen" is told by the radio dot only.
|
|
364
|
+
await expect(chosen.borderTopColor).not.toBe(
|
|
365
|
+
other.borderTopColor,
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
await expect(chosen.backgroundColor).not.toBe(
|
|
369
|
+
other.backgroundColor,
|
|
370
|
+
)
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
test("the column count comes from the container, not the window", async () => {
|
|
374
|
+
const { canvas } = await mountStory(Responsive)
|
|
375
|
+
|
|
376
|
+
const getColumnCount = (name: string) =>
|
|
377
|
+
getComputedStyle(
|
|
378
|
+
expectAgentDrivable(canvas, {
|
|
379
|
+
name,
|
|
380
|
+
role: "radiogroup",
|
|
381
|
+
}),
|
|
382
|
+
).gridTemplateColumns.split(" ").length
|
|
383
|
+
|
|
384
|
+
// One viewport, three container widths, and the answer changes
|
|
385
|
+
// with the container. Every panel is the same window.
|
|
386
|
+
await expect(getColumnCount("On import at 15rem")).toBe(1)
|
|
387
|
+
|
|
388
|
+
await expect(getColumnCount("On import at 34rem")).toBe(2)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
test("a tile grid never overflows a container narrower than one tile", async () => {
|
|
392
|
+
const { canvas } = await mountStory(Responsive)
|
|
393
|
+
|
|
394
|
+
const group = expectAgentDrivable(canvas, {
|
|
395
|
+
name: "On import at 15rem",
|
|
396
|
+
role: "radiogroup",
|
|
397
|
+
})
|
|
398
|
+
|
|
399
|
+
// 15rem is 240px and the tile floor is 200px, so this passes
|
|
400
|
+
// either way — the case that matters is the `min(…, 100%)`, which
|
|
401
|
+
// is what stops a 200px floor from becoming a horizontal
|
|
402
|
+
// scrollbar in the Narrow View.
|
|
403
|
+
await expect(
|
|
404
|
+
group.getBoundingClientRect().width,
|
|
405
|
+
).toBeLessThanOrEqual(
|
|
406
|
+
(group.parentElement?.getBoundingClientRect().width ??
|
|
407
|
+
0) + 1,
|
|
408
|
+
)
|
|
409
|
+
})
|
|
@@ -4,13 +4,40 @@ import {
|
|
|
4
4
|
useSinglePicker,
|
|
5
5
|
} from "@charcuterie/logic"
|
|
6
6
|
import type { ControlSize } from "@charcuterie/tokens"
|
|
7
|
-
import type { ReactNode } from "react"
|
|
7
|
+
import type { CSSProperties, ReactNode } from "react"
|
|
8
8
|
import { useCallback, useEffect, useRef } from "react"
|
|
9
9
|
|
|
10
10
|
import { toClassName } from "../toClassName.ts"
|
|
11
11
|
import { RadioGroupOption } from "./RadioGroupOption.tsx"
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Which shape the options are drawn in. The control is the same
|
|
15
|
+
* `radiogroup` either way — this is layout, and it is a prop rather
|
|
16
|
+
* than a second component because nothing about the semantics, the
|
|
17
|
+
* keyboard model or the element changes with it.
|
|
18
|
+
*
|
|
19
|
+
* - `row` — the default. Each option is a line of prose beside its
|
|
20
|
+
* radio, stacked down the page. The form-length list.
|
|
21
|
+
* - `tile` — each option is a bordered card carrying a name, an
|
|
22
|
+
* optional line of help and a selected surface, and the group is
|
|
23
|
+
* a grid that gains columns with the CONTAINER's width.
|
|
24
|
+
*/
|
|
25
|
+
export type RadioItemShape = "row" | "tile"
|
|
26
|
+
|
|
13
27
|
export type RadioItem = {
|
|
28
|
+
/**
|
|
29
|
+
* A line of help under the label. Drawn one step down the type
|
|
30
|
+
* ramp in `content-muted`, inside the option's accessible name —
|
|
31
|
+
* a hint a screen reader never reads is a hint half the audience
|
|
32
|
+
* does not have.
|
|
33
|
+
*/
|
|
34
|
+
hint?: ReactNode
|
|
35
|
+
/**
|
|
36
|
+
* Leading content, before the name. A `Badge`, an app's own icon,
|
|
37
|
+
* a count. `tile` only: a row already leads with its radio, and a
|
|
38
|
+
* second leading mark either side of it has nowhere to sit.
|
|
39
|
+
*/
|
|
40
|
+
icon?: ReactNode
|
|
14
41
|
/**
|
|
15
42
|
* A disabled option is simply **not registered** with the roving
|
|
16
43
|
* group, so the arrow keys skip it without any command in
|
|
@@ -24,6 +51,11 @@ export type RadioItem = {
|
|
|
24
51
|
|
|
25
52
|
export type RadioGroupProps = {
|
|
26
53
|
className?: string
|
|
54
|
+
/**
|
|
55
|
+
* Draws each option as a card in a grid instead of a row in a
|
|
56
|
+
* stack. Defaults to `row`.
|
|
57
|
+
*/
|
|
58
|
+
itemShape?: RadioItemShape
|
|
27
59
|
items: readonly RadioItem[]
|
|
28
60
|
/**
|
|
29
61
|
* Shows which option is chosen at full contrast but refuses to move
|
|
@@ -34,6 +66,13 @@ export type RadioGroupProps = {
|
|
|
34
66
|
isReadOnly?: boolean
|
|
35
67
|
/** The group's accessible name. Required. */
|
|
36
68
|
label: string
|
|
69
|
+
/**
|
|
70
|
+
* The narrowest a tile track may be, in CSS px. `tile` only.
|
|
71
|
+
*
|
|
72
|
+
* It is the grid's floor and not the tile's width: tracks below it
|
|
73
|
+
* are not created, and the ones that are share the row evenly.
|
|
74
|
+
*/
|
|
75
|
+
minTileInlineSize?: number
|
|
37
76
|
onChange?: (selectedValue: string | null) => void
|
|
38
77
|
/** **Initial** only. Charcuterie owns it from then on. */
|
|
39
78
|
selectedValue?: string
|
|
@@ -46,6 +85,47 @@ const GAP_CLASS: Record<ControlSize, string> = {
|
|
|
46
85
|
lg: "gap-2",
|
|
47
86
|
}
|
|
48
87
|
|
|
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
|
+
|
|
49
129
|
/**
|
|
50
130
|
* One choice out of many, each on its own row with a label the size
|
|
51
131
|
* of prose — the stacked sibling of `SegmentedControl`.
|
|
@@ -67,12 +147,24 @@ const GAP_CLASS: Record<ControlSize, string> = {
|
|
|
67
147
|
* choice from a named set is a radio group, and its arrow keys both
|
|
68
148
|
* move focus and check — APG's one activation mode, the same reason
|
|
69
149
|
* `SegmentedControl` has no `manual` prop.
|
|
150
|
+
*
|
|
151
|
+
* ### `itemShape="tile"` is the choice tile
|
|
152
|
+
*
|
|
153
|
+
* A bordered card carrying a name, a line of help and a selected
|
|
154
|
+
* surface, in a grid that gains columns with its container. It is a
|
|
155
|
+
* prop and not a third component because **only the box changes**:
|
|
156
|
+
* the element, the role, the roving tabindex, selection-follows-focus
|
|
157
|
+
* and `isReadOnly` are the ones already tested here. `SegmentedControl`
|
|
158
|
+
* earns its own file by being a different control — one connected
|
|
159
|
+
* strip, no radio affordance; a tile is this control with a border.
|
|
70
160
|
*/
|
|
71
161
|
export const RadioGroup = ({
|
|
72
162
|
className,
|
|
163
|
+
itemShape = "row",
|
|
73
164
|
items,
|
|
74
165
|
isReadOnly = false,
|
|
75
166
|
label,
|
|
167
|
+
minTileInlineSize = 200,
|
|
76
168
|
onChange,
|
|
77
169
|
selectedValue,
|
|
78
170
|
size = "md",
|
|
@@ -83,6 +175,8 @@ export const RadioGroup = ({
|
|
|
83
175
|
new Map<string, HTMLButtonElement>(),
|
|
84
176
|
)
|
|
85
177
|
|
|
178
|
+
const isTile = itemShape === "tile"
|
|
179
|
+
|
|
86
180
|
const initialValue =
|
|
87
181
|
selectedValue ??
|
|
88
182
|
items.find((one) => !one.isDisabled)?.value ??
|
|
@@ -152,8 +246,10 @@ export const RadioGroup = ({
|
|
|
152
246
|
aria-label={label}
|
|
153
247
|
aria-readonly={isReadOnly || undefined}
|
|
154
248
|
className={toClassName(
|
|
155
|
-
|
|
156
|
-
|
|
249
|
+
isTile
|
|
250
|
+
? toClassName("grid", TILE_COLUMNS_CLASS)
|
|
251
|
+
: "flex flex-col",
|
|
252
|
+
isTile ? TILE_GAP_CLASS[size] : GAP_CLASS[size],
|
|
157
253
|
className,
|
|
158
254
|
)}
|
|
159
255
|
onKeyDown={(keyEvent) => {
|
|
@@ -193,6 +289,13 @@ export const RadioGroup = ({
|
|
|
193
289
|
}
|
|
194
290
|
}}
|
|
195
291
|
ref={groupRef}
|
|
292
|
+
style={
|
|
293
|
+
isTile
|
|
294
|
+
? ({
|
|
295
|
+
"--charcuterie-tile-min-inline-size": `${minTileInlineSize}px`,
|
|
296
|
+
} as CSSProperties)
|
|
297
|
+
: undefined
|
|
298
|
+
}
|
|
196
299
|
role="radiogroup"
|
|
197
300
|
>
|
|
198
301
|
{items.map((item) => (
|
|
@@ -200,6 +303,7 @@ export const RadioGroup = ({
|
|
|
200
303
|
isChecked={checkedValue === item.value}
|
|
201
304
|
isReadOnly={isReadOnly}
|
|
202
305
|
item={item}
|
|
306
|
+
itemShape={itemShape}
|
|
203
307
|
key={item.value}
|
|
204
308
|
onChoose={chooseOption}
|
|
205
309
|
registerFocus={focus.register}
|
|
@@ -4,12 +4,16 @@ import { useEffect } from "react"
|
|
|
4
4
|
|
|
5
5
|
import { FOCUS_RING_CLASS } from "../intentStyles.ts"
|
|
6
6
|
import { toClassName } from "../toClassName.ts"
|
|
7
|
-
import type {
|
|
7
|
+
import type {
|
|
8
|
+
RadioItem,
|
|
9
|
+
RadioItemShape,
|
|
10
|
+
} from "./RadioGroup.tsx"
|
|
8
11
|
|
|
9
12
|
export type RadioGroupOptionProps = {
|
|
10
13
|
isChecked: boolean
|
|
11
14
|
isReadOnly: boolean
|
|
12
15
|
item: RadioItem
|
|
16
|
+
itemShape: RadioItemShape
|
|
13
17
|
onChoose: (value: string) => void
|
|
14
18
|
/** `RovingFocus.register` — membership of the arrow-key group. */
|
|
15
19
|
registerFocus: (value: string) => () => void
|
|
@@ -44,6 +48,36 @@ const TEXT_SIZE_CLASS: Record<ControlSize, string> = {
|
|
|
44
48
|
lg: "text-lg",
|
|
45
49
|
}
|
|
46
50
|
|
|
51
|
+
/**
|
|
52
|
+
* One step down from the option's own text, and never below `xs`:
|
|
53
|
+
* a hint that keeps shrinking with the density axis is the 11px
|
|
54
|
+
* caption the 2026-08-10 ramp rebuild exists to have removed.
|
|
55
|
+
*/
|
|
56
|
+
const HINT_TEXT_CLASS: Record<ControlSize, string> = {
|
|
57
|
+
sm: "text-xs",
|
|
58
|
+
md: "text-xs",
|
|
59
|
+
lg: "text-sm",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* A tile is a card, so its padding is a card's rather than a
|
|
64
|
+
* control's — enough that the border reads as a box around content
|
|
65
|
+
* and not as an outline on a line of text.
|
|
66
|
+
*/
|
|
67
|
+
const TILE_PADDING_CLASS: Record<ControlSize, string> = {
|
|
68
|
+
sm: "px-3 py-2.5",
|
|
69
|
+
md: "px-3.5 py-3",
|
|
70
|
+
lg: "px-4 py-3.5",
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The box, and only the box. Every state below it is a **border and
|
|
75
|
+
* a surface**, never colour alone — the same reason `Stepper`
|
|
76
|
+
* refuses to say "done" with a hue.
|
|
77
|
+
*/
|
|
78
|
+
const TILE_BOX_CLASS =
|
|
79
|
+
"rounded-lg border border-border-subtle bg-surface-raised"
|
|
80
|
+
|
|
47
81
|
/**
|
|
48
82
|
* Its own file for the same reason `SegmentedOption` is: **both
|
|
49
83
|
* registrations are effects**, and an effect cannot run in a loop.
|
|
@@ -52,11 +86,22 @@ const TEXT_SIZE_CLASS: Record<ControlSize, string> = {
|
|
|
52
86
|
* with a background, so the checked dot can be centred by the grid
|
|
53
87
|
* and sized independently of the ring — a filled `bg-intent-accent`
|
|
54
88
|
* ring with no inner dot reads as a checkbox, not a radio.
|
|
89
|
+
*
|
|
90
|
+
* ### The tile shape is the same element with a border
|
|
91
|
+
*
|
|
92
|
+
* `itemShape="tile"` swaps `inline-flex` for `flex` — a grid item
|
|
93
|
+
* stretches to its track, and an inline box would sit at
|
|
94
|
+
* max-content inside it — adds the card's padding and border, and
|
|
95
|
+
* moves the ring to the top of a two-line label. The radio dot is
|
|
96
|
+
* still there and is still the thing a screen reader reads: a tile
|
|
97
|
+
* with its border taken away is this row, which is why the two are
|
|
98
|
+
* one component.
|
|
55
99
|
*/
|
|
56
100
|
export const RadioGroupOption = ({
|
|
57
101
|
isChecked,
|
|
58
102
|
isReadOnly,
|
|
59
103
|
item,
|
|
104
|
+
itemShape,
|
|
60
105
|
onChoose,
|
|
61
106
|
registerFocus,
|
|
62
107
|
registerSelection,
|
|
@@ -64,7 +109,15 @@ export const RadioGroupOption = ({
|
|
|
64
109
|
tabIndex,
|
|
65
110
|
trackElement,
|
|
66
111
|
}: RadioGroupOptionProps): ReactNode => {
|
|
67
|
-
const {
|
|
112
|
+
const {
|
|
113
|
+
hint,
|
|
114
|
+
icon,
|
|
115
|
+
isDisabled = false,
|
|
116
|
+
label,
|
|
117
|
+
value,
|
|
118
|
+
} = item
|
|
119
|
+
|
|
120
|
+
const isTile = itemShape === "tile"
|
|
68
121
|
|
|
69
122
|
useEffect(
|
|
70
123
|
() => registerSelection(value),
|
|
@@ -84,7 +137,21 @@ export const RadioGroupOption = ({
|
|
|
84
137
|
<button
|
|
85
138
|
aria-checked={isChecked}
|
|
86
139
|
className={toClassName(
|
|
87
|
-
"
|
|
140
|
+
"gap-2 text-content-secondary text-start transition-colors duration-(--duration-fast) ease-standard",
|
|
141
|
+
isTile
|
|
142
|
+
? toClassName(
|
|
143
|
+
"flex min-w-0 items-start",
|
|
144
|
+
TILE_BOX_CLASS,
|
|
145
|
+
TILE_PADDING_CLASS[size],
|
|
146
|
+
)
|
|
147
|
+
: toClassName(
|
|
148
|
+
"inline-flex rounded-sm",
|
|
149
|
+
// A hint puts a second line under the label, and a ring
|
|
150
|
+
// centred against both sits beside neither.
|
|
151
|
+
hint === undefined
|
|
152
|
+
? "items-center"
|
|
153
|
+
: "items-start",
|
|
154
|
+
),
|
|
88
155
|
TEXT_SIZE_CLASS[size],
|
|
89
156
|
// Disabled turns the whole option down with `opacity-60`, the
|
|
90
157
|
// same family treatment as `Checkbox` and `Switch`, rather than
|
|
@@ -95,7 +162,18 @@ export const RadioGroupOption = ({
|
|
|
95
162
|
? "cursor-not-allowed opacity-60"
|
|
96
163
|
: isReadOnly
|
|
97
164
|
? "cursor-default"
|
|
98
|
-
:
|
|
165
|
+
: isTile
|
|
166
|
+
? "cursor-pointer hover:border-border-strong"
|
|
167
|
+
: "cursor-pointer hover:text-content-primary",
|
|
168
|
+
// The selected tile carries the accent on its EDGE and lifts
|
|
169
|
+
// its surface. A tile that said "chosen" with a background
|
|
170
|
+
// alone would be the one state a monochrome ePaper build
|
|
171
|
+
// cannot show at all.
|
|
172
|
+
isTile && isChecked
|
|
173
|
+
? isReadOnly
|
|
174
|
+
? "border-intent-neutral-solid bg-surface-overlay"
|
|
175
|
+
: "border-intent-accent-solid bg-surface-overlay"
|
|
176
|
+
: "",
|
|
99
177
|
FOCUS_RING_CLASS,
|
|
100
178
|
)}
|
|
101
179
|
disabled={isDisabled}
|
|
@@ -142,7 +220,46 @@ export const RadioGroupOption = ({
|
|
|
142
220
|
/>
|
|
143
221
|
</span>
|
|
144
222
|
|
|
145
|
-
|
|
223
|
+
{hint === undefined && !isTile ? (
|
|
224
|
+
<span>{label}</span>
|
|
225
|
+
) : (
|
|
226
|
+
// `min-w-0` and `wrap-anywhere` together, because a grid
|
|
227
|
+
// item's automatic minimum is its min-content width: one
|
|
228
|
+
// unbroken token — a path, a URL, a title with no spaces —
|
|
229
|
+
// otherwise sets the track's floor and shoves the grid out
|
|
230
|
+
// of its container.
|
|
231
|
+
<span className="flex min-w-0 flex-col gap-0.5 wrap-anywhere">
|
|
232
|
+
{icon !== undefined && isTile ? (
|
|
233
|
+
// Decorative by construction: the name it sits beside is
|
|
234
|
+
// in the same button, so an announced icon is the label
|
|
235
|
+
// read twice.
|
|
236
|
+
<span aria-hidden className="flex">
|
|
237
|
+
{icon}
|
|
238
|
+
</span>
|
|
239
|
+
) : null}
|
|
240
|
+
|
|
241
|
+
<span
|
|
242
|
+
className={
|
|
243
|
+
isTile
|
|
244
|
+
? "font-semibold text-content-primary"
|
|
245
|
+
: undefined
|
|
246
|
+
}
|
|
247
|
+
>
|
|
248
|
+
{label}
|
|
249
|
+
</span>
|
|
250
|
+
|
|
251
|
+
{hint === undefined ? null : (
|
|
252
|
+
<span
|
|
253
|
+
className={toClassName(
|
|
254
|
+
"font-normal text-content-muted",
|
|
255
|
+
HINT_TEXT_CLASS[size],
|
|
256
|
+
)}
|
|
257
|
+
>
|
|
258
|
+
{hint}
|
|
259
|
+
</span>
|
|
260
|
+
)}
|
|
261
|
+
</span>
|
|
262
|
+
)}
|
|
146
263
|
</button>
|
|
147
264
|
)
|
|
148
265
|
}
|
|
@@ -191,3 +191,111 @@ export const MoreIcon = (): ReactNode => (
|
|
|
191
191
|
<circle cx="12" cy="19" r="1.75" />
|
|
192
192
|
</svg>
|
|
193
193
|
)
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The markdown toolbar's nine, plus the bar's own overflow above.
|
|
197
|
+
*
|
|
198
|
+
* They are here rather than in the editor's story file because the
|
|
199
|
+
* two editors — the textarea one and the CodeMirror one — take the
|
|
200
|
+
* same `icons` bag and both boards want it. `MARKDOWN_ICONS` below
|
|
201
|
+
* is the whole bag, so a story passes one object instead of nine
|
|
202
|
+
* props and cannot demonstrate a half-iconed bar by accident.
|
|
203
|
+
*/
|
|
204
|
+
export const BoldIcon = (): ReactNode => (
|
|
205
|
+
<svg {...iconProps} aria-hidden="true">
|
|
206
|
+
<path d="M7 5h6.5a3.5 3.5 0 0 1 0 7H7z" />
|
|
207
|
+
|
|
208
|
+
<path d="M7 12h7.5a3.5 3.5 0 0 1 0 7H7z" />
|
|
209
|
+
</svg>
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
export const ItalicIcon = (): ReactNode => (
|
|
213
|
+
<svg {...iconProps} aria-hidden="true">
|
|
214
|
+
<path d="M19 5h-8M13 19H5M15 5l-4 14" />
|
|
215
|
+
</svg>
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
export const HeadingIcon = (): ReactNode => (
|
|
219
|
+
<svg {...iconProps} aria-hidden="true">
|
|
220
|
+
<path d="M6 5v14M18 5v14M6 12h12" />
|
|
221
|
+
</svg>
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
export const BulletedListIcon = (): ReactNode => (
|
|
225
|
+
<svg {...iconProps} aria-hidden="true">
|
|
226
|
+
<path d="M9 6h12M9 12h12M9 18h12" />
|
|
227
|
+
|
|
228
|
+
<path d="M4.5 6h.01M4.5 12h.01M4.5 18h.01" />
|
|
229
|
+
</svg>
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
export const TaskListIcon = (): ReactNode => (
|
|
233
|
+
<svg {...iconProps} aria-hidden="true">
|
|
234
|
+
<path d="M3 6.5l1.8 1.8L8 5M3 16.5l1.8 1.8L8 15" />
|
|
235
|
+
|
|
236
|
+
<path d="M12 7h9M12 17h9" />
|
|
237
|
+
</svg>
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
export const NumberedListIcon = (): ReactNode => (
|
|
241
|
+
<svg {...iconProps} aria-hidden="true">
|
|
242
|
+
<path d="M11 6h10M11 12h10M11 18h10" />
|
|
243
|
+
|
|
244
|
+
<path d="M4 5.5l1.5-.5V10M4 10h3" />
|
|
245
|
+
|
|
246
|
+
<path d="M4 15.2a1.6 1.6 0 1 1 2.6 1.3L4 19h3" />
|
|
247
|
+
</svg>
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Two quote marks, not an indented bar. An indent bar is what the
|
|
252
|
+
* *result* looks like; the marks are what the button does.
|
|
253
|
+
*
|
|
254
|
+
* Filled, and closed rather than an open stroke. The stroked
|
|
255
|
+
* version of this shape drew as a pair of `s` characters at
|
|
256
|
+
* `size-[1.15em]` — a curve that reads as a letter is worse than
|
|
257
|
+
* no icon, because the eye stops to read it.
|
|
258
|
+
*/
|
|
259
|
+
export const QuoteIcon = (): ReactNode => (
|
|
260
|
+
<svg
|
|
261
|
+
{...iconProps}
|
|
262
|
+
aria-hidden="true"
|
|
263
|
+
fill="currentColor"
|
|
264
|
+
stroke="none"
|
|
265
|
+
>
|
|
266
|
+
<path d="M10 6.2a6.6 6.6 0 0 0-4.7 6.2c0 2.1 1.3 3.6 3.2 3.6 1.7 0 2.9-1.2 2.9-2.8 0-1.6-1.1-2.7-2.6-2.7h-.5c.4-1.4 1.4-2.5 2.8-3.1z" />
|
|
267
|
+
|
|
268
|
+
<path d="M19 6.2a6.6 6.6 0 0 0-4.7 6.2c0 2.1 1.3 3.6 3.2 3.6 1.7 0 2.9-1.2 2.9-2.8 0-1.6-1.1-2.7-2.6-2.7h-.5c.4-1.4 1.4-2.5 2.8-3.1z" />
|
|
269
|
+
</svg>
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
export const CodeIcon = (): ReactNode => (
|
|
273
|
+
<svg {...iconProps} aria-hidden="true">
|
|
274
|
+
<path d="M16 18l6-6-6-6M8 6l-6 6 6 6" />
|
|
275
|
+
</svg>
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
export const LinkIcon = (): ReactNode => (
|
|
279
|
+
<svg {...iconProps} aria-hidden="true">
|
|
280
|
+
<path d="M10.5 13.5a4.5 4.5 0 0 0 6.8.5l2.5-2.5a4.5 4.5 0 0 0-6.4-6.4l-1.4 1.4" />
|
|
281
|
+
|
|
282
|
+
<path d="M13.5 10.5a4.5 4.5 0 0 0-6.8-.5l-2.5 2.5a4.5 4.5 0 0 0 6.4 6.4l1.4-1.4" />
|
|
283
|
+
</svg>
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The whole `MarkdownEditorIcons` bag, ready to spread into either
|
|
288
|
+
* editor's `icons` prop.
|
|
289
|
+
*/
|
|
290
|
+
export const MARKDOWN_ICONS = {
|
|
291
|
+
bold: <BoldIcon />,
|
|
292
|
+
bulletedList: <BulletedListIcon />,
|
|
293
|
+
code: <CodeIcon />,
|
|
294
|
+
heading: <HeadingIcon />,
|
|
295
|
+
italic: <ItalicIcon />,
|
|
296
|
+
link: <LinkIcon />,
|
|
297
|
+
numberedList: <NumberedListIcon />,
|
|
298
|
+
overflow: <MoreIcon />,
|
|
299
|
+
quote: <QuoteIcon />,
|
|
300
|
+
taskList: <TaskListIcon />,
|
|
301
|
+
}
|
|
@@ -155,6 +155,22 @@ from either edge, or flip the Markdown source toggle, which is exactly what it i
|
|
|
155
155
|
a literal paste is worse than not linkifying one.
|
|
156
156
|
- **Anything else** is left to the browser.
|
|
157
157
|
|
|
158
|
+
## The toolbar is words until you bring icons
|
|
159
|
+
|
|
160
|
+
This package ships **no icons**, so the bar defaults to ten word buttons — which is a bar
|
|
161
|
+
that has already collapsed most of itself into "More actions" at the width a description
|
|
162
|
+
field actually gets. Pass an `icons` bag and the whole row fits, with the words kept as the
|
|
163
|
+
accessible names.
|
|
164
|
+
|
|
165
|
+
<Canvas of={MarkdownEditorCodeMirrorStories.IconsVersusWords} />
|
|
166
|
+
|
|
167
|
+
`icons.overflow` is the tenth key and the odd one out — it is the **bar's own** trigger,
|
|
168
|
+
not a markdown command. It is in the same bag because an app that passes the nine and not
|
|
169
|
+
the tenth gets nine glyphs followed by the words "More actions", and until now there was no
|
|
170
|
+
prop to fix that with.
|
|
171
|
+
|
|
172
|
+
<Canvas of={MarkdownEditorCodeMirrorStories.IconToolbar} />
|
|
173
|
+
|
|
158
174
|
## Accessibility, and the honest cost
|
|
159
175
|
|
|
160
176
|
The `<textarea>` sibling gets caret movement, IME composition, spellcheck, the platform undo
|