@charcuterie/ui 3.0.0 → 3.2.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 (130) hide show
  1. package/README.md +10 -0
  2. package/dist/Badge/Badge.d.ts +33 -3
  3. package/dist/Badge/Badge.d.ts.map +1 -1
  4. package/dist/Badge/Badge.js +20 -2
  5. package/dist/Badge/Badge.js.map +1 -1
  6. package/dist/Board/Board.d.ts +119 -0
  7. package/dist/Board/Board.d.ts.map +1 -0
  8. package/dist/Board/Board.js +165 -0
  9. package/dist/Board/Board.js.map +1 -0
  10. package/dist/Board/BoardCard.d.ts +120 -0
  11. package/dist/Board/BoardCard.d.ts.map +1 -0
  12. package/dist/Board/BoardCard.js +77 -0
  13. package/dist/Board/BoardCard.js.map +1 -0
  14. package/dist/Board/BoardLaneList.d.ts +103 -0
  15. package/dist/Board/BoardLaneList.d.ts.map +1 -0
  16. package/dist/Board/BoardLaneList.js +69 -0
  17. package/dist/Board/BoardLaneList.js.map +1 -0
  18. package/dist/Board/boardMove.d.ts +116 -0
  19. package/dist/Board/boardMove.d.ts.map +1 -0
  20. package/dist/Board/boardMove.js +116 -0
  21. package/dist/Board/boardMove.js.map +1 -0
  22. package/dist/Board/useBoardDrag.d.ts +44 -0
  23. package/dist/Board/useBoardDrag.d.ts.map +1 -0
  24. package/dist/Board/useBoardDrag.js +237 -0
  25. package/dist/Board/useBoardDrag.js.map +1 -0
  26. package/dist/Checkbox/Checkbox.d.ts.map +1 -1
  27. package/dist/Checkbox/Checkbox.js +15 -4
  28. package/dist/Checkbox/Checkbox.js.map +1 -1
  29. package/dist/DataTable/DataTable.d.ts +159 -0
  30. package/dist/DataTable/DataTable.d.ts.map +1 -0
  31. package/dist/DataTable/DataTable.js +197 -0
  32. package/dist/DataTable/DataTable.js.map +1 -0
  33. package/dist/DatePicker/DateGrid.d.ts +79 -0
  34. package/dist/DatePicker/DateGrid.d.ts.map +1 -0
  35. package/dist/DatePicker/DateGrid.js +220 -0
  36. package/dist/DatePicker/DateGrid.js.map +1 -0
  37. package/dist/DatePicker/DatePicker.d.ts +138 -0
  38. package/dist/DatePicker/DatePicker.d.ts.map +1 -0
  39. package/dist/DatePicker/DatePicker.js +384 -0
  40. package/dist/DatePicker/DatePicker.js.map +1 -0
  41. package/dist/DatePicker/datePresets.d.ts +29 -0
  42. package/dist/DatePicker/datePresets.d.ts.map +1 -0
  43. package/dist/DatePicker/datePresets.js +28 -0
  44. package/dist/DatePicker/datePresets.js.map +1 -0
  45. package/dist/DatePicker/parseDateInput.d.ts +106 -0
  46. package/dist/DatePicker/parseDateInput.d.ts.map +1 -0
  47. package/dist/DatePicker/parseDateInput.js +419 -0
  48. package/dist/DatePicker/parseDateInput.js.map +1 -0
  49. package/dist/DatePicker/plainDate.d.ts +167 -0
  50. package/dist/DatePicker/plainDate.d.ts.map +1 -0
  51. package/dist/DatePicker/plainDate.js +272 -0
  52. package/dist/DatePicker/plainDate.js.map +1 -0
  53. package/dist/Dialog/Dialog.d.ts +12 -2
  54. package/dist/Dialog/Dialog.d.ts.map +1 -1
  55. package/dist/Dialog/Dialog.js +2 -2
  56. package/dist/Dialog/Dialog.js.map +1 -1
  57. package/dist/MarkdownEditor/MarkdownEditor.d.ts +147 -0
  58. package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -0
  59. package/dist/MarkdownEditor/MarkdownEditor.js +492 -0
  60. package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -0
  61. package/dist/MarkdownEditor/markdownCommands.d.ts +202 -0
  62. package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -0
  63. package/dist/MarkdownEditor/markdownCommands.js +379 -0
  64. package/dist/MarkdownEditor/markdownCommands.js.map +1 -0
  65. package/dist/MarkdownEditor/markdownSpans.d.ts +92 -0
  66. package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -0
  67. package/dist/MarkdownEditor/markdownSpans.js +303 -0
  68. package/dist/MarkdownEditor/markdownSpans.js.map +1 -0
  69. package/dist/Modal/Modal.d.ts +6 -2
  70. package/dist/Modal/Modal.d.ts.map +1 -1
  71. package/dist/Modal/Modal.js +1 -1
  72. package/dist/Modal/Modal.js.map +1 -1
  73. package/dist/Overlay/OverlayPanel.d.ts +13 -2
  74. package/dist/Overlay/OverlayPanel.d.ts.map +1 -1
  75. package/dist/Overlay/OverlayPanel.js +5 -1
  76. package/dist/Overlay/OverlayPanel.js.map +1 -1
  77. package/dist/categoricalStyles.d.ts +53 -0
  78. package/dist/categoricalStyles.d.ts.map +1 -0
  79. package/dist/categoricalStyles.js +195 -0
  80. package/dist/categoricalStyles.js.map +1 -0
  81. package/dist/index.d.ts +22 -0
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +11 -0
  84. package/dist/index.js.map +1 -1
  85. package/package.json +2 -2
  86. package/src/Badge/Badge.mdx +46 -0
  87. package/src/Badge/Badge.stories.tsx +98 -4
  88. package/src/Badge/Badge.test.tsx +157 -2
  89. package/src/Badge/Badge.tsx +49 -3
  90. package/src/Board/Board.mdx +199 -0
  91. package/src/Board/Board.stories.tsx +707 -0
  92. package/src/Board/Board.test.tsx +310 -0
  93. package/src/Board/Board.tsx +333 -0
  94. package/src/Board/BoardCard.tsx +290 -0
  95. package/src/Board/BoardLaneList.tsx +275 -0
  96. package/src/Board/boardMove.test.ts +213 -0
  97. package/src/Board/boardMove.ts +225 -0
  98. package/src/Board/useBoardDrag.ts +413 -0
  99. package/src/Checkbox/Checkbox.tsx +34 -3
  100. package/src/DataTable/DataTable.mdx +123 -0
  101. package/src/DataTable/DataTable.stories.tsx +411 -0
  102. package/src/DataTable/DataTable.test.tsx +386 -0
  103. package/src/DataTable/DataTable.tsx +497 -0
  104. package/src/DatePicker/DateGrid.tsx +467 -0
  105. package/src/DatePicker/DatePicker.mdx +170 -0
  106. package/src/DatePicker/DatePicker.stories.tsx +268 -0
  107. package/src/DatePicker/DatePicker.test.tsx +455 -0
  108. package/src/DatePicker/DatePicker.tsx +852 -0
  109. package/src/DatePicker/datePresets.ts +34 -0
  110. package/src/DatePicker/parseDateInput.test.ts +187 -0
  111. package/src/DatePicker/parseDateInput.ts +629 -0
  112. package/src/DatePicker/plainDate.test.ts +375 -0
  113. package/src/DatePicker/plainDate.ts +465 -0
  114. package/src/Dialog/Dialog.stories.tsx +48 -0
  115. package/src/Dialog/Dialog.test.tsx +30 -0
  116. package/src/Dialog/Dialog.tsx +13 -1
  117. package/src/MarkdownEditor/MarkdownEditor.mdx +189 -0
  118. package/src/MarkdownEditor/MarkdownEditor.stories.tsx +267 -0
  119. package/src/MarkdownEditor/MarkdownEditor.test.tsx +627 -0
  120. package/src/MarkdownEditor/MarkdownEditor.tsx +862 -0
  121. package/src/MarkdownEditor/markdownCommands.test.ts +384 -0
  122. package/src/MarkdownEditor/markdownCommands.ts +566 -0
  123. package/src/MarkdownEditor/markdownSpans.test.ts +217 -0
  124. package/src/MarkdownEditor/markdownSpans.ts +458 -0
  125. package/src/Modal/Modal.tsx +7 -1
  126. package/src/Overlay/OverlayPanel.tsx +17 -1
  127. package/src/argTypes.storyHelpers.ts +39 -0
  128. package/src/categoricalStyles.ts +244 -0
  129. package/src/index.ts +89 -0
  130. package/src/sourceRules.test.ts +52 -1
