@tangle-network/ui 11.25.0 → 11.27.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +22 -0
  3. package/dist/editor/editor-peers.js +1 -21
  4. package/dist/files/file-card.d.ts +30 -0
  5. package/dist/files/file-card.js +162 -0
  6. package/dist/files/file-list.d.ts +25 -0
  7. package/dist/files/file-list.js +88 -0
  8. package/dist/files/file-preview.js +16 -62
  9. package/dist/files/pdf-loader.d.ts +22 -0
  10. package/dist/files/pdf-loader.js +82 -0
  11. package/dist/files/pdf-viewer.d.ts +32 -0
  12. package/dist/files/pdf-viewer.js +567 -0
  13. package/dist/files.d.ts +5 -1
  14. package/dist/files.js +5 -1
  15. package/dist/index.d.ts +7 -3
  16. package/dist/index.js +5 -1
  17. package/dist/lib/optional-peer.js +29 -0
  18. package/dist/markdown/markdown.d.ts +3 -2
  19. package/dist/markdown/markdown.js +70 -4
  20. package/dist/primitives.d.ts +2 -2
  21. package/package.json +7 -2
  22. package/src/editor/editor-peers.ts +3 -25
  23. package/src/files/file-artifact-pane.test.tsx +12 -2
  24. package/src/files/file-card.test.tsx +62 -0
  25. package/src/files/file-card.tsx +210 -0
  26. package/src/files/file-list.stories.tsx +121 -0
  27. package/src/files/file-list.test.tsx +81 -0
  28. package/src/files/file-list.tsx +167 -0
  29. package/src/files/file-preview.tsx +25 -94
  30. package/src/files/index.ts +9 -0
  31. package/src/files/pdf-loader.test.ts +130 -0
  32. package/src/files/pdf-loader.ts +169 -0
  33. package/src/files/pdf-viewer.stories.tsx +81 -0
  34. package/src/files/pdf-viewer.test.tsx +245 -0
  35. package/src/files/pdf-viewer.tsx +677 -0
  36. package/src/files/pdfjs-worker.d.ts +3 -0
  37. package/src/lib/optional-peer.ts +30 -0
  38. package/src/markdown/markdown.test.tsx +32 -3
  39. package/src/markdown/markdown.tsx +85 -4
