@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.
Files changed (89) hide show
  1. package/commands/add.ts +41 -0
  2. package/commands/index.ts +7 -0
  3. package/commands/list.ts +9 -0
  4. package/dist/components.json +1 -0
  5. package/dist/index.d.ts +14 -0
  6. package/dist/index.js +48 -0
  7. package/package.json +49 -0
  8. package/registry.json +1445 -0
  9. package/scripts/build.ts +44 -0
  10. package/src/components/alert-dialog.tsx +150 -0
  11. package/src/components/autocomplete.tsx +288 -0
  12. package/src/components/autosuggest.tsx +314 -0
  13. package/src/components/avatar.tsx +54 -0
  14. package/src/components/boolean-select.tsx +48 -0
  15. package/src/components/button-group.tsx +75 -0
  16. package/src/components/button-link.tsx +71 -0
  17. package/src/components/button.tsx +132 -0
  18. package/src/components/checkbox-group.tsx +172 -0
  19. package/src/components/checkbox.tsx +207 -0
  20. package/src/components/chip.tsx +158 -0
  21. package/src/components/container.tsx +82 -0
  22. package/src/components/currency-field.tsx +183 -0
  23. package/src/components/date-input.tsx +189 -0
  24. package/src/components/date-picker.tsx +211 -0
  25. package/src/components/date-range-picker.tsx +290 -0
  26. package/src/components/date-time-picker.tsx +196 -0
  27. package/src/components/dialog.tsx +97 -0
  28. package/src/components/disclosure.tsx +114 -0
  29. package/src/components/drawer.tsx +78 -0
  30. package/src/components/enum-chip.tsx +30 -0
  31. package/src/components/file-input.tsx +245 -0
  32. package/src/components/form.tsx +82 -0
  33. package/src/components/headless-file-input.tsx +362 -0
  34. package/src/components/helpers/animated-popover.tsx +24 -0
  35. package/src/components/helpers/button-context.ts +10 -0
  36. package/src/components/helpers/calendar-month-year-picker.tsx +280 -0
  37. package/src/components/helpers/form-field.tsx +229 -0
  38. package/src/components/helpers/get-button-classes.ts +138 -0
  39. package/src/components/helpers/headless-button.tsx +36 -0
  40. package/src/components/helpers/pdf-dist.client.ts +6 -0
  41. package/src/components/icon.tsx +26 -0
  42. package/src/components/image-input.tsx +265 -0
  43. package/src/components/img.tsx +46 -0
  44. package/src/components/inline-alert.tsx +54 -0
  45. package/src/components/labeled-value.tsx +480 -0
  46. package/src/components/link-tabs.tsx +118 -0
  47. package/src/components/menu.tsx +152 -0
  48. package/src/components/month-day-input.tsx +176 -0
  49. package/src/components/multi-file-input.tsx +244 -0
  50. package/src/components/multi-image-input.tsx +389 -0
  51. package/src/components/multicomplete.tsx +322 -0
  52. package/src/components/multiselect.tsx +325 -0
  53. package/src/components/multisuggest.tsx +357 -0
  54. package/src/components/number-field.tsx +143 -0
  55. package/src/components/numeric-tag-field.tsx +271 -0
  56. package/src/components/pdf-input.tsx +249 -0
  57. package/src/components/pdf.tsx +86 -0
  58. package/src/components/percentage-field.tsx +187 -0
  59. package/src/components/phone-number-field.tsx +166 -0
  60. package/src/components/radio-group.tsx +112 -0
  61. package/src/components/radio.tsx +91 -0
  62. package/src/components/select.tsx +215 -0
  63. package/src/components/spinner.tsx +16 -0
  64. package/src/components/stepper.tsx +181 -0
  65. package/src/components/switch.tsx +186 -0
  66. package/src/components/tabs.tsx +151 -0
  67. package/src/components/tag-field.tsx +250 -0
  68. package/src/components/text-area.tsx +148 -0
  69. package/src/components/text-field.tsx +144 -0
  70. package/src/components/time-input.tsx +198 -0
  71. package/src/components/toast.tsx +176 -0
  72. package/src/components/toggle-button-group.tsx +94 -0
  73. package/src/components/year-month-input.tsx +187 -0
  74. package/src/utils/colors.ts +44 -0
  75. package/src/utils/compose-refs.ts +32 -0
  76. package/src/utils/enum-colors.ts +1 -0
  77. package/src/utils/file-input.ts +49 -0
  78. package/src/utils/icons.d.ts +20 -0
  79. package/src/utils/tw.ts +13 -0
  80. package/src/utils/use-element-size.ts +35 -0
  81. package/src/utils/use-number-input.ts +143 -0
  82. package/src/utils/use-pagination.ts +38 -0
  83. package/src/utils/use-prevent-default.ts +27 -0
  84. package/src/utils/use-render-props.ts +39 -0
  85. package/src/utils/use-spin-delay.ts +24 -0
  86. package/src/utils/use-stable-accessor.ts +11 -0
  87. package/src/utils/use-tab-indicator.ts +106 -0
  88. package/tests/commands.test.ts +81 -0
  89. 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
+ }