@proteos/ui 0.31.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 (226) hide show
  1. package/LICENSE +105 -0
  2. package/dist/chunk-IKAX76MB.js +113 -0
  3. package/dist/chunk-IKAX76MB.js.map +1 -0
  4. package/dist/chunk-MWAXK66H.cjs +119 -0
  5. package/dist/chunk-MWAXK66H.cjs.map +1 -0
  6. package/dist/index.cjs +6246 -0
  7. package/dist/index.cjs.map +1 -0
  8. package/dist/index.d.cts +2126 -0
  9. package/dist/index.d.ts +2126 -0
  10. package/dist/index.js +6069 -0
  11. package/dist/index.js.map +1 -0
  12. package/dist/pdf-view-3ZMMS6ML.cjs +95 -0
  13. package/dist/pdf-view-3ZMMS6ML.cjs.map +1 -0
  14. package/dist/pdf-view-BV5ZD6OC.js +93 -0
  15. package/dist/pdf-view-BV5ZD6OC.js.map +1 -0
  16. package/package.json +96 -0
  17. package/src/components/attachment-chip/attachment-chip.tsx +129 -0
  18. package/src/components/attachment-chip/index.ts +2 -0
  19. package/src/components/attribute-type-icon/attribute-type-icon.stories.tsx +70 -0
  20. package/src/components/attribute-type-icon/attribute-type-icon.tsx +125 -0
  21. package/src/components/attribute-type-icon/index.ts +8 -0
  22. package/src/components/avatar/avatar.stories.tsx +42 -0
  23. package/src/components/avatar/avatar.tsx +48 -0
  24. package/src/components/avatar/index.ts +2 -0
  25. package/src/components/avatar-group/avatar-group.test.tsx +38 -0
  26. package/src/components/avatar-group/avatar-group.tsx +60 -0
  27. package/src/components/avatar-group/index.ts +2 -0
  28. package/src/components/badge/badge.stories.tsx +62 -0
  29. package/src/components/badge/badge.test.tsx +36 -0
  30. package/src/components/badge/badge.tsx +54 -0
  31. package/src/components/badge/index.ts +2 -0
  32. package/src/components/badge-multi-select/badge-multi-select.stories.tsx +177 -0
  33. package/src/components/badge-multi-select/badge-multi-select.test.tsx +155 -0
  34. package/src/components/badge-multi-select/badge-multi-select.tsx +272 -0
  35. package/src/components/badge-multi-select/index.ts +2 -0
  36. package/src/components/breadcrumbs/breadcrumbs.stories.tsx +43 -0
  37. package/src/components/breadcrumbs/breadcrumbs.tsx +86 -0
  38. package/src/components/breadcrumbs/index.ts +2 -0
  39. package/src/components/button/button.stories.tsx +290 -0
  40. package/src/components/button/button.test.tsx +59 -0
  41. package/src/components/button/button.tsx +86 -0
  42. package/src/components/button/index.ts +2 -0
  43. package/src/components/card/card.stories.tsx +351 -0
  44. package/src/components/card/card.test.tsx +141 -0
  45. package/src/components/card/card.tsx +173 -0
  46. package/src/components/card/index.ts +13 -0
  47. package/src/components/checkbox/checkbox-group.tsx +52 -0
  48. package/src/components/checkbox/checkbox.stories.tsx +48 -0
  49. package/src/components/checkbox/checkbox.test.tsx +50 -0
  50. package/src/components/checkbox/checkbox.tsx +71 -0
  51. package/src/components/checkbox/index.ts +4 -0
  52. package/src/components/combobox/combobox.stories.tsx +97 -0
  53. package/src/components/combobox/combobox.test.tsx +123 -0
  54. package/src/components/combobox/combobox.tsx +239 -0
  55. package/src/components/combobox/index.ts +2 -0
  56. package/src/components/command/command-hint.tsx +46 -0
  57. package/src/components/command/command.stories.tsx +88 -0
  58. package/src/components/command/command.test.tsx +102 -0
  59. package/src/components/command/command.tsx +76 -0
  60. package/src/components/command/index.ts +16 -0
  61. package/src/components/currency-input/currency-input.tsx +192 -0
  62. package/src/components/currency-input/index.ts +6 -0
  63. package/src/components/data-table/cells.stories.tsx +120 -0
  64. package/src/components/data-table/cells.tsx +165 -0
  65. package/src/components/data-table/data-table.stories.tsx +371 -0
  66. package/src/components/data-table/data-table.tsx +556 -0
  67. package/src/components/data-table/index.ts +22 -0
  68. package/src/components/data-table/make-actions-column.ts +30 -0
  69. package/src/components/data-table/states/data-table-empty.tsx +40 -0
  70. package/src/components/data-table/states/data-table-error.tsx +30 -0
  71. package/src/components/data-table/states/data-table-skeleton.tsx +42 -0
  72. package/src/components/data-table/use-infinite-scroll-sentinel.ts +51 -0
  73. package/src/components/date-picker/calendar-internals.tsx +138 -0
  74. package/src/components/date-picker/date-picker.stories.tsx +78 -0
  75. package/src/components/date-picker/date-picker.test.tsx +71 -0
  76. package/src/components/date-picker/date-picker.tsx +140 -0
  77. package/src/components/date-picker/index.ts +2 -0
  78. package/src/components/date-time-picker/date-time-picker.stories.tsx +96 -0
  79. package/src/components/date-time-picker/date-time-picker.test.tsx +87 -0
  80. package/src/components/date-time-picker/date-time-picker.tsx +203 -0
  81. package/src/components/date-time-picker/index.ts +2 -0
  82. package/src/components/empty-state/empty-state.tsx +63 -0
  83. package/src/components/empty-state/index.ts +2 -0
  84. package/src/components/error-animation/error-animation.stories.tsx +50 -0
  85. package/src/components/error-animation/error-animation.test.tsx +22 -0
  86. package/src/components/error-animation/error-animation.tsx +26 -0
  87. package/src/components/error-animation/index.ts +2 -0
  88. package/src/components/field/field.tsx +142 -0
  89. package/src/components/field/index.ts +8 -0
  90. package/src/components/file-field/file-field.tsx +285 -0
  91. package/src/components/file-field/index.ts +2 -0
  92. package/src/components/file-viewer/fallback-card.tsx +68 -0
  93. package/src/components/file-viewer/file-viewer.stories.tsx +67 -0
  94. package/src/components/file-viewer/file-viewer.test.tsx +81 -0
  95. package/src/components/file-viewer/file-viewer.tsx +116 -0
  96. package/src/components/file-viewer/image-view.tsx +38 -0
  97. package/src/components/file-viewer/index.ts +7 -0
  98. package/src/components/file-viewer/pdf-view.tsx +122 -0
  99. package/src/components/file-viewer/video-view.tsx +16 -0
  100. package/src/components/image-attachment/image-attachment.stories.tsx +39 -0
  101. package/src/components/image-attachment/image-attachment.test.tsx +54 -0
  102. package/src/components/image-attachment/image-attachment.tsx +77 -0
  103. package/src/components/image-attachment/index.ts +3 -0
  104. package/src/components/image-attachment/is-previewable-image-name.ts +14 -0
  105. package/src/components/image-lightbox/image-lightbox.stories.tsx +38 -0
  106. package/src/components/image-lightbox/image-lightbox.test.tsx +72 -0
  107. package/src/components/image-lightbox/image-lightbox.tsx +92 -0
  108. package/src/components/image-lightbox/index.ts +2 -0
  109. package/src/components/input/index.ts +2 -0
  110. package/src/components/input/input.stories.tsx +71 -0
  111. package/src/components/input/input.test.tsx +36 -0
  112. package/src/components/input/input.tsx +76 -0
  113. package/src/components/kbd/index.ts +2 -0
  114. package/src/components/kbd/kbd.stories.tsx +34 -0
  115. package/src/components/kbd/kbd.tsx +27 -0
  116. package/src/components/menu/index.ts +2 -0
  117. package/src/components/menu/menu.tsx +121 -0
  118. package/src/components/modal/index.ts +10 -0
  119. package/src/components/modal/modal.stories.tsx +153 -0
  120. package/src/components/modal/modal.test.tsx +93 -0
  121. package/src/components/modal/modal.tsx +149 -0
  122. package/src/components/multi-select/index.ts +2 -0
  123. package/src/components/multi-select/multi-select.stories.tsx +211 -0
  124. package/src/components/multi-select/multi-select.test.tsx +151 -0
  125. package/src/components/multi-select/multi-select.tsx +339 -0
  126. package/src/components/nav/index.ts +7 -0
  127. package/src/components/nav/nav.stories.tsx +139 -0
  128. package/src/components/nav/nav.tsx +246 -0
  129. package/src/components/popover/index.ts +8 -0
  130. package/src/components/popover/popover.stories.tsx +92 -0
  131. package/src/components/popover/popover.tsx +135 -0
  132. package/src/components/progress-bar/index.ts +2 -0
  133. package/src/components/progress-bar/progress-bar.stories.tsx +75 -0
  134. package/src/components/progress-bar/progress-bar.test.tsx +29 -0
  135. package/src/components/progress-bar/progress-bar.tsx +57 -0
  136. package/src/components/radio-group/index.ts +2 -0
  137. package/src/components/radio-group/radio-group.stories.tsx +113 -0
  138. package/src/components/radio-group/radio-group.test.tsx +87 -0
  139. package/src/components/radio-group/radio-group.tsx +104 -0
  140. package/src/components/record-filter/attribute-picker.tsx +74 -0
  141. package/src/components/record-filter/chip-value.tsx +171 -0
  142. package/src/components/record-filter/filter-row.tsx +262 -0
  143. package/src/components/record-filter/filter-value-input.tsx +351 -0
  144. package/src/components/record-filter/group-add-controls.tsx +77 -0
  145. package/src/components/record-filter/group-body.tsx +173 -0
  146. package/src/components/record-filter/group-mutations.test.ts +104 -0
  147. package/src/components/record-filter/group-mutations.ts +116 -0
  148. package/src/components/record-filter/index.ts +58 -0
  149. package/src/components/record-filter/nested-group.tsx +138 -0
  150. package/src/components/record-filter/operator-picker.tsx +62 -0
  151. package/src/components/record-filter/operators.test.ts +105 -0
  152. package/src/components/record-filter/operators.ts +97 -0
  153. package/src/components/record-filter/record-filter-builder-panel.tsx +236 -0
  154. package/src/components/record-filter/record-filter-button.tsx +126 -0
  155. package/src/components/record-filter/record-filter-chips.tsx +527 -0
  156. package/src/components/record-filter/record-filter-editor.tsx +91 -0
  157. package/src/components/record-filter/record-filter.stories.tsx +196 -0
  158. package/src/components/record-filter/record-filter.test.tsx +437 -0
  159. package/src/components/record-filter/show-details-store.ts +72 -0
  160. package/src/components/record-filter/types.ts +150 -0
  161. package/src/components/segmented-control/index.ts +6 -0
  162. package/src/components/segmented-control/segmented-control.stories.tsx +67 -0
  163. package/src/components/segmented-control/segmented-control.test.tsx +47 -0
  164. package/src/components/segmented-control/segmented-control.tsx +105 -0
  165. package/src/components/select/index.ts +2 -0
  166. package/src/components/select/select.stories.tsx +76 -0
  167. package/src/components/select/select.test.tsx +56 -0
  168. package/src/components/select/select.tsx +159 -0
  169. package/src/components/separator/index.ts +2 -0
  170. package/src/components/separator/separator.stories.tsx +73 -0
  171. package/src/components/separator/separator.test.tsx +29 -0
  172. package/src/components/separator/separator.tsx +51 -0
  173. package/src/components/skeleton/index.ts +2 -0
  174. package/src/components/skeleton/skeleton.stories.tsx +68 -0
  175. package/src/components/skeleton/skeleton.test.tsx +38 -0
  176. package/src/components/skeleton/skeleton.tsx +43 -0
  177. package/src/components/success-animation/index.ts +2 -0
  178. package/src/components/success-animation/success-animation.stories.tsx +50 -0
  179. package/src/components/success-animation/success-animation.test.tsx +22 -0
  180. package/src/components/success-animation/success-animation.tsx +26 -0
  181. package/src/components/switch/index.ts +2 -0
  182. package/src/components/switch/switch.stories.tsx +60 -0
  183. package/src/components/switch/switch.test.tsx +50 -0
  184. package/src/components/switch/switch.tsx +49 -0
  185. package/src/components/table/index.ts +25 -0
  186. package/src/components/table/table.stories.tsx +306 -0
  187. package/src/components/table/table.test.tsx +115 -0
  188. package/src/components/table/table.tsx +543 -0
  189. package/src/components/tabs/index.ts +8 -0
  190. package/src/components/tabs/tabs.tsx +141 -0
  191. package/src/components/tag-input/index.ts +2 -0
  192. package/src/components/tag-input/tag-input.stories.tsx +73 -0
  193. package/src/components/tag-input/tag-input.test.tsx +80 -0
  194. package/src/components/tag-input/tag-input.tsx +193 -0
  195. package/src/components/textarea/index.ts +2 -0
  196. package/src/components/textarea/textarea.stories.tsx +75 -0
  197. package/src/components/textarea/textarea.test.tsx +41 -0
  198. package/src/components/textarea/textarea.tsx +86 -0
  199. package/src/components/time-field/index.ts +2 -0
  200. package/src/components/time-field/time-field.stories.tsx +76 -0
  201. package/src/components/time-field/time-field.test.tsx +51 -0
  202. package/src/components/time-field/time-field.tsx +98 -0
  203. package/src/components/tooltip/index.ts +2 -0
  204. package/src/components/tooltip/tooltip.stories.tsx +176 -0
  205. package/src/components/tooltip/tooltip.test.tsx +52 -0
  206. package/src/components/tooltip/tooltip.tsx +56 -0
  207. package/src/index.ts +47 -0
  208. package/src/lib/cn.ts +6 -0
  209. package/src/lib/commands/discovery-store.test.tsx +76 -0
  210. package/src/lib/commands/discovery-store.ts +110 -0
  211. package/src/lib/commands/index.ts +9 -0
  212. package/src/lib/commands/match-event.test.ts +99 -0
  213. package/src/lib/commands/match-event.ts +31 -0
  214. package/src/lib/commands/parse-combo.test.ts +65 -0
  215. package/src/lib/commands/parse-combo.ts +48 -0
  216. package/src/lib/commands/platform.ts +27 -0
  217. package/src/lib/commands/protected-combos.test.ts +18 -0
  218. package/src/lib/commands/protected-combos.ts +38 -0
  219. package/src/lib/commands/registry.test.ts +97 -0
  220. package/src/lib/commands/registry.ts +59 -0
  221. package/src/lib/commands/use-command.test.tsx +91 -0
  222. package/src/lib/commands/use-command.ts +32 -0
  223. package/src/lib/commands/use-escape-key.test.tsx +97 -0
  224. package/src/lib/commands/use-escape-key.ts +62 -0
  225. package/src/styles.css +321 -0
  226. package/src/test/setup.ts +9 -0