@@ -0,0 +1,121 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { Button } from "../primitives/button";
4
+ import { FileCard } from "./file-card";
5
+ import { FileList, type FileListItem } from "./file-list";
6
+
7
+ const CASE_FILES: FileListItem[] = [
8
+ {
9
+ id: "lease",
10
+ filename: "Commercial lease — 418 Harbor Street.pdf",
11
+ size: 2_516_582,
12
+ date: "2026-09-28T16:20:00Z",
13
+ uploader: { name: "Dana Ruiz", role: "Client" },
14
+ },
15
+ {
16
+ id: "notice",
17
+ filename: "Notice of lease termination.pdf",
18
+ size: 75_955,
19
+ date: "2026-10-06T09:05:00Z",
20
+ uploader: { name: "Priya Adeyemi", role: "Attorney" },
21
+ },
22
+ {
23
+ id: "photos",
24
+ filename: "Rear storage room, water damage.jpg",
25
+ mimeType: "image/jpeg",
26
+ size: 3_145_728,
27
+ date: "2026-08-12T18:42:00Z",
28
+ uploader: { name: "Dana Ruiz", role: "Client" },
29
+ },
30
+ {
31
+ id: "ledger",
32
+ filename: "Rent ledger 2023–2026.xlsx",
33
+ size: 48_640,
34
+ date: "2026-09-30T11:00:00Z",
35
+ uploader: { name: "Coastal Property Holdings" },
36
+ },
37
+ {
38
+ id: "estimate",
39
+ filename: "Contractor estimate.docx",
40
+ size: 182_000,
41
+ date: "2026-08-20T14:10:00Z",
42
+ },
43
+ ];
44
+
45
+ function CaseDocuments() {
46
+ const [files, setFiles] = useState(CASE_FILES);
47
+ const [openId, setOpenId] = useState("notice");
48
+ return (
49
+ <FileList
50
+ aria-label="Case documents"
51
+ files={files}
52
+ activeId={openId}
53
+ onOpen={(file) => setOpenId(file.id)}
54
+ onDownload={() => {}}
55
+ onDelete={(file) => setFiles((current) => current.filter((item) => item.id !== file.id))}
56
+ empty="No documents yet. Upload the lease to get started."
57
+ />
58
+ );
59
+ }
60
+
61
+ const meta: Meta<typeof FileList> = {
62
+ title: "Files/FileList",
63
+ component: FileList,
64
+ parameters: { layout: "fullscreen" },
65
+ decorators: [
66
+ (Story) => (
67
+ <div className="min-h-[100dvh] bg-background p-4 text-foreground">
68
+ <div className="mx-auto max-w-2xl">
69
+ <Story />
70
+ </div>
71
+ </div>
72
+ ),
73
+ ],
74
+ };
75
+
76
+ export default meta;
77
+ type Story = StoryObj<typeof FileList>;
78
+
79
+ export const Documents: Story = {
80
+ name: "Case documents",
81
+ render: () => <CaseDocuments />,
82
+ };
83
+
84
+ export const DocumentsLight: Story = {
85
+ name: "Case documents (light)",
86
+ globals: { theme: "light" },
87
+ render: () => <CaseDocuments />,
88
+ };
89
+
90
+ export const ReadOnly: Story = {
91
+ name: "Read-only list",
92
+ render: () => <FileList aria-label="Shared with you" files={CASE_FILES.slice(0, 3)} />,
93
+ };
94
+
95
+ export const Empty: Story = {
96
+ render: () => <FileList files={[]} empty="No documents yet. Upload the lease to get started." />,
97
+ };
98
+
99
+ export const Card: Story = {
100
+ name: "FileCard",
101
+ render: () => (
102
+ <FileCard
103
+ filename="Commercial lease — 418 Harbor Street.pdf"
104
+ size={2_516_582}
105
+ date="2026-09-28T16:20:00Z"
106
+ uploader={{ name: "Dana Ruiz", role: "Client" }}
107
+ onDownload={() => {}}
108
+ actions={
109
+ <Button type="button" size="sm">
110
+ Open
111
+ </Button>
112
+ }
113
+ />
114
+ ),
115
+ };
116
+
117
+ export const CardLight: Story = {
118
+ name: "FileCard (light)",
119
+ globals: { theme: "light" },
120
+ render: Card.render,
121
+ };
@@ -0,0 +1,81 @@
1
+ import { fireEvent, render, screen, within } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { FileList, type FileListItem } from "./file-list";
4
+
5
+ interface CaseDocument extends FileListItem {
6
+ matterId: string;
7
+ }
8
+
9
+ const FILES: CaseDocument[] = [
10
+ {
11
+ id: "lease",
12
+ matterId: "m-1",
13
+ filename: "Commercial lease.pdf",
14
+ size: 482_000,
15
+ date: "2026-10-01T12:00:00Z",
16
+ uploader: { name: "Dana Ruiz", role: "Client" },
17
+ },
18
+ {
19
+ id: "photos",
20
+ matterId: "m-1",
21
+ filename: "Water damage.jpg",
22
+ mimeType: "image/jpeg",
23
+ size: 3_100_000,
24
+ },
25
+ ];
26
+
27
+ describe("FileList", () => {
28
+ it("shows each file's kind, size and uploader", () => {
29
+ render(<FileList files={FILES} aria-label="Case documents" />);
30
+ const rows = within(screen.getByRole("list", { name: "Case documents" })).getAllByRole(
31
+ "listitem",
32
+ );
33
+ expect(rows).toHaveLength(2);
34
+ expect(rows[0]).toHaveTextContent("Commercial lease.pdf");
35
+ // A dot between facts on screen, a comma for a screen reader.
36
+ expect(rows[0]).toHaveTextContent(/PDF · , 471 KB · , .+ · , Dana Ruiz \(Client\)/);
37
+ expect(rows[1]).toHaveTextContent("Image · , 3.0 MB");
38
+ });
39
+
40
+ it("opens a file from its row and hands back the host's own record", () => {
41
+ const onOpen = vi.fn();
42
+ render(<FileList files={FILES} onOpen={onOpen} activeId="photos" />);
43
+
44
+ fireEvent.click(screen.getByRole("button", { name: /Commercial lease\.pdf/ }));
45
+ expect(onOpen).toHaveBeenCalledWith(FILES[0]);
46
+ expect(onOpen.mock.calls[0][0].matterId).toBe("m-1");
47
+
48
+ expect(screen.getByRole("button", { name: /Water damage\.jpg/ })).toHaveAttribute(
49
+ "aria-current",
50
+ "true",
51
+ );
52
+ expect(screen.getByRole("button", { name: /Commercial lease\.pdf/ })).not.toHaveAttribute(
53
+ "aria-current",
54
+ );
55
+ });
56
+
57
+ it("downloads and deletes through per-file buttons", () => {
58
+ const onDownload = vi.fn();
59
+ const onDelete = vi.fn();
60
+ render(<FileList files={FILES} onDownload={onDownload} onDelete={onDelete} />);
61
+
62
+ fireEvent.click(screen.getByRole("button", { name: "Download Water damage.jpg" }));
63
+ expect(onDownload).toHaveBeenCalledWith(FILES[1]);
64
+ fireEvent.click(screen.getByRole("button", { name: "Delete Commercial lease.pdf" }));
65
+ expect(onDelete).toHaveBeenCalledWith(FILES[0]);
66
+ });
67
+
68
+ it("renders no controls the host did not ask for", () => {
69
+ render(<FileList files={FILES} />);
70
+ expect(screen.queryByRole("button")).toBeNull();
71
+ });
72
+
73
+ it("shows the empty message in place of the list", () => {
74
+ const { rerender } = render(<FileList files={[]} />);
75
+ expect(screen.queryByRole("list")).toBeNull();
76
+ expect(screen.getByText("No files yet.")).toBeInTheDocument();
77
+
78
+ rerender(<FileList files={[]} empty="Upload the lease to start." />);
79
+ expect(screen.getByText("Upload the lease to start.")).toBeInTheDocument();
80
+ });
81
+ });
@@ -0,0 +1,167 @@
1
+ /**
2
+ * FileList — a flat list of documents: each row names the file and its kind,
3
+ * size, date and uploader; selecting the row opens it, and the row's own
4
+ * buttons download or delete it.
5
+ */
6
+
7
+ import * as React from "react";
8
+ import { Download, Trash2 } from "lucide-react";
9
+ import { focusRingInset } from "../lib/focus";
10
+ import { cn } from "../lib/utils";
11
+ import { Button } from "../primitives/button";
12
+ import {
13
+ FactSeparator,
14
+ FileFacts,
15
+ FileKindTile,
16
+ uploaderLabel,
17
+ type FileDetails,
18
+ } from "./file-card";
19
+
20
+ export interface FileListItem extends FileDetails {
21
+ /** Stable key, and what `activeId` matches. */
22
+ id: string;
23
+ }
24
+
25
+ export interface FileListProps<T extends FileListItem = FileListItem>
26
+ extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
27
+ files: readonly T[];
28
+ /** Opens a file, usually in a preview. Makes each row a button. */
29
+ onOpen?: (file: T) => void;
30
+ /** Adds a Download button to each row. */
31
+ onDownload?: (file: T) => void;
32
+ /**
33
+ * Adds a Delete button to each row. It calls this at once, so a host that
34
+ * wants a confirmation asks for it here.
35
+ */
36
+ onDelete?: (file: T) => void;
37
+ /** The open file. Its row is marked as the current one. */
38
+ activeId?: string;
39
+ /** Shown in place of the list when `files` is empty. */
40
+ empty?: React.ReactNode;
41
+ }
42
+
43
+ export function FileList<T extends FileListItem>({
44
+ files,
45
+ onOpen,
46
+ onDownload,
47
+ onDelete,
48
+ activeId,
49
+ empty = "No files yet.",
50
+ className,
51
+ ...props
52
+ }: FileListProps<T>) {
53
+ if (files.length === 0) {
54
+ return (
55
+ <div
56
+ className={cn(
57
+ "rounded-[var(--radius-md)] border border-border bg-card px-4 py-10 text-center text-sm text-muted-foreground",
58
+ className,
59
+ )}
60
+ {...props}
61
+ >
62
+ {empty}
63
+ </div>
64
+ );
65
+ }
66
+
67
+ return (
68
+ <ul
69
+ className={cn(
70
+ "divide-y divide-border overflow-hidden rounded-[var(--radius-md)] border border-border bg-card",
71
+ className,
72
+ )}
73
+ {...props}
74
+ >
75
+ {files.map((file) => {
76
+ const active = file.id === activeId;
77
+ const body = (
78
+ <>
79
+ <FileKindTile filename={file.filename} mimeType={file.mimeType} />
80
+ {/* On a phone the name may take two lines and the uploader takes
81
+ its own, since one line of facts cannot hold who sent the file. */}
82
+ <span className="grid min-w-0 flex-1">
83
+ <span className="line-clamp-2 text-sm font-medium text-foreground [overflow-wrap:anywhere] sm:line-clamp-1">
84
+ {file.filename}
85
+ </span>
86
+ <span className="truncate text-xs text-muted-foreground">
87
+ <FileFacts
88
+ filename={file.filename}
89
+ mimeType={file.mimeType}
90
+ size={file.size}
91
+ date={file.date}
92
+ showKind
93
+ />
94
+ {file.uploader && (
95
+ <span className="hidden sm:inline">
96
+ <FactSeparator />
97
+ {uploaderLabel(file.uploader)}
98
+ </span>
99
+ )}
100
+ </span>
101
+ {file.uploader && (
102
+ <span className="truncate text-xs text-muted-foreground sm:hidden">
103
+ {uploaderLabel(file.uploader)}
104
+ </span>
105
+ )}
106
+ </span>
107
+ </>
108
+ );
109
+ return (
110
+ <li
111
+ key={file.id}
112
+ className={cn(
113
+ "flex items-center gap-1 pr-2",
114
+ active ? "bg-primary/10" : onOpen && "hover:bg-[var(--bg-hover)]",
115
+ )}
116
+ >
117
+ {onOpen ? (
118
+ <button
119
+ type="button"
120
+ onClick={() => onOpen(file)}
121
+ aria-current={active ? "true" : undefined}
122
+ className={cn(
123
+ "flex min-w-0 flex-1 items-center gap-3 py-2.5 pl-3 text-left",
124
+ focusRingInset,
125
+ )}
126
+ >
127
+ {body}
128
+ </button>
129
+ ) : (
130
+ <div
131
+ aria-current={active ? "true" : undefined}
132
+ className="flex min-w-0 flex-1 items-center gap-3 py-2.5 pl-3"
133
+ >
134
+ {body}
135
+ </div>
136
+ )}
137
+ {onDownload && (
138
+ <Button
139
+ type="button"
140
+ variant="ghost"
141
+ size="icon"
142
+ aria-label={`Download ${file.filename}`}
143
+ title="Download"
144
+ onClick={() => onDownload(file)}
145
+ >
146
+ <Download />
147
+ </Button>
148
+ )}
149
+ {onDelete && (
150
+ <Button
151
+ type="button"
152
+ variant="ghost"
153
+ size="icon"
154
+ aria-label={`Delete ${file.filename}`}
155
+ title="Delete"
156
+ className="hover:text-[var(--surface-danger-text)]"
157
+ onClick={() => onDelete(file)}
158
+ >
159
+ <Trash2 />
160
+ </Button>
161
+ )}
162
+ </li>
163
+ );
164
+ })}
165
+ </ul>
166
+ );
167
+ }
@@ -2,7 +2,7 @@
2
2
  * FilePreview — universal file renderer.
