@anchrd/intel-ui 0.41.0 → 0.42.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 +1 -1
- package/src/attachment-viewer/attachment-viewer.tsx +6 -5
- package/src/board/board-assignee/board-assignee.tsx +45 -0
- package/src/board/board-chip/board-chip.tsx +31 -0
- package/src/board/board-status/board-status.tsx +60 -0
- package/src/board/board-table/board-table.tsx +207 -101
- package/src/board/board-task/board-task.tsx +22 -105
- package/src/file-preview/file-preview-view.tsx +326 -12
- package/src/file-preview/pdf-file-preview.tsx +1 -1
- package/src/i18n/de.json +25 -2
- package/src/i18n/en.json +25 -2
- package/src/i18n/es.json +25 -2
package/package.json
CHANGED
|
@@ -105,6 +105,7 @@ export function AttachmentViewer({ document }: { document: NodeDocument }) {
|
|
|
105
105
|
const kind = document.version
|
|
106
106
|
? attachmentPreviewKind(document.node.title, document.version.mediaType)
|
|
107
107
|
: "unsupported";
|
|
108
|
+
const hasViewerControls = kind !== "image" && kind !== "unsupported";
|
|
108
109
|
|
|
109
110
|
return (
|
|
110
111
|
<div className="flex min-h-0 flex-1 flex-col px-6 pb-6">
|
|
@@ -146,15 +147,15 @@ export function AttachmentViewer({ document }: { document: NodeDocument }) {
|
|
|
146
147
|
<AttachmentDetails
|
|
147
148
|
document={document}
|
|
148
149
|
file={file}
|
|
149
|
-
footerInset={
|
|
150
|
+
footerInset={hasViewerControls}
|
|
150
151
|
kind={kind}
|
|
151
152
|
close={() => setDetailsOpen(false)}
|
|
152
153
|
/>
|
|
153
154
|
) : null}
|
|
154
|
-
{detailsOpen &&
|
|
155
|
+
{detailsOpen && hasViewerControls ? (
|
|
155
156
|
<div
|
|
156
157
|
aria-hidden="true"
|
|
157
|
-
className="pointer-events-none absolute inset-x-0 bottom-
|
|
158
|
+
className="pointer-events-none absolute inset-x-0 bottom-14 z-10 hidden border-t md:block"
|
|
158
159
|
/>
|
|
159
160
|
) : null}
|
|
160
161
|
</div>
|
|
@@ -258,8 +259,8 @@ function AttachmentDetails({
|
|
|
258
259
|
aria-label={i18n.t("attachment.details.title")}
|
|
259
260
|
className={cn(
|
|
260
261
|
"absolute inset-x-0 bottom-0 z-10 max-h-[80%] overflow-auto border-t bg-background shadow-lg md:static md:z-auto md:w-80 md:max-h-none md:shrink-0 md:border-l md:border-t-0 md:shadow-none",
|
|
261
|
-
//
|
|
262
|
-
footerInset && "md:mb-
|
|
262
|
+
// File renderers keep their controls in a shared footer; the panel ends beside that footer.
|
|
263
|
+
footerInset && "md:mb-14",
|
|
263
264
|
)}
|
|
264
265
|
>
|
|
265
266
|
<div className="flex items-center justify-between border-b px-5 py-4">
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
2
|
+
import { initials } from "@/user-name/user-name.ts";
|
|
3
|
+
import { useAssigneeLabel } from "./board-assignee.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The circle for whoever a card is for, and the way to take the assignment off.
|
|
7
|
+
*
|
|
8
|
+
* ⚠️ **One truth for two surfaces.** The opened card and the table both draw it; written twice they
|
|
9
|
+
* would drift, and the drift would be an accessibility bug on exactly one of them.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ **One person, not a group.** Jack's decision 2026-08-20: the STYLE is borrowed from the access
|
|
12
|
+
* summary, the field stays `assigneeId`. A row of circles would imply a second data model.
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ **Only ever drawn for somebody.** "Nobody yet" as a chip is a placeholder for an absence, and
|
|
15
|
+
* an absence needs no place on screen (#692).
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ **The NAME lives on the button, and the circle is hidden.** Children of a `<button>` are
|
|
18
|
+
* presentational in ARIA, so a label inside one is never announced and the button's own name wins.
|
|
19
|
+
* Wrapping the circle in a control therefore takes the person out of the accessibility tree unless
|
|
20
|
+
* the control says it too. That is the trap in `packages/ui/CLAUDE.md`.
|
|
21
|
+
*/
|
|
22
|
+
export function AssigneeChip({ id, clear }: { id: string; clear(): void }) {
|
|
23
|
+
const i18n = useI18n();
|
|
24
|
+
const label = useAssigneeLabel(id);
|
|
25
|
+
return (
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
aria-label={i18n.t("board.clearAssignee", { name: label })}
|
|
29
|
+
title={label}
|
|
30
|
+
onClick={clear}
|
|
31
|
+
className="shrink-0 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
32
|
+
>
|
|
33
|
+
<span
|
|
34
|
+
// ⚠️ **Hidden, and it must stay hidden.** This span used to carry `role="img"` with the
|
|
35
|
+
// person's name, and that reached nobody: it sits inside a button. Giving it a role and a
|
|
36
|
+
// label back would not add a second announcement, it would add none, while making the code
|
|
37
|
+
// look as though the name were covered here rather than on the button.
|
|
38
|
+
aria-hidden="true"
|
|
39
|
+
className="flex size-7 items-center justify-center rounded-full bg-accent text-xs font-medium text-accent-foreground ring-2 ring-background"
|
|
40
|
+
>
|
|
41
|
+
{initials(label)}
|
|
42
|
+
</span>
|
|
43
|
+
</button>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A value on a card, and the way to take it off again.
|
|
3
|
+
*
|
|
4
|
+
* ⚠️ **One truth for two surfaces.** The opened card and the table both draw these; written twice
|
|
5
|
+
* they drift in size, in shape and — the expensive one — in what they are called.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ **The name says the value AND what pressing it does.** A button called `Zebra` that quietly
|
|
8
|
+
* clears a dependency is one nobody can predict; a name that drops the value is the other half of
|
|
9
|
+
* the mistake, because somebody speaking the visible words has to be able to reach the control by
|
|
10
|
+
* them (WCAG 2.5.3). Value first, then the consequence.
|
|
11
|
+
*/
|
|
12
|
+
export function BoardChip({
|
|
13
|
+
label,
|
|
14
|
+
action,
|
|
15
|
+
onClick,
|
|
16
|
+
}: {
|
|
17
|
+
label: string;
|
|
18
|
+
action: string;
|
|
19
|
+
onClick(): void;
|
|
20
|
+
}) {
|
|
21
|
+
return (
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
aria-label={`${label}, ${action}`}
|
|
25
|
+
onClick={onClick}
|
|
26
|
+
className="shrink-0 rounded-full border px-2.5 py-0.5 text-xs tabular-nums outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
27
|
+
>
|
|
28
|
+
{label}
|
|
29
|
+
</button>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { BoardColumn } from "@anchrd/intel-contract/board";
|
|
2
|
+
import { ARCHIVE_COLUMN_ID } from "@anchrd/intel-contract/board";
|
|
3
|
+
import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
|
|
4
|
+
import {
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
DropdownMenuContent,
|
|
7
|
+
DropdownMenuRadioGroup,
|
|
8
|
+
DropdownMenuRadioItem,
|
|
9
|
+
DropdownMenuTrigger,
|
|
10
|
+
} from "@/components/ui/dropdown-menu.tsx";
|
|
11
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The column a card sits in, and the way to move it.
|
|
15
|
+
*
|
|
16
|
+
* ⚠️ **One truth for two surfaces.** The opened card and the table both offer this, and the archive
|
|
17
|
+
* is an entry in it: written twice, one of them would eventually keep an entry the other dropped,
|
|
18
|
+
* and a reader would find a card archivable from one screen and not from the other. Exactly the
|
|
19
|
+
* reason `AssigneeChip` was pulled out beside it.
|
|
20
|
+
*/
|
|
21
|
+
export function StatusChip({
|
|
22
|
+
status,
|
|
23
|
+
title,
|
|
24
|
+
columns,
|
|
25
|
+
write,
|
|
26
|
+
}: {
|
|
27
|
+
status: string;
|
|
28
|
+
title: string;
|
|
29
|
+
columns: BoardColumn[];
|
|
30
|
+
write(input: Partial<Omit<Parameters<BoardHandle["updateTask"]>[0], "taskId">>): void;
|
|
31
|
+
}) {
|
|
32
|
+
const i18n = useI18n();
|
|
33
|
+
return (
|
|
34
|
+
<DropdownMenu>
|
|
35
|
+
<DropdownMenuTrigger
|
|
36
|
+
// ⚠️ The VALUE first, then what pressing it does. A label that says only "move to a column"
|
|
37
|
+
// replaces the one thing the pill is there to show, and that fact then exists on screen and
|
|
38
|
+
// nowhere else.
|
|
39
|
+
aria-label={`${title}, ${i18n.t("board.moveTo")}`}
|
|
40
|
+
className="shrink-0 rounded-full border px-2.5 py-0.5 text-xs outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
41
|
+
>
|
|
42
|
+
{title}
|
|
43
|
+
</DropdownMenuTrigger>
|
|
44
|
+
<DropdownMenuContent align="start">
|
|
45
|
+
{/* ⚠️ Where a card SITS is a move, not a field edit: the server computes a position and
|
|
46
|
+
checks the cycles for it. Everything else on these screens is a plain change. */}
|
|
47
|
+
<DropdownMenuRadioGroup value={status} onValueChange={(next) => write({ status: next })}>
|
|
48
|
+
{columns.map((entry) => (
|
|
49
|
+
<DropdownMenuRadioItem key={entry.id} value={entry.id}>
|
|
50
|
+
{entry.title}
|
|
51
|
+
</DropdownMenuRadioItem>
|
|
52
|
+
))}
|
|
53
|
+
<DropdownMenuRadioItem value={ARCHIVE_COLUMN_ID}>
|
|
54
|
+
{i18n.t("board.column.archive")}
|
|
55
|
+
</DropdownMenuRadioItem>
|
|
56
|
+
</DropdownMenuRadioGroup>
|
|
57
|
+
</DropdownMenuContent>
|
|
58
|
+
</DropdownMenu>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
createGroupedRowModel,
|
|
9
9
|
createSortedRowModel,
|
|
10
10
|
type ExpandedState,
|
|
11
|
-
flexRender,
|
|
12
11
|
type GroupingState,
|
|
13
12
|
type Row as LibRow,
|
|
14
13
|
rowExpandingFeature,
|
|
@@ -19,11 +18,14 @@ import {
|
|
|
19
18
|
tableFeatures,
|
|
20
19
|
useTable,
|
|
21
20
|
} from "@tanstack/react-table";
|
|
22
|
-
import { ChevronDown, ChevronRight, Columns3, Filter, LayoutList } from "lucide-react";
|
|
21
|
+
import { ArrowUpDown, ChevronDown, ChevronRight, Columns3, Filter, LayoutList } from "lucide-react";
|
|
23
22
|
import { useMemo, useState } from "react";
|
|
24
23
|
import { useAssigneeLabel } from "@/board/board-assignee/board-assignee.ts";
|
|
24
|
+
import { AssigneeChip } from "@/board/board-assignee/board-assignee.tsx";
|
|
25
|
+
import { BoardChip } from "@/board/board-chip/board-chip.tsx";
|
|
25
26
|
import { isFiltering } from "@/board/board-data/board-data.ts";
|
|
26
27
|
import type { BoardHandle } from "@/board/board-data/board-data.types.ts";
|
|
28
|
+
import { StatusChip } from "@/board/board-status/board-status.tsx";
|
|
27
29
|
import {
|
|
28
30
|
DropdownMenu,
|
|
29
31
|
DropdownMenuCheckboxItem,
|
|
@@ -112,45 +114,13 @@ export function BoardTable({
|
|
|
112
114
|
helper.accessor((row) => row.task.title, {
|
|
113
115
|
id: "title",
|
|
114
116
|
header: i18n.t("board.column.title"),
|
|
117
|
+
// ⚠️ Not hideable. It IS the row: an entry in the chooser that changes nothing when
|
|
118
|
+
// pressed is a control that lies about what it does.
|
|
119
|
+
enableHiding: false,
|
|
115
120
|
sortFn: "alphanumeric",
|
|
116
121
|
// Grouping by the title would make one group per row, which is the table again with an
|
|
117
122
|
// extra line above every entry.
|
|
118
123
|
enableGrouping: false,
|
|
119
|
-
cell: ({ row, getValue }) => (
|
|
120
|
-
<span
|
|
121
|
-
className="flex items-center gap-1"
|
|
122
|
-
// The depth is the indent, and it is the only thing that says what hangs under what.
|
|
123
|
-
style={{ paddingLeft: `${row.depth * 20}px` }}
|
|
124
|
-
>
|
|
125
|
-
{row.getCanExpand() ? (
|
|
126
|
-
<button
|
|
127
|
-
type="button"
|
|
128
|
-
aria-label={
|
|
129
|
-
row.getIsExpanded()
|
|
130
|
-
? i18n.t("board.table.collapse", { row: String(getValue()) })
|
|
131
|
-
: i18n.t("board.table.expand", { row: String(getValue()) })
|
|
132
|
-
}
|
|
133
|
-
onClick={row.getToggleExpandedHandler()}
|
|
134
|
-
className="rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
135
|
-
>
|
|
136
|
-
{row.getIsExpanded() ? (
|
|
137
|
-
<ChevronDown aria-hidden="true" className="size-4 text-muted-foreground" />
|
|
138
|
-
) : (
|
|
139
|
-
<ChevronRight aria-hidden="true" className="size-4 text-muted-foreground" />
|
|
140
|
-
)}
|
|
141
|
-
</button>
|
|
142
|
-
) : (
|
|
143
|
-
<span className="size-4" />
|
|
144
|
-
)}
|
|
145
|
-
<button
|
|
146
|
-
type="button"
|
|
147
|
-
onClick={() => onOpen(row.original.task.id)}
|
|
148
|
-
className="truncate rounded-sm text-left outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
|
149
|
-
>
|
|
150
|
-
{String(getValue())}
|
|
151
|
-
</button>
|
|
152
|
-
</span>
|
|
153
|
-
),
|
|
154
124
|
}),
|
|
155
125
|
helper.accessor((row) => row.columnTitle, {
|
|
156
126
|
id: "status",
|
|
@@ -164,7 +134,14 @@ export function BoardTable({
|
|
|
164
134
|
sortUndefined: "last",
|
|
165
135
|
// ⚠️ Grouped by the RAW id, drawn as a name. Grouping by what is drawn would put everybody
|
|
166
136
|
// this browser cannot name into one group called "a user account".
|
|
167
|
-
|
|
137
|
+
}),
|
|
138
|
+
helper.accessor((row) => orNothing(row.task.startDate), {
|
|
139
|
+
id: "start",
|
|
140
|
+
header: i18n.t("board.column.start"),
|
|
141
|
+
// An ISO date sorts correctly as a string, so `basic` is the whole of the comparison.
|
|
142
|
+
sortFn: "basic",
|
|
143
|
+
sortUndefined: "last",
|
|
144
|
+
enableGrouping: false,
|
|
168
145
|
}),
|
|
169
146
|
helper.accessor((row) => orNothing(row.task.dueDate), {
|
|
170
147
|
id: "due",
|
|
@@ -173,9 +150,6 @@ export function BoardTable({
|
|
|
173
150
|
sortFn: "basic",
|
|
174
151
|
sortUndefined: "last",
|
|
175
152
|
enableGrouping: false,
|
|
176
|
-
cell: ({ getValue }) => (
|
|
177
|
-
<span className="tabular-nums">{getValue()?.slice(0, 10) ?? ""}</span>
|
|
178
|
-
),
|
|
179
153
|
}),
|
|
180
154
|
helper.accessor((row) => orNothing(row.blocking), {
|
|
181
155
|
id: "dependsOn",
|
|
@@ -184,7 +158,6 @@ export function BoardTable({
|
|
|
184
158
|
sortUndefined: "last",
|
|
185
159
|
// Grouping by what a card waits for is a real question: everything blocked by the same
|
|
186
160
|
// card, in one place.
|
|
187
|
-
cell: ({ getValue }) => getValue() ?? "",
|
|
188
161
|
}),
|
|
189
162
|
helper.accessor((row) => row.task.labels.join(", "), {
|
|
190
163
|
id: "labels",
|
|
@@ -197,7 +170,7 @@ export function BoardTable({
|
|
|
197
170
|
enableGrouping: false,
|
|
198
171
|
}),
|
|
199
172
|
]),
|
|
200
|
-
[i18n
|
|
173
|
+
[i18n],
|
|
201
174
|
);
|
|
202
175
|
|
|
203
176
|
const table = useTable({
|
|
@@ -236,11 +209,14 @@ export function BoardTable({
|
|
|
236
209
|
|
|
237
210
|
const grouped = grouping[0] ?? "none";
|
|
238
211
|
const groupable = table.getAllLeafColumns().filter((column) => column.getCanGroup());
|
|
212
|
+
const sortable = table.getAllLeafColumns().filter((column) => column.getCanSort());
|
|
239
213
|
const configured: BoardColumn[] = board.columns
|
|
240
214
|
.filter((entry) => entry.unknown !== true)
|
|
241
215
|
.map((entry) => entry.column);
|
|
242
216
|
const rows = table.getRowModel().rows;
|
|
243
|
-
|
|
217
|
+
// Which chips a row may draw. The column chooser therefore still decides what is on screen, it
|
|
218
|
+
// just decides it per chip instead of per column.
|
|
219
|
+
const visible = new Set(table.getVisibleLeafColumns().map((column) => column.id));
|
|
244
220
|
// ⚠️ Measured on the ANSWER, not on the rows on screen. The filter travels to the server, so an
|
|
245
221
|
// empty answer under a filter means "nothing matches" while an empty answer without one means
|
|
246
222
|
// "this board has no cards" — two different sentences for the same zero.
|
|
@@ -328,8 +304,56 @@ export function BoardTable({
|
|
|
328
304
|
</DropdownMenuContent>
|
|
329
305
|
</DropdownMenu>
|
|
330
306
|
|
|
307
|
+
<DropdownMenu>
|
|
308
|
+
{/* ⚠️ **Sorting needs its own place now.** It used to live on the column headings, and
|
|
309
|
+
those are gone: one heading is left, and hanging four sort orders off it would be a
|
|
310
|
+
control nobody finds. The toolbar is where the other three view settings already are. */}
|
|
311
|
+
<DropdownMenuTrigger
|
|
312
|
+
aria-label={i18n.t("board.sortBy")}
|
|
313
|
+
className={`inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring aria-expanded:bg-muted ${sorting.length === 0 ? "" : "bg-accent text-accent-foreground"}`}
|
|
314
|
+
>
|
|
315
|
+
<ArrowUpDown aria-hidden="true" className="size-4" />
|
|
316
|
+
</DropdownMenuTrigger>
|
|
317
|
+
<DropdownMenuContent align="start">
|
|
318
|
+
<DropdownMenuRadioGroup
|
|
319
|
+
value={
|
|
320
|
+
sorting[0] === undefined
|
|
321
|
+
? ""
|
|
322
|
+
: `${sorting[0].id}:${sorting[0].desc ? "desc" : "asc"}`
|
|
323
|
+
}
|
|
324
|
+
onValueChange={(next) =>
|
|
325
|
+
setSorting(
|
|
326
|
+
next === ""
|
|
327
|
+
? []
|
|
328
|
+
: [{ id: next.split(":")[0] ?? "", desc: next.endsWith(":desc") }],
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
>
|
|
332
|
+
<DropdownMenuRadioItem value="">{i18n.t("board.sort.none")}</DropdownMenuRadioItem>
|
|
333
|
+
{/* ⚠️ Read off the COLUMNS like the grouping menu, not from a list written here: a
|
|
334
|
+
column that stops being sortable disappears from the menu on its own. */}
|
|
335
|
+
{sortable.flatMap((column) => [
|
|
336
|
+
<DropdownMenuRadioItem key={`${column.id}:asc`} value={`${column.id}:asc`}>
|
|
337
|
+
{i18n.t("board.sort.asc", { column: String(column.columnDef.header) })}
|
|
338
|
+
</DropdownMenuRadioItem>,
|
|
339
|
+
<DropdownMenuRadioItem key={`${column.id}:desc`} value={`${column.id}:desc`}>
|
|
340
|
+
{i18n.t("board.sort.desc", { column: String(column.columnDef.header) })}
|
|
341
|
+
</DropdownMenuRadioItem>,
|
|
342
|
+
])}
|
|
343
|
+
</DropdownMenuRadioGroup>
|
|
344
|
+
</DropdownMenuContent>
|
|
345
|
+
</DropdownMenu>
|
|
346
|
+
|
|
331
347
|
<span className="ml-2 text-xs text-muted-foreground">
|
|
332
348
|
{[
|
|
349
|
+
sorting[0] === undefined
|
|
350
|
+
? null
|
|
351
|
+
: i18n.t(sorting[0].desc ? "board.sort.desc" : "board.sort.asc", {
|
|
352
|
+
column: String(
|
|
353
|
+
sortable.find((column) => column.id === sorting[0]?.id)?.columnDef.header ??
|
|
354
|
+
sorting[0].id,
|
|
355
|
+
),
|
|
356
|
+
}),
|
|
333
357
|
grouped === "none"
|
|
334
358
|
? null
|
|
335
359
|
: i18n.t("board.table.groupedBy", {
|
|
@@ -350,44 +374,26 @@ export function BoardTable({
|
|
|
350
374
|
|
|
351
375
|
<div className="min-h-0 flex-1 overflow-auto">
|
|
352
376
|
<table className="w-full border-collapse text-left text-sm">
|
|
377
|
+
{/* ⚠️ ONE heading. The other five said what their column was called and nothing about the
|
|
378
|
+
row; a card without a due date filled a cell with emptiness, and a table half made of
|
|
379
|
+
emptiness is one people stop reading. What a value IS, its chip says. */}
|
|
353
380
|
<thead>
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
<button
|
|
371
|
-
type="button"
|
|
372
|
-
onClick={header.column.getToggleSortingHandler()}
|
|
373
|
-
className="inline-flex items-center gap-1 rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
374
|
-
>
|
|
375
|
-
{String(header.column.columnDef.header)}
|
|
376
|
-
<span aria-hidden="true" className="text-xs text-muted-foreground">
|
|
377
|
-
{header.column.getIsSorted() === "asc"
|
|
378
|
-
? "\u25b2"
|
|
379
|
-
: header.column.getIsSorted() === "desc"
|
|
380
|
-
? "\u25bc"
|
|
381
|
-
: ""}
|
|
382
|
-
</span>
|
|
383
|
-
</button>
|
|
384
|
-
) : (
|
|
385
|
-
String(header.column.columnDef.header)
|
|
386
|
-
)}
|
|
387
|
-
</th>
|
|
388
|
-
))}
|
|
389
|
-
</tr>
|
|
390
|
-
))}
|
|
381
|
+
<tr className="border-b">
|
|
382
|
+
{/* ⚠️ `aria-sort` survives the loss of the other headings. It is the only place the
|
|
383
|
+
TABLE itself says it is ordered; the quiet line in the toolbar says it to whoever
|
|
384
|
+
reads the toolbar, and a screen reader announcing a table does not. It reads
|
|
385
|
+
`none` while another column orders the rows, because that is what is true of this
|
|
386
|
+
one. */}
|
|
387
|
+
<th
|
|
388
|
+
scope="col"
|
|
389
|
+
aria-sort={
|
|
390
|
+
sorting[0]?.id !== "title" ? "none" : sorting[0].desc ? "descending" : "ascending"
|
|
391
|
+
}
|
|
392
|
+
className="px-2 py-3 font-medium"
|
|
393
|
+
>
|
|
394
|
+
{i18n.t("board.column.title")}
|
|
395
|
+
</th>
|
|
396
|
+
</tr>
|
|
391
397
|
</thead>
|
|
392
398
|
{/* ⚠️ One `tbody` PER GROUP, and the heading scopes to it. That is what actually
|
|
393
399
|
associates a heading with the rows under it: `scope="row"` binds to the heading's own
|
|
@@ -395,21 +401,23 @@ export function BoardTable({
|
|
|
395
401
|
through it reads to a screen reader as one long list with odd rows in it. */}
|
|
396
402
|
{groupsOf(rows).map((group) => (
|
|
397
403
|
<tbody key={group.key}>
|
|
398
|
-
{group.heading === undefined ? null : <GroupRow row={group.heading} span={
|
|
404
|
+
{group.heading === undefined ? null : <GroupRow row={group.heading} span={1} />}
|
|
399
405
|
{group.rows.map((row) => (
|
|
400
|
-
<tr
|
|
401
|
-
{row.
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
{
|
|
411
|
-
|
|
412
|
-
|
|
406
|
+
<tr
|
|
407
|
+
key={row.id}
|
|
408
|
+
// ⚠️ A very light grey, and on the whole row. It says which row the pointer is on,
|
|
409
|
+
// which is the only thing a table this wide cannot say by itself.
|
|
410
|
+
className="border-b last:border-0 hover:bg-muted/40"
|
|
411
|
+
>
|
|
412
|
+
<td className="px-2 py-3">
|
|
413
|
+
<RowContent
|
|
414
|
+
row={row}
|
|
415
|
+
visible={visible}
|
|
416
|
+
onOpen={onOpen}
|
|
417
|
+
board={board}
|
|
418
|
+
columns={configured}
|
|
419
|
+
/>
|
|
420
|
+
</td>
|
|
413
421
|
</tr>
|
|
414
422
|
))}
|
|
415
423
|
</tbody>
|
|
@@ -489,12 +497,110 @@ function GroupRow({ row, span }: { row: TableRow; span: number }) {
|
|
|
489
497
|
}
|
|
490
498
|
|
|
491
499
|
/**
|
|
492
|
-
*
|
|
500
|
+
* One row: the title, and beside it only the values that are set.
|
|
493
501
|
*
|
|
494
|
-
* ⚠️ **
|
|
495
|
-
*
|
|
496
|
-
*
|
|
502
|
+
* ⚠️ **Always the same order** — column, dates, labels, what it waits for, then who it is for. The
|
|
503
|
+
* rows carry different numbers of chips, so without a fixed order the eye has no line to follow and
|
|
504
|
+
* every row has to be read from the start.
|
|
505
|
+
*
|
|
506
|
+
* ⚠️ **The circle is last, always.** It is the only round thing in the row; between the chips it
|
|
507
|
+
* breaks the line, and a row nobody is on simply ends earlier.
|
|
508
|
+
*
|
|
509
|
+
* ⚠️ **A chip is drawn only when its column is visible AND its value is set.** The column chooser
|
|
510
|
+
* therefore still works, it just hides a chip rather than an empty cell.
|
|
497
511
|
*/
|
|
498
|
-
function
|
|
499
|
-
|
|
512
|
+
function RowContent({
|
|
513
|
+
row,
|
|
514
|
+
visible,
|
|
515
|
+
onOpen,
|
|
516
|
+
board,
|
|
517
|
+
columns,
|
|
518
|
+
}: {
|
|
519
|
+
row: TableRow;
|
|
520
|
+
visible: Set<string>;
|
|
521
|
+
onOpen(taskId: string): void;
|
|
522
|
+
board: BoardHandle;
|
|
523
|
+
columns: BoardColumn[];
|
|
524
|
+
}) {
|
|
525
|
+
const i18n = useI18n();
|
|
526
|
+
const { task, columnTitle, blocking } = row.original;
|
|
527
|
+
|
|
528
|
+
const write = (input: Partial<Omit<Parameters<BoardHandle["updateTask"]>[0], "taskId">>) => {
|
|
529
|
+
// ⚠️ Refused while a write is in flight, like every other surface of this board (#651).
|
|
530
|
+
if (board.isWriting) return;
|
|
531
|
+
void board.updateTask({ taskId: task.id, ...input, idempotencyKey: crypto.randomUUID() });
|
|
532
|
+
};
|
|
533
|
+
|
|
534
|
+
return (
|
|
535
|
+
<span className="flex items-center gap-2" style={{ paddingLeft: `${row.depth * 20}px` }}>
|
|
536
|
+
{row.getCanExpand() ? (
|
|
537
|
+
<button
|
|
538
|
+
type="button"
|
|
539
|
+
aria-label={i18n.t(row.getIsExpanded() ? "board.table.collapse" : "board.table.expand", {
|
|
540
|
+
row: task.title,
|
|
541
|
+
})}
|
|
542
|
+
onClick={row.getToggleExpandedHandler()}
|
|
543
|
+
className="rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
544
|
+
>
|
|
545
|
+
{row.getIsExpanded() ? (
|
|
546
|
+
<ChevronDown aria-hidden="true" className="size-4 text-muted-foreground" />
|
|
547
|
+
) : (
|
|
548
|
+
<ChevronRight aria-hidden="true" className="size-4 text-muted-foreground" />
|
|
549
|
+
)}
|
|
550
|
+
</button>
|
|
551
|
+
) : (
|
|
552
|
+
<span className="size-4" />
|
|
553
|
+
)}
|
|
554
|
+
|
|
555
|
+
<button
|
|
556
|
+
type="button"
|
|
557
|
+
onClick={() => onOpen(task.id)}
|
|
558
|
+
className="min-w-0 flex-1 truncate rounded-sm text-left outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring"
|
|
559
|
+
>
|
|
560
|
+
{task.title}
|
|
561
|
+
</button>
|
|
562
|
+
|
|
563
|
+
{visible.has("status") ? (
|
|
564
|
+
<StatusChip status={task.status} title={columnTitle} columns={columns} write={write} />
|
|
565
|
+
) : null}
|
|
566
|
+
|
|
567
|
+
{visible.has("start") && task.startDate !== null ? (
|
|
568
|
+
<BoardChip
|
|
569
|
+
label={i18n.t("board.fromDate", { date: task.startDate.slice(0, 10) })}
|
|
570
|
+
action={i18n.t("board.clearStart")}
|
|
571
|
+
onClick={() => write({ startDate: null })}
|
|
572
|
+
/>
|
|
573
|
+
) : null}
|
|
574
|
+
{visible.has("due") && task.dueDate !== null ? (
|
|
575
|
+
<BoardChip
|
|
576
|
+
label={i18n.t("board.untilDate", { date: task.dueDate.slice(0, 10) })}
|
|
577
|
+
action={i18n.t("board.clearDue")}
|
|
578
|
+
onClick={() => write({ dueDate: null })}
|
|
579
|
+
/>
|
|
580
|
+
) : null}
|
|
581
|
+
|
|
582
|
+
{visible.has("labels")
|
|
583
|
+
? task.labels.map((label) => (
|
|
584
|
+
<BoardChip
|
|
585
|
+
key={label}
|
|
586
|
+
label={label}
|
|
587
|
+
action={i18n.t("board.removeLabel")}
|
|
588
|
+
onClick={() => write({ labels: task.labels.filter((keep) => keep !== label) })}
|
|
589
|
+
/>
|
|
590
|
+
))
|
|
591
|
+
: null}
|
|
592
|
+
|
|
593
|
+
{visible.has("dependsOn") && blocking !== null ? (
|
|
594
|
+
<BoardChip
|
|
595
|
+
label={blocking}
|
|
596
|
+
action={i18n.t("board.clearDependsOn")}
|
|
597
|
+
onClick={() => write({ dependsOn: null })}
|
|
598
|
+
/>
|
|
599
|
+
) : null}
|
|
600
|
+
|
|
601
|
+
{visible.has("assignee") && task.assigneeId !== null ? (
|
|
602
|
+
<AssigneeChip id={task.assigneeId} clear={() => write({ assigneeId: null })} />
|
|
603
|
+
) : null}
|
|
604
|
+
</span>
|
|
605
|
+
);
|
|
500
606
|
}
|