@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.
Files changed (34) hide show
  1. package/dist/Combobox/Combobox.d.ts.map +1 -1
  2. package/dist/Combobox/Combobox.js +35 -0
  3. package/dist/Combobox/Combobox.js.map +1 -1
  4. package/dist/MarkdownEditor/MarkdownEditor.d.ts +7 -1
  5. package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -1
  6. package/dist/MarkdownEditor/MarkdownEditor.js +1 -1
  7. package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -1
  8. package/dist/RadioGroup/RadioGroup.d.ts +49 -1
  9. package/dist/RadioGroup/RadioGroup.d.ts.map +1 -1
  10. package/dist/RadioGroup/RadioGroup.js +58 -3
  11. package/dist/RadioGroup/RadioGroup.js.map +1 -1
  12. package/dist/RadioGroup/RadioGroupOption.d.ts +13 -2
  13. package/dist/RadioGroup/RadioGroupOption.d.ts.map +1 -1
  14. package/dist/RadioGroup/RadioGroupOption.js +62 -5
  15. package/dist/RadioGroup/RadioGroupOption.js.map +1 -1
  16. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.d.ts.map +1 -1
  17. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js +1 -1
  18. package/dist/markdownEditorCodeMirror/MarkdownEditorCodeMirror.js.map +1 -1
  19. package/package.json +1 -1
  20. package/src/Combobox/Combobox.stories.tsx +16 -5
  21. package/src/Combobox/Combobox.test.tsx +45 -7
  22. package/src/Combobox/Combobox.tsx +44 -0
  23. package/src/MarkdownEditor/MarkdownEditor.mdx +12 -0
  24. package/src/MarkdownEditor/MarkdownEditor.stories.tsx +17 -0
  25. package/src/MarkdownEditor/MarkdownEditor.tsx +8 -0
  26. package/src/RadioGroup/RadioGroup.mdx +62 -0
  27. package/src/RadioGroup/RadioGroup.stories.tsx +164 -1
  28. package/src/RadioGroup/RadioGroup.test.tsx +179 -2
  29. package/src/RadioGroup/RadioGroup.tsx +107 -3
  30. package/src/RadioGroup/RadioGroupOption.tsx +122 -5
  31. package/src/icons.storyHelpers.tsx +108 -0
  32. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.mdx +16 -0
  33. package/src/markdownEditorCodeMirror/MarkdownEditorCodeMirror.stories.tsx +55 -0
  34. 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 { AllStates, Default, Interactive } =
11
- composeStories(stories)
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
- "flex flex-col",
156
- GAP_CLASS[size],
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 { RadioItem } from "./RadioGroup.tsx"
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 { isDisabled = false, label, value } = item
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
- "inline-flex items-center gap-2 rounded-sm text-content-secondary text-start transition-colors duration-(--duration-fast) ease-standard",
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
- : "cursor-pointer hover:text-content-primary",
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
- <span>{label}</span>
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