@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.
- package/LICENSE +105 -0
- package/dist/chunk-IKAX76MB.js +113 -0
- package/dist/chunk-IKAX76MB.js.map +1 -0
- package/dist/chunk-MWAXK66H.cjs +119 -0
- package/dist/chunk-MWAXK66H.cjs.map +1 -0
- package/dist/index.cjs +6246 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2126 -0
- package/dist/index.d.ts +2126 -0
- package/dist/index.js +6069 -0
- package/dist/index.js.map +1 -0
- package/dist/pdf-view-3ZMMS6ML.cjs +95 -0
- package/dist/pdf-view-3ZMMS6ML.cjs.map +1 -0
- package/dist/pdf-view-BV5ZD6OC.js +93 -0
- package/dist/pdf-view-BV5ZD6OC.js.map +1 -0
- package/package.json +96 -0
- package/src/components/attachment-chip/attachment-chip.tsx +129 -0
- package/src/components/attachment-chip/index.ts +2 -0
- package/src/components/attribute-type-icon/attribute-type-icon.stories.tsx +70 -0
- package/src/components/attribute-type-icon/attribute-type-icon.tsx +125 -0
- package/src/components/attribute-type-icon/index.ts +8 -0
- package/src/components/avatar/avatar.stories.tsx +42 -0
- package/src/components/avatar/avatar.tsx +48 -0
- package/src/components/avatar/index.ts +2 -0
- package/src/components/avatar-group/avatar-group.test.tsx +38 -0
- package/src/components/avatar-group/avatar-group.tsx +60 -0
- package/src/components/avatar-group/index.ts +2 -0
- package/src/components/badge/badge.stories.tsx +62 -0
- package/src/components/badge/badge.test.tsx +36 -0
- package/src/components/badge/badge.tsx +54 -0
- package/src/components/badge/index.ts +2 -0
- package/src/components/badge-multi-select/badge-multi-select.stories.tsx +177 -0
- package/src/components/badge-multi-select/badge-multi-select.test.tsx +155 -0
- package/src/components/badge-multi-select/badge-multi-select.tsx +272 -0
- package/src/components/badge-multi-select/index.ts +2 -0
- package/src/components/breadcrumbs/breadcrumbs.stories.tsx +43 -0
- package/src/components/breadcrumbs/breadcrumbs.tsx +86 -0
- package/src/components/breadcrumbs/index.ts +2 -0
- package/src/components/button/button.stories.tsx +290 -0
- package/src/components/button/button.test.tsx +59 -0
- package/src/components/button/button.tsx +86 -0
- package/src/components/button/index.ts +2 -0
- package/src/components/card/card.stories.tsx +351 -0
- package/src/components/card/card.test.tsx +141 -0
- package/src/components/card/card.tsx +173 -0
- package/src/components/card/index.ts +13 -0
- package/src/components/checkbox/checkbox-group.tsx +52 -0
- package/src/components/checkbox/checkbox.stories.tsx +48 -0
- package/src/components/checkbox/checkbox.test.tsx +50 -0
- package/src/components/checkbox/checkbox.tsx +71 -0
- package/src/components/checkbox/index.ts +4 -0
- package/src/components/combobox/combobox.stories.tsx +97 -0
- package/src/components/combobox/combobox.test.tsx +123 -0
- package/src/components/combobox/combobox.tsx +239 -0
- package/src/components/combobox/index.ts +2 -0
- package/src/components/command/command-hint.tsx +46 -0
- package/src/components/command/command.stories.tsx +88 -0
- package/src/components/command/command.test.tsx +102 -0
- package/src/components/command/command.tsx +76 -0
- package/src/components/command/index.ts +16 -0
- package/src/components/currency-input/currency-input.tsx +192 -0
- package/src/components/currency-input/index.ts +6 -0
- package/src/components/data-table/cells.stories.tsx +120 -0
- package/src/components/data-table/cells.tsx +165 -0
- package/src/components/data-table/data-table.stories.tsx +371 -0
- package/src/components/data-table/data-table.tsx +556 -0
- package/src/components/data-table/index.ts +22 -0
- package/src/components/data-table/make-actions-column.ts +30 -0
- package/src/components/data-table/states/data-table-empty.tsx +40 -0
- package/src/components/data-table/states/data-table-error.tsx +30 -0
- package/src/components/data-table/states/data-table-skeleton.tsx +42 -0
- package/src/components/data-table/use-infinite-scroll-sentinel.ts +51 -0
- package/src/components/date-picker/calendar-internals.tsx +138 -0
- package/src/components/date-picker/date-picker.stories.tsx +78 -0
- package/src/components/date-picker/date-picker.test.tsx +71 -0
- package/src/components/date-picker/date-picker.tsx +140 -0
- package/src/components/date-picker/index.ts +2 -0
- package/src/components/date-time-picker/date-time-picker.stories.tsx +96 -0
- package/src/components/date-time-picker/date-time-picker.test.tsx +87 -0
- package/src/components/date-time-picker/date-time-picker.tsx +203 -0
- package/src/components/date-time-picker/index.ts +2 -0
- package/src/components/empty-state/empty-state.tsx +63 -0
- package/src/components/empty-state/index.ts +2 -0
- package/src/components/error-animation/error-animation.stories.tsx +50 -0
- package/src/components/error-animation/error-animation.test.tsx +22 -0
- package/src/components/error-animation/error-animation.tsx +26 -0
- package/src/components/error-animation/index.ts +2 -0
- package/src/components/field/field.tsx +142 -0
- package/src/components/field/index.ts +8 -0
- package/src/components/file-field/file-field.tsx +285 -0
- package/src/components/file-field/index.ts +2 -0
- package/src/components/file-viewer/fallback-card.tsx +68 -0
- package/src/components/file-viewer/file-viewer.stories.tsx +67 -0
- package/src/components/file-viewer/file-viewer.test.tsx +81 -0
- package/src/components/file-viewer/file-viewer.tsx +116 -0
- package/src/components/file-viewer/image-view.tsx +38 -0
- package/src/components/file-viewer/index.ts +7 -0
- package/src/components/file-viewer/pdf-view.tsx +122 -0
- package/src/components/file-viewer/video-view.tsx +16 -0
- package/src/components/image-attachment/image-attachment.stories.tsx +39 -0
- package/src/components/image-attachment/image-attachment.test.tsx +54 -0
- package/src/components/image-attachment/image-attachment.tsx +77 -0
- package/src/components/image-attachment/index.ts +3 -0
- package/src/components/image-attachment/is-previewable-image-name.ts +14 -0
- package/src/components/image-lightbox/image-lightbox.stories.tsx +38 -0
- package/src/components/image-lightbox/image-lightbox.test.tsx +72 -0
- package/src/components/image-lightbox/image-lightbox.tsx +92 -0
- package/src/components/image-lightbox/index.ts +2 -0
- package/src/components/input/index.ts +2 -0
- package/src/components/input/input.stories.tsx +71 -0
- package/src/components/input/input.test.tsx +36 -0
- package/src/components/input/input.tsx +76 -0
- package/src/components/kbd/index.ts +2 -0
- package/src/components/kbd/kbd.stories.tsx +34 -0
- package/src/components/kbd/kbd.tsx +27 -0
- package/src/components/menu/index.ts +2 -0
- package/src/components/menu/menu.tsx +121 -0
- package/src/components/modal/index.ts +10 -0
- package/src/components/modal/modal.stories.tsx +153 -0
- package/src/components/modal/modal.test.tsx +93 -0
- package/src/components/modal/modal.tsx +149 -0
- package/src/components/multi-select/index.ts +2 -0
- package/src/components/multi-select/multi-select.stories.tsx +211 -0
- package/src/components/multi-select/multi-select.test.tsx +151 -0
- package/src/components/multi-select/multi-select.tsx +339 -0
- package/src/components/nav/index.ts +7 -0
- package/src/components/nav/nav.stories.tsx +139 -0
- package/src/components/nav/nav.tsx +246 -0
- package/src/components/popover/index.ts +8 -0
- package/src/components/popover/popover.stories.tsx +92 -0
- package/src/components/popover/popover.tsx +135 -0
- package/src/components/progress-bar/index.ts +2 -0
- package/src/components/progress-bar/progress-bar.stories.tsx +75 -0
- package/src/components/progress-bar/progress-bar.test.tsx +29 -0
- package/src/components/progress-bar/progress-bar.tsx +57 -0
- package/src/components/radio-group/index.ts +2 -0
- package/src/components/radio-group/radio-group.stories.tsx +113 -0
- package/src/components/radio-group/radio-group.test.tsx +87 -0
- package/src/components/radio-group/radio-group.tsx +104 -0
- package/src/components/record-filter/attribute-picker.tsx +74 -0
- package/src/components/record-filter/chip-value.tsx +171 -0
- package/src/components/record-filter/filter-row.tsx +262 -0
- package/src/components/record-filter/filter-value-input.tsx +351 -0
- package/src/components/record-filter/group-add-controls.tsx +77 -0
- package/src/components/record-filter/group-body.tsx +173 -0
- package/src/components/record-filter/group-mutations.test.ts +104 -0
- package/src/components/record-filter/group-mutations.ts +116 -0
- package/src/components/record-filter/index.ts +58 -0
- package/src/components/record-filter/nested-group.tsx +138 -0
- package/src/components/record-filter/operator-picker.tsx +62 -0
- package/src/components/record-filter/operators.test.ts +105 -0
- package/src/components/record-filter/operators.ts +97 -0
- package/src/components/record-filter/record-filter-builder-panel.tsx +236 -0
- package/src/components/record-filter/record-filter-button.tsx +126 -0
- package/src/components/record-filter/record-filter-chips.tsx +527 -0
- package/src/components/record-filter/record-filter-editor.tsx +91 -0
- package/src/components/record-filter/record-filter.stories.tsx +196 -0
- package/src/components/record-filter/record-filter.test.tsx +437 -0
- package/src/components/record-filter/show-details-store.ts +72 -0
- package/src/components/record-filter/types.ts +150 -0
- package/src/components/segmented-control/index.ts +6 -0
- package/src/components/segmented-control/segmented-control.stories.tsx +67 -0
- package/src/components/segmented-control/segmented-control.test.tsx +47 -0
- package/src/components/segmented-control/segmented-control.tsx +105 -0
- package/src/components/select/index.ts +2 -0
- package/src/components/select/select.stories.tsx +76 -0
- package/src/components/select/select.test.tsx +56 -0
- package/src/components/select/select.tsx +159 -0
- package/src/components/separator/index.ts +2 -0
- package/src/components/separator/separator.stories.tsx +73 -0
- package/src/components/separator/separator.test.tsx +29 -0
- package/src/components/separator/separator.tsx +51 -0
- package/src/components/skeleton/index.ts +2 -0
- package/src/components/skeleton/skeleton.stories.tsx +68 -0
- package/src/components/skeleton/skeleton.test.tsx +38 -0
- package/src/components/skeleton/skeleton.tsx +43 -0
- package/src/components/success-animation/index.ts +2 -0
- package/src/components/success-animation/success-animation.stories.tsx +50 -0
- package/src/components/success-animation/success-animation.test.tsx +22 -0
- package/src/components/success-animation/success-animation.tsx +26 -0
- package/src/components/switch/index.ts +2 -0
- package/src/components/switch/switch.stories.tsx +60 -0
- package/src/components/switch/switch.test.tsx +50 -0
- package/src/components/switch/switch.tsx +49 -0
- package/src/components/table/index.ts +25 -0
- package/src/components/table/table.stories.tsx +306 -0
- package/src/components/table/table.test.tsx +115 -0
- package/src/components/table/table.tsx +543 -0
- package/src/components/tabs/index.ts +8 -0
- package/src/components/tabs/tabs.tsx +141 -0
- package/src/components/tag-input/index.ts +2 -0
- package/src/components/tag-input/tag-input.stories.tsx +73 -0
- package/src/components/tag-input/tag-input.test.tsx +80 -0
- package/src/components/tag-input/tag-input.tsx +193 -0
- package/src/components/textarea/index.ts +2 -0
- package/src/components/textarea/textarea.stories.tsx +75 -0
- package/src/components/textarea/textarea.test.tsx +41 -0
- package/src/components/textarea/textarea.tsx +86 -0
- package/src/components/time-field/index.ts +2 -0
- package/src/components/time-field/time-field.stories.tsx +76 -0
- package/src/components/time-field/time-field.test.tsx +51 -0
- package/src/components/time-field/time-field.tsx +98 -0
- package/src/components/tooltip/index.ts +2 -0
- package/src/components/tooltip/tooltip.stories.tsx +176 -0
- package/src/components/tooltip/tooltip.test.tsx +52 -0
- package/src/components/tooltip/tooltip.tsx +56 -0
- package/src/index.ts +47 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/commands/discovery-store.test.tsx +76 -0
- package/src/lib/commands/discovery-store.ts +110 -0
- package/src/lib/commands/index.ts +9 -0
- package/src/lib/commands/match-event.test.ts +99 -0
- package/src/lib/commands/match-event.ts +31 -0
- package/src/lib/commands/parse-combo.test.ts +65 -0
- package/src/lib/commands/parse-combo.ts +48 -0
- package/src/lib/commands/platform.ts +27 -0
- package/src/lib/commands/protected-combos.test.ts +18 -0
- package/src/lib/commands/protected-combos.ts +38 -0
- package/src/lib/commands/registry.test.ts +97 -0
- package/src/lib/commands/registry.ts +59 -0
- package/src/lib/commands/use-command.test.tsx +91 -0
- package/src/lib/commands/use-command.ts +32 -0
- package/src/lib/commands/use-escape-key.test.tsx +97 -0
- package/src/lib/commands/use-escape-key.ts +62 -0
- package/src/styles.css +321 -0
- 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,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'
|