@maestro-js/components 1.0.0-alpha.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/commands/add.ts +41 -0
- package/commands/index.ts +7 -0
- package/commands/list.ts +9 -0
- package/dist/components.json +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +48 -0
- package/package.json +49 -0
- package/registry.json +1445 -0
- package/scripts/build.ts +44 -0
- package/src/components/alert-dialog.tsx +150 -0
- package/src/components/autocomplete.tsx +288 -0
- package/src/components/autosuggest.tsx +314 -0
- package/src/components/avatar.tsx +54 -0
- package/src/components/boolean-select.tsx +48 -0
- package/src/components/button-group.tsx +75 -0
- package/src/components/button-link.tsx +71 -0
- package/src/components/button.tsx +132 -0
- package/src/components/checkbox-group.tsx +172 -0
- package/src/components/checkbox.tsx +207 -0
- package/src/components/chip.tsx +158 -0
- package/src/components/container.tsx +82 -0
- package/src/components/currency-field.tsx +183 -0
- package/src/components/date-input.tsx +189 -0
- package/src/components/date-picker.tsx +211 -0
- package/src/components/date-range-picker.tsx +290 -0
- package/src/components/date-time-picker.tsx +196 -0
- package/src/components/dialog.tsx +97 -0
- package/src/components/disclosure.tsx +114 -0
- package/src/components/drawer.tsx +78 -0
- package/src/components/enum-chip.tsx +30 -0
- package/src/components/file-input.tsx +245 -0
- package/src/components/form.tsx +82 -0
- package/src/components/headless-file-input.tsx +362 -0
- package/src/components/helpers/animated-popover.tsx +24 -0
- package/src/components/helpers/button-context.ts +10 -0
- package/src/components/helpers/calendar-month-year-picker.tsx +280 -0
- package/src/components/helpers/form-field.tsx +229 -0
- package/src/components/helpers/get-button-classes.ts +138 -0
- package/src/components/helpers/headless-button.tsx +36 -0
- package/src/components/helpers/pdf-dist.client.ts +6 -0
- package/src/components/icon.tsx +26 -0
- package/src/components/image-input.tsx +265 -0
- package/src/components/img.tsx +46 -0
- package/src/components/inline-alert.tsx +54 -0
- package/src/components/labeled-value.tsx +480 -0
- package/src/components/link-tabs.tsx +118 -0
- package/src/components/menu.tsx +152 -0
- package/src/components/month-day-input.tsx +176 -0
- package/src/components/multi-file-input.tsx +244 -0
- package/src/components/multi-image-input.tsx +389 -0
- package/src/components/multicomplete.tsx +322 -0
- package/src/components/multiselect.tsx +325 -0
- package/src/components/multisuggest.tsx +357 -0
- package/src/components/number-field.tsx +143 -0
- package/src/components/numeric-tag-field.tsx +271 -0
- package/src/components/pdf-input.tsx +249 -0
- package/src/components/pdf.tsx +86 -0
- package/src/components/percentage-field.tsx +187 -0
- package/src/components/phone-number-field.tsx +166 -0
- package/src/components/radio-group.tsx +112 -0
- package/src/components/radio.tsx +91 -0
- package/src/components/select.tsx +215 -0
- package/src/components/spinner.tsx +16 -0
- package/src/components/stepper.tsx +181 -0
- package/src/components/switch.tsx +186 -0
- package/src/components/tabs.tsx +151 -0
- package/src/components/tag-field.tsx +250 -0
- package/src/components/text-area.tsx +148 -0
- package/src/components/text-field.tsx +144 -0
- package/src/components/time-input.tsx +198 -0
- package/src/components/toast.tsx +176 -0
- package/src/components/toggle-button-group.tsx +94 -0
- package/src/components/year-month-input.tsx +187 -0
- package/src/utils/colors.ts +44 -0
- package/src/utils/compose-refs.ts +32 -0
- package/src/utils/enum-colors.ts +1 -0
- package/src/utils/file-input.ts +49 -0
- package/src/utils/icons.d.ts +20 -0
- package/src/utils/tw.ts +13 -0
- package/src/utils/use-element-size.ts +35 -0
- package/src/utils/use-number-input.ts +143 -0
- package/src/utils/use-pagination.ts +38 -0
- package/src/utils/use-prevent-default.ts +27 -0
- package/src/utils/use-render-props.ts +39 -0
- package/src/utils/use-spin-delay.ts +24 -0
- package/src/utils/use-stable-accessor.ts +11 -0
- package/src/utils/use-tab-indicator.ts +106 -0
- package/tests/commands.test.ts +81 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { HeadlessForm } from '@maestro-js/form'
|
|
3
|
+
import type { ValidationFunction } from '@maestro-js/form'
|
|
4
|
+
import { tw } from '../utils/tw'
|
|
5
|
+
import { FormFieldComponents } from './helpers/form-field'
|
|
6
|
+
import { Icon } from './icon'
|
|
7
|
+
|
|
8
|
+
export type NumericTagFieldProps = {
|
|
9
|
+
// Form identification
|
|
10
|
+
name?: string
|
|
11
|
+
form?: string
|
|
12
|
+
|
|
13
|
+
// Display elements
|
|
14
|
+
label?: React.ReactNode
|
|
15
|
+
cornerHint?: React.ReactNode
|
|
16
|
+
helpText?: React.ReactNode
|
|
17
|
+
placeholder?: string
|
|
18
|
+
warningMessage?: React.ReactNode
|
|
19
|
+
|
|
20
|
+
// Validation and state
|
|
21
|
+
isRequired?: boolean
|
|
22
|
+
isDisabled?: boolean
|
|
23
|
+
isInvalid?: boolean
|
|
24
|
+
validate?: ValidationFunction<number[]>
|
|
25
|
+
|
|
26
|
+
// Value management
|
|
27
|
+
value?: number[]
|
|
28
|
+
defaultValue?: number[]
|
|
29
|
+
onChange?: (value: number[]) => void
|
|
30
|
+
|
|
31
|
+
// Tag specific
|
|
32
|
+
maxTags?: number
|
|
33
|
+
hideTagCount?: boolean
|
|
34
|
+
allowDuplicates?: boolean
|
|
35
|
+
separator?: string | RegExp | (string | RegExp)[]
|
|
36
|
+
validateTag?: (tag: number) => boolean
|
|
37
|
+
transformTag?: (tag: number) => number
|
|
38
|
+
min?: number
|
|
39
|
+
max?: number
|
|
40
|
+
shape?: 'rectangle' | 'oval'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function NumericTagField(props: NumericTagFieldProps) {
|
|
44
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
45
|
+
const hiddenInputRef = React.useRef<HTMLInputElement>(null)
|
|
46
|
+
const helpTextId = React.useId()
|
|
47
|
+
const inputId = React.useId()
|
|
48
|
+
const [query, setQuery] = React.useState('')
|
|
49
|
+
|
|
50
|
+
// Controlled state management
|
|
51
|
+
const [tags, setTags] = HeadlessForm.useControlledState(props.value, props.defaultValue ?? [], props.onChange)
|
|
52
|
+
|
|
53
|
+
// Form validation
|
|
54
|
+
const {
|
|
55
|
+
validationMessage,
|
|
56
|
+
isInvalid: validationInvalid,
|
|
57
|
+
commitValidation
|
|
58
|
+
} = HeadlessForm.useValidation(
|
|
59
|
+
{
|
|
60
|
+
validate: props.validate,
|
|
61
|
+
value: tags,
|
|
62
|
+
name: props.name,
|
|
63
|
+
isRequired: props.isRequired,
|
|
64
|
+
form: props.form,
|
|
65
|
+
focus() {
|
|
66
|
+
inputRef.current?.focus()
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
hiddenInputRef
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const isInvalid = props.isInvalid || validationInvalid
|
|
73
|
+
const hasWarning = Boolean(props.warningMessage) && !isInvalid
|
|
74
|
+
|
|
75
|
+
const separators = React.useMemo(() => {
|
|
76
|
+
const sep = props.separator ?? [',', '\n']
|
|
77
|
+
return Array.isArray(sep) ? sep : [sep]
|
|
78
|
+
}, [props.separator])
|
|
79
|
+
|
|
80
|
+
const addTag = React.useCallback(
|
|
81
|
+
(raw: string) => {
|
|
82
|
+
const trimmed = raw.trim()
|
|
83
|
+
if (!trimmed || Number.isNaN(Number(trimmed))) return
|
|
84
|
+
|
|
85
|
+
let num = Number(trimmed)
|
|
86
|
+
|
|
87
|
+
// Transform tag if needed
|
|
88
|
+
if (props.transformTag) num = props.transformTag(num)
|
|
89
|
+
|
|
90
|
+
// Check min/max bounds
|
|
91
|
+
if (props.min !== undefined && num < props.min) return
|
|
92
|
+
if (props.max !== undefined && num > props.max) return
|
|
93
|
+
|
|
94
|
+
// Validate tag
|
|
95
|
+
if (props.validateTag && !props.validateTag(num)) return
|
|
96
|
+
|
|
97
|
+
// Check for duplicates
|
|
98
|
+
if (!props.allowDuplicates && tags.includes(num)) return
|
|
99
|
+
|
|
100
|
+
// Check max tags
|
|
101
|
+
if (props.maxTags !== undefined && tags.length >= props.maxTags) return
|
|
102
|
+
|
|
103
|
+
setTags([...tags, num])
|
|
104
|
+
setQuery('')
|
|
105
|
+
commitValidation()
|
|
106
|
+
},
|
|
107
|
+
[
|
|
108
|
+
tags,
|
|
109
|
+
setTags,
|
|
110
|
+
commitValidation,
|
|
111
|
+
props.transformTag,
|
|
112
|
+
props.validateTag,
|
|
113
|
+
props.allowDuplicates,
|
|
114
|
+
props.maxTags,
|
|
115
|
+
props.min,
|
|
116
|
+
props.max
|
|
117
|
+
]
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
const removeTag = React.useCallback(
|
|
121
|
+
(index: number) => {
|
|
122
|
+
setTags(tags.filter((_, i) => i !== index))
|
|
123
|
+
commitValidation()
|
|
124
|
+
},
|
|
125
|
+
[tags, setTags, commitValidation]
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
129
|
+
const value = e.target.value
|
|
130
|
+
|
|
131
|
+
for (const sep of separators) {
|
|
132
|
+
const matches = typeof sep === 'string' ? value.includes(sep) : sep.test(value)
|
|
133
|
+
if (matches) {
|
|
134
|
+
const parts = typeof sep === 'string' ? value.split(sep) : value.split(sep)
|
|
135
|
+
for (const part of parts) {
|
|
136
|
+
if (part.trim()) addTag(part)
|
|
137
|
+
}
|
|
138
|
+
setQuery('')
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
setQuery(value)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
147
|
+
if (e.key === 'Enter') {
|
|
148
|
+
e.preventDefault()
|
|
149
|
+
if (query) addTag(query)
|
|
150
|
+
} else if (e.key === 'Backspace' && !query && tags.length > 0) {
|
|
151
|
+
removeTag(tags.length - 1)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const atMax = props.maxTags !== undefined && tags.length >= props.maxTags
|
|
156
|
+
const isDisabled = props.isDisabled || atMax
|
|
157
|
+
|
|
158
|
+
const defaultPlaceholder =
|
|
159
|
+
props.min !== undefined && props.max !== undefined
|
|
160
|
+
? `Add numbers (${props.min}–${props.max})...`
|
|
161
|
+
: props.min !== undefined
|
|
162
|
+
? `Add numbers (min: ${props.min})...`
|
|
163
|
+
: props.max !== undefined
|
|
164
|
+
? `Add numbers (max: ${props.max})...`
|
|
165
|
+
: 'Add numbers...'
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<div className="relative">
|
|
169
|
+
{/* Hidden inputs for form submission */}
|
|
170
|
+
{tags.map((tag, i) => (
|
|
171
|
+
<HeadlessForm.HiddenInput
|
|
172
|
+
key={i}
|
|
173
|
+
ref={i === 0 ? hiddenInputRef : undefined}
|
|
174
|
+
name={props.name}
|
|
175
|
+
value={tag}
|
|
176
|
+
form={props.form}
|
|
177
|
+
/>
|
|
178
|
+
))}
|
|
179
|
+
{tags.length === 0 && <HeadlessForm.HiddenInput ref={hiddenInputRef} name={props.name} value="" form={props.form} />}
|
|
180
|
+
|
|
181
|
+
<FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} htmlFor={inputId} />
|
|
182
|
+
|
|
183
|
+
<FormFieldComponents.FieldGroup isDisabled={props.isDisabled} isInvalid={isInvalid} hasWarning={hasWarning} shape={props.shape} onClick={() => inputRef.current?.focus()}>
|
|
184
|
+
<div className="flex flex-wrap items-center gap-1.5 px-3 py-1.5 min-h-[38px] w-full">
|
|
185
|
+
{/* Display existing tags */}
|
|
186
|
+
{tags.map((tag, index) => (
|
|
187
|
+
<Tag key={index} onRemove={!props.isDisabled ? () => removeTag(index) : undefined} isDisabled={props.isDisabled}>
|
|
188
|
+
{tag}
|
|
189
|
+
</Tag>
|
|
190
|
+
))}
|
|
191
|
+
|
|
192
|
+
{/* Text input for adding tags */}
|
|
193
|
+
<input
|
|
194
|
+
id={inputId}
|
|
195
|
+
ref={inputRef}
|
|
196
|
+
className={tw(
|
|
197
|
+
'flex-1 min-w-[120px] bg-transparent p-0 border-0 focus:ring-0 focus:outline-none text-sm text-neutral-900 placeholder:text-neutral-400',
|
|
198
|
+
props.isDisabled && 'cursor-not-allowed'
|
|
199
|
+
)}
|
|
200
|
+
value={query}
|
|
201
|
+
onChange={handleInputChange}
|
|
202
|
+
onKeyDown={handleKeyDown}
|
|
203
|
+
onBlur={() => {
|
|
204
|
+
if (query) addTag(query)
|
|
205
|
+
commitValidation()
|
|
206
|
+
}}
|
|
207
|
+
placeholder={
|
|
208
|
+
tags.length === 0 ? (props.placeholder ?? defaultPlaceholder) : atMax ? `Maximum ${props.maxTags} tags` : ''
|
|
209
|
+
}
|
|
210
|
+
disabled={isDisabled}
|
|
211
|
+
aria-invalid={isInvalid || undefined}
|
|
212
|
+
aria-describedby={helpTextId}
|
|
213
|
+
autoComplete="off"
|
|
214
|
+
inputMode="numeric"
|
|
215
|
+
/>
|
|
216
|
+
</div>
|
|
217
|
+
</FormFieldComponents.FieldGroup>
|
|
218
|
+
|
|
219
|
+
{/* Tag count */}
|
|
220
|
+
{!props.hideTagCount && props.maxTags !== undefined && (
|
|
221
|
+
<div className="mt-1 text-xs text-neutral-500">
|
|
222
|
+
{tags.length} / {props.maxTags} tags
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
|
|
226
|
+
<FormFieldComponents.FormFieldHelpText
|
|
227
|
+
id={helpTextId}
|
|
228
|
+
isInvalid={isInvalid}
|
|
229
|
+
validationMessage={validationMessage}
|
|
230
|
+
hasWarning={hasWarning}
|
|
231
|
+
warningMessage={props.warningMessage}
|
|
232
|
+
helpText={props.helpText}
|
|
233
|
+
/>
|
|
234
|
+
</div>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function Tag({
|
|
239
|
+
children,
|
|
240
|
+
onRemove,
|
|
241
|
+
isDisabled
|
|
242
|
+
}: {
|
|
243
|
+
children: React.ReactNode
|
|
244
|
+
onRemove?: () => void
|
|
245
|
+
isDisabled?: boolean
|
|
246
|
+
}) {
|
|
247
|
+
return (
|
|
248
|
+
<span
|
|
249
|
+
className={tw(
|
|
250
|
+
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-normal bg-primary-500/10 text-primary-500',
|
|
251
|
+
isDisabled && 'opacity-50'
|
|
252
|
+
)}
|
|
253
|
+
>
|
|
254
|
+
<span className="truncate">{children}</span>
|
|
255
|
+
{onRemove && (
|
|
256
|
+
<button
|
|
257
|
+
type="button"
|
|
258
|
+
onClick={(e) => {
|
|
259
|
+
e.stopPropagation()
|
|
260
|
+
onRemove()
|
|
261
|
+
}}
|
|
262
|
+
className="inline-flex items-center justify-center h-4 w-4 -m-0.5 p-0.5 rounded-full translate-x-1 hover:bg-black/10"
|
|
263
|
+
aria-label="Remove"
|
|
264
|
+
tabIndex={-1}
|
|
265
|
+
>
|
|
266
|
+
<Icon name="x-mark" className="h-2 w-2" aria-hidden="true" />
|
|
267
|
+
</button>
|
|
268
|
+
)}
|
|
269
|
+
</span>
|
|
270
|
+
)
|
|
271
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { HeadlessForm } from '@maestro-js/form'
|
|
3
|
+
import type { ValidationFunction, ValidationResult } from '@maestro-js/form'
|
|
4
|
+
import { tw } from '../utils/tw'
|
|
5
|
+
import { formatFileSize, checkFileType, checkFileMaxSize } from '../utils/file-input'
|
|
6
|
+
import { FormFieldComponents } from './helpers/form-field'
|
|
7
|
+
import { HeadlessFileInput } from './headless-file-input'
|
|
8
|
+
import { Icon } from './icon'
|
|
9
|
+
import { Pdf } from './pdf'
|
|
10
|
+
|
|
11
|
+
export type PdfInputProps = {
|
|
12
|
+
// Form identification
|
|
13
|
+
name?: string
|
|
14
|
+
form?: string
|
|
15
|
+
|
|
16
|
+
// Display elements
|
|
17
|
+
label?: React.ReactNode
|
|
18
|
+
cornerHint?: React.ReactNode
|
|
19
|
+
helpText?: React.ReactNode
|
|
20
|
+
warningMessage?: React.ReactNode
|
|
21
|
+
emptyState?: React.ReactNode
|
|
22
|
+
renderPreview?: (props: PdfInputPreviewProps) => React.ReactNode
|
|
23
|
+
|
|
24
|
+
// Validation and state
|
|
25
|
+
isRequired?: boolean
|
|
26
|
+
isDisabled?: boolean
|
|
27
|
+
isInvalid?: boolean
|
|
28
|
+
validate?: ValidationFunction<string | File | null>
|
|
29
|
+
errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
|
|
30
|
+
|
|
31
|
+
// Value management (File object or string URL, held in memory until form submit)
|
|
32
|
+
value?: string | File | null
|
|
33
|
+
defaultValue?: string | File | null
|
|
34
|
+
onChange?: (value: string | File | null) => void
|
|
35
|
+
|
|
36
|
+
maxSize?: number // in bytes
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface PdfInputPreviewProps {
|
|
40
|
+
fileUrl: string
|
|
41
|
+
isDisabled: boolean
|
|
42
|
+
onChange(value: string | null | File): unknown
|
|
43
|
+
inputId: string
|
|
44
|
+
name: string | undefined
|
|
45
|
+
inputRef: React.Ref<HTMLInputElement>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function PdfInput(props: PdfInputProps) {
|
|
49
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
50
|
+
const helpTextId = React.useId()
|
|
51
|
+
const inputId = React.useId()
|
|
52
|
+
|
|
53
|
+
const exceededMaxSizeFn = React.useCallback(
|
|
54
|
+
(value: string | File | null) => {
|
|
55
|
+
if (value && props.maxSize && value instanceof File) {
|
|
56
|
+
return checkFileMaxSize(value, props.maxSize)
|
|
57
|
+
}
|
|
58
|
+
return null
|
|
59
|
+
},
|
|
60
|
+
[props.maxSize]
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const formatNotSupportedFn = React.useCallback((value: string | File | null) => {
|
|
64
|
+
if (value instanceof File) {
|
|
65
|
+
return checkFileType(value, 'application/pdf')
|
|
66
|
+
}
|
|
67
|
+
return null
|
|
68
|
+
}, [])
|
|
69
|
+
|
|
70
|
+
// Controlled state management
|
|
71
|
+
const [value, setValue] = HeadlessForm.useControlledState(props.value, props.defaultValue ?? null, (value) => {
|
|
72
|
+
if (props.onChange) {
|
|
73
|
+
props.onChange(value)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
const exceededMaxSize = React.useMemo(() => exceededMaxSizeFn(value), [value, exceededMaxSizeFn])
|
|
78
|
+
const formatNotSupported = React.useMemo(() => formatNotSupportedFn(value), [value, formatNotSupportedFn])
|
|
79
|
+
|
|
80
|
+
// Form validation
|
|
81
|
+
const {
|
|
82
|
+
validationMessage,
|
|
83
|
+
isInvalid: validationInvalid,
|
|
84
|
+
commitValidation
|
|
85
|
+
} = HeadlessForm.useValidation(
|
|
86
|
+
{
|
|
87
|
+
validate: props.validate,
|
|
88
|
+
value,
|
|
89
|
+
name: props.name,
|
|
90
|
+
isRequired: props.isRequired,
|
|
91
|
+
form: props.form,
|
|
92
|
+
focus() {
|
|
93
|
+
inputRef.current?.focus()
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
inputRef
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
const isInvalid = props.isInvalid || validationInvalid || Boolean(exceededMaxSize || formatNotSupported)
|
|
100
|
+
const hasWarning = Boolean(props.warningMessage) && !isInvalid
|
|
101
|
+
const errorMessage = exceededMaxSize || formatNotSupported || validationMessage
|
|
102
|
+
|
|
103
|
+
const PdfPreview = props.renderPreview ?? DefaultPdfInputPreview
|
|
104
|
+
|
|
105
|
+
const emptyState = props.emptyState ?? (
|
|
106
|
+
<div className="px-6 py-12">
|
|
107
|
+
<Icon name="document" className="h-12 w-12 mx-auto mb-3 text-neutral-400" />
|
|
108
|
+
<p className="text-sm text-neutral-600 font-medium">Click to upload or drag and drop</p>
|
|
109
|
+
<p className="text-xs text-neutral-500 mt-1">PDF</p>
|
|
110
|
+
{props.maxSize ? <p className="text-xs text-neutral-500 mt-1">Max size: {formatFileSize(props.maxSize)}</p> : null}
|
|
111
|
+
</div>
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<HeadlessFileInput
|
|
116
|
+
value={value}
|
|
117
|
+
onChange={(v) => {
|
|
118
|
+
setValue(v)
|
|
119
|
+
commitValidation()
|
|
120
|
+
}}
|
|
121
|
+
>
|
|
122
|
+
<div className="relative">
|
|
123
|
+
<FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} htmlFor={inputId} />
|
|
124
|
+
|
|
125
|
+
<HeadlessFileInput.Preview>
|
|
126
|
+
{(files) => {
|
|
127
|
+
const hasFile = files.length > 0
|
|
128
|
+
return (
|
|
129
|
+
<div
|
|
130
|
+
className={tw(
|
|
131
|
+
'pdf-input-border relative block w-full rounded-md overflow-hidden text-center focus:outline-none focus-within:ring-2 focus-within:ring-primary-500 focus-within:ring-offset-2',
|
|
132
|
+
'border-2 border-neutral-200 hover:border-neutral-300 min-h-56',
|
|
133
|
+
!hasFile && 'border-dashed',
|
|
134
|
+
props.isDisabled && 'border-neutral-100 cursor-not-allowed',
|
|
135
|
+
hasWarning &&
|
|
136
|
+
'border-notice-300 hover:border-notice-400 text-notice-900 placeholder-notice-300 focus-within:border-notice-500 focus-within:ring-notice-500',
|
|
137
|
+
isInvalid &&
|
|
138
|
+
'border-negative-300 hover:border-negative-400 text-negative-900 placeholder-negative-300 focus-within:border-negative-500 focus-within:ring-negative-500'
|
|
139
|
+
)}
|
|
140
|
+
>
|
|
141
|
+
{hasFile ? (
|
|
142
|
+
PdfPreview({
|
|
143
|
+
isDisabled: !!props.isDisabled,
|
|
144
|
+
fileUrl: files[0]!.fileUrl,
|
|
145
|
+
onChange: (v) => {
|
|
146
|
+
setValue(v)
|
|
147
|
+
commitValidation()
|
|
148
|
+
},
|
|
149
|
+
inputId,
|
|
150
|
+
inputRef,
|
|
151
|
+
name: props.name
|
|
152
|
+
})
|
|
153
|
+
) : (
|
|
154
|
+
<HeadlessFileInput.DropZone
|
|
155
|
+
inputRef={inputRef}
|
|
156
|
+
accept="application/pdf"
|
|
157
|
+
name={props.name}
|
|
158
|
+
inputId={inputId}
|
|
159
|
+
disabled={props.isDisabled}
|
|
160
|
+
className="size-full"
|
|
161
|
+
>
|
|
162
|
+
{({ dragOver }) => (
|
|
163
|
+
<div className="relative pointer-events-none">
|
|
164
|
+
{emptyState}
|
|
165
|
+
{dragOver ? (
|
|
166
|
+
<div className="absolute inset-0">
|
|
167
|
+
<DragOverContent />
|
|
168
|
+
</div>
|
|
169
|
+
) : null}
|
|
170
|
+
</div>
|
|
171
|
+
)}
|
|
172
|
+
</HeadlessFileInput.DropZone>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
)
|
|
176
|
+
}}
|
|
177
|
+
</HeadlessFileInput.Preview>
|
|
178
|
+
|
|
179
|
+
<FormFieldComponents.FormFieldHelpText
|
|
180
|
+
id={helpTextId}
|
|
181
|
+
isInvalid={isInvalid}
|
|
182
|
+
validationMessage={errorMessage}
|
|
183
|
+
hasWarning={hasWarning}
|
|
184
|
+
warningMessage={props.warningMessage}
|
|
185
|
+
helpText={props.helpText}
|
|
186
|
+
/>
|
|
187
|
+
</div>
|
|
188
|
+
</HeadlessFileInput>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function DragOverContent() {
|
|
193
|
+
return (
|
|
194
|
+
<div
|
|
195
|
+
className="absolute inset-0 p-12 rounded flex items-center justify-center"
|
|
196
|
+
style={{
|
|
197
|
+
background: `repeating-linear-gradient(45deg, #e5e7eb, #e5e7eb 20px, #d1d5db 20px, #d1d5db 50px)`
|
|
198
|
+
}}
|
|
199
|
+
>
|
|
200
|
+
<div>
|
|
201
|
+
<Icon name="cloud-arrow-up" className="h-12 w-12 mx-auto text-gray-400" />
|
|
202
|
+
<span className="mt-2 block text-sm font-semibold text-gray-900">Upload a PDF</span>
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function DefaultPdfInputPreview({ name, fileUrl, inputRef, isDisabled, onChange, inputId }: PdfInputPreviewProps) {
|
|
209
|
+
return (
|
|
210
|
+
<>
|
|
211
|
+
<HeadlessFileInput.DropZone
|
|
212
|
+
inputRef={inputRef}
|
|
213
|
+
accept="application/pdf"
|
|
214
|
+
name={name}
|
|
215
|
+
inputId={inputId}
|
|
216
|
+
disabled={isDisabled}
|
|
217
|
+
>
|
|
218
|
+
{({ dragOver }) => (
|
|
219
|
+
<div className="min-h-56 pointer-events-none">
|
|
220
|
+
{dragOver ? (
|
|
221
|
+
<div className="absolute inset-0">
|
|
222
|
+
<DragOverContent />
|
|
223
|
+
</div>
|
|
224
|
+
) : null}
|
|
225
|
+
<div className="relative">
|
|
226
|
+
<div
|
|
227
|
+
aria-hidden="true"
|
|
228
|
+
className="absolute inset-x-0 top-0 rounded-t-md h-20 bg-gradient-to-b from-white opacity-75 mix-blend-lighten"
|
|
229
|
+
/>
|
|
230
|
+
<Pdf src={fileUrl} className="w-full min-h-96 rounded-md border-0" />
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
)}
|
|
234
|
+
</HeadlessFileInput.DropZone>
|
|
235
|
+
<div className="absolute top-2 right-2">
|
|
236
|
+
{!isDisabled ? (
|
|
237
|
+
<button
|
|
238
|
+
type="button"
|
|
239
|
+
aria-label="Remove"
|
|
240
|
+
onClick={() => onChange(null)}
|
|
241
|
+
className="p-1 h-5 w-5 flex items-center justify-center bg-gray-800/60 hover:bg-gray-500/60 rounded-full transition-colors"
|
|
242
|
+
>
|
|
243
|
+
<Icon name="x-mark" className="h-4 w-4 text-gray-200" />
|
|
244
|
+
</button>
|
|
245
|
+
) : null}
|
|
246
|
+
</div>
|
|
247
|
+
</>
|
|
248
|
+
)
|
|
249
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { usePdf } from 'react-pdf-hook'
|
|
3
|
+
import { tw } from '../utils/tw'
|
|
4
|
+
import { usePagination } from '../utils/use-pagination'
|
|
5
|
+
import { Icon } from './icon'
|
|
6
|
+
import pdfjs from './helpers/pdf-dist.client'
|
|
7
|
+
|
|
8
|
+
export interface PdfProps extends React.ComponentProps<'div'> {
|
|
9
|
+
src: string
|
|
10
|
+
onDownload?: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Pdf({ src, onDownload, className, ...props }: PdfProps) {
|
|
14
|
+
const containerRef = React.useRef<HTMLDivElement>(null)
|
|
15
|
+
const [pageNumber, setPageNumber] = React.useState(0)
|
|
16
|
+
|
|
17
|
+
const { pdf, canvasRef } = usePdf(pdfjs, src, pageNumber + 1, { width: containerRef.current?.clientWidth })
|
|
18
|
+
|
|
19
|
+
const numPages = pdf?.numPages
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div ref={containerRef} className={tw('relative overflow-hidden border border-neutral-300', className)} {...props}>
|
|
23
|
+
<canvas
|
|
24
|
+
ref={canvasRef}
|
|
25
|
+
style={{ display: !pdf ? 'none' : undefined, maxWidth: '100%' }}
|
|
26
|
+
className={tw('pdf-page', onDownload && 'cursor-pointer')}
|
|
27
|
+
onClick={onDownload}
|
|
28
|
+
/>
|
|
29
|
+
{!pdf || !containerRef.current?.clientWidth ? <div className="w-full h-full m-auto rounded" /> : null}
|
|
30
|
+
{numPages && numPages > 1 ? (
|
|
31
|
+
<div className="flex absolute bottom-5 w-full justify-center">
|
|
32
|
+
<Pagination page={pageNumber} setPageNumber={setPageNumber} count={numPages} />
|
|
33
|
+
</div>
|
|
34
|
+
) : null}
|
|
35
|
+
</div>
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
Pdf.displayName = 'Pdf'
|
|
40
|
+
|
|
41
|
+
function Pagination({ page, count, setPageNumber }: { page: number; count: number; setPageNumber: (n: number) => void }) {
|
|
42
|
+
const pages = usePagination({ currentPage: page, pageCount: count })
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div className="inline-flex items-center gap-1 pointer-events-auto">
|
|
46
|
+
<button
|
|
47
|
+
type="button"
|
|
48
|
+
onClick={() => setPageNumber(page > 0 ? page - 1 : 0)}
|
|
49
|
+
disabled={page === 0}
|
|
50
|
+
className="p-1 rounded bg-white border border-neutral-300 hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
51
|
+
aria-label="Previous page"
|
|
52
|
+
>
|
|
53
|
+
<Icon name="chevron-left" className="h-4 w-4" aria-hidden="true" />
|
|
54
|
+
</button>
|
|
55
|
+
|
|
56
|
+
{pages.map((p, i) => (
|
|
57
|
+
<button
|
|
58
|
+
type="button"
|
|
59
|
+
key={p.readonly ? `ellipsis-${i}` : p.label}
|
|
60
|
+
onClick={() => setPageNumber(p.readonly ? page : (p.value as number))}
|
|
61
|
+
disabled={p.readonly}
|
|
62
|
+
className={tw(
|
|
63
|
+
'min-w-[28px] bg-white h-7 px-2 text-xs font-medium rounded border transition-colors',
|
|
64
|
+
p.selected
|
|
65
|
+
? 'bg-primary-500 text-white border-primary-500'
|
|
66
|
+
: p.readonly
|
|
67
|
+
? 'border-transparent cursor-default'
|
|
68
|
+
: 'border-neutral-300 hover:bg-neutral-50'
|
|
69
|
+
)}
|
|
70
|
+
>
|
|
71
|
+
{p.label}
|
|
72
|
+
</button>
|
|
73
|
+
))}
|
|
74
|
+
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
onClick={() => setPageNumber(page < count - 1 ? page + 1 : page)}
|
|
78
|
+
disabled={page === count - 1}
|
|
79
|
+
className="p-1 rounded bg-white border border-neutral-300 hover:bg-neutral-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
|
80
|
+
aria-label="Next page"
|
|
81
|
+
>
|
|
82
|
+
<Icon name="chevron-right" className="h-4 w-4" aria-hidden="true" />
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
)
|
|
86
|
+
}
|