@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,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,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,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
|
+
})
|