@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,122 @@
1
+ import { ChevronLeft, ChevronRight, Download } from 'lucide-react'
2
+ import pdfWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
3
+ import { useEffect, useRef, useState } from 'react'
4
+ import { Document, Page, pdfjs } from 'react-pdf'
5
+
6
+ import { Button } from '../button/button'
7
+ import { Skeleton } from '../skeleton/skeleton'
8
+
9
+ /* =========================================================================
10
+ PdfView — the FileViewer's PDF pane, backed by react-pdf (pdf.js).
11
+ Lazy-loaded by the FileViewer (default export) so pdf.js and its worker
12
+ stay out of the main bundle. Text/annotation layers are off: this is a
13
+ read-only preview and the layers would need pdf.js's own stylesheets,
14
+ which the token-based design system deliberately doesn't import.
15
+ ========================================================================= */
16
+
17
+ // The worker must come from the SAME pdfjs-dist instance react-pdf renders
18
+ // with (API/worker version mismatch throws). `?url` lets Vite emit it as an
19
+ // asset and hand back the served URL. In the published dist build the `?url`
20
+ // module is rewritten to a version-pinned CDN URL (see tsup.config.ts), and
21
+ // the guard lets consumers pre-set their own workerSrc before this lazy
22
+ // chunk loads.
23
+ if (!pdfjs.GlobalWorkerOptions.workerSrc) {
24
+ pdfjs.GlobalWorkerOptions.workerSrc = pdfWorkerUrl
25
+ }
26
+
27
+ export interface PdfViewProps {
28
+ name: string
29
+ /** Blob object URL of the PDF bytes. */
30
+ src: string
31
+ onDownload?: () => void
32
+ }
33
+
34
+ export default function PdfView({ name, src, onDownload }: PdfViewProps) {
35
+ const [pageCount, setPageCount] = useState<number | null>(null)
36
+ const [pageNumber, setPageNumber] = useState(1)
37
+ const [loadError, setLoadError] = useState(false)
38
+
39
+ // Fit-width: render the page at the scroll area's current width.
40
+ const scrollRef = useRef<HTMLDivElement | null>(null)
41
+ const [pageWidth, setPageWidth] = useState<number | null>(null)
42
+ useEffect(() => {
43
+ const element = scrollRef.current
44
+ if (!element) return
45
+ const observer = new ResizeObserver(([entry]) => {
46
+ if (entry) setPageWidth(Math.floor(entry.contentRect.width))
47
+ })
48
+ observer.observe(element)
49
+ return () => observer.disconnect()
50
+ }, [])
51
+
52
+ if (loadError) {
53
+ return (
54
+ <div className="flex h-full w-full items-center justify-center p-4">
55
+ <span className="font-sans text-[13px] text-danger">
56
+ Could not display this PDF. Download it instead.
57
+ </span>
58
+ </div>
59
+ )
60
+ }
61
+
62
+ return (
63
+ <div className="flex h-full w-full flex-col">
64
+ <div ref={scrollRef} className="min-h-0 flex-1 overflow-auto">
65
+ <Document
66
+ file={src}
67
+ onLoadSuccess={({ numPages }) => {
68
+ setPageCount(numPages)
69
+ setPageNumber(1)
70
+ }}
71
+ onLoadError={() => setLoadError(true)}
72
+ loading={
73
+ <Skeleton aria-label={`Loading ${name}`} className="h-full w-full rounded-none" />
74
+ }
75
+ error={null}
76
+ >
77
+ {pageWidth ? (
78
+ <Page
79
+ pageNumber={pageNumber}
80
+ width={pageWidth}
81
+ renderTextLayer={false}
82
+ renderAnnotationLayer={false}
83
+ />
84
+ ) : null}
85
+ </Document>
86
+ </div>
87
+ <div className="flex shrink-0 items-center justify-center gap-1.5 border-t border-rule bg-surface px-2 py-1">
88
+ <Button
89
+ variant="ghost"
90
+ size="icon-sm"
91
+ aria-label="Previous page"
92
+ isDisabled={pageNumber <= 1}
93
+ onPress={() => setPageNumber((page) => Math.max(1, page - 1))}
94
+ >
95
+ <ChevronLeft aria-hidden />
96
+ </Button>
97
+ <span className="min-w-14 text-center font-sans text-[12px] tabular-nums text-ink-3">
98
+ {pageCount ? `${pageNumber} / ${pageCount}` : '–'}
99
+ </span>
100
+ <Button
101
+ variant="ghost"
102
+ size="icon-sm"
103
+ aria-label="Next page"
104
+ isDisabled={pageCount === null || pageNumber >= pageCount}
105
+ onPress={() => setPageNumber((page) => Math.min(pageCount ?? page, page + 1))}
106
+ >
107
+ <ChevronRight aria-hidden />
108
+ </Button>
109
+ {onDownload ? (
110
+ <Button
111
+ variant="ghost"
112
+ size="icon-sm"
113
+ aria-label={`Download ${name}`}
114
+ onPress={onDownload}
115
+ >
116
+ <Download aria-hidden />
117
+ </Button>
118
+ ) : null}
119
+ </div>
120
+ </div>
121
+ )
122
+ }
@@ -0,0 +1,16 @@
1
+ /* =========================================================================
2
+ VideoView — the FileViewer's video pane. Native playback controls; the
3
+ video letterboxes inside the viewer box.
4
+ ========================================================================= */
5
+
6
+ export interface VideoViewProps {
7
+ name: string
8
+ src: string
9
+ }
10
+
11
+ export function VideoView({ name, src }: VideoViewProps) {
12
+ return (
13
+ // biome-ignore lint/a11y/useMediaCaption: user-stored files carry no caption tracks
14
+ <video controls src={src} aria-label={name} className="h-full w-full object-contain" />
15
+ )
16
+ }
@@ -0,0 +1,39 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+
3
+ import { ImageAttachment } from './image-attachment'
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="280" height="180"><rect width="280" height="180" fill="#d8cfc0"/><circle cx="80" cy="70" r="34" fill="#8a8171"/><path d="M0 180 L100 90 L170 150 L230 100 L280 130 V180 Z" fill="#a89f8d"/></svg>',
8
+ )}`
9
+
10
+ const meta: Meta<typeof ImageAttachment> = {
11
+ title: 'Components/ImageAttachment',
12
+ component: ImageAttachment,
13
+ tags: ['autodocs'],
14
+ args: {
15
+ name: 'sunset.png',
16
+ src: SAMPLE_IMAGE,
17
+ },
18
+ }
19
+
20
+ export default meta
21
+ type Story = StoryObj<typeof ImageAttachment>
22
+
23
+ export const Playground: Story = {}
24
+
25
+ export const Loading: Story = {
26
+ parameters: { controls: { hideNoControlsWarning: true } },
27
+ render: () => <ImageAttachment name="pending.png" />,
28
+ }
29
+
30
+ export const Row: Story = {
31
+ parameters: { controls: { hideNoControlsWarning: true } },
32
+ render: () => (
33
+ <div className="flex flex-wrap gap-1.5">
34
+ <ImageAttachment name="sunset.png" src={SAMPLE_IMAGE} />
35
+ <ImageAttachment name="pending.png" />
36
+ <ImageAttachment name="hills.png" src={SAMPLE_IMAGE} />
37
+ </div>
38
+ ),
39
+ }
@@ -0,0 +1,54 @@
1
+ import { fireEvent, render, screen } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+
5
+ import { ImageAttachment } from './image-attachment'
6
+ import { isPreviewableImageName } from './is-previewable-image-name'
7
+
8
+ const SVG_DATA_URI = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"/>')}`
9
+
10
+ describe('isPreviewableImageName', () => {
11
+ it.each(['photo.png', 'photo.PNG', 'pic.jpeg', 'anim.gif', 'a.webp', 'icon.svg', 'shot.avif'])(
12
+ 'accepts %s',
13
+ (name) => {
14
+ expect(isPreviewableImageName(name)).toBe(true)
15
+ },
16
+ )
17
+
18
+ it.each(['doc.pdf', 'archive.tar.gz', 'noext', '.png', 'photo.', 'movie.mp4'])(
19
+ 'rejects %s',
20
+ (name) => {
21
+ expect(isPreviewableImageName(name)).toBe(false)
22
+ },
23
+ )
24
+ })
25
+
26
+ describe('ImageAttachment', () => {
27
+ it('renders a skeleton and no img while src is unresolved', () => {
28
+ render(<ImageAttachment name="photo.png" />)
29
+ expect(screen.getByRole('button', { name: 'Preview photo.png' })).toBeInTheDocument()
30
+ expect(screen.queryByAltText('photo.png')).not.toBeInTheDocument()
31
+ })
32
+
33
+ it('renders the image with the name as alt text', () => {
34
+ render(<ImageAttachment name="photo.png" src={SVG_DATA_URI} />)
35
+ expect(screen.getByAltText('photo.png')).toHaveAttribute('src', SVG_DATA_URI)
36
+ })
37
+
38
+ it('fires onPress when the thumbnail is pressed', async () => {
39
+ const user = userEvent.setup()
40
+ const onPress = vi.fn()
41
+ render(<ImageAttachment name="photo.png" src={SVG_DATA_URI} onPress={onPress} />)
42
+
43
+ await user.click(screen.getByRole('button', { name: 'Preview photo.png' }))
44
+ expect(onPress).toHaveBeenCalledOnce()
45
+ })
46
+
47
+ it('fires onLoadError when the image fails to load', () => {
48
+ const onLoadError = vi.fn()
49
+ render(<ImageAttachment name="photo.png" src="/broken.png" onLoadError={onLoadError} />)
50
+
51
+ fireEvent.error(screen.getByAltText('photo.png'))
52
+ expect(onLoadError).toHaveBeenCalledOnce()
53
+ })
54
+ })
@@ -0,0 +1,77 @@
1
+ import { useState } from 'react'
2
+ import { Button as AriaButton } from 'react-aria-components'
3
+ import { tv, type VariantProps } from 'tailwind-variants'
4
+
5
+ import { cn } from '../../lib/cn'
6
+ import { Skeleton } from '../skeleton/skeleton'
7
+
8
+ /* =========================================================================
9
+ ImageAttachment — a pressable image thumbnail for chat/message rows.
10
+ Shows a skeleton while the URL resolves and the image loads, then the
11
+ image constrained to a Slack-style thumbnail box. Pure presentation:
12
+ the consumer resolves the src, opens the lightbox on press, and decides
13
+ what to do when the image fails to load (typically fall back to a
14
+ plain file chip).
15
+ ========================================================================= */
16
+
17
+ export const imageAttachmentStyles = tv({
18
+ base: [
19
+ 'relative block overflow-hidden',
20
+ 'rounded-[8px] border border-rule bg-surface-2',
21
+ 'cursor-pointer outline-none focus-visible:shadow-[var(--shadow-focus)]',
22
+ ],
23
+ })
24
+
25
+ export type ImageAttachmentVariants = VariantProps<typeof imageAttachmentStyles>
26
+
27
+ export interface ImageAttachmentProps {
28
+ /** File name — the alt text and the accessible button name. */
29
+ name: string
30
+ /** Resolved image URL. While undefined, the skeleton placeholder renders. */
31
+ src?: string
32
+ /** Fired when the browser fails to load `src` (e.g. not actually an image). */
33
+ onLoadError?: () => void
34
+ /** Fired on thumbnail press (consumer opens the lightbox). */
35
+ onPress?: () => void
36
+ className?: string
37
+ }
38
+
39
+ export function ImageAttachment({
40
+ name,
41
+ src,
42
+ onLoadError,
43
+ onPress,
44
+ className,
45
+ }: ImageAttachmentProps) {
46
+ // Tracks which src has finished loading, so a swapped-out URL goes back to
47
+ // the skeleton instead of flashing the stale image.
48
+ const [loadedSrc, setLoadedSrc] = useState<string | null>(null)
49
+ const isLoaded = src !== undefined && loadedSrc === src
50
+
51
+ return (
52
+ <AriaButton
53
+ aria-label={`Preview ${name}`}
54
+ {...(onPress ? { onPress } : {})}
55
+ className={cn(imageAttachmentStyles(), className)}
56
+ >
57
+ {src ? (
58
+ // Kept in the DOM (invisible) while loading so onLoad still fires
59
+ // behind the skeleton.
60
+ <img
61
+ src={src}
62
+ alt={name}
63
+ loading="lazy"
64
+ onLoad={() => setLoadedSrc(src)}
65
+ onError={() => onLoadError?.()}
66
+ className={cn(
67
+ 'block max-h-[220px] max-w-[280px] object-contain',
68
+ !isLoaded && 'invisible absolute inset-0',
69
+ )}
70
+ />
71
+ ) : null}
72
+ {/* Fixed footprint — intrinsic dimensions are unknown until load, and a
73
+ 0-height placeholder would make the row jump. */}
74
+ {!isLoaded ? <Skeleton className="h-[140px] w-[200px]" /> : null}
75
+ </AriaButton>
76
+ )
77
+ }
@@ -0,0 +1,3 @@
1
+ export type { ImageAttachmentProps, ImageAttachmentVariants } from './image-attachment'
2
+ export { ImageAttachment, imageAttachmentStyles } from './image-attachment'
3
+ export { isPreviewableImageName } from './is-previewable-image-name'
@@ -0,0 +1,14 @@
1
+ const PREVIEWABLE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'bmp'])
2
+
3
+ /**
4
+ * True when a filename's extension is one the browser renders natively in
5
+ * `<img>`. A heuristic for contexts where only a name is known (e.g. a
6
+ * FileRef carries no MIME type) — a wrong guess should degrade gracefully via
7
+ * the consumer's img-error fallback. SVG is deliberately included: scripts do
8
+ * not execute in an `<img>` context.
9
+ */
10
+ export function isPreviewableImageName(name: string): boolean {
11
+ const dot = name.lastIndexOf('.')
12
+ if (dot <= 0 || dot === name.length - 1) return false
13
+ return PREVIEWABLE_EXTENSIONS.has(name.slice(dot + 1).toLowerCase())
14
+ }
@@ -0,0 +1,38 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useState } from 'react'
3
+
4
+ import { Button } from '../button/button'
5
+ import { ImageLightbox } from './image-lightbox'
6
+
7
+ // Inline data URI so stories need no network.
8
+ const SAMPLE_IMAGE = `data:image/svg+xml,${encodeURIComponent(
9
+ '<svg xmlns="http://www.w3.org/2000/svg" width="960" height="600"><rect width="960" height="600" fill="#d8cfc0"/><circle cx="240" cy="200" r="90" fill="#8a8171"/><path d="M0 600 L340 280 L560 480 L760 320 L960 430 V600 Z" fill="#a89f8d"/></svg>',
10
+ )}`
11
+
12
+ const meta: Meta<typeof ImageLightbox> = {
13
+ title: 'Components/ImageLightbox',
14
+ component: ImageLightbox,
15
+ tags: ['autodocs'],
16
+ }
17
+
18
+ export default meta
19
+ type Story = StoryObj<typeof ImageLightbox>
20
+
21
+ export const Playground: Story = {
22
+ parameters: { controls: { hideNoControlsWarning: true } },
23
+ render: () => {
24
+ const [isOpen, setOpen] = useState(false)
25
+ return (
26
+ <>
27
+ <Button onPress={() => setOpen(true)}>Open lightbox</Button>
28
+ <ImageLightbox
29
+ isOpen={isOpen}
30
+ onOpenChange={setOpen}
31
+ src={SAMPLE_IMAGE}
32
+ name="landscape.png"
33
+ onDownload={() => alert('download')}
34
+ />
35
+ </>
36
+ )
37
+ },
38
+ }
@@ -0,0 +1,72 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+
5
+ import { ImageLightbox } from './image-lightbox'
6
+
7
+ const SVG_DATA_URI = `data:image/svg+xml,${encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="8" height="8"/>')}`
8
+
9
+ function renderLightbox({
10
+ isOpen = true,
11
+ onOpenChange = vi.fn(),
12
+ onDownload,
13
+ }: {
14
+ isOpen?: boolean
15
+ onOpenChange?: (isOpen: boolean) => void
16
+ onDownload?: () => void
17
+ } = {}) {
18
+ render(
19
+ <ImageLightbox
20
+ isOpen={isOpen}
21
+ onOpenChange={onOpenChange}
22
+ src={SVG_DATA_URI}
23
+ name="photo.png"
24
+ {...(onDownload ? { onDownload } : {})}
25
+ />,
26
+ )
27
+ }
28
+
29
+ describe('ImageLightbox', () => {
30
+ it('renders nothing while closed', () => {
31
+ renderLightbox({ isOpen: false })
32
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
33
+ })
34
+
35
+ it('renders a dialog named after the file with the image inside', () => {
36
+ renderLightbox()
37
+ expect(screen.getByRole('dialog', { name: 'photo.png' })).toBeInTheDocument()
38
+ expect(screen.getByAltText('photo.png')).toHaveAttribute('src', SVG_DATA_URI)
39
+ })
40
+
41
+ it('closes on Escape', async () => {
42
+ const user = userEvent.setup()
43
+ const onOpenChange = vi.fn()
44
+ renderLightbox({ onOpenChange })
45
+
46
+ await user.keyboard('{Escape}')
47
+ expect(onOpenChange).toHaveBeenCalledWith(false)
48
+ })
49
+
50
+ it('closes via the close button', async () => {
51
+ const user = userEvent.setup()
52
+ const onOpenChange = vi.fn()
53
+ renderLightbox({ onOpenChange })
54
+
55
+ await user.click(screen.getByRole('button', { name: 'Close' }))
56
+ expect(onOpenChange).toHaveBeenLastCalledWith(false)
57
+ })
58
+
59
+ it('fires onDownload from the caption bar', async () => {
60
+ const user = userEvent.setup()
61
+ const onDownload = vi.fn()
62
+ renderLightbox({ onDownload })
63
+
64
+ await user.click(screen.getByRole('button', { name: 'Download photo.png' }))
65
+ expect(onDownload).toHaveBeenCalledOnce()
66
+ })
67
+
68
+ it('omits the download button when no handler is given', () => {
69
+ renderLightbox()
70
+ expect(screen.queryByRole('button', { name: 'Download photo.png' })).not.toBeInTheDocument()
71
+ })
72
+ })
@@ -0,0 +1,92 @@
1
+ import { Download, X } from 'lucide-react'
2
+ import {
3
+ Dialog as AriaDialog,
4
+ Modal as AriaModal,
5
+ ModalOverlay as AriaModalOverlay,
6
+ } from 'react-aria-components'
7
+ import { tv, type VariantProps } from 'tailwind-variants'
8
+
9
+ import { cn } from '../../lib/cn'
10
+ import { Button } from '../button/button'
11
+
12
+ /* =========================================================================
13
+ ImageLightbox — a full-viewport image overlay (Slack-style). Unlike
14
+ Modal there is no surface panel: the image floats on a deep scrim with
15
+ a small caption bar (file name + optional download + close) beneath it.
16
+ Controlled-only — the trigger is the consumer's thumbnail, so there is
17
+ no DialogTrigger composition. RAC provides Esc dismissal, backdrop
18
+ click, and focus trap/restore.
19
+ ========================================================================= */
20
+
21
+ export const imageLightboxStyles = tv({
22
+ base: [
23
+ 'fixed inset-0 z-50 flex items-center justify-center p-4',
24
+ // Deeper scrim than Modal's 40% — the image needs the page fully dimmed.
25
+ 'bg-[color-mix(in_oklab,var(--color-ink)_72%,transparent)]',
26
+ 'entering:animate-in entering:fade-in-0 entering:duration-150',
27
+ 'exiting:animate-out exiting:fade-out-0 exiting:duration-100',
28
+ ],
29
+ })
30
+
31
+ export type ImageLightboxVariants = VariantProps<typeof imageLightboxStyles>
32
+
33
+ export interface ImageLightboxProps {
34
+ isOpen: boolean
35
+ onOpenChange: (isOpen: boolean) => void
36
+ /** Full-size image URL; nothing renders in the frame while undefined. */
37
+ src?: string
38
+ /** File name — the dialog's accessible name, alt text, and caption. */
39
+ name: string
40
+ /** When set, a download action renders in the caption bar. */
41
+ onDownload?: () => void
42
+ }
43
+
44
+ export function ImageLightbox({ isOpen, onOpenChange, src, name, onDownload }: ImageLightboxProps) {
45
+ return (
46
+ <AriaModalOverlay
47
+ isOpen={isOpen}
48
+ onOpenChange={onOpenChange}
49
+ isDismissable
50
+ className={imageLightboxStyles()}
51
+ >
52
+ <AriaModal className="outline-none">
53
+ <AriaDialog
54
+ aria-label={name}
55
+ className="outline-none flex flex-col items-center gap-3"
56
+ >
57
+ {src ? (
58
+ <img
59
+ src={src}
60
+ alt={name}
61
+ className="block max-h-[82dvh] max-w-[90dvw] rounded-[8px] object-contain"
62
+ />
63
+ ) : null}
64
+ {/* Caption chip sits on its own surface so tokens stay legible over
65
+ the dark scrim. */}
66
+ <div
67
+ className={cn(
68
+ 'flex items-center gap-1.5',
69
+ 'rounded-[8px] border border-rule bg-surface py-1 pl-3 pr-1',
70
+ 'shadow-[var(--shadow-float)]',
71
+ )}
72
+ >
73
+ <span className="max-w-[320px] truncate font-sans text-[12.5px] text-ink">{name}</span>
74
+ {onDownload ? (
75
+ <Button
76
+ variant="ghost"
77
+ size="icon-sm"
78
+ aria-label={`Download ${name}`}
79
+ onPress={onDownload}
80
+ >
81
+ <Download aria-hidden />
82
+ </Button>
83
+ ) : null}
84
+ <Button slot="close" variant="ghost" size="icon-sm" aria-label="Close">
85
+ <X aria-hidden />
86
+ </Button>
87
+ </div>
88
+ </AriaDialog>
89
+ </AriaModal>
90
+ </AriaModalOverlay>
91
+ )
92
+ }
@@ -0,0 +1,2 @@
1
+ export type { ImageLightboxProps, ImageLightboxVariants } from './image-lightbox'
2
+ export { ImageLightbox, imageLightboxStyles } from './image-lightbox'
@@ -0,0 +1,2 @@
1
+ export type { InputProps } from './input'
2
+ export { Input } from './input'
@@ -0,0 +1,71 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+
3
+ import { Input } from './input'
4
+
5
+ const meta: Meta<typeof Input> = {
6
+ title: 'Components/Input',
7
+ component: Input,
8
+ tags: ['autodocs'],
9
+ args: {
10
+ label: 'Company name',
11
+ placeholder: 'Meridian Freight, Inc.',
12
+ },
13
+ argTypes: {
14
+ isDisabled: { control: { type: 'boolean' } },
15
+ isInvalid: { control: { type: 'boolean' } },
16
+ isReadOnly: { control: { type: 'boolean' } },
17
+ },
18
+ decorators: [
19
+ (Story) => (
20
+ <div className="w-[440px]">
21
+ <Story />
22
+ </div>
23
+ ),
24
+ ],
25
+ }
26
+
27
+ export default meta
28
+ type Story = StoryObj<typeof Input>
29
+
30
+ export const Playground: Story = {}
31
+
32
+ export const WithDescription: Story = {
33
+ args: {
34
+ label: 'Describe the system',
35
+ placeholder: 'A mid-size freight broker. Quotes, loads, carriers, lanes…',
36
+ description: 'Plain language. Proteos will infer entities, workflows and reports.',
37
+ },
38
+ }
39
+
40
+ export const Invalid: Story = {
41
+ args: {
42
+ label: 'State',
43
+ placeholder: 'Error example',
44
+ isInvalid: true,
45
+ errorMessage: 'Required — tell us what to build.',
46
+ },
47
+ }
48
+
49
+ export const Disabled: Story = {
50
+ args: {
51
+ label: 'Company name',
52
+ defaultValue: 'Meridian Freight, Inc.',
53
+ isDisabled: true,
54
+ },
55
+ }
56
+
57
+ export const AllStates: Story = {
58
+ parameters: { controls: { hideNoControlsWarning: true } },
59
+ render: () => (
60
+ <div className="grid gap-5 w-[440px]">
61
+ <Input label="Rest" placeholder="Hairline border, paper fill" />
62
+ <Input
63
+ label="Filled"
64
+ defaultValue="Meridian Freight, Inc."
65
+ description="Hover or focus to see the indigo shift."
66
+ />
67
+ <Input label="Invalid" isInvalid errorMessage="Required — tell us what to build." />
68
+ <Input label="Disabled" defaultValue="Can't touch this" isDisabled />
69
+ </div>
70
+ ),
71
+ }
@@ -0,0 +1,36 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ import { Input } from './input'
6
+
7
+ describe('Input', () => {
8
+ it('renders a labelled textbox', () => {
9
+ render(<Input label="Company name" placeholder="Meridian Freight, Inc." />)
10
+ expect(screen.getByLabelText('Company name')).toBeInTheDocument()
11
+ })
12
+
13
+ it('accepts user input', async () => {
14
+ const user = userEvent.setup()
15
+ render(<Input label="Company name" />)
16
+
17
+ const input = screen.getByLabelText('Company name')
18
+ await user.type(input, 'Proteos')
19
+ expect(input).toHaveValue('Proteos')
20
+ })
21
+
22
+ it('shows its description slot', () => {
23
+ render(<Input label="Industry" description="Plain language is fine." />)
24
+ expect(screen.getByText('Plain language is fine.')).toBeInTheDocument()
25
+ })
26
+
27
+ it('renders the error message when invalid', () => {
28
+ render(<Input label="State" isInvalid errorMessage="Required — tell us what to build." />)
29
+ expect(screen.getByText('Required — tell us what to build.')).toBeInTheDocument()
30
+ })
31
+
32
+ it('forwards disabled state to the underlying input', () => {
33
+ render(<Input label="Industry" isDisabled />)
34
+ expect(screen.getByLabelText('Industry')).toBeDisabled()
35
+ })
36
+ })