@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.
- package/README.md +10 -0
- package/dist/Badge/Badge.d.ts +33 -3
- package/dist/Badge/Badge.d.ts.map +1 -1
- package/dist/Badge/Badge.js +20 -2
- package/dist/Badge/Badge.js.map +1 -1
- package/dist/Board/Board.d.ts +119 -0
- package/dist/Board/Board.d.ts.map +1 -0
- package/dist/Board/Board.js +165 -0
- package/dist/Board/Board.js.map +1 -0
- package/dist/Board/BoardCard.d.ts +120 -0
- package/dist/Board/BoardCard.d.ts.map +1 -0
- package/dist/Board/BoardCard.js +77 -0
- package/dist/Board/BoardCard.js.map +1 -0
- package/dist/Board/BoardLaneList.d.ts +103 -0
- package/dist/Board/BoardLaneList.d.ts.map +1 -0
- package/dist/Board/BoardLaneList.js +69 -0
- package/dist/Board/BoardLaneList.js.map +1 -0
- package/dist/Board/boardMove.d.ts +116 -0
- package/dist/Board/boardMove.d.ts.map +1 -0
- package/dist/Board/boardMove.js +116 -0
- package/dist/Board/boardMove.js.map +1 -0
- package/dist/Board/useBoardDrag.d.ts +44 -0
- package/dist/Board/useBoardDrag.d.ts.map +1 -0
- package/dist/Board/useBoardDrag.js +237 -0
- package/dist/Board/useBoardDrag.js.map +1 -0
- package/dist/Checkbox/Checkbox.d.ts.map +1 -1
- package/dist/Checkbox/Checkbox.js +15 -4
- package/dist/Checkbox/Checkbox.js.map +1 -1
- package/dist/DataTable/DataTable.d.ts +159 -0
- package/dist/DataTable/DataTable.d.ts.map +1 -0
- package/dist/DataTable/DataTable.js +197 -0
- package/dist/DataTable/DataTable.js.map +1 -0
- package/dist/DatePicker/DateGrid.d.ts +79 -0
- package/dist/DatePicker/DateGrid.d.ts.map +1 -0
- package/dist/DatePicker/DateGrid.js +220 -0
- package/dist/DatePicker/DateGrid.js.map +1 -0
- package/dist/DatePicker/DatePicker.d.ts +138 -0
- package/dist/DatePicker/DatePicker.d.ts.map +1 -0
- package/dist/DatePicker/DatePicker.js +384 -0
- package/dist/DatePicker/DatePicker.js.map +1 -0
- package/dist/DatePicker/datePresets.d.ts +29 -0
- package/dist/DatePicker/datePresets.d.ts.map +1 -0
- package/dist/DatePicker/datePresets.js +28 -0
- package/dist/DatePicker/datePresets.js.map +1 -0
- package/dist/DatePicker/parseDateInput.d.ts +106 -0
- package/dist/DatePicker/parseDateInput.d.ts.map +1 -0
- package/dist/DatePicker/parseDateInput.js +419 -0
- package/dist/DatePicker/parseDateInput.js.map +1 -0
- package/dist/DatePicker/plainDate.d.ts +167 -0
- package/dist/DatePicker/plainDate.d.ts.map +1 -0
- package/dist/DatePicker/plainDate.js +272 -0
- package/dist/DatePicker/plainDate.js.map +1 -0
- package/dist/Dialog/Dialog.d.ts +12 -2
- package/dist/Dialog/Dialog.d.ts.map +1 -1
- package/dist/Dialog/Dialog.js +2 -2
- package/dist/Dialog/Dialog.js.map +1 -1
- package/dist/MarkdownEditor/MarkdownEditor.d.ts +147 -0
- package/dist/MarkdownEditor/MarkdownEditor.d.ts.map +1 -0
- package/dist/MarkdownEditor/MarkdownEditor.js +492 -0
- package/dist/MarkdownEditor/MarkdownEditor.js.map +1 -0
- package/dist/MarkdownEditor/markdownCommands.d.ts +202 -0
- package/dist/MarkdownEditor/markdownCommands.d.ts.map +1 -0
- package/dist/MarkdownEditor/markdownCommands.js +379 -0
- package/dist/MarkdownEditor/markdownCommands.js.map +1 -0
- package/dist/MarkdownEditor/markdownSpans.d.ts +92 -0
- package/dist/MarkdownEditor/markdownSpans.d.ts.map +1 -0
- package/dist/MarkdownEditor/markdownSpans.js +303 -0
- package/dist/MarkdownEditor/markdownSpans.js.map +1 -0
- package/dist/Modal/Modal.d.ts +6 -2
- package/dist/Modal/Modal.d.ts.map +1 -1
- package/dist/Modal/Modal.js +1 -1
- package/dist/Modal/Modal.js.map +1 -1
- package/dist/Overlay/OverlayPanel.d.ts +13 -2
- package/dist/Overlay/OverlayPanel.d.ts.map +1 -1
- package/dist/Overlay/OverlayPanel.js +5 -1
- package/dist/Overlay/OverlayPanel.js.map +1 -1
- package/dist/categoricalStyles.d.ts +53 -0
- package/dist/categoricalStyles.d.ts.map +1 -0
- package/dist/categoricalStyles.js +195 -0
- package/dist/categoricalStyles.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/Badge/Badge.mdx +46 -0
- package/src/Badge/Badge.stories.tsx +98 -4
- package/src/Badge/Badge.test.tsx +157 -2
- package/src/Badge/Badge.tsx +49 -3
- package/src/Board/Board.mdx +199 -0
- package/src/Board/Board.stories.tsx +707 -0
- package/src/Board/Board.test.tsx +310 -0
- package/src/Board/Board.tsx +333 -0
- package/src/Board/BoardCard.tsx +290 -0
- package/src/Board/BoardLaneList.tsx +275 -0
- package/src/Board/boardMove.test.ts +213 -0
- package/src/Board/boardMove.ts +225 -0
- package/src/Board/useBoardDrag.ts +413 -0
- package/src/Checkbox/Checkbox.tsx +34 -3
- package/src/DataTable/DataTable.mdx +123 -0
- package/src/DataTable/DataTable.stories.tsx +411 -0
- package/src/DataTable/DataTable.test.tsx +386 -0
- package/src/DataTable/DataTable.tsx +497 -0
- package/src/DatePicker/DateGrid.tsx +467 -0
- package/src/DatePicker/DatePicker.mdx +170 -0
- package/src/DatePicker/DatePicker.stories.tsx +268 -0
- package/src/DatePicker/DatePicker.test.tsx +455 -0
- package/src/DatePicker/DatePicker.tsx +852 -0
- package/src/DatePicker/datePresets.ts +34 -0
- package/src/DatePicker/parseDateInput.test.ts +187 -0
- package/src/DatePicker/parseDateInput.ts +629 -0
- package/src/DatePicker/plainDate.test.ts +375 -0
- package/src/DatePicker/plainDate.ts +465 -0
- package/src/Dialog/Dialog.stories.tsx +48 -0
- package/src/Dialog/Dialog.test.tsx +30 -0
- package/src/Dialog/Dialog.tsx +13 -1
- package/src/MarkdownEditor/MarkdownEditor.mdx +189 -0
- package/src/MarkdownEditor/MarkdownEditor.stories.tsx +267 -0
- package/src/MarkdownEditor/MarkdownEditor.test.tsx +627 -0
- package/src/MarkdownEditor/MarkdownEditor.tsx +862 -0
- package/src/MarkdownEditor/markdownCommands.test.ts +384 -0
- package/src/MarkdownEditor/markdownCommands.ts +566 -0
- package/src/MarkdownEditor/markdownSpans.test.ts +217 -0
- package/src/MarkdownEditor/markdownSpans.ts +458 -0
- package/src/Modal/Modal.tsx +7 -1
- package/src/Overlay/OverlayPanel.tsx +17 -1
- package/src/argTypes.storyHelpers.ts +39 -0
- package/src/categoricalStyles.ts +244 -0
- package/src/index.ts +89 -0
- 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
|
+
}
|