@anchrd/intel-ui 0.40.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.40.0",
3
+ "version": "0.42.0",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -40,6 +40,12 @@
40
40
  "@dnd-kit/core": "^6.3.1",
41
41
  "@dnd-kit/sortable": "^10.0.0",
42
42
  "@dnd-kit/utilities": "^3.2.2",
43
+ "@file-viewer/react": "2.3.0",
44
+ "@file-viewer/renderer-pdf": "2.3.0",
45
+ "@file-viewer/renderer-presentation": "2.3.0",
46
+ "@file-viewer/renderer-spreadsheet": "2.3.2",
47
+ "@file-viewer/renderer-word": "2.3.1",
48
+ "@file-viewer/vite-plugin": "2.3.1",
43
49
  "@react-sigma/core": "^5.0.6",
44
50
  "@tailwindcss/vite": "^4.3.3",
45
51
  "@tanstack/react-query": "^5.101.4",
@@ -58,6 +64,7 @@
58
64
  "react": "^19.2.0",
59
65
  "react-aria-components": "^1.19.0",
60
66
  "react-dom": "^19.2.0",
67
+ "readable-stream": "2.3.8",
61
68
  "sigma": "^3.0.3",
62
69
  "tailwind-merge": "^3.6.0",
63
70
  "tailwindcss": "^4.3.3",
@@ -1,6 +1,7 @@
1
1
  import type { NodeKind } from "@anchrd/intel-contract/node";
2
2
  import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
3
3
  import { useNavigate, useRouterState } from "@tanstack/react-router";
4
+ import { zipSync } from "fflate";
4
5
  import {
5
6
  ChevronRight,
6
7
  CornerLeftUp,
@@ -80,6 +81,16 @@ async function fileBase64(file: File): Promise<string> {
80
81
  });
81
82
  }
82
83
 
84
+ function nativeUpload(file: File): boolean {
85
+ const name = file.name.toLowerCase();
86
+ return name.endsWith(".md") || name.endsWith(".csv");
87
+ }
88
+
89
+ async function singleFileBundle(file: File): Promise<Blob> {
90
+ const bytes = new Uint8Array(await file.arrayBuffer());
91
+ return new Blob([zipSync({ [file.name]: bytes }).slice().buffer], { type: "application/zip" });
92
+ }
93
+
83
94
  // A level is either a folder of the shared tree or the flows one flow calls. The second kind is
84
95
  // derived from that flow's graph, not from `parent_id` (ADR-0004 §3), which is why it is its own
85
96
  // level rather than another folder.