@@ -0,0 +1,310 @@
1
+ import { composeStories } from "@storybook/react"
2
+ import {
3
+ expect,
4
+ userEvent,
5
+ waitFor,
6
+ within,
7
+ } from "storybook/test"
8
+ import { test } from "vitest"
9
+
10
+ import { expectNoAxeViolations } from "../expectNoAxeViolations.testHelpers.ts"
11
+ import { mountStory } from "../mountStory.testHelpers.ts"
12
+ import { expectAgentDrivable } from "../testing/index.ts"
13
+ import * as stories from "./Board.stories.tsx"
14
+
15
+ const {
16
+ AllStates,
17
+ Default,
18
+ InBoardScreen,
19
+ Interactive,
20
+ Responsive,
21
+ } = composeStories(stories)
22
+
23
+ /**
24
+ * The board is the landmark; the lanes are **groups** inside it.
25
+ *
26
+ * A lane that were a `region` would put three landmarks on a page
27
+ * that has one job — and two boards on one page, or three copies of
28
+ * one in a container-width story, would put two landmarks called
29
+ * "Todo" in the document. That is axe's `landmark-unique`, and it
30
+ * was a real failure here before the lane became a group.
31
+ */
32
+ test("the board is a named landmark and its lanes are named groups", async () => {
33
+ const { canvas } = await mountStory(AllStates)
34
+
35
+ const board = expectAgentDrivable(canvas, {
36
+ name: "Today, mixed lanes",
37
+ role: "region",
38
+ })
39
+
40
+ for (const laneLabel of [
41
+ "Todo",
42
+ "In Progress",
43
+ "Needs Review",
44
+ ]) {
45
+ expectAgentDrivable(canvas, {
46
+ name: laneLabel,
47
+ role: "group",
48
+ })
49
+ }
50
+
51
+ await expectNoAxeViolations(board)
52
+ })
53
+
54
+ /**
55
+ * Three lanes at once is a **container** decision. The story frames
56
+ * the board at a fixed 72rem, so this passes or fails on the
57
+ * board's own box and not on however wide the test runner's window
58
+ * happens to be — which is the whole claim the component makes.
59
+ */
60
+ test("a board wider than cq-lg shows every lane at once", async () => {
61
+ const { canvas } = await mountStory(AllStates)
62
+
63
+ await expect(canvas.getAllByRole("group")).toHaveLength(3)
64
+
65
+ // And the lane picker, which only exists for the narrow layout,
66
+ // is not in the accessibility tree at all — `display: none`
67
+ // rather than hidden-but-present.
68
+ await expect(
69
+ canvas.queryAllByRole("radiogroup"),
70
+ ).toHaveLength(0)
71
+ })
72
+
73
+ /**
74
+ * The count is the lane's **real** size, not the number of rows
75
+ * painted. A board that truncates and then reports the truncated
76
+ * number is a board that quietly lies about how much work there is,
77
+ * which is the one number the lane exists to carry.
78
+ */
79
+ test("a truncated lane reports its true size and says what is missing", async () => {
80
+ const { canvas } = await mountStory(AllStates)
81
+
82
+ const review = expectAgentDrivable(canvas, {
83
+ name: "Needs Review",
84
+ role: "group",
85
+ })
86
+
87
+ // By the **title link** rather than the `<li>`. A list item takes
88
+ // no accessible name from its content, so `getByRole("listitem",
89
+ // { name })` can never match — and the title is what an agent or a
90
+ // person actually clicks anyway.
91
+ await expect(
92
+ within(review).getByRole("link", {
93
+ name: /Merge manager rename/,
94
+ }),
95
+ ).toBeInTheDocument()
96
+
97
+ // Eight rendered, nineteen real.
98
+ await expect(
99
+ within(review).getAllByRole("listitem"),
100
+ ).toHaveLength(8)
101
+
102
+ await expect(
103
+ within(review).getByText("19"),
104
+ ).toBeInTheDocument()
105
+
106
+ await expect(
107
+ within(review).getByText("+ 11 more in Needs Review"),
108
+ ).toBeInTheDocument()
109
+ })
110
+
111
+ /**
112
+ * A colour bar is a WCAG 1.4.1 failure on its own and invisible to
113
+ * every screen reader, so the priority it encodes is stated in
114
+ * words too. This is the assertion that would have caught Docket's
115
+ * first board, where the bars were painted with a token family that
116
+ * does not exist and rendered transparent while every "is it
117
+ * rendered" check passed.
118
+ */
119
+ test("the priority bar's meaning is available as text", async () => {
120
+ const { canvas } = await mountStory(Interactive)
121
+
122
+ const todo = expectAgentDrivable(canvas, {
123
+ name: "Todo",
124
+ role: "group",
125
+ })
126
+
127
+ await expect(
128
+ within(todo).getAllByText("Priority 0").length,
129
+ ).toBeGreaterThan(0)
130
+
131
+ await expect(
132
+ within(todo).getAllByText("Priority 1").length,
133
+ ).toBeGreaterThan(0)
134
+ })
135
+
136
+ /**
137
+ * A read-only board carries no affordances. Not a cosmetic
138
+ * difference: a handle that opens a menu whose every item does
139
+ * nothing is worse than no handle, because it costs a tab stop and
140
+ * a promise.
141
+ */
142
+ test("a board with no onMove has no move handles", async () => {
143
+ const { canvas } = await mountStory(Default)
144
+
145
+ await expect(
146
+ canvas.queryAllByRole("button", { name: /^Move / }),
147
+ ).toHaveLength(0)
148
+ })
149
+
150
+ /**
151
+ * The whole keyboard path, and the one the component treats as
152
+ * primary. Tab to a handle, Enter to open, arrow to a lane, Enter to
153
+ * commit — every step through `Menu`, which already owns the
154
+ * roving-focus model, so there is no second keyboard implementation
155
+ * here to drift.
156
+ */
157
+ test("a card moves between lanes from the keyboard alone", async () => {
158
+ const { body, canvas } = await mountStory(Interactive)
159
+
160
+ const todo = expectAgentDrivable(canvas, {
161
+ name: "Todo",
162
+ role: "group",
163
+ })
164
+
165
+ const handle = within(todo).getByRole("button", {
166
+ name: /^Move Retire the second scheduler/,
167
+ })
168
+
169
+ handle.focus()
170
+
171
+ await expect(handle).toHaveFocus()
172
+
173
+ await userEvent.keyboard("{Enter}")
174
+
175
+ const menuItem = await waitFor(() =>
176
+ body.getByRole("menuitem", { name: "In Progress" }),
177
+ )
178
+
179
+ // The panel, not the whole document: floating-ui's own focus
180
+ // guards are `aria-hidden` and focusable by design, so auditing
181
+ // `body` audits the overlay library rather than this component.
182
+ // Same scope `Menu`'s own tests use.
183
+ await expectNoAxeViolations(body.getByRole("menu"))
184
+
185
+ await userEvent.keyboard("{ArrowDown}")
186
+
187
+ await userEvent.click(menuItem)
188
+
189
+ const inProgress = expectAgentDrivable(canvas, {
190
+ name: "In Progress",
191
+ role: "group",
192
+ })
193
+
194
+ await waitFor(() => {
195
+ expect(
196
+ within(inProgress).getByRole("link", {
197
+ name: /Retire the second scheduler/,
198
+ }),
199
+ ).toBeInTheDocument()
200
+ })
201
+
202
+ // And it left the lane it came from, rather than being copied.
203
+ await waitFor(() => {
204
+ expect(
205
+ within(todo).queryByRole("link", {
206
+ name: /Retire the second scheduler/,
207
+ }),
208
+ ).toBeNull()
209
+ })
210
+ })
211
+
212
+ /**
213
+ * The feedback a sighted user gets free from watching the card land,
214
+ * said out loud — and it names the **position**, not only the lane.
215
+ * "Moved to In Progress" leaves a screen-reader user unable to tell
216
+ * the top of a lane from the bottom of thirty.
217
+ */
218
+ test("the move is announced with its destination and position", async () => {
219
+ const { canvas, canvasElement } =
220
+ await mountStory(Interactive)
221
+
222
+ const todo = expectAgentDrivable(canvas, {
223
+ name: "Todo",
224
+ role: "group",
225
+ })
226
+
227
+ await userEvent.click(
228
+ within(todo).getByRole("button", {
229
+ name: /^Move One composed Storybook/,
230
+ }),
231
+ )
232
+
233
+ await userEvent.click(
234
+ await waitFor(() =>
235
+ within(document.body).getByRole("menuitem", {
236
+ name: "Needs Review",
237
+ }),
238
+ ),
239
+ )
240
+
241
+ const status = await waitFor(() => {
242
+ const region = canvasElement.querySelector(
243
+ '[role="status"]',
244
+ )
245
+
246
+ expect(region?.textContent).toMatch(
247
+ /^Moved One composed Storybook for every app in the fleet to Needs Review, position \d+ of \d+\.$/,
248
+ )
249
+
250
+ return region
251
+ })
252
+
253
+ await expect(status).toHaveAttribute(
254
+ "aria-label",
255
+ "Today, movable activity",
256
+ )
257
+ })
258
+
259
+ /**
260
+ * The narrow layout is one lane plus a `radiogroup`, and the
261
+ * radiogroup is a **container** decision — this story is three fixed
262
+ * widths inside one unchanged window, so the viewport cannot be what
263
+ * chose it.
264
+ */
265
+ test("the narrow layout picks lanes with a named radiogroup", async () => {
266
+ const { canvas } = await mountStory(Responsive)
267
+
268
+ const pickers = canvas.getAllByRole("radiogroup")
269
+
270
+ // One per panel: the same board, three container widths, one
271
+ // browser window that never moved.
272
+ await expect(pickers).toHaveLength(3)
273
+
274
+ const [narrowest] = pickers
275
+
276
+ const inProgress = within(narrowest).getByRole("radio", {
277
+ name: /In Progress/,
278
+ })
279
+
280
+ await userEvent.click(inProgress)
281
+
282
+ await waitFor(() => {
283
+ expect(inProgress).toBeChecked()
284
+ })
285
+ })
286
+
287
+ /**
288
+ * An empty lane says something a person can act on, and it is a real
289
+ * heading rather than a grey word — so it is findable by role, which
290
+ * "Empty" in a `<div>` never is.
291
+ */
292
+ test("an empty lane renders a real, findable empty state", async () => {
293
+ const { canvas } = await mountStory(AllStates)
294
+
295
+ expectAgentDrivable(canvas, {
296
+ name: "Nothing chosen yet",
297
+ role: "heading",
298
+ })
299
+ })
300
+
301
+ /**
302
+ * The composition the docs page recommends, audited as a whole: the
303
+ * cross-lane banner is an `Alert` above the board rather than a
304
+ * fourth lane inside it, and the two together still pass.
305
+ */
306
+ test("the board sits under a separate attention banner, and the page is clean", async () => {
307
+ const { canvasElement } = await mountStory(InBoardScreen)
308
+
309
+ await expectNoAxeViolations(canvasElement)
310
+ })
@@ -0,0 +1,333 @@
1
+ import type { ReactNode } from "react"
2
+ import { useCallback, useState } from "react"
3
+
4
+ import { SegmentedControl } from "../SegmentedControl/SegmentedControl.tsx"
5
+ import { toClassName } from "../toClassName.ts"
6
+ import { VisuallyHidden } from "../VisuallyHidden/VisuallyHidden.tsx"
7
+ import type { BoardLane } from "./BoardLaneList.tsx"
8
+ import { BoardLaneList } from "./BoardLaneList.tsx"
9
+ import {
10
+ describeMove,
11
+ getIsMoveMeaningful,
12
+ toSettledIndex,
13
+ } from "./boardMove.ts"
14
+ import { useBoardDrag } from "./useBoardDrag.ts"
15
+
16
+ export type BoardMove = {
17
+ /** Where the card was, in `fromLane.items`. */
18
+ fromIndex: number
19
+ fromLaneKey: string
20
+ itemKey: string
21
+ /**
22
+ * Where it goes, **already corrected for its own removal** — so a
23
+ * consumer's handler is `splice(fromIndex, 1)` then
24
+ * `splice(toIndex, 0, item)` with no arithmetic of its own. A
25
+ * within-lane move quoted against the un-spliced list is one too
26
+ * high for every position below the origin, and that off-by-one is
27
+ * the single most common bug in a hand-rolled board.
28
+ */
29
+ toIndex: number
30
+ toLaneKey: string
31
+ }
32
+
33
+ export type BoardProps = {
34
+ className?: string
35
+ /**
36
+ * What level the lane headings are. The caller's, because only the
37
+ * caller knows what the board sits under: a board directly below a
38
+ * page `<h1>` wants `2`, and one inside an already-deep section
39
+ * wants `4`. Getting it wrong is an axe `heading-order` failure and
40
+ * a broken screen-reader outline.
41
+ */
42
+ headingLevel?: 2 | 3 | 4 | 5
43
+ /** The board's accessible name. Required — it is a landmark. */
44
+ label: string
45
+ lanes: readonly BoardLane[]
46
+ /**
47
+ * What the move handle shows instead of the word "Move". The
48
+ * library ships no icons and no symbol glyphs in a default —
49
+ * they render as nothing where the font lacks them, which
50
+ * includes this sandbox's chromium, the kiosk image and the
51
+ * ePaper build — so the default is a word and an app that owns a
52
+ * glyph set passes one. On a dense board it is worth ~55px a row.
53
+ */
54
+ moveIcon?: ReactNode
55
+ /**
56
+ * Which lane is on screen below `cq-lg`. **Initial only** — the
57
+ * segmented control owns it from then on, matching every other
58
+ * uncontrolled control in this library.
59
+ */
60
+ narrowLaneKey?: string
61
+ /**
62
+ * The one thing that makes a board a board. **Absent means
63
+ * read-only**: no handles, no menus, no drag, and nothing
64
+ * announcing itself. A board with no `onMove` is a legitimate
65
+ * thing — a dashboard, an ePaper render — and it should not be
66
+ * carrying affordances that do nothing.
67
+ */
68
+ onMove?: (move: BoardMove) => void
69
+ }
70
+
71
+ /**
72
+ * Lanes of cards, sized by their **container**, moved by keyboard or
73
+ * by pointer.
74
+ *
75
+ * ### The container rule, which is the whole point
76
+ *
77
+ * There is no media query in this component, and adding one would be
78
+ * a defect rather than a shortcut. Two facts make window width
79
+ * useless here and they compound:
80
+ *
81
+ * - **A lane in a three-up board is narrow on any monitor.** Three
82
+ * lanes across 1600px is ~500px each, which is a phone's worth of
83
+ * room, on a 4K display, in a maximised window.
84
+ * - **The owner browses zoomed in.** A 1500px window at 175% zoom
85
+ * is ~860 effective CSS pixels, so the number a media query reads
86
+ * is not the number of pixels anything actually has.
87
+ *
88
+ * So there are **two nested containers**, and they answer two
89
+ * different questions:
90
+ *
91
+ * - The board's own box decides *how many lanes are on screen* —
92
+ * three-up at `cq-lg` and wider, one lane plus a segmented
93
+ * control below it.
94
+ * - Each lane's list decides *what shape a card is* — two lines,
95
+ * one line, or its own card. See `BoardCard`.
96
+ *
97
+ * Both are `@container`, and neither is queried by the element that
98
+ * declares it, because a container query matches descendants only.
99
+ *
100
+ * ### There is no horizontal scroll, at any width
101
+ *
102
+ * *"No horizontal scroll, ever. The board becomes a segmented
103
+ * control, never a pan surface."* A board that pans sideways hides
104
+ * lanes behind a gesture with no affordance, and on a trackpad it
105
+ * fights the browser's own back-navigation. So the narrow layout
106
+ * shows one lane at a time and names the others in a `radiogroup`
107
+ * that says how many cards each holds.
108
+ *
109
+ * ### Moving a card is a first-class operation, and it is not a drag
110
+ *
111
+ * The move is a **`Menu` on every card**, listing the other lanes.
112
+ * That is the primary path, not an accessibility fallback: it is the
113
+ * only one that works from the keyboard, the only one a screen
114
+ * reader can drive, and the only one available in the Narrow View
115
+ * where the other lanes are not on screen to drop onto. Pointer
116
+ * dragging is layered on the same handle and commits through the
117
+ * same callback.
118
+ *
119
+ * **No drag-and-drop dependency is taken.** The reasoning — licence,
120
+ * provenance, bundle cost, and what a general DnD library would
121
+ * actually be buying for one card going into one lane — is in
122
+ * [the decision](../../../docs/decisions/2026-08-19-the-board-owns-the-move-and-takes-no-drag-and-drop-dependency.md).
123
+ *
124
+ * ### What this component deliberately does not do
125
+ *
126
+ * Lanes, counts, truncation, empty states and the move; **not**
127
+ * data, not virtualisation, and not the cross-lane "needs attention"
128
+ * summary. A board is a view of a list the consumer owns, the lane
129
+ * truncates by construction so there is nothing to virtualise, and a
130
+ * panel that reaches across every lane is not a lane — it is an
131
+ * `Alert` above the board, which is how the docs page composes it.
132
+ */
133
+ export const Board = ({
134
+ className,
135
+ headingLevel = 3,
136
+ label,
137
+ lanes,
138
+ moveIcon,
139
+ narrowLaneKey,
140
+ onMove,
141
+ }: BoardProps): ReactNode => {
142
+ const [visibleLaneKey, setVisibleLaneKey] = useState(
143
+ narrowLaneKey ?? lanes[0]?.key ?? "",
144
+ )
145
+
146
+ /**
147
+ * What the live region last said. A string rather than a boolean
148
+ * plus a lookup, because the region's job is to have the sentence
149
+ * ready the moment it changes — and because a move announced as
150
+ * "moved" with no destination tells a screen-reader user strictly
151
+ * less than watching the card land tells everyone else.
152
+ */
153
+ const [moveAnnouncement, setMoveAnnouncement] =
154
+ useState("")
155
+
156
+ const commitMove = useCallback(
157
+ (move: BoardMove) => {
158
+ const toLane = lanes.find(
159
+ (one) => one.key === move.toLaneKey,
160
+ )
161
+
162
+ if (!toLane) {
163
+ return
164
+ }
165
+
166
+ const item = lanes
167
+ .flatMap((one) => one.items)
168
+ .find((one) => one.key === move.itemKey)
169
+
170
+ setMoveAnnouncement(
171
+ describeMove({
172
+ index: move.toIndex,
173
+ laneLabel: toLane.label,
174
+ laneSize:
175
+ move.fromLaneKey === move.toLaneKey
176
+ ? (toLane.itemCount ?? toLane.items.length)
177
+ : (toLane.itemCount ?? toLane.items.length) +
178
+ 1,
179
+ title: item?.title ?? move.itemKey,
180
+ }),
181
+ )
182
+
183
+ onMove?.(move)
184
+ },
185
+ [lanes, onMove],
186
+ )
187
+
188
+ const { drag, registerLane, startDrag } = useBoardDrag({
189
+ onDrop: (origin, target) => {
190
+ if (
191
+ !getIsMoveMeaningful({
192
+ fromIndex: origin.fromIndex,
193
+ fromLaneKey: origin.fromLaneKey,
194
+ toIndex: target.index,
195
+ toLaneKey: target.laneKey,
196
+ })
197
+ ) {
198
+ return
199
+ }
200
+
201
+ commitMove({
202
+ fromIndex: origin.fromIndex,
203
+ fromLaneKey: origin.fromLaneKey,
204
+ itemKey: origin.itemKey,
205
+ toIndex: toSettledIndex({
206
+ fromIndex: origin.fromIndex,
207
+ fromLaneKey: origin.fromLaneKey,
208
+ toIndex: target.index,
209
+ toLaneKey: target.laneKey,
210
+ }),
211
+ toLaneKey: target.laneKey,
212
+ })
213
+ },
214
+ })
215
+
216
+ return (
217
+ <section
218
+ aria-label={label}
219
+ className={toClassName("@container", className)}
220
+ >
221
+ <div className="flex flex-col gap-3">
222
+ {/*
223
+ * Hidden rather than unmounted above `cq-lg`, and that is
224
+ * not laziness. Rendering it conditionally would need the
225
+ * board's own width in JavaScript, which means a
226
+ * `ResizeObserver`, which means the first paint is wrong and
227
+ * a kiosk Pi shows it. `display: none` is invisible to
228
+ * assistive technology as well as to the eye, so the wide
229
+ * board has no stray radiogroup in it.
230
+ */}
231
+ <SegmentedControl
232
+ className="max-w-full flex-wrap cq-lg:hidden"
233
+ items={lanes.map((lane) => ({
234
+ // Stacked rather than `Todo (6)`, because the count is
235
+ // the reason to look: a lane picker that reads
236
+ // "In Progress (5)" on one line is a long label with a
237
+ // number in it, and at 15rem three of them do not fit
238
+ // on any line at all.
239
+ label: (
240
+ <span className="flex flex-col items-center leading-tight">
241
+ <span>{lane.label}</span>
242
+
243
+ <span className="font-normal text-content-muted text-xs">
244
+ {String(
245
+ lane.itemCount ?? lane.items.length,
246
+ )}
247
+ </span>
248
+ </span>
249
+ ),
250
+ value: lane.key,
251
+ }))}
252
+ label={`${label} lanes`}
253
+ onChange={(value) => {
254
+ setVisibleLaneKey(value ?? "")
255
+ }}
256
+ selectedValue={visibleLaneKey}
257
+ />
258
+
259
+ <div className="flex flex-col gap-4 cq-lg:flex-row cq-lg:items-start">
260
+ {lanes.map((lane) => (
261
+ <BoardLaneList
262
+ headingLevel={headingLevel}
263
+ dropIndicatorOffset={
264
+ drag?.target?.laneKey === lane.key
265
+ ? drag.indicatorOffset
266
+ : null
267
+ }
268
+ isVisibleWhenNarrow={
269
+ lane.key === visibleLaneKey
270
+ }
271
+ key={lane.key}
272
+ lane={lane}
273
+ moveIcon={moveIcon}
274
+ moveTargets={lanes
275
+ .filter((one) => one.key !== lane.key)
276
+ .map((one) => ({
277
+ key: one.key,
278
+ label: one.label,
279
+ }))}
280
+ onMoveToLane={
281
+ onMove
282
+ ? (itemKey, toLaneKey) => {
283
+ const toLane = lanes.find(
284
+ (one) => one.key === toLaneKey,
285
+ )
286
+
287
+ commitMove({
288
+ fromIndex: lane.items.findIndex(
289
+ (one) => one.key === itemKey,
290
+ ),
291
+ fromLaneKey: lane.key,
292
+ itemKey,
293
+ // A menu move appends. There is no pointer
294
+ // to read a position off, and "the end of
295
+ // the lane you just sent it to" is the one
296
+ // answer that needs no second question.
297
+ toIndex: toLane?.items.length ?? 0,
298
+ toLaneKey,
299
+ })
300
+ }
301
+ : undefined
302
+ }
303
+ onStartDrag={
304
+ onMove
305
+ ? (pointerEvent, origin) => {
306
+ startDrag(pointerEvent, {
307
+ ...origin,
308
+ fromLaneKey: lane.key,
309
+ })
310
+ }
311
+ : undefined
312
+ }
313
+ registerListElement={registerLane(lane.key)}
314
+ />
315
+ ))}
316
+ </div>
317
+ </div>
318
+
319
+ {/*
320
+ * `aria-label` on a `role="status"`, per the library's own
321
+ * rule — a live region with no name is announced with no
322
+ * context, and this one fires while the user's attention is on
323
+ * a card rather than on the board.
324
+ */}
325
+ <VisuallyHidden
326
+ aria-label={`${label} activity`}
327
+ role="status"
328
+ >
329
+ {moveAnnouncement}
330
+ </VisuallyHidden>
331
+ </section>
332
+ )
333
+ }