@@ -0,0 +1,285 @@
1
+ import {
2
+ File as FileIcon,
3
+ FileArchive,
4
+ FileImage,
5
+ FileSpreadsheet,
6
+ FileText,
7
+ Loader2,
8
+ Paperclip,
9
+ X,
10
+ } from 'lucide-react'
11
+ import type { LucideIcon } from 'lucide-react'
12
+ import type { ComponentProps } from 'react'
13
+ import { DropZone, FileTrigger, Heading, Text } from 'react-aria-components'
14
+ import { tv } from 'tailwind-variants'
15
+
16
+ import { cn } from '../../lib/cn'
17
+ import { Button } from '../button'
18
+ import {
19
+ PopoverContent,
20
+ PopoverPanelBody,
21
+ PopoverPanelFooter,
22
+ PopoverPanelHeader,
23
+ Popover,
24
+ } from '../popover'
25
+
26
+ /* =========================================================================
27
+ FileField — presentational control for a single-file attachment.
28
+
29
+ Pure presentation: it knows nothing about the storage-service. The
30
+ consumer wires uploads/downloads/deletes through the callbacks. Two
31
+ states:
32
+
33
+ - empty → a click-to-pick control that doubles as a drop zone
34
+ (highlights on drag-over). Picking or dropping a file
35
+ calls `onSelectFile`.
36
+ - filled → a card showing a file-type icon, the name (clicking the
37
+ card calls `onDownload`), and below it the size + an (x)
38
+ that opens a confirm popover before calling `onDelete`.
39
+
40
+ One file per field — selecting a new file replaces the current one.
41
+ ========================================================================= */
42
+
43
+ export interface FileFieldValue {
44
+ /** Filename shown as the label. */
45
+ name: string
46
+ /** Size in bytes — rendered when known (resolved from storage on read). */
47
+ sizeInBytes?: number
48
+ /** MIME type — picks the file-type icon when known. */
49
+ contentType?: string
50
+ }
51
+
52
+ export interface FileFieldProps {
53
+ /** The attached file, or null/undefined when empty. */
54
+ value?: FileFieldValue | null
55
+ /** True while an upload is in flight — shows a spinner, blocks re-pick. */
56
+ isUploading?: boolean
57
+ /** Read-only: no picker, no delete — download still works. */
58
+ isReadOnly?: boolean
59
+ /** Restrict the native picker (e.g. `['image/*', '.pdf']`). */
60
+ acceptedFileTypes?: string[]
61
+ /** Called with the picked/dropped File. */
62
+ onSelectFile?: (file: File) => void
63
+ /** Called when the filled card is clicked. */
64
+ onDownload?: () => void
65
+ /** Called after the user confirms removal in the (x) popover. */
66
+ onDelete?: () => void
67
+ /** Inline validation message (edit surface). */
68
+ errorMessage?: string
69
+ /** True iff the field is touched and currently invalid. */
70
+ isInvalid?: boolean
71
+ className?: string
72
+ }
73
+
74
+ const dropZoneStyles = tv({
75
+ base: [
76
+ 'flex items-center justify-center gap-2 rounded-md border border-dashed border-rule',
77
+ 'bg-surface-2/40 px-3 py-4 text-ink-3 transition-colors duration-150 outline-none',
78
+ 'data-[drop-target]:border-ink-4 data-[drop-target]:bg-surface-2 data-[drop-target]:text-ink',
79
+ 'focus-visible:shadow-[var(--shadow-focus)]',
80
+ ],
81
+ variants: {
82
+ isInvalid: { true: 'border-danger' },
83
+ },
84
+ })
85
+
86
+ const cardStyles = tv({
87
+ base: [
88
+ 'group relative flex w-full items-start gap-3 rounded-md border border-rule bg-surface px-3 py-2.5',
89
+ 'text-left transition-colors duration-150',
90
+ ],
91
+ variants: {
92
+ isClickable: {
93
+ true: 'cursor-pointer hover:border-ink-4 hover:bg-surface-2 outline-none focus-visible:shadow-[var(--shadow-focus)]',
94
+ false: 'cursor-default',
95
+ },
96
+ },
97
+ })
98
+
99
+ /** Maps a MIME type to a file-type glyph. Falls back to a generic file. */
100
+ export function iconForContentType(contentType?: string): LucideIcon {
101
+ if (!contentType) return FileIcon
102
+ if (contentType.startsWith('image/')) return FileImage
103
+ if (contentType === 'application/pdf') return FileText
104
+ if (contentType.startsWith('text/')) return FileText
105
+ if (
106
+ contentType.includes('spreadsheet') ||
107
+ contentType.includes('excel') ||
108
+ contentType === 'text/csv'
109
+ ) {
110
+ return FileSpreadsheet
111
+ }
112
+ if (
113
+ contentType.includes('zip') ||
114
+ contentType.includes('tar') ||
115
+ contentType.includes('compressed') ||
116
+ contentType.includes('archive')
117
+ ) {
118
+ return FileArchive
119
+ }
120
+ return FileIcon
121
+ }
122
+
123
+ /** Human-readable byte size (1 KB = 1024 B). */
124
+ export function formatFileSize(bytes: number | undefined): string {
125
+ if (bytes == null || !Number.isFinite(bytes) || bytes < 0) return ''
126
+ if (bytes < 1024) return `${bytes} B`
127
+ const units = ['KB', 'MB', 'GB', 'TB']
128
+ let size = bytes / 1024
129
+ let unit = 0
130
+ while (size >= 1024 && unit < units.length - 1) {
131
+ size /= 1024
132
+ unit += 1
133
+ }
134
+ return `${size < 10 ? size.toFixed(1) : Math.round(size)} ${units[unit]}`
135
+ }
136
+
137
+ export function FileField({
138
+ value,
139
+ isUploading = false,
140
+ isReadOnly = false,
141
+ acceptedFileTypes,
142
+ onSelectFile,
143
+ onDownload,
144
+ onDelete,
145
+ errorMessage,
146
+ isInvalid = false,
147
+ className,
148
+ }: FileFieldProps) {
149
+ const handleSelect = (files: FileList | null) => {
150
+ const file = files?.item(0)
151
+ if (file) onSelectFile?.(file)
152
+ }
153
+
154
+ const handleDrop: NonNullable<ComponentProps<typeof DropZone>['onDrop']> = async (event) => {
155
+ const item = event.items.find((candidate) => candidate.kind === 'file')
156
+ if (item && item.kind === 'file') {
157
+ onSelectFile?.(await item.getFile())
158
+ }
159
+ }
160
+
161
+ // ---- Empty state -------------------------------------------------------
162
+ if (!value) {
163
+ if (isReadOnly) {
164
+ return <span className={cn('text-ink-4', className)}>—</span>
165
+ }
166
+
167
+ if (isUploading) {
168
+ return (
169
+ <div className={cn(dropZoneStyles({ isInvalid }), className)}>
170
+ <Loader2 className="size-3.5 animate-spin" aria-hidden />
171
+ <span className="font-sans text-[12.5px]">Uploading…</span>
172
+ </div>
173
+ )
174
+ }
175
+
176
+ return (
177
+ <div className={className}>
178
+ <DropZone
179
+ aria-label="Drop a file to upload"
180
+ onDrop={handleDrop}
181
+ className={dropZoneStyles({ isInvalid })}
182
+ >
183
+ <Paperclip className="size-3.5 shrink-0" aria-hidden />
184
+ <Text slot="label" className="font-sans text-[12.5px]">
185
+ Drop a file here, or
186
+ </Text>
187
+ <FileTrigger
188
+ onSelect={handleSelect}
189
+ {...(acceptedFileTypes ? { acceptedFileTypes } : {})}
190
+ >
191
+ <Button variant="ghost" size="sm">
192
+ Browse
193
+ </Button>
194
+ </FileTrigger>
195
+ </DropZone>
196
+ {isInvalid && errorMessage ? (
197
+ <p className="mt-1 font-sans text-[11.5px] text-danger">{errorMessage}</p>
198
+ ) : null}
199
+ </div>
200
+ )
201
+ }
202
+
203
+ // ---- Filled state ------------------------------------------------------
204
+ const Icon = isUploading ? Loader2 : iconForContentType(value.contentType)
205
+ const isClickable = Boolean(onDownload) && !isUploading
206
+
207
+ return (
208
+ <div className={className}>
209
+ <div className={cardStyles({ isClickable })}>
210
+ {/* Download surface — the icon + name + size. A button so the whole
211
+ card is one click target; the (x) is a sibling, never nested. */}
212
+ <Button
213
+ variant="subtle"
214
+ // Reset button chrome — this is a borderless full-bleed click target.
215
+ className="m-0 flex min-w-0 flex-1 items-start gap-3 border-0 bg-transparent p-0 text-left hover:bg-transparent"
216
+ isDisabled={!isClickable}
217
+ {...(onDownload ? { onPress: onDownload } : {})}
218
+ >
219
+ <Icon
220
+ className={cn('mt-0.5 size-5 shrink-0 text-ink-3', isUploading && 'animate-spin')}
221
+ aria-hidden
222
+ strokeWidth={1.6}
223
+ />
224
+ <span className="flex min-w-0 flex-col">
225
+ <span className="truncate font-sans text-[13px] text-ink">{value.name}</span>
226
+ <span className="font-sans text-[11.5px] text-ink-4">
227
+ {isUploading ? 'Uploading…' : formatFileSize(value.sizeInBytes)}
228
+ </span>
229
+ </span>
230
+ </Button>
231
+
232
+ {!isReadOnly && onDelete && !isUploading ? (
233
+ <Popover>
234
+ <Button
235
+ aria-label="Remove file"
236
+ size="icon-sm"
237
+ variant="subtle"
238
+ className="shrink-0 text-ink-4 hover:text-danger"
239
+ >
240
+ <X className="size-3.5" />
241
+ </Button>
242
+ <PopoverContent placement="top end" panelClassName="w-[260px]">
243
+ {({ close }) => (
244
+ <>
245
+ <PopoverPanelHeader>
246
+ <Heading
247
+ slot="title"
248
+ className="m-0 font-sans text-[12.5px] font-medium text-ink"
249
+ >
250
+ Delete this file?
251
+ </Heading>
252
+ </PopoverPanelHeader>
253
+ <PopoverPanelBody className="px-3.5 py-3">
254
+ <p className="m-0 font-sans text-[12px] leading-snug text-ink-2">
255
+ This removes the attachment from this record.
256
+ </p>
257
+ </PopoverPanelBody>
258
+ <PopoverPanelFooter>
259
+ <Button variant="ghost" size="sm" onPress={close}>
260
+ Cancel
261
+ </Button>
262
+ <Button
263
+ variant="primary"
264
+ size="sm"
265
+ className={cn('bg-danger border-danger', 'hover:bg-danger hover:border-danger')}
266
+ onPress={() => {
267
+ onDelete()
268
+ close()
269
+ }}
270
+ >
271
+ Delete
272
+ </Button>
273
+ </PopoverPanelFooter>
274
+ </>
275
+ )}
276
+ </PopoverContent>
277
+ </Popover>
278
+ ) : null}
279
+ </div>
280
+ {isInvalid && errorMessage ? (
281
+ <p className="mt-1 font-sans text-[11.5px] text-danger">{errorMessage}</p>
282
+ ) : null}
283
+ </div>
284
+ )
285
+ }
@@ -0,0 +1,2 @@
1
+ export type { FileFieldProps, FileFieldValue } from './file-field'
2
+ export { FileField, formatFileSize, iconForContentType } from './file-field'
@@ -0,0 +1,68 @@
1
+ import { Download } from 'lucide-react'
2
+ import { tv, type VariantProps } from 'tailwind-variants'
3
+
4
+ import { cn } from '../../lib/cn'
5
+ import { Button } from '../button/button'
6
+ import { formatFileSize, iconForContentType } from '../file-field/file-field'
7
+
8
+ /* =========================================================================
9
+ FileViewerFallbackCard — what the FileViewer shows when it cannot render
10
+ the file inline (unpreviewable type, load error, oversized). Mirrors the
11
+ FileField card chrome: type icon, name, meta line, download action.
12
+ ========================================================================= */
13
+
14
+ export const fileViewerFallbackCardStyles = tv({
15
+ base: [
16
+ 'flex w-full items-start gap-3 rounded-md border border-rule bg-surface px-3 py-2.5',
17
+ 'text-left',
18
+ ],
19
+ })
20
+
21
+ export type FileViewerFallbackCardVariants = VariantProps<typeof fileViewerFallbackCardStyles>
22
+
23
+ export interface FileViewerFallbackCardProps {
24
+ name: string
25
+ contentType?: string
26
+ sizeInBytes?: number
27
+ /** Why the preview is unavailable (e.g. "Too large to preview"). */
28
+ note?: string
29
+ /** Load failure — rendered in the danger tone, wins over `note`. */
30
+ errorMessage?: string
31
+ onDownload?: () => void
32
+ className?: string
33
+ }
34
+
35
+ export function FileViewerFallbackCard({
36
+ name,
37
+ contentType,
38
+ sizeInBytes,
39
+ note,
40
+ errorMessage,
41
+ onDownload,
42
+ className,
43
+ }: FileViewerFallbackCardProps) {
44
+ const Icon = iconForContentType(contentType)
45
+ const size = formatFileSize(sizeInBytes)
46
+ const meta = [size, note].filter(Boolean).join(' · ')
47
+
48
+ return (
49
+ <div className={cn(fileViewerFallbackCardStyles(), className)}>
50
+ <span className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-md bg-surface-2 text-ink-3">
51
+ <Icon aria-hidden className="size-4" />
52
+ </span>
53
+ <span className="flex min-w-0 flex-1 flex-col">
54
+ <span className="truncate font-sans text-[13px] font-medium text-ink">{name}</span>
55
+ {errorMessage ? (
56
+ <span className="truncate font-sans text-[12px] text-danger">{errorMessage}</span>
57
+ ) : meta ? (
58
+ <span className="truncate font-sans text-[12px] text-ink-3">{meta}</span>
59
+ ) : null}
60
+ </span>
61
+ {onDownload ? (
62
+ <Button variant="ghost" size="icon-sm" aria-label={`Download ${name}`} onPress={onDownload}>
63
+ <Download aria-hidden />
64
+ </Button>
65
+ ) : null}
66
+ </div>
67
+ )
68
+ }
@@ -0,0 +1,67 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+
3
+ import { FileViewer } from './file-viewer'
4
+
5
+ // Inline data URI so stories need no network.
6
+ const SAMPLE_IMAGE = `data:image/svg+xml,${encodeURIComponent(
7
+ '<svg xmlns="http://www.w3.org/2000/svg" width="640" height="420"><rect width="640" height="420" fill="#d8cfc0"/><circle cx="180" cy="150" r="70" fill="#8a8171"/><path d="M0 420 L220 200 L380 340 L520 230 L640 300 V420 Z" fill="#a89f8d"/></svg>',
8
+ )}`
9
+
10
+ const meta: Meta<typeof FileViewer> = {
11
+ title: 'Components/FileViewer',
12
+ component: FileViewer,
13
+ tags: ['autodocs'],
14
+ args: {
15
+ name: 'sunset.png',
16
+ contentType: 'image/png',
17
+ src: SAMPLE_IMAGE,
18
+ heightPx: 420,
19
+ },
20
+ }
21
+
22
+ export default meta
23
+ type Story = StoryObj<typeof FileViewer>
24
+
25
+ export const Image: Story = {}
26
+
27
+ export const Loading: Story = {
28
+ parameters: { controls: { hideNoControlsWarning: true } },
29
+ render: () => <FileViewer name="pending.png" contentType="image/png" heightPx={420} />,
30
+ }
31
+
32
+ export const FallbackCard: Story = {
33
+ parameters: { controls: { hideNoControlsWarning: true } },
34
+ render: () => (
35
+ <FileViewer
36
+ name="quarterly-report.docx"
37
+ contentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document"
38
+ sizeInBytes={48213}
39
+ onDownload={() => {}}
40
+ />
41
+ ),
42
+ }
43
+
44
+ export const Oversized: Story = {
45
+ parameters: { controls: { hideNoControlsWarning: true } },
46
+ render: () => (
47
+ <FileViewer
48
+ name="raw-footage.mp4"
49
+ contentType="video/mp4"
50
+ sizeInBytes={120 * 1024 * 1024}
51
+ isOversized
52
+ onDownload={() => {}}
53
+ />
54
+ ),
55
+ }
56
+
57
+ export const LoadError: Story = {
58
+ parameters: { controls: { hideNoControlsWarning: true } },
59
+ render: () => (
60
+ <FileViewer
61
+ name="contract.pdf"
62
+ contentType="application/pdf"
63
+ errorMessage="Download failed"
64
+ onDownload={() => {}}
65
+ />
66
+ ),
67
+ }
@@ -0,0 +1,81 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { describe, expect, it, vi } from 'vitest'
3
+
4
+ import { FileViewer, viewerKindForContentType } from './file-viewer'
5
+
6
+ // pdf.js cannot run in jsdom — stub the lazy-loaded pane.
7
+ vi.mock('./pdf-view', () => ({
8
+ default: ({ name }: { name: string }) => <div data-testid="pdf-view">{name}</div>,
9
+ }))
10
+
11
+ const IMAGE_DATA_URI = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"/>')}`
12
+
13
+ describe('viewerKindForContentType', () => {
14
+ it.each([
15
+ ['image/png', 'image'],
16
+ ['image/svg+xml', 'image'],
17
+ ['video/mp4', 'video'],
18
+ ['application/pdf', 'pdf'],
19
+ ['application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'none'],
20
+ [undefined, 'none'],
21
+ ])('maps %s to %s', (contentType, kind) => {
22
+ expect(viewerKindForContentType(contentType)).toBe(kind)
23
+ })
24
+ })
25
+
26
+ describe('FileViewer', () => {
27
+ it('renders the image pane for image content', () => {
28
+ render(<FileViewer name="photo.png" contentType="image/png" src={IMAGE_DATA_URI} />)
29
+ expect(screen.getByRole('button', { name: 'Preview photo.png' })).toBeInTheDocument()
30
+ expect(screen.getByAltText('photo.png')).toBeInTheDocument()
31
+ })
32
+
33
+ it('renders the video pane for video content', () => {
34
+ render(<FileViewer name="clip.mp4" contentType="video/mp4" src="blob:clip" />)
35
+ expect(screen.getByLabelText('clip.mp4')).toBeInTheDocument()
36
+ })
37
+
38
+ it('renders the pdf pane for pdf content', async () => {
39
+ render(<FileViewer name="contract.pdf" contentType="application/pdf" src="blob:pdf" />)
40
+ expect(await screen.findByTestId('pdf-view')).toHaveTextContent('contract.pdf')
41
+ })
42
+
43
+ it('renders the fallback card for unpreviewable content', () => {
44
+ render(
45
+ <FileViewer
46
+ name="deck.pptx"
47
+ contentType="application/vnd.ms-powerpoint"
48
+ sizeInBytes={2048}
49
+ onDownload={() => {}}
50
+ />,
51
+ )
52
+ expect(screen.getByText('deck.pptx')).toBeInTheDocument()
53
+ expect(screen.getByText('2.0 KB')).toBeInTheDocument()
54
+ expect(screen.getByRole('button', { name: 'Download deck.pptx' })).toBeInTheDocument()
55
+ })
56
+
57
+ it('renders the fallback card with a note when oversized, even for previewable content', () => {
58
+ render(<FileViewer name="huge.png" contentType="image/png" src={IMAGE_DATA_URI} isOversized />)
59
+ expect(screen.getByText('Too large to preview')).toBeInTheDocument()
60
+ expect(screen.queryByAltText('huge.png')).not.toBeInTheDocument()
61
+ })
62
+
63
+ it('renders the fallback card with the error message on load failure', () => {
64
+ render(
65
+ <FileViewer name="broken.pdf" contentType="application/pdf" errorMessage="Download failed" />,
66
+ )
67
+ expect(screen.getByText('Download failed')).toBeInTheDocument()
68
+ })
69
+
70
+ it('renders a loading skeleton while previewable bytes are unresolved', () => {
71
+ render(<FileViewer name="photo.png" contentType="image/png" />)
72
+ expect(screen.getByLabelText('Loading preview of photo.png')).toBeInTheDocument()
73
+ })
74
+
75
+ it('fires onDownload from the fallback card action', () => {
76
+ const onDownload = vi.fn()
77
+ render(<FileViewer name="data.zip" contentType="application/zip" onDownload={onDownload} />)
78
+ screen.getByRole('button', { name: 'Download data.zip' }).click()
79
+ expect(onDownload).toHaveBeenCalledOnce()
80
+ })
81
+ })
@@ -0,0 +1,116 @@
1
+ import { lazy, Suspense } from 'react'
2
+ import { tv, type VariantProps } from 'tailwind-variants'
3
+
4
+ import { cn } from '../../lib/cn'
5
+ import { Skeleton } from '../skeleton/skeleton'
6
+ import { FileViewerFallbackCard } from './fallback-card'
7
+ import { ImageView } from './image-view'
8
+ import { VideoView } from './video-view'
9
+
10
+ /* =========================================================================
11
+ FileViewer — inline preview for a stored file. Renders the file itself
12
+ when the content type is previewable (image / video / PDF), otherwise a
13
+ file-type card with a download action. Pure presentation: the consumer
14
+ fetches the bytes (authenticated) and hands in a blob object URL — the
15
+ viewer never fetches anything itself.
16
+
17
+ The PDF renderer (react-pdf + the pdf.js worker) is lazy-loaded so it
18
+ lands in its own chunk and non-PDF surfaces never pay for it.
19
+ ========================================================================= */
20
+
21
+ // Code-split: pdf.js is heavy and only needed when a PDF is actually shown.
22
+ const PdfView = lazy(() => import('./pdf-view'))
23
+
24
+ export const fileViewerStyles = tv({
25
+ base: [
26
+ 'relative flex w-full flex-col overflow-hidden',
27
+ 'rounded-md border border-rule bg-surface-2',
28
+ ],
29
+ })
30
+
31
+ export type FileViewerVariants = VariantProps<typeof fileViewerStyles>
32
+
33
+ /** Which renderer a content type maps to (`none` → fallback card). */
34
+ export type FileViewerKind = 'image' | 'video' | 'pdf' | 'none'
35
+
36
+ /**
37
+ * Maps a MIME type to the viewer that can render it inline. Consumers use
38
+ * this to decide whether fetching the bytes is worthwhile at all.
39
+ */
40
+ export function viewerKindForContentType(contentType?: string): FileViewerKind {
41
+ if (!contentType) return 'none'
42
+ if (contentType.startsWith('image/')) return 'image'
43
+ if (contentType.startsWith('video/')) return 'video'
44
+ if (contentType === 'application/pdf') return 'pdf'
45
+ return 'none'
46
+ }
47
+
48
+ export interface FileViewerProps {
49
+ /** Filename — labels, alt text and the fallback card. */
50
+ name: string
51
+ /** MIME type; unknown/unpreviewable types render the fallback card. */
52
+ contentType?: string
53
+ /** Blob object URL of the file bytes. While undefined (and previewable), the skeleton renders. */
54
+ src?: string
55
+ /** Size in bytes — shown on the fallback card when known. */
56
+ sizeInBytes?: number
57
+ /** Viewer height in px for previewable files (the fallback card keeps its natural height). */
58
+ heightPx?: number
59
+ /** Error loading the file — renders the fallback card with the message. */
60
+ errorMessage?: string
61
+ /** File too large for inline preview — renders the fallback card. */
62
+ isOversized?: boolean
63
+ /** When set, download actions render (card button, lightbox, PDF toolbar). */
64
+ onDownload?: () => void
65
+ className?: string
66
+ }
67
+
68
+ const DEFAULT_HEIGHT_PX = 640
69
+
70
+ export function FileViewer({
71
+ name,
72
+ contentType,
73
+ src,
74
+ sizeInBytes,
75
+ heightPx = DEFAULT_HEIGHT_PX,
76
+ errorMessage,
77
+ isOversized = false,
78
+ onDownload,
79
+ className,
80
+ }: FileViewerProps) {
81
+ const kind = viewerKindForContentType(contentType)
82
+
83
+ const fallback = (
84
+ <FileViewerFallbackCard
85
+ name={name}
86
+ {...(contentType !== undefined ? { contentType } : {})}
87
+ {...(sizeInBytes !== undefined ? { sizeInBytes } : {})}
88
+ {...(errorMessage !== undefined ? { errorMessage } : {})}
89
+ {...(isOversized ? { note: 'Too large to preview' } : {})}
90
+ {...(onDownload ? { onDownload } : {})}
91
+ {...(className !== undefined ? { className } : {})}
92
+ />
93
+ )
94
+
95
+ if (kind === 'none' || errorMessage || isOversized) return fallback
96
+
97
+ const skeleton = (
98
+ <Skeleton aria-label={`Loading preview of ${name}`} className="h-full w-full rounded-none" />
99
+ )
100
+
101
+ return (
102
+ <div className={cn(fileViewerStyles(), className)} style={{ height: heightPx }}>
103
+ {!src ? (
104
+ skeleton
105
+ ) : kind === 'image' ? (
106
+ <ImageView name={name} src={src} {...(onDownload ? { onDownload } : {})} />
107
+ ) : kind === 'video' ? (
108
+ <VideoView name={name} src={src} />
109
+ ) : (
110
+ <Suspense fallback={skeleton}>
111
+ <PdfView name={name} src={src} {...(onDownload ? { onDownload } : {})} />
112
+ </Suspense>
113
+ )}
114
+ </div>
115
+ )
116
+ }
@@ -0,0 +1,38 @@
1
+ import { useState } from 'react'
2
+ import { Button as AriaButton } from 'react-aria-components'
3
+
4
+ import { ImageLightbox } from '../image-lightbox/image-lightbox'
5
+
6
+ /* =========================================================================
7
+ ImageView — the FileViewer's image pane. The image letterboxes inside
8
+ the viewer box; pressing it opens the full-viewport lightbox.
9
+ ========================================================================= */
10
+
11
+ export interface ImageViewProps {
12
+ name: string
13
+ src: string
14
+ onDownload?: () => void
15
+ }
16
+
17
+ export function ImageView({ name, src, onDownload }: ImageViewProps) {
18
+ const [isLightboxOpen, setIsLightboxOpen] = useState(false)
19
+
20
+ return (
21
+ <>
22
+ <AriaButton
23
+ aria-label={`Preview ${name}`}
24
+ onPress={() => setIsLightboxOpen(true)}
25
+ className="flex h-full w-full cursor-zoom-in items-center justify-center outline-none focus-visible:shadow-[var(--shadow-focus)]"
26
+ >
27
+ <img src={src} alt={name} className="max-h-full max-w-full object-contain" />
28
+ </AriaButton>
29
+ <ImageLightbox
30
+ isOpen={isLightboxOpen}
31
+ onOpenChange={setIsLightboxOpen}
32
+ src={src}
33
+ name={name}
34
+ {...(onDownload ? { onDownload } : {})}
35
+ />
36
+ </>
37
+ )
38
+ }
@@ -0,0 +1,7 @@
1
+ export type {
2
+ FileViewerFallbackCardProps,
3
+ FileViewerFallbackCardVariants,
4
+ } from './fallback-card'
5
+ export { FileViewerFallbackCard, fileViewerFallbackCardStyles } from './fallback-card'
6
+ export type { FileViewerKind, FileViewerProps, FileViewerVariants } from './file-viewer'
7
+ export { FileViewer, fileViewerStyles, viewerKindForContentType } from './file-viewer'