@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -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={kind === "pdf"}
150
+ footerInset={hasViewerControls}
150
151
  kind={kind}
151
152
  close={() => setDetailsOpen(false)}
152
153
  />
153
154
  ) : null}
154
- {detailsOpen && kind === "pdf" ? (
155
+ {detailsOpen && hasViewerControls ? (
155
156
  <div
156
157
  aria-hidden="true"
157
- className="pointer-events-none absolute inset-x-0 bottom-12 z-10 hidden border-t md:block"
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
- // The PDF renderer alone owns a bottom toolbar; keep the desktop panel beside its content.
262
- footerInset && "md:mb-12",
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
- cell: ({ getValue }) => <Assignee id={getValue() ?? null} />,
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, onOpen],
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
- const span = table.getVisibleLeafColumns().length;
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
- {table.getHeaderGroups().map((group) => (
355
- <tr key={group.id} className="border-b">
356
- {group.headers.map((header) => (
357
- <th
358
- key={header.id}
359
- scope="col"
360
- aria-sort={
361
- header.column.getIsSorted() === "asc"
362
- ? "ascending"
363
- : header.column.getIsSorted() === "desc"
364
- ? "descending"
365
- : "none"
366
- }
367
- className="px-2 py-3 font-medium"
368
- >
369
- {header.column.getCanSort() ? (
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={span} />}
404
+ {group.heading === undefined ? null : <GroupRow row={group.heading} span={1} />}
399
405
  {group.rows.map((row) => (
400
- <tr key={row.id} className="border-b last:border-0">
401
- {row.getVisibleCells().map((cell) => (
402
- // ⚠️ Room above and below. A table one has to decipher rather than read is a
403
- // table people stop reading.
404
- <td key={cell.id} className="px-2 py-3">
405
- {/* ⚠️ `flexRender` with the cell's OWN context, not the cell. A hand-rolled
406
- call that passes the cell itself hands the renderer an object without the
407
- context's fields, and the first `{ row }` destructuring throws — the whole
408
- table then vanishes behind the router's error boundary, which reads as
409
- "the table does not render" rather than as a wrong argument. */}
410
- {flexRender(cell.column.columnDef.cell, cell.getContext())}
411
- </td>
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
- * Who a card belongs to, by the one rule this application has for saying so.
500
+ * One row: the title, and beside it only the values that are set.
493
501
  *
494
- * ⚠️ **Never the raw id.** Intel has no user directory `useUserName` resolves exactly one id, the
495
- * signed-in person's and an id under the heading "assigned to" is not an answer to "who", it is
496
- * the question again in smaller type (#258).
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 Assignee({ id }: { id: string | null }) {
499
- return <>{useAssigneeLabel(id)}</>;
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
  }