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