3
3
  *
4
4
  * Renders any file type:
5
- * - PDF: embedded viewer at full pane height
5
+ * - PDF: PdfViewer's rendered pages at full pane height
6
6
  * - Images: fit to the pane on a checker ground; click toggles natural size
7
7
  * - Video / audio: native players
8
8
  * - CSV: sticky-header table, capped at CSV_PREVIEW_ROW_LIMIT rows
@@ -14,21 +14,13 @@
14
14
  */
15
15
 
16
16
  import { useState } from "react";
17
- import {
18
- Download,
19
- FileSpreadsheet,
20
- FileText,
21
- Music,
22
- X,
23
- type LucideIcon,
24
- } from "lucide-react";
17
+ import { Download, Music, X } from "lucide-react";
25
18
  import type { UrlTransform } from "react-markdown";
26
19
  import { focusRingInset } from "../lib/focus";
27
20
  import { cn } from "../lib/utils";
28
- import { Button } from "../primitives/button";
29
21
  import { Markdown } from "../markdown/markdown";
30
22
  import { CodeBlock, CopyButton } from "../markdown/code-block";
31
- import { formatBytes } from "../utils/format";
23
+ import { FileCard } from "./file-card";
32
24
  import {
33
25
  fileExtension,
34
26
  getCodeLanguage,
@@ -36,6 +28,7 @@ import {
36
28
  resolveFilePreviewKind,
37
29
  type FilePreviewKind,
38
30
  } from "./file-format";
31
+ import { PdfViewer } from "./pdf-viewer";
39
32
 
40
33
  export interface FilePreviewProps {
41
34
  filename: string;
@@ -239,38 +232,6 @@ function ImagePreview({ src, filename }: { src: string; filename: string }) {
239
232
  );
240
233
  }
241
234
 
242
- function PdfPreview({
243
- blobUrl,
244
- filename,
245
- size,
246
- onDownload,
247
- }: {
248
- blobUrl: string;
249
- filename: string;
250
- size?: number;
251
- onDownload?: () => void;
252
- }) {
253
- // <object> lets a browser without an inline PDF viewer render the fallback
254
- // card instead of a blank frame. The viewer draws its own chrome, so the
255
- // object has no border; the card brings its own and fills the same height.
256
- return (
257
- <object
258
- data={blobUrl}
259
- type="application/pdf"
260
- title={filename}
261
- className="min-h-[24rem] w-full flex-1 rounded-[var(--radius-md)]"
262
- >
263
- <DownloadCard
264
- filename={filename}
265
- size={size}
266
- sentence="This browser does not show PDFs inline."
267
- onDownload={onDownload}
268
- className="h-full"
269
- />
270
- </object>
271
- );
272
- }
273
-
274
235
  function VideoPreview({ src, filename }: { src: string; filename: string }) {
275
236
  return (
276
237
  <div className="flex min-h-[12rem] flex-1 items-center justify-center overflow-hidden rounded-[var(--radius-md)] border border-border bg-background">
@@ -318,52 +279,10 @@ function MarkdownPreview({
318
279
  );
319
280
  }
320
281
 
321
- /**
322
- * One muted sentence plus the filename, its size when known, and a Download
323
- * button when the host wires `onDownload`. Serves spreadsheets, binaries, and
324
- * every kind whose source (blob or text) is missing.
325
- */
326
- function DownloadCard({
327
- filename,
328
- size,
329
- sentence,
330
- icon: Icon = FileText,
331
- onDownload,
332
- className,
333
- }: {
334
- filename: string;
335
- size?: number;
336
- sentence: string;
337
- icon?: LucideIcon;
338
- onDownload?: () => void;
339
- className?: string;
340
- }) {
341
- return (
342
- <div
343
- className={cn(
344
- "flex flex-col items-center justify-center rounded-[var(--radius-md)] border border-dashed border-border bg-background px-6 py-16 text-center",
345
- className,
346
- )}
347
- >
348
- <Icon className="mb-3 h-12 w-12 text-muted-foreground opacity-40" />
349
- <p className="max-w-full truncate text-sm font-medium text-foreground">{filename}</p>
350
- {size !== undefined && (
351
- <p className="mt-1 text-xs text-muted-foreground">{formatBytes(size)}</p>
352
- )}
353
- <p className="mt-3 max-w-md text-sm text-muted-foreground">{sentence}</p>
354
- {onDownload && (
355
- <Button type="button" variant="outline" size="sm" className="mt-5" onClick={onDownload}>
356
- <Download />
357
- Download
358
- </Button>
359
- )}
360
- </div>
361
- );
362
- }
363
-
364
282
  function PreviewBody({
365
283
  kind,
366
284
  filename,
285
+ mimeType,
367
286
  content,
368
287
  blobUrl,
369
288
  size,
@@ -372,6 +291,7 @@ function PreviewBody({
372
291
  }: {
373
292
  kind: FilePreviewKind;
374
293
  filename: string;
294
+ mimeType?: string;
375
295
  content?: string;
376
296
  blobUrl?: string;
377
297
  size?: number;
@@ -379,12 +299,14 @@ function PreviewBody({
379
299
  onDownload?: () => void;
380
300
  }) {
381
301
  const hasText = typeof content === "string";
382
- const card = (sentence: string, icon?: LucideIcon) => (
383
- <DownloadCard
302
+ // The card takes its icon from the kind, so a spreadsheet or a recording
303
+ // reads as one even when it cannot be shown.
304
+ const card = (sentence: string) => (
305
+ <FileCard
384
306
  filename={filename}
307
+ mimeType={mimeType}
385
308
  size={size}
386
- sentence={sentence}
387
- icon={icon}
309
+ description={sentence}
388
310
  onDownload={onDownload}
389
311
  />
390
312
  );
@@ -392,7 +314,15 @@ function PreviewBody({
392
314
  switch (kind) {
393
315
  case "pdf":
394
316
  if (blobUrl) {
395
- return <PdfPreview blobUrl={blobUrl} filename={filename} size={size} onDownload={onDownload} />;
317
+ return (
318
+ <PdfViewer
319
+ src={blobUrl}
320
+ filename={filename}
321
+ size={size}
322
+ onDownload={onDownload}
323
+ className="min-h-[24rem] flex-1"
324
+ />
325
+ );
396
326
  }
397
327
  return card(NEEDS_DOWNLOAD_LINK);
398
328
  case "image":
@@ -405,10 +335,10 @@ function PreviewBody({
405
335
  return card(NEEDS_DOWNLOAD_LINK);
406
336
  case "audio":
407
337
  if (blobUrl) return <AudioPreview src={blobUrl} filename={filename} />;
408
- return card(NEEDS_DOWNLOAD_LINK, Music);
338
+ return card(NEEDS_DOWNLOAD_LINK);
409
339
  case "csv":
410
340
  if (hasText) return <CsvPreview content={content} />;
411
- return card(NO_INLINE_CONTENT, FileSpreadsheet);
341
+ return card(NO_INLINE_CONTENT);
412
342
  case "code":
413
343
  case "json":
414
344
  case "yaml":
@@ -421,7 +351,7 @@ function PreviewBody({
421
351
  if (hasText) return <TextPreview content={content} />;
422
352
  return card(NO_INLINE_CONTENT);
423
353
  case "spreadsheet":
424
- return card("Download to open this workbook in a spreadsheet app.", FileSpreadsheet);
354
+ return card("Download to open this workbook in a spreadsheet app.");
425
355
  case "binary":
426
356
  if (hasText) return <TextPreview content={content} />;
427
357
  return card(blobUrl ? NO_INLINE_PREVIEW : NEEDS_DOWNLOAD_LINK);
@@ -482,6 +412,7 @@ export function FilePreview({
482
412
  <PreviewBody
483
413
  kind={kind}
484
414
  filename={filename}
415
+ mimeType={mimeType}
485
416
  content={content}
486
417
  blobUrl={blobUrl}
487
418
  size={size}
@@ -18,6 +18,15 @@ export {
18
18
  parseCsv,
19
19
  type FilePreviewProps,
20
20
  } from "./file-preview";
21
+ export { PdfViewer, type PdfViewerProps } from "./pdf-viewer";
22
+ export { configurePdfViewer, type PdfViewerConfig } from "./pdf-loader";
23
+ export {
24
+ FileCard,
25
+ type FileCardProps,
26
+ type FileDetails,
27
+ type FileUploader,
28
+ } from "./file-card";
29
+ export { FileList, type FileListProps, type FileListItem } from "./file-list";
21
30
  export { FileTabs, type FileTabsProps, type FileTabData } from "./file-tabs";
22
31
  export { FileArtifactPane, type FileArtifactPaneProps } from "./file-artifact-pane";
23
32
  export {