@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,362 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { useComposedRefs } from '../utils/compose-refs'
|
|
3
|
+
|
|
4
|
+
export type HeadlessFileInputProps = {
|
|
5
|
+
children?: React.ReactNode
|
|
6
|
+
} & (
|
|
7
|
+
| {
|
|
8
|
+
multiple: true
|
|
9
|
+
value?: (string | File)[]
|
|
10
|
+
defaultValue?: (string | File)[]
|
|
11
|
+
onChange?(value: (string | File)[]): unknown
|
|
12
|
+
}
|
|
13
|
+
| {
|
|
14
|
+
multiple?: false
|
|
15
|
+
value?: string | File | null
|
|
16
|
+
defaultValue?: string | File | null
|
|
17
|
+
onChange?(value: string | File | null): unknown
|
|
18
|
+
}
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
type FileInputContext =
|
|
22
|
+
| { multiple?: false; value: File | string | null; onChange(value: File | string | null): unknown }
|
|
23
|
+
| { multiple: true; value: (File | string)[]; onChange(value: (File | string)[]): unknown }
|
|
24
|
+
|
|
25
|
+
const fileInputContext = React.createContext<FileInputContext>({
|
|
26
|
+
multiple: false,
|
|
27
|
+
value: null,
|
|
28
|
+
onChange: () => null
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
function FileInputContainer({ multiple, value, defaultValue, onChange, children }: HeadlessFileInputProps) {
|
|
32
|
+
const controlled = useControlled({ multiple, value, defaultValue, onChange }) as FileInputContext
|
|
33
|
+
return <fileInputContext.Provider value={controlled}>{children}</fileInputContext.Provider>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
FileInputContainer.displayName = 'HeadlessFileInput'
|
|
37
|
+
|
|
38
|
+
const FileInputDropZone = React.forwardRef<
|
|
39
|
+
HTMLLabelElement,
|
|
40
|
+
{
|
|
41
|
+
name?: string
|
|
42
|
+
children: ((props: { dragOver: boolean }) => React.ReactElement) | React.ReactElement
|
|
43
|
+
inputId?: string
|
|
44
|
+
accept?: string | undefined
|
|
45
|
+
onDragChange?(value: boolean): unknown
|
|
46
|
+
capture?: React.InputHTMLAttributes<'file'>['capture']
|
|
47
|
+
disabled?: React.InputHTMLAttributes<'file'>['disabled']
|
|
48
|
+
processFile?: (file: File) => Promise<File>
|
|
49
|
+
inputRef?: React.Ref<HTMLInputElement>
|
|
50
|
+
} & Omit<React.ComponentProps<'label'>, 'children'>
|
|
51
|
+
>(({ children, inputId, inputRef, onDragChange, name, accept, capture, disabled, processFile, ...props }, ref) => {
|
|
52
|
+
const { onChange, value, multiple } = React.useContext(fileInputContext)
|
|
53
|
+
const [dragOver, setDragOver] = React.useState(false)
|
|
54
|
+
const startDrag = React.useCallback(
|
|
55
|
+
(e: React.DragEvent<HTMLInputElement>) => {
|
|
56
|
+
e.preventDefault()
|
|
57
|
+
e.stopPropagation()
|
|
58
|
+
setDragOver(true)
|
|
59
|
+
onDragChange && onDragChange(true)
|
|
60
|
+
},
|
|
61
|
+
[setDragOver, onDragChange]
|
|
62
|
+
)
|
|
63
|
+
const stopDrag = React.useCallback(
|
|
64
|
+
(e: React.DragEvent<HTMLInputElement>) => {
|
|
65
|
+
e.preventDefault()
|
|
66
|
+
e.stopPropagation()
|
|
67
|
+
setDragOver(false)
|
|
68
|
+
onDragChange && onDragChange(false)
|
|
69
|
+
},
|
|
70
|
+
[setDragOver, onDragChange]
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
const childrenResult = typeof children === 'function' ? children({ dragOver }) : children
|
|
74
|
+
const childProps = childrenResult.props as Record<string, unknown>
|
|
75
|
+
const cloned = React.cloneElement(
|
|
76
|
+
childrenResult,
|
|
77
|
+
{
|
|
78
|
+
...childProps
|
|
79
|
+
},
|
|
80
|
+
[
|
|
81
|
+
...React.Children.toArray(childProps.children as React.ReactNode),
|
|
82
|
+
<FileInputInput
|
|
83
|
+
key="file-input-hidden"
|
|
84
|
+
id={inputId}
|
|
85
|
+
name={name}
|
|
86
|
+
accept={accept}
|
|
87
|
+
ref={inputRef}
|
|
88
|
+
style={{ width: '0.1px', height: '0.1px', opacity: 0, overflow: 'hidden', position: 'absolute', zIndex: -10 }}
|
|
89
|
+
capture={capture}
|
|
90
|
+
disabled={disabled}
|
|
91
|
+
processFile={processFile}
|
|
92
|
+
/>
|
|
93
|
+
]
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<label {...props} ref={ref}>
|
|
98
|
+
<div
|
|
99
|
+
onDrag={cancel}
|
|
100
|
+
onDragStart={cancel}
|
|
101
|
+
onDragOver={cancel}
|
|
102
|
+
onDragEnter={startDrag}
|
|
103
|
+
onDragLeave={stopDrag}
|
|
104
|
+
onDrop={(e) => {
|
|
105
|
+
e.preventDefault()
|
|
106
|
+
e.stopPropagation()
|
|
107
|
+
setDragOver(false)
|
|
108
|
+
onDragChange && onDragChange(false)
|
|
109
|
+
if (e.dataTransfer.files.length) {
|
|
110
|
+
if (multiple) {
|
|
111
|
+
onChange([...value, ...Array.from(e.dataTransfer.files)])
|
|
112
|
+
} else {
|
|
113
|
+
onChange(e.dataTransfer.files.item(0) || null)
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
const uri = e.dataTransfer.getData('text/uri-list')
|
|
117
|
+
if (uri) {
|
|
118
|
+
fetchFileFromUri(uri).then((file) => {
|
|
119
|
+
if (multiple) {
|
|
120
|
+
onChange([...value, file])
|
|
121
|
+
} else {
|
|
122
|
+
onChange(file)
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}}
|
|
128
|
+
>
|
|
129
|
+
{cloned}
|
|
130
|
+
</div>
|
|
131
|
+
</label>
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
FileInputDropZone.displayName = 'FileInputDropZone'
|
|
135
|
+
|
|
136
|
+
const FileInputInput = React.forwardRef<
|
|
137
|
+
HTMLInputElement,
|
|
138
|
+
React.ComponentPropsWithoutRef<'input'> & { processFile?: (file: File) => Promise<File> }
|
|
139
|
+
>(({ name, processFile, form, ...props }, refProp) => {
|
|
140
|
+
const { multiple, value, onChange } = React.useContext(fileInputContext)
|
|
141
|
+
const localRef = React.useRef<HTMLInputElement>(null)
|
|
142
|
+
const ref = useComposedRefs(localRef, refProp)
|
|
143
|
+
|
|
144
|
+
React.useEffect(() => {
|
|
145
|
+
if (localRef.current) {
|
|
146
|
+
const dt = new DataTransfer()
|
|
147
|
+
if (Array.isArray(value)) {
|
|
148
|
+
value.forEach(async (v) => v instanceof File && dt.items.add(v))
|
|
149
|
+
} else if (value && value instanceof File) {
|
|
150
|
+
dt.items.add(value)
|
|
151
|
+
}
|
|
152
|
+
localRef.current.files = dt.files
|
|
153
|
+
}
|
|
154
|
+
}, [value, processFile])
|
|
155
|
+
|
|
156
|
+
const urls = React.useMemo(() => {
|
|
157
|
+
if (Array.isArray(value)) {
|
|
158
|
+
return value.map((v, i) => (typeof v === 'string' ? { name: `${name}.${i}`, value: v } : null)).filter(Boolean) as {
|
|
159
|
+
name: string
|
|
160
|
+
value: string
|
|
161
|
+
}[]
|
|
162
|
+
} else if (value && typeof value === 'string') {
|
|
163
|
+
return [{ name: name, value: value }]
|
|
164
|
+
} else {
|
|
165
|
+
return []
|
|
166
|
+
}
|
|
167
|
+
}, [value, name])
|
|
168
|
+
|
|
169
|
+
const hasFiles =
|
|
170
|
+
typeof value !== 'string' &&
|
|
171
|
+
!!value &&
|
|
172
|
+
!(Array.isArray(value) && !value.filter((v) => v && typeof v !== 'string').length)
|
|
173
|
+
const hasUrls = !!urls.length
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<>
|
|
177
|
+
<input
|
|
178
|
+
{...props}
|
|
179
|
+
type="file"
|
|
180
|
+
form={form}
|
|
181
|
+
ref={hasFiles ? ref : undefined}
|
|
182
|
+
name={hasFiles ? name : undefined}
|
|
183
|
+
onChange={async (e) => {
|
|
184
|
+
const newValue = (
|
|
185
|
+
multiple
|
|
186
|
+
? e.currentTarget.files
|
|
187
|
+
? [
|
|
188
|
+
...value,
|
|
189
|
+
...(await Promise.all(
|
|
190
|
+
Array.from(e.currentTarget.files).map(async (file) => (processFile ? processFile(file) : file))
|
|
191
|
+
))
|
|
192
|
+
]
|
|
193
|
+
: []
|
|
194
|
+
: processFile && e.currentTarget.files?.item(0)
|
|
195
|
+
? await processFile(e.currentTarget.files?.item(0) as File)
|
|
196
|
+
: e.currentTarget.files?.item(0)
|
|
197
|
+
) as any
|
|
198
|
+
|
|
199
|
+
onChange(newValue)
|
|
200
|
+
}}
|
|
201
|
+
multiple={multiple}
|
|
202
|
+
/>
|
|
203
|
+
{urls.map((url, i) => (
|
|
204
|
+
<input
|
|
205
|
+
type="hidden"
|
|
206
|
+
ref={i === 0 && !hasFiles ? ref : undefined}
|
|
207
|
+
form={form}
|
|
208
|
+
value={url.value}
|
|
209
|
+
name={url.name}
|
|
210
|
+
key={url.name}
|
|
211
|
+
/>
|
|
212
|
+
))}
|
|
213
|
+
{!hasUrls && !hasFiles ? <input type="hidden" ref={ref} form={form} name={name} value="" /> : null}
|
|
214
|
+
</>
|
|
215
|
+
)
|
|
216
|
+
})
|
|
217
|
+
FileInputInput.displayName = 'FileInputInput'
|
|
218
|
+
|
|
219
|
+
function FilePreview({
|
|
220
|
+
children
|
|
221
|
+
}: {
|
|
222
|
+
children(
|
|
223
|
+
props: {
|
|
224
|
+
file: File | null
|
|
225
|
+
fileUrl: string
|
|
226
|
+
remove(): unknown
|
|
227
|
+
replace(newFile: File | string): unknown
|
|
228
|
+
}[]
|
|
229
|
+
): React.ReactNode
|
|
230
|
+
}) {
|
|
231
|
+
const { value, onChange } = React.useContext(fileInputContext)
|
|
232
|
+
const urlMap = useFileUrls(value)
|
|
233
|
+
const removeSingle = React.useCallback(() => onChange(null as any), [onChange])
|
|
234
|
+
const replaceSingle = React.useCallback((file: File | string) => onChange(file as any), [onChange])
|
|
235
|
+
|
|
236
|
+
const removeMultiple = React.useCallback(
|
|
237
|
+
(index: number) => onChange((value as File[]).filter((f, i) => i !== index) as any),
|
|
238
|
+
[value, onChange]
|
|
239
|
+
)
|
|
240
|
+
const replaceMultiple = React.useCallback(
|
|
241
|
+
(index: number, file: File | string) =>
|
|
242
|
+
onChange((value as (File | string)[]).map((f, i) => (i === index ? file : f)) as any),
|
|
243
|
+
[value, onChange]
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
if (!value) {
|
|
247
|
+
return <>{children([])}</>
|
|
248
|
+
} else if (Array.isArray(value)) {
|
|
249
|
+
return (
|
|
250
|
+
<>
|
|
251
|
+
{children(
|
|
252
|
+
value.map((v, i) => ({
|
|
253
|
+
file: typeof v === 'string' ? null : v,
|
|
254
|
+
fileUrl: urlMap.get(v) ?? (typeof v === 'string' ? v : ''),
|
|
255
|
+
remove: () => removeMultiple(i),
|
|
256
|
+
replace: (f) => replaceMultiple(i, f)
|
|
257
|
+
}))
|
|
258
|
+
)}
|
|
259
|
+
</>
|
|
260
|
+
)
|
|
261
|
+
} else {
|
|
262
|
+
return (
|
|
263
|
+
<>
|
|
264
|
+
{children([
|
|
265
|
+
{
|
|
266
|
+
file: typeof value === 'string' ? null : value,
|
|
267
|
+
fileUrl: urlMap.get(value) ?? (typeof value === 'string' ? value : ''),
|
|
268
|
+
remove: removeSingle,
|
|
269
|
+
replace: replaceSingle
|
|
270
|
+
}
|
|
271
|
+
])}
|
|
272
|
+
</>
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export const HeadlessFileInput = Object.assign(FileInputContainer, {
|
|
278
|
+
Input: FileInputInput,
|
|
279
|
+
Preview: FilePreview,
|
|
280
|
+
DropZone: FileInputDropZone
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
function cancel(e: React.SyntheticEvent) {
|
|
286
|
+
e.stopPropagation()
|
|
287
|
+
e.preventDefault()
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function useControlled<T extends string | File | null | (string | File)[]>({
|
|
291
|
+
defaultValue,
|
|
292
|
+
value,
|
|
293
|
+
onChange: onChangeRaw,
|
|
294
|
+
multiple
|
|
295
|
+
}: {
|
|
296
|
+
value?: T
|
|
297
|
+
defaultValue?: T
|
|
298
|
+
onChange?(value: null | string | File | (string | File)[]): unknown
|
|
299
|
+
multiple?: boolean
|
|
300
|
+
}) {
|
|
301
|
+
const [state, setState] = React.useState(defaultValue)
|
|
302
|
+
|
|
303
|
+
const onChange = React.useCallback(
|
|
304
|
+
(v: T) => {
|
|
305
|
+
setState(v)
|
|
306
|
+
onChangeRaw && onChangeRaw(v)
|
|
307
|
+
},
|
|
308
|
+
[onChangeRaw, setState]
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
const proposed = value === undefined ? state : value
|
|
312
|
+
|
|
313
|
+
return { value: !proposed && multiple ? [] : proposed || null, onChange, multiple: !!multiple }
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function useFileUrls(value: (File | string)[] | File | string | null): Map<File | string, string> {
|
|
317
|
+
const urlMapRef = React.useRef(new Map<File | string, string>())
|
|
318
|
+
|
|
319
|
+
const items = value === null ? [] : Array.isArray(value) ? value : [value]
|
|
320
|
+
|
|
321
|
+
// Create blob URLs for new items only
|
|
322
|
+
for (const item of items) {
|
|
323
|
+
if (!urlMapRef.current.has(item)) {
|
|
324
|
+
urlMapRef.current.set(item, typeof item === 'string' ? item : URL.createObjectURL(item))
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Prune stale entries and revoke their blob URLs
|
|
329
|
+
const currentKeys = new Set(items)
|
|
330
|
+
React.useEffect(() => {
|
|
331
|
+
for (const [key, url] of urlMapRef.current) {
|
|
332
|
+
if (!currentKeys.has(key)) {
|
|
333
|
+
if (typeof key !== 'string') URL.revokeObjectURL(url)
|
|
334
|
+
urlMapRef.current.delete(key)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
// Revoke all blob URLs on unmount
|
|
340
|
+
React.useEffect(() => {
|
|
341
|
+
const map = urlMapRef.current
|
|
342
|
+
return () => {
|
|
343
|
+
for (const [key, url] of map) {
|
|
344
|
+
if (typeof key !== 'string') URL.revokeObjectURL(url)
|
|
345
|
+
}
|
|
346
|
+
map.clear()
|
|
347
|
+
}
|
|
348
|
+
}, [])
|
|
349
|
+
|
|
350
|
+
return urlMapRef.current
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function fetchFileFromUri(uri: string): Promise<File> {
|
|
354
|
+
const res = await fetch(uri)
|
|
355
|
+
const blob = await res.blob()
|
|
356
|
+
const contentType = res.headers.get('content-type') || blob.type || ''
|
|
357
|
+
const contentDisposition = res.headers.get('content-disposition')
|
|
358
|
+
let filename = new URL(uri).pathname.split('/').pop() || 'file'
|
|
359
|
+
const match = contentDisposition?.match(/filename\*?=(?:UTF-8'')?["']?([^"';\n]+)/)
|
|
360
|
+
if (match) filename = decodeURIComponent(match[1]!)
|
|
361
|
+
return new File([blob], filename, { type: contentType })
|
|
362
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Popover, type PopoverProps } from 'react-aria-components'
|
|
2
|
+
import { tw } from '../../utils/tw'
|
|
3
|
+
|
|
4
|
+
const animationClasses = tw(
|
|
5
|
+
'transition-all duration-150 ease-out',
|
|
6
|
+
// resting state: keep transform properties defined so transitions have endpoints
|
|
7
|
+
'translate-x-0 translate-y-0',
|
|
8
|
+
// entering: fade + slide from trigger direction
|
|
9
|
+
'data-[entering]:opacity-0',
|
|
10
|
+
'data-[entering]:data-[placement=bottom]:-translate-y-1',
|
|
11
|
+
'data-[entering]:data-[placement=top]:translate-y-1',
|
|
12
|
+
'data-[entering]:data-[placement=left]:translate-x-1',
|
|
13
|
+
'data-[entering]:data-[placement=right]:-translate-x-1',
|
|
14
|
+
// exiting: fade + slide toward trigger direction
|
|
15
|
+
'data-[exiting]:opacity-0',
|
|
16
|
+
'data-[exiting]:data-[placement=bottom]:-translate-y-1',
|
|
17
|
+
'data-[exiting]:data-[placement=top]:translate-y-1',
|
|
18
|
+
'data-[exiting]:data-[placement=left]:translate-x-1',
|
|
19
|
+
'data-[exiting]:data-[placement=right]:-translate-x-1'
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
export function AnimatedPopover({ className, ...props }: PopoverProps) {
|
|
23
|
+
return <Popover {...props} className={tw(animationClasses, className)} />
|
|
24
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import type { ButtonVariant, ButtonColor, ButtonSize, ButtonShape } from './get-button-classes'
|
|
3
|
+
|
|
4
|
+
export const ButtonContext = React.createContext<{
|
|
5
|
+
variant?: ButtonVariant
|
|
6
|
+
color?: ButtonColor
|
|
7
|
+
size?: ButtonSize
|
|
8
|
+
shape?: ButtonShape
|
|
9
|
+
fullWidth?: boolean
|
|
10
|
+
}>({})
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Button,
|
|
4
|
+
Calendar,
|
|
5
|
+
CalendarGrid,
|
|
6
|
+
CalendarCell,
|
|
7
|
+
Heading,
|
|
8
|
+
CalendarGridHeader,
|
|
9
|
+
CalendarHeaderCell,
|
|
10
|
+
CalendarGridBody
|
|
11
|
+
} from 'react-aria-components'
|
|
12
|
+
import { CalendarDate, CalendarDateTime } from '@internationalized/date'
|
|
13
|
+
import { tw } from '../../utils/tw'
|
|
14
|
+
import { Icon } from '../icon'
|
|
15
|
+
|
|
16
|
+
type MonthYearPickerProps = {
|
|
17
|
+
currentDate: CalendarDate | CalendarDateTime
|
|
18
|
+
onSelect: (year: number, month: number) => void
|
|
19
|
+
onCancel: () => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function MonthYearPicker({ currentDate, onSelect, onCancel }: MonthYearPickerProps) {
|
|
23
|
+
const [selectedYear, setSelectedYear] = React.useState(currentDate.year)
|
|
24
|
+
const [viewingYear, setViewingYear] = React.useState(currentDate.year)
|
|
25
|
+
|
|
26
|
+
const months = [
|
|
27
|
+
'January',
|
|
28
|
+
'February',
|
|
29
|
+
'March',
|
|
30
|
+
'April',
|
|
31
|
+
'May',
|
|
32
|
+
'June',
|
|
33
|
+
'July',
|
|
34
|
+
'August',
|
|
35
|
+
'September',
|
|
36
|
+
'October',
|
|
37
|
+
'November',
|
|
38
|
+
'December'
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
// Generate year range (current year ± 50 years)
|
|
42
|
+
const startYear = currentDate.year - 50
|
|
43
|
+
const endYear = currentDate.year + 50
|
|
44
|
+
const years = Array.from({ length: endYear - startYear + 1 }, (_, i) => startYear + i)
|
|
45
|
+
|
|
46
|
+
// Find years to display (show 12 years at a time, 3x4 grid)
|
|
47
|
+
const yearIndex = years.indexOf(viewingYear)
|
|
48
|
+
const displayYears = years.slice(Math.max(0, yearIndex - 5), Math.min(years.length, yearIndex + 7))
|
|
49
|
+
|
|
50
|
+
const handlePreviousYears = () => {
|
|
51
|
+
setViewingYear((prev) => prev - 12)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const handleNextYears = () => {
|
|
55
|
+
setViewingYear((prev) => prev + 12)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="bg-white border border-neutral-200 rounded-md shadow-lg p-4 w-80">
|
|
60
|
+
{/* Year selection header */}
|
|
61
|
+
<div className="mb-4">
|
|
62
|
+
<header className="flex items-center justify-between mb-3">
|
|
63
|
+
<Button className="p-1 hover:bg-neutral-100 rounded" onPress={handlePreviousYears}>
|
|
64
|
+
<Icon name="chevron-left" className="h-4 w-4" />
|
|
65
|
+
</Button>
|
|
66
|
+
<div className="text-sm font-semibold text-neutral-900">
|
|
67
|
+
{displayYears[0]} - {displayYears[displayYears.length - 1]}
|
|
68
|
+
</div>
|
|
69
|
+
<Button className="p-1 hover:bg-neutral-100 rounded" onPress={handleNextYears}>
|
|
70
|
+
<Icon name="chevron-right" className="h-4 w-4" />
|
|
71
|
+
</Button>
|
|
72
|
+
</header>
|
|
73
|
+
|
|
74
|
+
{/* Year grid */}
|
|
75
|
+
<div className="grid grid-cols-3 gap-2">
|
|
76
|
+
{displayYears.map((year) => (
|
|
77
|
+
<button
|
|
78
|
+
key={year}
|
|
79
|
+
type="button"
|
|
80
|
+
onClick={() => setSelectedYear(year)}
|
|
81
|
+
className={tw(
|
|
82
|
+
'py-2 px-3 text-sm rounded cursor-pointer transition-colors',
|
|
83
|
+
'hover:bg-neutral-100',
|
|
84
|
+
selectedYear === year && 'bg-primary-500 text-white hover:bg-primary-500',
|
|
85
|
+
year === currentDate.year && selectedYear !== year && 'font-semibold'
|
|
86
|
+
)}
|
|
87
|
+
>
|
|
88
|
+
{year}
|
|
89
|
+
</button>
|
|
90
|
+
))}
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{/* Month selection */}
|
|
95
|
+
<div>
|
|
96
|
+
<div className="text-sm font-semibold text-neutral-900 mb-2">{selectedYear}</div>
|
|
97
|
+
<div className="grid grid-cols-3 gap-2">
|
|
98
|
+
{months.map((month, index) => {
|
|
99
|
+
const monthNum = index + 1
|
|
100
|
+
const isCurrentMonth = selectedYear === currentDate.year && monthNum === currentDate.month
|
|
101
|
+
|
|
102
|
+
return (
|
|
103
|
+
<button
|
|
104
|
+
key={month}
|
|
105
|
+
type="button"
|
|
106
|
+
onClick={() => onSelect(selectedYear, monthNum)}
|
|
107
|
+
className={tw(
|
|
108
|
+
'py-2 px-2 text-sm rounded cursor-pointer transition-colors',
|
|
109
|
+
'hover:bg-neutral-100',
|
|
110
|
+
isCurrentMonth && 'font-semibold border border-primary-500'
|
|
111
|
+
)}
|
|
112
|
+
>
|
|
113
|
+
{month.slice(0, 3)}
|
|
114
|
+
</button>
|
|
115
|
+
)
|
|
116
|
+
})}
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
{/* Cancel button */}
|
|
121
|
+
<div className="mt-4 flex justify-end">
|
|
122
|
+
<button
|
|
123
|
+
type="button"
|
|
124
|
+
onClick={onCancel}
|
|
125
|
+
className="py-1 px-3 text-sm text-neutral-600 hover:text-neutral-900 hover:bg-neutral-100 rounded transition-colors"
|
|
126
|
+
>
|
|
127
|
+
Cancel
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
type CustomCalendarPropsBase = {
|
|
135
|
+
isDateUnavailable?: (date: CalendarDate | CalendarDateTime) => boolean
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type CustomCalendarPropsDate = CustomCalendarPropsBase & {
|
|
139
|
+
value: CalendarDate | null
|
|
140
|
+
onChange: (date: CalendarDate | null) => void
|
|
141
|
+
minValue?: CalendarDate
|
|
142
|
+
maxValue?: CalendarDate
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
type CustomCalendarPropsDateTime = CustomCalendarPropsBase & {
|
|
146
|
+
value: CalendarDateTime | null
|
|
147
|
+
onChange: (date: CalendarDateTime | null) => void
|
|
148
|
+
minValue?: CalendarDateTime
|
|
149
|
+
maxValue?: CalendarDateTime
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
type CustomCalendarProps = CustomCalendarPropsDate | CustomCalendarPropsDateTime
|
|
153
|
+
|
|
154
|
+
export function CustomCalendar(props: CustomCalendarProps) {
|
|
155
|
+
const { value, onChange, minValue, maxValue, isDateUnavailable } = props
|
|
156
|
+
const [showMonthYearPicker, setShowMonthYearPicker] = React.useState(false)
|
|
157
|
+
|
|
158
|
+
// Create a default focused date
|
|
159
|
+
const createDefaultDate = () => {
|
|
160
|
+
const now = new Date()
|
|
161
|
+
const year = now.getFullYear()
|
|
162
|
+
const month = now.getMonth() + 1
|
|
163
|
+
const day = now.getDate()
|
|
164
|
+
|
|
165
|
+
// Check if we're working with CalendarDateTime
|
|
166
|
+
if (value && 'hour' in value) {
|
|
167
|
+
return new CalendarDateTime(year, month, day, 0, 0, 0)
|
|
168
|
+
}
|
|
169
|
+
return new CalendarDate(year, month, day)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const [focusedDate, setFocusedDate] = React.useState(value || createDefaultDate())
|
|
173
|
+
|
|
174
|
+
const handleMonthYearSelect = (year: number, month: number) => {
|
|
175
|
+
if ('hour' in focusedDate) {
|
|
176
|
+
// CalendarDateTime
|
|
177
|
+
const newDate = focusedDate.set({ year, month })
|
|
178
|
+
setFocusedDate(newDate)
|
|
179
|
+
} else {
|
|
180
|
+
// CalendarDate - use safe day value
|
|
181
|
+
const newDate = new CalendarDate(year, month, Math.min(focusedDate.day, 28))
|
|
182
|
+
setFocusedDate(newDate)
|
|
183
|
+
}
|
|
184
|
+
setShowMonthYearPicker(false)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const handleFocusChange = (date: CalendarDate) => {
|
|
188
|
+
if ('hour' in focusedDate) {
|
|
189
|
+
// Convert CalendarDate to CalendarDateTime by preserving time from focusedDate
|
|
190
|
+
const newDateTime = focusedDate.set({
|
|
191
|
+
year: date.year,
|
|
192
|
+
month: date.month,
|
|
193
|
+
day: date.day
|
|
194
|
+
})
|
|
195
|
+
setFocusedDate(newDateTime)
|
|
196
|
+
} else {
|
|
197
|
+
setFocusedDate(date)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (showMonthYearPicker) {
|
|
202
|
+
return (
|
|
203
|
+
<MonthYearPicker
|
|
204
|
+
currentDate={focusedDate}
|
|
205
|
+
onSelect={handleMonthYearSelect}
|
|
206
|
+
onCancel={() => setShowMonthYearPicker(false)}
|
|
207
|
+
/>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const handleCalendarChange = (date: CalendarDate | CalendarDateTime | null) => {
|
|
212
|
+
if (typeof onChange === 'function') {
|
|
213
|
+
// Type assertion needed due to union type constraints
|
|
214
|
+
;(onChange as (date: CalendarDate | CalendarDateTime | null) => void)(date)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return (
|
|
219
|
+
<Calendar
|
|
220
|
+
value={value ?? undefined}
|
|
221
|
+
onChange={handleCalendarChange}
|
|
222
|
+
focusedValue={focusedDate}
|
|
223
|
+
onFocusChange={handleFocusChange}
|
|
224
|
+
minValue={minValue !== undefined && minValue !== null ? minValue : undefined}
|
|
225
|
+
maxValue={maxValue !== undefined && maxValue !== null ? maxValue : undefined}
|
|
226
|
+
isDateUnavailable={
|
|
227
|
+
isDateUnavailable
|
|
228
|
+
? (date) => {
|
|
229
|
+
// Convert DateValue to CalendarDate for the callback
|
|
230
|
+
if ('hour' in date) {
|
|
231
|
+
// It's a CalendarDateTime, convert to CalendarDate
|
|
232
|
+
const calendarDate = new CalendarDate(date.year, date.month, date.day)
|
|
233
|
+
return isDateUnavailable(calendarDate)
|
|
234
|
+
}
|
|
235
|
+
return isDateUnavailable(date as CalendarDate)
|
|
236
|
+
}
|
|
237
|
+
: undefined
|
|
238
|
+
}
|
|
239
|
+
className="bg-white border border-neutral-200 rounded-md shadow-lg p-3"
|
|
240
|
+
>
|
|
241
|
+
<header className="flex items-center justify-between mb-2">
|
|
242
|
+
<Button slot="previous" className="p-1 hover:bg-neutral-100 rounded">
|
|
243
|
+
<Icon name="chevron-left" className="h-4 w-4" />
|
|
244
|
+
</Button>
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
onClick={() => setShowMonthYearPicker(true)}
|
|
248
|
+
className="text-sm font-semibold text-neutral-900 hover:bg-neutral-100 px-2 py-1 rounded transition-colors"
|
|
249
|
+
>
|
|
250
|
+
<Heading />
|
|
251
|
+
</button>
|
|
252
|
+
<Button slot="next" className="p-1 hover:bg-neutral-100 rounded">
|
|
253
|
+
<Icon name="chevron-right" className="h-4 w-4" />
|
|
254
|
+
</Button>
|
|
255
|
+
</header>
|
|
256
|
+
<CalendarGrid>
|
|
257
|
+
<CalendarGridHeader>
|
|
258
|
+
{(day) => <CalendarHeaderCell className="text-xs text-neutral-500 font-medium w-8 h-8">{day}</CalendarHeaderCell>}
|
|
259
|
+
</CalendarGridHeader>
|
|
260
|
+
<CalendarGridBody>
|
|
261
|
+
{(date) => (
|
|
262
|
+
<CalendarCell
|
|
263
|
+
date={date}
|
|
264
|
+
className={({ isSelected, isOutsideMonth, isDisabled, isUnavailable, isFocusVisible }) =>
|
|
265
|
+
tw(
|
|
266
|
+
'w-8 h-8 text-sm rounded cursor-pointer flex items-center justify-center',
|
|
267
|
+
'hover:bg-neutral-100',
|
|
268
|
+
isOutsideMonth && 'text-neutral-300',
|
|
269
|
+
(isDisabled || isUnavailable) && 'text-neutral-300 cursor-not-allowed',
|
|
270
|
+
isSelected && 'bg-primary-500 text-white hover:bg-primary-500',
|
|
271
|
+
isFocusVisible && 'ring-2 ring-primary-500 ring-offset-1'
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
/>
|
|
275
|
+
)}
|
|
276
|
+
</CalendarGridBody>
|
|
277
|
+
</CalendarGrid>
|
|
278
|
+
</Calendar>
|
|
279
|
+
)
|
|
280
|
+
}
|