@anchrd/intel-ui 0.38.0 → 0.40.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/package.json +6 -2
- package/src/access-summary/access-summary.tsx +1 -11
- package/src/app/app-tree/app-tree.tsx +37 -0
- package/src/board/board-assignee/board-assignee.ts +18 -0
- package/src/board/board-data/board-data.ts +31 -5
- package/src/board/board-data/board-data.types.ts +10 -1
- package/src/board/board-kanban/board-kanban.ts +63 -9
- package/src/board/board-kanban/board-kanban.tsx +371 -63
- package/src/board/board-panel/board-panel.tsx +222 -31
- package/src/board/board-settings/board-settings.tsx +209 -0
- package/src/board/board-stripes/board-stripes.ts +128 -0
- package/src/board/board-table/board-table.ts +23 -105
- package/src/board/board-table/board-table.tsx +436 -135
- package/src/board/board-task/board-task.ts +105 -0
- package/src/board/board-task/board-task.tsx +364 -0
- package/src/frontmatter/frontmatter.tsx +11 -2
- package/src/i18n/de.json +44 -3
- package/src/i18n/en.json +44 -3
- package/src/i18n/es.json +44 -3
- package/src/router/selection-search.ts +17 -2
- package/src/styles.css +29 -25
- package/src/user-name/user-name.ts +17 -0
|
@@ -1,51 +1,62 @@
|
|
|
1
1
|
import type { BoardTask } from "@anchrd/intel-contract/board";
|
|
2
|
+
import {
|
|
3
|
+
closestCorners,
|
|
4
|
+
DndContext,
|
|
5
|
+
type DragEndEvent,
|
|
6
|
+
DragOverlay,
|
|
7
|
+
MouseSensor,
|
|
8
|
+
TouchSensor,
|
|
9
|
+
useDroppable,
|
|
10
|
+
useSensor,
|
|
11
|
+
useSensors,
|
|
12
|
+
} from "@dnd-kit/core";
|
|
13
|
+
import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
|
14
|
+
import { CSS } from "@dnd-kit/utilities";
|
|
2
15
|
import { useState } from "react";
|
|
3
16
|
import type { BoardHandle, ColumnWithCards } from "@/board/board-data/board-data.types.ts";
|
|
17
|
+
import { type Family, familiesOf } from "@/board/board-stripes/board-stripes.ts";
|
|
4
18
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
19
|
+
import {
|
|
20
|
+
type Drop,
|
|
21
|
+
dragEndToDrop,
|
|
22
|
+
dropIsAfter,
|
|
23
|
+
dropToWrite,
|
|
24
|
+
neighbourDrop,
|
|
25
|
+
} from "./board-kanban.ts";
|
|
10
26
|
|
|
11
27
|
function Card({
|
|
12
28
|
card,
|
|
13
29
|
column,
|
|
14
|
-
row,
|
|
15
30
|
board,
|
|
16
31
|
onMove,
|
|
32
|
+
onOpen,
|
|
33
|
+
family,
|
|
34
|
+
parentColumn,
|
|
17
35
|
}: {
|
|
18
36
|
card: BoardTask;
|
|
19
37
|
column: ColumnWithCards;
|
|
20
|
-
row: number;
|
|
21
38
|
board: BoardHandle;
|
|
22
39
|
onMove(card: BoardTask, drop: Drop): void;
|
|
40
|
+
onOpen(taskId: string): void;
|
|
41
|
+
family: Family;
|
|
42
|
+
/** The title of the column the parent card sits in, or `undefined` when there is none here. */
|
|
43
|
+
parentColumn: string | undefined;
|
|
23
44
|
}) {
|
|
24
45
|
const i18n = useI18n();
|
|
25
46
|
const locked = board.isWriting;
|
|
47
|
+
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
|
48
|
+
id: card.id,
|
|
49
|
+
disabled: locked,
|
|
50
|
+
});
|
|
51
|
+
|
|
26
52
|
return (
|
|
27
53
|
<li
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (locked) return;
|
|
35
|
-
event.preventDefault();
|
|
36
|
-
// ⚠️ The column below is a drop target too, and it means "append". Letting this event reach
|
|
37
|
-
// it would overwrite the gap just measured with the end of the column — the bug this
|
|
38
|
-
// handler exists to fix, restored by the bubbling.
|
|
39
|
-
event.stopPropagation();
|
|
40
|
-
const dragged = cardById(board, event.dataTransfer.getData(CARRIED));
|
|
41
|
-
if (dragged === undefined) return;
|
|
42
|
-
const index = dropIndexOnCard(
|
|
43
|
-
row,
|
|
44
|
-
event.clientY,
|
|
45
|
-
event.currentTarget.getBoundingClientRect(),
|
|
46
|
-
);
|
|
47
|
-
onMove(dragged, { status: column.column.id, index });
|
|
48
|
-
}}
|
|
54
|
+
ref={setNodeRef}
|
|
55
|
+
style={{ transform: CSS.Translate.toString(transform), transition }}
|
|
56
|
+
// ⚠️ The card keeps its place in the list while it is carried, and only fades. Removing it
|
|
57
|
+
// would collapse the gap it came from, every card below would jump up by one, and the drop
|
|
58
|
+
// target the pointer is over would change underneath the gesture.
|
|
59
|
+
className={isDragging ? "opacity-40" : undefined}
|
|
49
60
|
>
|
|
50
61
|
{/* ⚠️ A real button, not an `<li role="button">`. The role override needs two lint
|
|
51
62
|
suppressions and still leaves a listitem that only *claims* to be operable; a button is
|
|
@@ -53,13 +64,20 @@ function Card({
|
|
|
53
64
|
— may perfectly well be a drag source. */}
|
|
54
65
|
<button
|
|
55
66
|
type="button"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
67
|
+
{...attributes}
|
|
68
|
+
{...listeners}
|
|
69
|
+
// ⚠️ After the spread, deliberately. `attributes` carries `aria-roledescription="sortable"`,
|
|
70
|
+
// which announces a gesture this board does not offer from the keyboard.
|
|
71
|
+
aria-roledescription={undefined}
|
|
72
|
+
// ⚠️ The name carries everything this card cannot say any other way: that it OPENS (#671),
|
|
73
|
+
// how it moves without a pointer, and how DEEP it sits (#366). The stripes on the right are
|
|
74
|
+
// `aria-hidden` — a button's children are presentational, so no label inside one is ever
|
|
75
|
+
// announced, and the depth would exist in colour alone.
|
|
76
|
+
aria-label={labelOf(i18n, card, column, family, parentColumn)}
|
|
77
|
+
onClick={() => onOpen(card.id)}
|
|
78
|
+
// `relative` and `overflow-hidden` are for the depth pattern: it is positioned against this
|
|
79
|
+
// card, and nothing of it may spill past the rounded edge.
|
|
80
|
+
className="relative w-full cursor-grab overflow-hidden rounded-md border border-border bg-card p-2 text-left text-sm shadow-xs focus-visible:outline-2 focus-visible:outline-ring"
|
|
63
81
|
onKeyDown={(event) => {
|
|
64
82
|
// ⚠️ Not while a write is in flight. Two moves in quick succession both read the same
|
|
65
83
|
// not-yet-updated `board.columns`, compute a position independently and both write — the
|
|
@@ -70,6 +88,12 @@ function Card({
|
|
|
70
88
|
// ⚠️ Only with a modifier. The arrows alone belong to whatever the reader is used to —
|
|
71
89
|
// scrolling the column, moving focus — and taking them would make a board that cannot be
|
|
72
90
|
// read without moving its cards.
|
|
91
|
+
//
|
|
92
|
+
// ⚠️ This is kept INSTEAD of `dnd-kit`'s own keyboard sensor. That sensor lifts a card
|
|
93
|
+
// with the space bar and then steers it by collision detection, which needs measured
|
|
94
|
+
// geometry — a board whose columns are off screen to the right cannot be reached without
|
|
95
|
+
// scrolling first, and jsdom measures nothing at all, so the whole path would leave the
|
|
96
|
+
// suite. Alt and an arrow names the neighbour directly and is proven without a layout.
|
|
73
97
|
if (!event.altKey) return;
|
|
74
98
|
const direction =
|
|
75
99
|
event.key === "ArrowLeft"
|
|
@@ -88,7 +112,17 @@ function Card({
|
|
|
88
112
|
onMove(card, drop);
|
|
89
113
|
}}
|
|
90
114
|
>
|
|
91
|
-
<
|
|
115
|
+
<Stripes family={family} label={depthOf(i18n, family, parentColumn)} />
|
|
116
|
+
{/* ⚠️ The room on the right follows the STRIPES, not the level. A fixed `pr-6` holds four
|
|
117
|
+
stripes and the title runs under the fifth; but reserving room for the real level once
|
|
118
|
+
the pattern caps takes width from the title for stripes that are not drawn — at level 20
|
|
119
|
+
that is 76 px of a 240 px card, given away for nothing. */}
|
|
120
|
+
<span
|
|
121
|
+
className="block"
|
|
122
|
+
style={{ paddingRight: `${8 + Math.min(family.level, MAX_STRIPES) * 5}px` }}
|
|
123
|
+
>
|
|
124
|
+
{card.title}
|
|
125
|
+
</span>
|
|
92
126
|
{card.dueDate === null ? null : (
|
|
93
127
|
<span className="mt-1 block text-xs text-muted-foreground">
|
|
94
128
|
{card.dueDate.slice(0, 10)}
|
|
@@ -99,6 +133,83 @@ function Card({
|
|
|
99
133
|
);
|
|
100
134
|
}
|
|
101
135
|
|
|
136
|
+
/**
|
|
137
|
+
* The depth pattern on the right edge of a card.
|
|
138
|
+
*
|
|
139
|
+
* ⚠️ **Right, where no text stands.** Jack's decision 2026-08-08: an indent costs width exactly
|
|
140
|
+
* where the title is, and by the third level the card is a strip. The stripes cost nothing a reader
|
|
141
|
+
* was using.
|
|
142
|
+
*
|
|
143
|
+
* ⚠️ **`aria-hidden`, and the words live on the BUTTON.** A `role="img"` with a label inside a
|
|
144
|
+
* `<button>` reaches nobody: a button's children are presentational in ARIA, and the button already
|
|
145
|
+
* carries a name of its own, which wins. The mark was drawn, the test was green, and the sentence
|
|
146
|
+
* existed for no one — see `labelOf` and `packages/ui/CLAUDE.md`.
|
|
147
|
+
*/
|
|
148
|
+
function Stripes({ family, label }: { family: Family; label: string }) {
|
|
149
|
+
if (family.level === 0) return null;
|
|
150
|
+
return (
|
|
151
|
+
<span
|
|
152
|
+
aria-hidden="true"
|
|
153
|
+
// ⚠️ A tooltip BESIDE the accessible name, not instead of it: the name serves whoever cannot
|
|
154
|
+
// see the stripes, the tooltip whoever sees them and wonders what they mean.
|
|
155
|
+
//
|
|
156
|
+
// ⚠️ And therefore NOT `pointer-events-none`. A native `title` hangs on hover, and an element
|
|
157
|
+
// that is not a hit target never gets one — the attribute would sit there, the test would find
|
|
158
|
+
// it, and the tooltip would never appear once. The class is not needed either: this span lies
|
|
159
|
+
// inside the button, so a click reaches it anyway.
|
|
160
|
+
title={label}
|
|
161
|
+
className="absolute inset-y-1 right-1 flex gap-[2px]"
|
|
162
|
+
>
|
|
163
|
+
{levels(family.level).map((level) => (
|
|
164
|
+
<span
|
|
165
|
+
key={`${family.rootId}-${level}`}
|
|
166
|
+
// ⚠️ Each level a fainter step of the SAME tone, not a different tone. The hue answers
|
|
167
|
+
// "which family", the count answers "how deep" — one channel per question.
|
|
168
|
+
//
|
|
169
|
+
// ⚠️ Clamped, and the clamp survives its own reason. It was added because an unclamped
|
|
170
|
+
// ramp went negative — CSS pins that to zero and the deepest stripes are simply not drawn.
|
|
171
|
+
// Since the pattern caps at six the lowest raw value is 0.25, so the clamp no longer
|
|
172
|
+
// fires; it stays because it is the guard, not the arithmetic, that must hold if the cap
|
|
173
|
+
// ever moves.
|
|
174
|
+
className={`w-[3px] rounded-full ${toneClasses[family.tone - 1]}`}
|
|
175
|
+
style={{ opacity: Math.max(0.3, 1 - (level - 1) * 0.15) }}
|
|
176
|
+
/>
|
|
177
|
+
))}
|
|
178
|
+
</span>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* ⚠️ **The pattern caps at six, the NAME does not.** Seven stripes are twenty pixels, and nobody
|
|
184
|
+
* counts them — past that the mark stops being a count and becomes texture. The accessible name
|
|
185
|
+
* keeps saying the real level, so nothing is lost, only unread pixels.
|
|
186
|
+
*/
|
|
187
|
+
const MAX_STRIPES = 6;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* `[1, 2, … min(depth, MAX_STRIPES)]` — the level each stripe stands for.
|
|
191
|
+
*
|
|
192
|
+
* ⚠️ The level, not the index. Both are the same number here, and the rule against index keys is
|
|
193
|
+
* about lists whose order can change; this one cannot, because stripe two is always level two.
|
|
194
|
+
*/
|
|
195
|
+
function levels(depth: number): number[] {
|
|
196
|
+
return Array.from({ length: Math.min(depth, MAX_STRIPES) }, (_, step) => step + 1);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* ⚠️ Written out rather than built from a template string. Tailwind reads class names statically,
|
|
201
|
+
* and `bg-board-family-${n}` is a name it never sees — the stripes would be invisible in the build
|
|
202
|
+
* and perfectly fine in the tests.
|
|
203
|
+
*/
|
|
204
|
+
const toneClasses = [
|
|
205
|
+
"bg-board-family-1",
|
|
206
|
+
"bg-board-family-2",
|
|
207
|
+
"bg-board-family-3",
|
|
208
|
+
"bg-board-family-4",
|
|
209
|
+
"bg-board-family-5",
|
|
210
|
+
"bg-board-family-6",
|
|
211
|
+
] as const;
|
|
212
|
+
|
|
102
213
|
function cardById(board: BoardHandle, id: string): BoardTask | undefined {
|
|
103
214
|
return board.columns.flatMap((entry) => entry.cards).find((card) => card.id === id);
|
|
104
215
|
}
|
|
@@ -117,15 +228,28 @@ function Column({
|
|
|
117
228
|
entry,
|
|
118
229
|
board,
|
|
119
230
|
onMove,
|
|
231
|
+
onOpen,
|
|
232
|
+
families,
|
|
233
|
+
columnOf,
|
|
120
234
|
}: {
|
|
121
235
|
entry: ColumnWithCards;
|
|
122
236
|
board: BoardHandle;
|
|
123
237
|
onMove(card: BoardTask, drop: Drop): void;
|
|
238
|
+
onOpen(taskId: string): void;
|
|
239
|
+
families: Map<string, Family>;
|
|
240
|
+
columnOf: Map<string, string>;
|
|
124
241
|
}) {
|
|
125
242
|
const i18n = useI18n();
|
|
126
243
|
const [composing, setComposing] = useState(false);
|
|
127
244
|
const [draft, setDraft] = useState("");
|
|
128
245
|
const [failed, setFailed] = useState(false);
|
|
246
|
+
// ⚠️ The column is a drop target in its OWN right, and it means "append": the free area below
|
|
247
|
+
// the last card. Without it a board could never take a card into an empty column — there would be
|
|
248
|
+
// no card there to let go on.
|
|
249
|
+
const { setNodeRef: setDropRef } = useDroppable({
|
|
250
|
+
id: entry.column.id,
|
|
251
|
+
disabled: board.isWriting,
|
|
252
|
+
});
|
|
129
253
|
// ⚠️ A column the board does not configure takes no new card: the server refuses a status that
|
|
130
254
|
// names no column (`unknown_column`), so offering the control would promise a refusal.
|
|
131
255
|
const addable = entry.unknown !== true;
|
|
@@ -155,6 +279,9 @@ function Column({
|
|
|
155
279
|
startDate: null,
|
|
156
280
|
dueDate: null,
|
|
157
281
|
dependsOn: null,
|
|
282
|
+
// A card made in a column sits on the board itself. Making a SUBTASK is the same call with
|
|
283
|
+
// the parent named — that is the detail view's business (#671), not the column's.
|
|
284
|
+
parentTaskId: null,
|
|
158
285
|
idempotencyKey: crypto.randomUUID(),
|
|
159
286
|
})
|
|
160
287
|
.then(close)
|
|
@@ -165,24 +292,9 @@ function Column({
|
|
|
165
292
|
|
|
166
293
|
return (
|
|
167
294
|
<section
|
|
295
|
+
ref={setDropRef}
|
|
168
296
|
aria-label={entry.column.title}
|
|
169
297
|
className="group/column flex w-64 shrink-0 flex-col gap-2"
|
|
170
|
-
onDragOver={(event) => {
|
|
171
|
-
if (board.isWriting) return;
|
|
172
|
-
event.preventDefault();
|
|
173
|
-
event.dataTransfer.dropEffect = "move";
|
|
174
|
-
}}
|
|
175
|
-
onDrop={(event) => {
|
|
176
|
-
if (board.isWriting) return;
|
|
177
|
-
event.preventDefault();
|
|
178
|
-
const dragged = cardById(board, event.dataTransfer.getData(CARRIED));
|
|
179
|
-
// ⚠️ Let go on the column itself rather than on one of its cards: the end of it. A card
|
|
180
|
-
// drop stops the event before it arrives here, so this is the free area below the last
|
|
181
|
-
// card — and there, "append" is what the gesture means.
|
|
182
|
-
if (dragged !== undefined) {
|
|
183
|
-
onMove(dragged, { status: entry.column.id, index: entry.cards.length });
|
|
184
|
-
}
|
|
185
|
-
}}
|
|
186
298
|
>
|
|
187
299
|
<h3 className="flex items-baseline gap-2 px-1 text-sm font-medium">
|
|
188
300
|
{entry.column.title}
|
|
@@ -206,11 +318,27 @@ function Column({
|
|
|
206
318
|
</button>
|
|
207
319
|
) : null}
|
|
208
320
|
</h3>
|
|
209
|
-
<
|
|
210
|
-
{entry.cards.map((card
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
321
|
+
<SortableContext
|
|
322
|
+
items={entry.cards.map((card) => card.id)}
|
|
323
|
+
strategy={verticalListSortingStrategy}
|
|
324
|
+
>
|
|
325
|
+
<ul className="flex flex-col gap-2">
|
|
326
|
+
{entry.cards.map((card) => (
|
|
327
|
+
<Card
|
|
328
|
+
key={card.id}
|
|
329
|
+
card={card}
|
|
330
|
+
column={entry}
|
|
331
|
+
board={board}
|
|
332
|
+
onMove={onMove}
|
|
333
|
+
onOpen={onOpen}
|
|
334
|
+
family={families.get(card.id) ?? noFamily}
|
|
335
|
+
parentColumn={
|
|
336
|
+
card.parentTaskId === null ? undefined : columnOf.get(card.parentTaskId)
|
|
337
|
+
}
|
|
338
|
+
/>
|
|
339
|
+
))}
|
|
340
|
+
</ul>
|
|
341
|
+
</SortableContext>
|
|
214
342
|
{entry.cards.length === 0 ? (
|
|
215
343
|
<p className="px-1 text-xs text-muted-foreground">{i18n.t("board.columnEmpty")}</p>
|
|
216
344
|
) : null}
|
|
@@ -258,8 +386,26 @@ function Column({
|
|
|
258
386
|
);
|
|
259
387
|
}
|
|
260
388
|
|
|
261
|
-
export function BoardKanban({
|
|
389
|
+
export function BoardKanban({
|
|
390
|
+
board,
|
|
391
|
+
onOpen,
|
|
392
|
+
}: {
|
|
393
|
+
board: BoardHandle;
|
|
394
|
+
onOpen(taskId: string): void;
|
|
395
|
+
}) {
|
|
262
396
|
const i18n = useI18n();
|
|
397
|
+
// The card under the pointer, for the preview. Nothing else about the gesture is state.
|
|
398
|
+
const [carried, setCarried] = useState<string | null>(null);
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* ⚠️ **A distance before the drag starts, or the card stops being a button.** Without it every
|
|
402
|
+
* press is a gesture: `dnd-kit` swallows the click that follows, and opening a card by clicking
|
|
403
|
+
* it — the thing the card is mostly for — silently stops working.
|
|
404
|
+
*/
|
|
405
|
+
const sensors = useSensors(
|
|
406
|
+
useSensor(MouseSensor, { activationConstraint: { distance: 4 } }),
|
|
407
|
+
useSensor(TouchSensor, { activationConstraint: { delay: 200, tolerance: 6 } }),
|
|
408
|
+
);
|
|
263
409
|
|
|
264
410
|
if (board.isPending) {
|
|
265
411
|
return <p className="p-6 text-sm text-muted-foreground">{i18n.t("common.loading")}</p>;
|
|
@@ -285,11 +431,173 @@ export function BoardKanban({ board }: { board: BoardHandle }) {
|
|
|
285
431
|
});
|
|
286
432
|
};
|
|
287
433
|
|
|
434
|
+
/**
|
|
435
|
+
* What a finished gesture means as a write, or `null` for "nothing changed".
|
|
436
|
+
*
|
|
437
|
+
* ⚠️ Shared by the handler and the announcement on purpose. Computed twice they would drift, and
|
|
438
|
+
* the drift would be invisible: the screen reader would report a move nobody made, or stay quiet
|
|
439
|
+
* about one that happened.
|
|
440
|
+
*
|
|
441
|
+
* ⚠️ **The lock belongs HERE, not in the caller.** With it one level up, a write starting
|
|
442
|
+
* underneath a running gesture stopped the handler and left the announcement saying "moved to
|
|
443
|
+
* Offen" over zero writes — the drift the paragraph above claims to rule out, restored by the
|
|
444
|
+
* one line that was not shared.
|
|
445
|
+
*/
|
|
446
|
+
const resolve = (event: Pick<DragEndEvent, "active" | "over">) => {
|
|
447
|
+
if (board.isWriting) return null;
|
|
448
|
+
const dragged = cardById(board, String(event.active.id));
|
|
449
|
+
if (dragged === undefined) return null;
|
|
450
|
+
const drop = dragEndToDrop(
|
|
451
|
+
String(event.active.id),
|
|
452
|
+
event.over === null ? null : String(event.over.id),
|
|
453
|
+
board.columns,
|
|
454
|
+
dropIsAfter(event.active.rect.current.translated, event.over?.rect),
|
|
455
|
+
);
|
|
456
|
+
return drop === null ? null : dropToWrite(dragged, board.columns, drop);
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* ⚠️ **ONE derivation for the whole board, not one per card** — and that has to include the
|
|
461
|
+
* lookup the LABEL needs, not only the families. `dnd-kit` re-renders on every pointer move while
|
|
462
|
+
* a card is carried, so anything per-card that scans the board is quadratic: measured at 800
|
|
463
|
+
* cards it was 36 ms per render before, and a `find` left in the label put the same shape back
|
|
464
|
+
* at 5 ms per 1600 cards.
|
|
465
|
+
*/
|
|
466
|
+
const families = familiesOf(board.columns.flatMap((entry) => entry.cards));
|
|
467
|
+
const columnOf = new Map(
|
|
468
|
+
board.columns.flatMap((entry) => entry.cards.map((card) => [card.id, entry.column.title])),
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
const carriedCard = carried === null ? undefined : cardById(board, carried);
|
|
472
|
+
|
|
288
473
|
return (
|
|
289
|
-
<
|
|
290
|
-
{
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
474
|
+
<DndContext
|
|
475
|
+
sensors={sensors}
|
|
476
|
+
// ⚠️ Corners, not centres. A card is far taller than the gap between two cards, so the closest
|
|
477
|
+
// CENTRE stays the same card across most of a column and the gesture feels stuck; the closest
|
|
478
|
+
// corner changes at the boundary the reader is aiming at.
|
|
479
|
+
collisionDetection={closestCorners}
|
|
480
|
+
// ⚠️ `dnd-kit`'s own wording announces the space bar, and no keyboard sensor is registered —
|
|
481
|
+
// pressing it does nothing. Announcing a gesture that is not there is worse than announcing
|
|
482
|
+
// none: the reader tries it, nothing happens, and the one that works is never mentioned.
|
|
483
|
+
accessibility={{
|
|
484
|
+
screenReaderInstructions: { draggable: i18n.t("board.dragInstructions") },
|
|
485
|
+
// ⚠️ `dnd-kit`'s own announcements read out the raw ids — "Draggable item a was moved over
|
|
486
|
+
// droppable area a" — and report a drop even when nothing was written. Both are replaced:
|
|
487
|
+
// the card is named by its TITLE, the move-over is silent because there is nothing about a
|
|
488
|
+
// position this code could say honestly, and the end says what actually happened.
|
|
489
|
+
announcements: {
|
|
490
|
+
onDragStart: ({ active }) => {
|
|
491
|
+
const card = cardById(board, String(active.id));
|
|
492
|
+
return card === undefined
|
|
493
|
+
? undefined
|
|
494
|
+
: i18n.t("board.drag.lifted", { card: card.title });
|
|
495
|
+
},
|
|
496
|
+
onDragOver: () => undefined,
|
|
497
|
+
onDragMove: () => undefined,
|
|
498
|
+
onDragEnd: (event) => {
|
|
499
|
+
const write = resolve(event);
|
|
500
|
+
const card = cardById(board, String(event.active.id));
|
|
501
|
+
// ⚠️ Silence, not a sentence without a subject. A card that vanished mid-gesture — an
|
|
502
|
+
// answer arrived, somebody else archived it — has no name left to announce, and
|
|
503
|
+
// " put back" read out on its own says less than nothing.
|
|
504
|
+
if (card === undefined) return undefined;
|
|
505
|
+
if (write === null) return i18n.t("board.drag.putBack", { card: card.title });
|
|
506
|
+
return i18n.t("board.drag.moved", {
|
|
507
|
+
card: card.title,
|
|
508
|
+
column:
|
|
509
|
+
board.columns.find((entry) => entry.column.id === write.status)?.column.title ??
|
|
510
|
+
write.status,
|
|
511
|
+
});
|
|
512
|
+
},
|
|
513
|
+
onDragCancel: ({ active }) => {
|
|
514
|
+
const card = cardById(board, String(active.id));
|
|
515
|
+
return card === undefined
|
|
516
|
+
? undefined
|
|
517
|
+
: i18n.t("board.drag.putBack", { card: card.title });
|
|
518
|
+
},
|
|
519
|
+
},
|
|
520
|
+
}}
|
|
521
|
+
onDragStart={(event) => setCarried(String(event.active.id))}
|
|
522
|
+
onDragCancel={() => setCarried(null)}
|
|
523
|
+
onDragEnd={(event) => {
|
|
524
|
+
setCarried(null);
|
|
525
|
+
// ⚠️ The SECOND lock lives inside `resolve`, so the announcement obeys it too — see there.
|
|
526
|
+
const write = resolve(event);
|
|
527
|
+
if (write === null) return;
|
|
528
|
+
void board.updateTask({
|
|
529
|
+
taskId: String(event.active.id),
|
|
530
|
+
status: write.status,
|
|
531
|
+
position: write.position,
|
|
532
|
+
idempotencyKey: `move-${event.active.id}-${write.status}-${write.position}`,
|
|
533
|
+
});
|
|
534
|
+
}}
|
|
535
|
+
>
|
|
536
|
+
<div className="flex gap-3 overflow-x-auto p-4" aria-busy={board.isWriting}>
|
|
537
|
+
{board.columns.map((column) => (
|
|
538
|
+
<Column
|
|
539
|
+
key={column.column.id}
|
|
540
|
+
entry={column}
|
|
541
|
+
board={board}
|
|
542
|
+
onMove={move}
|
|
543
|
+
onOpen={onOpen}
|
|
544
|
+
families={families}
|
|
545
|
+
columnOf={columnOf}
|
|
546
|
+
/>
|
|
547
|
+
))}
|
|
548
|
+
</div>
|
|
549
|
+
{/* ⚠️ The preview is what a drag looks like. Without it the card stays put and only the
|
|
550
|
+
cursor moves, and there is nothing on screen saying the gesture was picked up at all. */}
|
|
551
|
+
<DragOverlay>
|
|
552
|
+
{carriedCard === undefined ? null : (
|
|
553
|
+
<div className="w-64 rounded-md border border-border bg-card p-2 text-left text-sm shadow-lg">
|
|
554
|
+
{carriedCard.title}
|
|
555
|
+
</div>
|
|
556
|
+
)}
|
|
557
|
+
</DragOverlay>
|
|
558
|
+
</DndContext>
|
|
294
559
|
);
|
|
295
560
|
}
|
|
561
|
+
|
|
562
|
+
/** A card that belongs to no family — the answer when the board has not been read yet. */
|
|
563
|
+
const noFamily: Family = { level: 0, tone: 0, rootId: null };
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* What the card's button is CALLED.
|
|
567
|
+
*
|
|
568
|
+
* ⚠️ **This is where the depth becomes readable, and the only place it can be.** The stripes are
|
|
569
|
+
* `aria-hidden` because a button's children are presentational: a `role="img"` with a label inside
|
|
570
|
+
* one is never announced, and the button's own name wins in any case. The board already spends a
|
|
571
|
+
* colour axis on the status, so the family may not be a second one — a reader who does not see the
|
|
572
|
+
* difference has to be able to read it.
|
|
573
|
+
*/
|
|
574
|
+
function labelOf(
|
|
575
|
+
i18n: ReturnType<typeof useI18n>,
|
|
576
|
+
card: BoardTask,
|
|
577
|
+
column: ColumnWithCards,
|
|
578
|
+
family: Family,
|
|
579
|
+
parentColumn: string | undefined,
|
|
580
|
+
): string {
|
|
581
|
+
const name = i18n.t("board.cardLabel", { title: card.title, column: column.column.title });
|
|
582
|
+
if (family.level === 0) return name;
|
|
583
|
+
// ⚠️ Handed IN, not looked up. A `find` over the board here runs once per card on every render,
|
|
584
|
+
// and puts back the quadratic shape the one derivation above exists to remove.
|
|
585
|
+
return `${name} ${depthOf(i18n, family, parentColumn)}`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* What the depth pattern says, in words.
|
|
590
|
+
*
|
|
591
|
+
* ⚠️ Always the REAL level, even where the stripes have capped. The mark stops counting at six; the
|
|
592
|
+
* sentence does not, because it is the channel somebody relies on when they cannot count stripes.
|
|
593
|
+
*/
|
|
594
|
+
function depthOf(
|
|
595
|
+
i18n: ReturnType<typeof useI18n>,
|
|
596
|
+
family: Family,
|
|
597
|
+
parentColumn: string | undefined,
|
|
598
|
+
): string {
|
|
599
|
+
if (family.level === 0) return "";
|
|
600
|
+
return parentColumn === undefined
|
|
601
|
+
? i18n.t("board.stripes.root", { level: family.level })
|
|
602
|
+
: i18n.t("board.stripes.under", { level: family.level, column: parentColumn });
|
|
603
|
+
}
|