@@ -352,6 +363,16 @@ export function AppTree() {
352
363
 
353
364
  const upload = useMutation({
354
365
  mutationFn: async ({ parentId, file }: { parentId: string | null; file: File }) => {
366
+ if (nativeUpload(file)) {
367
+ const result = await data.importNodeBundle({
368
+ nodeId: parentId,
369
+ zip: await singleFileBundle(file),
370
+ idempotencyKey: crypto.randomUUID(),
371
+ });
372
+ const id = result.rootNodeIds[0];
373
+ if (!id) throw new Error("Native upload created no root node");
374
+ return { id, parentId };
375
+ }
355
376
  const contentBase64 = await fileBase64(file);
356
377
  const node = await data.createNodes({
357
378
  parentId,
@@ -0,0 +1,305 @@
1
+ import type { NodeDocument } from "@anchrd/intel-contract/node";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { unzipSync } from "fflate";
4
+ import { FileQuestion, Info, X } from "lucide-react";
5
+ import { Component, lazy, type ReactNode, Suspense, useEffect, useMemo, useState } from "react";
6
+ import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
7
+ import { useI18n } from "@/i18n/i18n-context.tsx";
8
+ import { cn } from "@/lib/utils";
9
+ import { useIntelRouterContext } from "@/router/router-context.ts";
10
+ import { useDateTime } from "@/time/time-context.tsx";
11
+
12
+ const FilePreview = lazy(async () => ({
13
+ default: (await import("@/file-preview/file-preview.tsx")).FilePreview,
14
+ }));
15
+
16
+ export type AttachmentPreviewKind =
17
+ | "image"
18
+ | "pdf"
19
+ | "word"
20
+ | "spreadsheet"
21
+ | "presentation"
22
+ | "unsupported";
23
+
24
+ const extensions: Record<Exclude<AttachmentPreviewKind, "unsupported">, readonly string[]> = {
25
+ image: ["avif", "bmp", "gif", "jpeg", "jpg", "png", "svg", "webp"],
26
+ pdf: ["pdf"],
27
+ word: ["docx"],
28
+ spreadsheet: ["xlsx"],
29
+ presentation: ["pptx"],
30
+ };
31
+
32
+ export function attachmentPreviewKind(title: string, mediaType: string): AttachmentPreviewKind {
33
+ if (mediaType.startsWith("image/")) return "image";
34
+ if (mediaType === "application/pdf") return "pdf";
35
+ const extension = title.toLowerCase().split(".").at(-1) ?? "";
36
+ for (const [kind, supported] of Object.entries(extensions)) {
37
+ if (supported.includes(extension)) return kind as AttachmentPreviewKind;
38
+ }
39
+ return "unsupported";
40
+ }
41
+
42
+ export function formatFileSize(bytes: number): string {
43
+ if (bytes < 1_000) return `${bytes} B`;
44
+ if (bytes < 1_000_000) return `${(bytes / 1_000).toFixed(bytes < 10_000 ? 1 : 0)} KB`;
45
+ return `${(bytes / 1_000_000).toFixed(bytes < 10_000_000 ? 1 : 0)} MB`;
46
+ }
47
+
48
+ type FormatDetail = {
49
+ label: "dimensions" | "pages" | "sections" | "slides" | "worksheets";
50
+ value: string;
51
+ };
52
+
53
+ export async function attachmentFormatDetail(
54
+ file: File,
55
+ kind: AttachmentPreviewKind,
56
+ ): Promise<FormatDetail | null> {
57
+ if (kind === "image") {
58
+ const bitmap = await createImageBitmap(file);
59
+ const value = `${bitmap.width} × ${bitmap.height} px`;
60
+ bitmap.close();
61
+ return { label: "dimensions", value };
62
+ }
63
+ const bytes = new Uint8Array(await file.arrayBuffer());
64
+ if (kind === "pdf") {
65
+ const source = new TextDecoder("latin1").decode(bytes);
66
+ const pages = source.match(/\/Type\s*\/Page\b/g)?.length ?? 0;
67
+ return pages > 0 ? { label: "pages", value: String(pages) } : null;
68
+ }
69
+ if (kind === "unsupported") return null;
70
+ const archive = unzipSync(bytes);
71
+ if (kind === "presentation") {
72
+ const slides = Object.keys(archive).filter((name) => /^ppt\/slides\/slide\d+\.xml$/.test(name));
73
+ return { label: "slides", value: String(slides.length) };
74
+ }
75
+ const decoder = new TextDecoder();
76
+ if (kind === "spreadsheet") {
77
+ const workbook = archive["xl/workbook.xml"];
78
+ if (!workbook) return null;
79
+ const worksheets = decoder.decode(workbook).match(/<sheet\b/g)?.length ?? 0;
80
+ return { label: "worksheets", value: String(worksheets) };
81
+ }
82
+ const document = archive["word/document.xml"];
83
+ if (!document) return null;
84
+ const sections = Math.max(1, decoder.decode(document).match(/<w:sectPr\b/g)?.length ?? 0);
85
+ return { label: "sections", value: String(sections) };
86
+ }
87
+
88
+ export function AttachmentViewer({ document }: { document: NodeDocument }) {
89
+ const { data } = useIntelRouterContext();
90
+ const i18n = useI18n();
91
+ const [detailsOpen, setDetailsOpen] = useState(false);
92
+ const attachment = useQuery({
93
+ queryKey: ["attachment", document.node.id, document.version?.id],
94
+ queryFn: () => data.getNodeAttachment(document.node.id),
95
+ enabled: document.version !== null,
96
+ retry: false,
97
+ });
98
+ const file = useMemo(
99
+ () =>
100
+ attachment.data && document.version
101
+ ? new File([attachment.data], document.node.title, { type: document.version.mediaType })
102
+ : null,
103
+ [attachment.data, document.node.title, document.version],
104
+ );
105
+ const kind = document.version
106
+ ? attachmentPreviewKind(document.node.title, document.version.mediaType)
107
+ : "unsupported";
108
+ const hasViewerControls = kind !== "image" && kind !== "unsupported";
109
+
110
+ return (
111
+ <div className="flex min-h-0 flex-1 flex-col px-6 pb-6">
112
+ <ActionSlot name="title-actions">
113
+ <button
114
+ type="button"
115
+ aria-label={i18n.t(detailsOpen ? "attachment.detailsHide" : "attachment.detailsShow")}
116
+ aria-expanded={detailsOpen}
117
+ onClick={() => setDetailsOpen((current) => !current)}
118
+ className="inline-flex size-8 shrink-0 items-center justify-center rounded-md bg-muted outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
119
+ >
120
+ <Info aria-hidden="true" className="size-4" />
121
+ </button>
122
+ </ActionSlot>
123
+ <div className="relative flex min-h-0 flex-1 overflow-hidden rounded-xl border bg-background">
124
+ <div className="relative min-h-0 min-w-0 flex-1 bg-muted/40">
125
+ {document.version === null ? (
126
+ <PreviewMessage message={i18n.t("attachment.empty")} />
127
+ ) : attachment.isPending ? (
128
+ <PreviewMessage message={i18n.t("common.loading")} />
129
+ ) : attachment.isError || !file ? (
130
+ <PreviewMessage error message={i18n.t("attachment.loadFailed")} />
131
+ ) : kind === "unsupported" ? (
132
+ <PreviewMessage message={i18n.t("attachment.unsupported")} />
133
+ ) : kind === "image" ? (
134
+ <ImagePreview file={file} title={document.node.title} />
135
+ ) : (
136
+ <ViewerErrorBoundary
137
+ key={`${document.node.id}:${document.version.id}`}
138
+ fallback={<PreviewMessage error message={i18n.t("attachment.loadFailed")} />}
139
+ >
140
+ <Suspense fallback={<PreviewMessage message={i18n.t("common.loading")} />}>
141
+ <FilePreview file={file} kind={kind} />
142
+ </Suspense>
143
+ </ViewerErrorBoundary>
144
+ )}
145
+ </div>
146
+ {detailsOpen ? (
147
+ <AttachmentDetails
148
+ document={document}
149
+ file={file}
150
+ footerInset={hasViewerControls}
151
+ kind={kind}
152
+ close={() => setDetailsOpen(false)}
153
+ />
154
+ ) : null}
155
+ {detailsOpen && hasViewerControls ? (
156
+ <div
157
+ aria-hidden="true"
158
+ className="pointer-events-none absolute inset-x-0 bottom-14 z-10 hidden border-t md:block"
159
+ />
160
+ ) : null}
161
+ </div>
162
+ </div>
163
+ );
164
+ }
165
+
166
+ class ViewerErrorBoundary extends Component<
167
+ { children: ReactNode; fallback: ReactNode },
168
+ { failed: boolean }
169
+ > {
170
+ override state = { failed: false };
171
+
172
+ static getDerivedStateFromError() {
173
+ return { failed: true };
174
+ }
175
+
176
+ override render() {
177
+ return this.state.failed ? this.props.fallback : this.props.children;
178
+ }
179
+ }
180
+
181
+ function PreviewMessage({ message, error = false }: { message: string; error?: boolean }) {
182
+ return (
183
+ <div
184
+ role={error ? "alert" : undefined}
185
+ className={cn(
186
+ "grid h-full min-h-64 place-items-center p-8 text-center text-sm text-muted-foreground",
187
+ error && "text-destructive",
188
+ )}
189
+ >
190
+ <div className="max-w-sm space-y-3">
191
+ <FileQuestion aria-hidden="true" className="mx-auto size-9" />
192
+ <p>{message}</p>
193
+ </div>
194
+ </div>
195
+ );
196
+ }
197
+
198
+ function ImagePreview({ file, title }: { file: File; title: string }) {
199
+ const [url, setUrl] = useState("");
200
+ useEffect(() => {
201
+ const next = URL.createObjectURL(file);
202
+ setUrl(next);
203
+ return () => URL.revokeObjectURL(next);
204
+ }, [file]);
205
+ return url ? (
206
+ <div className="grid h-full min-h-64 place-items-center overflow-auto p-4">
207
+ <img src={url} alt={title} className="max-h-full max-w-full object-contain" />
208
+ </div>
209
+ ) : null;
210
+ }
211
+
212
+ function AttachmentDetails({
213
+ document,
214
+ file,
215
+ footerInset,
216
+ kind,
217
+ close,
218
+ }: {
219
+ document: NodeDocument;
220
+ file: File | null;
221
+ footerInset: boolean;
222
+ kind: AttachmentPreviewKind;
223
+ close(): void;
224
+ }) {
225
+ const i18n = useI18n();
226
+ const dateTime = useDateTime();
227
+ const version = document.version;
228
+ const formatDetail = useQuery({
229
+ queryKey: ["attachment-format-detail", document.node.id, version?.id],
230
+ queryFn: async () => (file ? await attachmentFormatDetail(file, kind) : null),
231
+ enabled: file !== null && kind !== "unsupported",
232
+ retry: false,
233
+ });
234
+ const rows = version
235
+ ? [
236
+ [i18n.t("attachment.details.name"), document.node.title],
237
+ [
238
+ i18n.t("attachment.details.type"),
239
+ i18n.t(
240
+ `attachment.kind.${attachmentPreviewKind(document.node.title, version.mediaType)}`,
241
+ ),
242
+ ],
243
+ [i18n.t("attachment.details.mimeType"), version.mediaType],
244
+ [
245
+ i18n.t("attachment.details.format"),
246
+ document.node.title.split(".").at(-1)?.toUpperCase() ?? "—",
247
+ ],
248
+ [i18n.t("attachment.details.size"), formatFileSize(version.size)],
249
+ [i18n.t("attachment.details.uploaded"), dateTime.at(version.createdAt)],
250
+ [i18n.t("attachment.details.uploadedBy"), version.createdBy],
251
+ [i18n.t("attachment.details.version"), String(version.sequence)],
252
+ ]
253
+ : [[i18n.t("attachment.details.name"), document.node.title]];
254
+ if (formatDetail.data) {
255
+ rows.push([i18n.t(`attachment.details.${formatDetail.data.label}`), formatDetail.data.value]);
256
+ }
257
+ return (
258
+ <aside
259
+ aria-label={i18n.t("attachment.details.title")}
260
+ className={cn(
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",
262
+ // File renderers keep their controls in a shared footer; the panel ends beside that footer.
263
+ footerInset && "md:mb-14",
264
+ )}
265
+ >
266
+ <div className="flex items-center justify-between border-b px-5 py-4">
267
+ <h3 className="font-semibold">{i18n.t("attachment.details.title")}</h3>
268
+ <button
269
+ type="button"
270
+ aria-label={i18n.t("common.close")}
271
+ onClick={close}
272
+ className="inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
273
+ >
274
+ <X aria-hidden="true" className="size-4" />
275
+ </button>
276
+ </div>
277
+ <dl className="divide-y px-5">
278
+ {rows.map(([label, value]) => (
279
+ <div
280
+ key={label}
281
+ className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)] gap-4 py-3 text-sm"
282
+ >
283
+ <dt className="text-muted-foreground">{label}</dt>
284
+ <dd className="break-words">{value}</dd>
285
+ </div>
286
+ ))}
287
+ </dl>
288
+ {version ? (
289
+ <div className="border-t px-5 py-4">
290
+ <h4 className="mb-2 font-medium">{i18n.t("attachment.details.technical")}</h4>
291
+ <dl className="divide-y">
292
+ <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)] gap-4 py-3 text-sm">
293
+ <dt className="text-muted-foreground">{i18n.t("attachment.details.storage")}</dt>
294
+ <dd>{i18n.t("attachment.details.private")}</dd>
295
+ </div>
296
+ <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,1.2fr)] gap-4 py-3 text-sm">
297
+ <dt className="text-muted-foreground">{i18n.t("attachment.details.checksum")}</dt>
298
+ <dd className="font-mono text-xs">{`${version.contentHash.slice(0, 8)}…${version.contentHash.slice(-4)}`}</dd>
299
+ </div>
300
+ </dl>
301
+ </div>
302
+ ) : null}
303
+ </aside>
304
+ );
305
+ }
@@ -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,56 @@
1
+ import type { BoardTask } from "@anchrd/intel-contract/board";
2
+
3
+ /** One step of the path above an open card. The board is a step like any other. */
4
+ export interface Crumb {
5
+ /** The node id to open, or `null` for the board itself. */
6
+ taskId: string | null;
7
+ title: string;
8
+ }
9
+
10
+ /**
11
+ * The path from the board down to the open card, board first.
12
+ *
13
+ * ⚠️ **An unknown parent ENDS the walk rather than losing the card.** Under a filter a subtask can
14
+ * be in the answer while its parent is not (`board.ts` says so on `parentTaskId`). The path is then
15
+ * shorter than the tree, which is honest: it shows what this answer knows.
16
+ */
17
+ export function crumbsOf(taskId: string, boardTitle: string, tasks: BoardTask[]): Crumb[] {
18
+ const byId = new Map(tasks.map((task) => [task.id, task]));
19
+ const trail: Crumb[] = [];
20
+ const seen = new Set<string>();
21
+
22
+ /**
23
+ * ⚠️ **`seen` is the whole guard, and a second counter beside it would be decoration.** A loop in
24
+ * the parent chain ends the walk here because every id is visited once; a step limit could only
25
+ * fire where this already has, and a guard that cannot fire is one nobody can test.
26
+ */
27
+ let current = byId.get(taskId);
28
+ while (current !== undefined) {
29
+ if (seen.has(current.id)) break;
30
+ seen.add(current.id);
31
+ trail.unshift({ taskId: current.id, title: current.title });
32
+ const parentId = current.parentTaskId;
33
+ current = parentId === null ? undefined : byId.get(parentId);
34
+ }
35
+
36
+ return [{ taskId: null, title: boardTitle }, ...trail];
37
+ }
38
+
39
+ /**
40
+ * The path as it is drawn: board, then an ellipsis standing for what was left out, then the last
41
+ * `keep` steps.
42
+ *
43
+ * ⚠️ **The board and the open card are never hidden.** They are the two ends somebody navigates by:
44
+ * where they came from and where they are. Everything between them is what the ellipsis holds.
45
+ *
46
+ * ⚠️ **`hidden` is the LIST, not a count.** The three dots open it, so the view needs the entries
47
+ * themselves; handing over a number would force it to recompute what was dropped.
48
+ */
49
+ export function foldCrumbs(crumbs: Crumb[], keep = 1): { shown: Crumb[]; hidden: Crumb[] } {
50
+ // Board + the kept tail + at least one in between is what makes folding worth anything.
51
+ if (crumbs.length <= keep + 2) return { shown: crumbs, hidden: [] };
52
+ return {
53
+ shown: [...crumbs.slice(0, 1), ...crumbs.slice(crumbs.length - keep)],
54
+ hidden: crumbs.slice(1, crumbs.length - keep),
55
+ };
56
+ }
@@ -0,0 +1,108 @@
1
+ import { ChevronLeft } from "lucide-react";
2
+ import {
3
+ DropdownMenu,
4
+ DropdownMenuContent,
5
+ DropdownMenuItem,
6
+ DropdownMenuTrigger,
7
+ } from "@/components/ui/dropdown-menu.tsx";
8
+ import { useI18n } from "@/i18n/i18n-context.tsx";
9
+ import { type Crumb, foldCrumbs } from "./board-crumbs.ts";
10
+
11
+ /**
12
+ * The path above an open card: back to the board, then every level down to this one.
13
+ *
14
+ * ⚠️ **The arrow belongs to the BOARD, the path to the cards.** Jack's decision 2026-08-21: one
15
+ * control that always means the same thing, and a path whose steps are each their own way back. An
16
+ * arrow that went one level up would mean something different on every card.
17
+ *
18
+ * ⚠️ **This replaces the heading rather than standing beside it.** The last step IS the card's name;
19
+ * drawn next to a heading it would be the same sentence twice.
20
+ */
21
+ export function BoardCrumbs({
22
+ crumbs,
23
+ onOpen,
24
+ }: {
25
+ crumbs: Crumb[];
26
+ onOpen(taskId: string | null): void;
27
+ }) {
28
+ const i18n = useI18n();
29
+ const { shown, hidden } = foldCrumbs(crumbs);
30
+ const board = shown[0];
31
+ const rest = shown.slice(1);
32
+
33
+ return (
34
+ <nav aria-label={i18n.t("board.crumbs")} className="flex min-w-0 items-center gap-1.5 text-sm">
35
+ <button
36
+ type="button"
37
+ aria-label={i18n.t("board.backTo", { board: board?.title ?? "" })}
38
+ onClick={() => onOpen(null)}
39
+ className="-ml-1 rounded-md p-0.5 text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
40
+ >
41
+ <ChevronLeft aria-hidden="true" className="size-4" />
42
+ </button>
43
+
44
+ <Step crumb={board} onOpen={onOpen} />
45
+
46
+ {hidden.length === 0 ? null : (
47
+ <>
48
+ <Separator />
49
+ <DropdownMenu>
50
+ {/* ⚠️ A menu, not a tooltip. On a touch screen there is no hover, and the levels behind
51
+ the dots are the only way to reach them — a hint that needs a pointer would put them
52
+ out of reach on the device where the path folds soonest. */}
53
+ <DropdownMenuTrigger
54
+ aria-label={i18n.t("board.crumbsFolded")}
55
+ className="rounded-md border px-1.5 leading-none text-muted-foreground outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
56
+ >
57
+
58
+ </DropdownMenuTrigger>
59
+ <DropdownMenuContent align="start">
60
+ {hidden.map((crumb) => (
61
+ <DropdownMenuItem key={crumb.taskId} onSelect={() => onOpen(crumb.taskId)}>
62
+ {crumb.title}
63
+ </DropdownMenuItem>
64
+ ))}
65
+ </DropdownMenuContent>
66
+ </DropdownMenu>
67
+ </>
68
+ )}
69
+
70
+ {rest.map((crumb, step) => (
71
+ <span key={crumb.taskId} className="flex min-w-0 items-center gap-1.5">
72
+ <Separator />
73
+ {/* The last step is where the reader IS: named, not offered as somewhere to go. */}
74
+ {step === rest.length - 1 ? (
75
+ <span className="truncate font-medium">{crumb.title}</span>
76
+ ) : (
77
+ <Step crumb={crumb} onOpen={onOpen} />
78
+ )}
79
+ </span>
80
+ ))}
81
+ </nav>
82
+ );
83
+ }
84
+
85
+ const Separator = () => (
86
+ <span aria-hidden="true" className="text-muted-foreground">
87
+ /
88
+ </span>
89
+ );
90
+
91
+ function Step({
92
+ crumb,
93
+ onOpen,
94
+ }: {
95
+ crumb: Crumb | undefined;
96
+ onOpen(taskId: string | null): void;
97
+ }) {
98
+ if (crumb === undefined) return null;
99
+ return (
100
+ <button
101
+ type="button"
102
+ onClick={() => onOpen(crumb.taskId)}
103
+ className="truncate rounded-sm text-muted-foreground outline-none hover:text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-ring"
104
+ >
105
+ {crumb.title}
106
+ </button>
107
+ );
108
+ }