@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,290 @@
1
+ import * as React from 'react'
2
+ import {
3
+ DateRangePicker as AriaDateRangePicker,
4
+ DateInput,
5
+ DateSegment,
6
+ Button,
7
+ Dialog,
8
+ RangeCalendar,
9
+ CalendarGrid,
10
+ CalendarCell,
11
+ Heading,
12
+ CalendarGridHeader,
13
+ CalendarHeaderCell,
14
+ CalendarGridBody,
15
+ I18nProvider
16
+ } from 'react-aria-components'
17
+ import { AnimatedPopover } from './helpers/animated-popover'
18
+ import { CalendarDate, parseDate } from '@internationalized/date'
19
+ import { tw } from '../utils/tw'
20
+ import { FormFieldComponents } from './helpers/form-field'
21
+ import { Icon } from './icon'
22
+ import { HeadlessForm } from '@maestro-js/form'
23
+ import type { ValidationFunction, ValidationResult } from '@maestro-js/form'
24
+
25
+ export type DateRange = {
26
+ start: string | null
27
+ end: string | null
28
+ }
29
+
30
+ export type DateRangePickerProps = {
31
+ // Form identification
32
+ name?: string
33
+ form?: string
34
+
35
+ // Display elements
36
+ label?: React.ReactNode
37
+ cornerHint?: React.ReactNode
38
+ helpText?: React.ReactNode
39
+ warningMessage?: React.ReactNode
40
+
41
+ // Validation and state
42
+ isRequired?: boolean
43
+ isDisabled?: boolean
44
+ isReadonly?: boolean
45
+ isInvalid?: boolean
46
+ validate?: ValidationFunction<DateRange>
47
+ errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
48
+
49
+ // Value management
50
+ value?: DateRange
51
+ defaultValue?: DateRange
52
+ onChange?: (value: DateRange) => void
53
+
54
+ // Date specific (ISO date strings)
55
+ minValue?: string
56
+ maxValue?: string
57
+ presetRanges?: Array<{
58
+ label: string
59
+ start: string
60
+ end: string
61
+ }>
62
+
63
+ // Calendar display
64
+ locale?: string
65
+ shape?: 'rectangle' | 'oval'
66
+ }
67
+
68
+ export function DateRangePicker(props: DateRangePickerProps) {
69
+ const hiddenInputRef = React.useRef<HTMLInputElement>(null)
70
+ const helpTextId = React.useId()
71
+ const labelId = React.useId()
72
+
73
+ // Controlled state management
74
+ const [value, setValue] = HeadlessForm.useControlledState<DateRange>(
75
+ props.value,
76
+ props.defaultValue ?? { start: null, end: null },
77
+ props.onChange
78
+ )
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
+ // Focus handled by AriaDateRangePicker
94
+ }
95
+ },
96
+ hiddenInputRef as React.RefObject<HTMLInputElement>
97
+ )
98
+
99
+ const isInvalid = props.isInvalid || validationInvalid
100
+ const hasWarning = Boolean(props.warningMessage) && !isInvalid
101
+
102
+ // Convert ISO strings to CalendarDate for aria components
103
+ const ariaValue = React.useMemo(() => {
104
+ if (!value.start || !value.end) return null
105
+ try {
106
+ return {
107
+ start: parseDate(value.start),
108
+ end: parseDate(value.end)
109
+ }
110
+ } catch {
111
+ return null
112
+ }
113
+ }, [value])
114
+
115
+ const minDate = React.useMemo(() => {
116
+ if (!props.minValue) return undefined
117
+ try {
118
+ return parseDate(props.minValue)
119
+ } catch {
120
+ return undefined
121
+ }
122
+ }, [props.minValue])
123
+
124
+ const maxDate = React.useMemo(() => {
125
+ if (!props.maxValue) return undefined
126
+ try {
127
+ return parseDate(props.maxValue)
128
+ } catch {
129
+ return undefined
130
+ }
131
+ }, [props.maxValue])
132
+
133
+ const handleRangeChange = (range: { start: CalendarDate; end: CalendarDate } | null) => {
134
+ if (!range) {
135
+ setValue({ start: null, end: null })
136
+ } else {
137
+ setValue({
138
+ start: range.start.toString(),
139
+ end: range.end.toString()
140
+ })
141
+ }
142
+ commitValidation()
143
+ }
144
+
145
+ const handlePresetClick = (preset: { start: string; end: string }) => {
146
+ setValue({ start: preset.start, end: preset.end })
147
+ commitValidation()
148
+ }
149
+
150
+ return (
151
+ <I18nProvider locale={props.locale ?? 'en'}>
152
+ <div className="relative">
153
+ {/* Hidden inputs for form submission */}
154
+ <HeadlessForm.HiddenInput
155
+ ref={hiddenInputRef}
156
+ name={props.name ? `${props.name}.start` : undefined}
157
+ value={value.start || ''}
158
+ form={props.form}
159
+ />
160
+ <HeadlessForm.HiddenInput
161
+ name={props.name ? `${props.name}.end` : undefined}
162
+ value={value.end || ''}
163
+ form={props.form}
164
+ />
165
+
166
+ <FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} labelId={labelId} />
167
+
168
+ {/* Preset ranges */}
169
+ {props.presetRanges && props.presetRanges.length > 0 && (
170
+ <div className="flex flex-wrap gap-2 mb-2">
171
+ {props.presetRanges.map((preset, i) => (
172
+ <button
173
+ key={i}
174
+ type="button"
175
+ onClick={() => handlePresetClick(preset)}
176
+ disabled={props.isDisabled}
177
+ className="px-2 py-1 text-xs rounded border border-neutral-200 hover:bg-neutral-50 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
178
+ >
179
+ {preset.label}
180
+ </button>
181
+ ))}
182
+ </div>
183
+ )}
184
+
185
+ {/* Date range picker */}
186
+ <AriaDateRangePicker
187
+ aria-labelledby={labelId}
188
+ value={ariaValue}
189
+ onChange={handleRangeChange}
190
+ minValue={minDate}
191
+ maxValue={maxDate}
192
+ isDisabled={props.isDisabled}
193
+ isReadOnly={props.isReadonly}
194
+ isInvalid={isInvalid}
195
+ isRequired={props.isRequired}
196
+ >
197
+ <FormFieldComponents.FieldGroup
198
+ isDisabled={props.isDisabled}
199
+ isReadonly={props.isReadonly}
200
+ isInvalid={isInvalid}
201
+ hasWarning={hasWarning}
202
+ shape={props.shape}
203
+ className="flex"
204
+ >
205
+ <div className="flex items-center justify-start flex-1 px-3 py-2 text-sm">
206
+ <DateInput slot="start" className="flex">
207
+ {(segment) => (
208
+ <DateSegment
209
+ segment={segment}
210
+ className={tw('px-0.5 tabular-nums outline-none rounded-sm', 'focus:bg-primary-500 focus:text-white')}
211
+ />
212
+ )}
213
+ </DateInput>
214
+ <span className="mx-2 text-neutral-400">&ndash;</span>
215
+ <DateInput slot="end" className="flex">
216
+ {(segment) => (
217
+ <DateSegment
218
+ segment={segment}
219
+ className={tw('px-0.5 tabular-nums outline-none rounded-sm', 'focus:bg-primary-500 focus:text-white')}
220
+ />
221
+ )}
222
+ </DateInput>
223
+ </div>
224
+ <Button className="flex items-center px-2 py-2">
225
+ <Icon name="calendar" className="h-4 w-4 text-neutral-400" />
226
+ </Button>
227
+ </FormFieldComponents.FieldGroup>
228
+
229
+ <AnimatedPopover>
230
+ <Dialog>
231
+ <RangeCalendar className="bg-white border border-neutral-200 rounded-md shadow-lg p-3">
232
+ <header className="flex items-center justify-between mb-2">
233
+ <Button slot="previous" className="p-1 hover:bg-neutral-100 rounded">
234
+ <Icon name="chevron-left" className="h-4 w-4" />
235
+ </Button>
236
+ <Heading className="text-sm font-semibold text-neutral-900 flex-1 text-center" />
237
+ <Button slot="next" className="p-1 hover:bg-neutral-100 rounded">
238
+ <Icon name="chevron-right" className="h-4 w-4" />
239
+ </Button>
240
+ </header>
241
+ <CalendarGrid>
242
+ <CalendarGridHeader>
243
+ {(day) => (
244
+ <CalendarHeaderCell className="text-xs text-neutral-500 font-medium w-8 h-8">{day}</CalendarHeaderCell>
245
+ )}
246
+ </CalendarGridHeader>
247
+ <CalendarGridBody>
248
+ {(date) => (
249
+ <CalendarCell
250
+ date={date}
251
+ className={({
252
+ isSelected,
253
+ isSelectionStart,
254
+ isSelectionEnd,
255
+ isOutsideMonth,
256
+ isDisabled,
257
+ isUnavailable,
258
+ isFocusVisible
259
+ }) =>
260
+ tw(
261
+ 'w-8 h-8 text-sm rounded cursor-pointer flex items-center justify-center',
262
+ 'hover:bg-neutral-100',
263
+ isOutsideMonth && 'text-neutral-300',
264
+ (isDisabled || isUnavailable) && 'text-neutral-300 cursor-not-allowed',
265
+ isSelected && !isSelectionStart && !isSelectionEnd && 'bg-primary-500/20',
266
+ (isSelectionStart || isSelectionEnd) && 'bg-primary-500 text-white hover:bg-primary-500',
267
+ isFocusVisible && 'ring-2 ring-primary-500 ring-offset-1'
268
+ )
269
+ }
270
+ />
271
+ )}
272
+ </CalendarGridBody>
273
+ </CalendarGrid>
274
+ </RangeCalendar>
275
+ </Dialog>
276
+ </AnimatedPopover>
277
+ </AriaDateRangePicker>
278
+
279
+ <FormFieldComponents.FormFieldHelpText
280
+ id={helpTextId}
281
+ isInvalid={isInvalid}
282
+ validationMessage={validationMessage}
283
+ hasWarning={hasWarning}
284
+ warningMessage={props.warningMessage}
285
+ helpText={props.helpText}
286
+ />
287
+ </div>
288
+ </I18nProvider>
289
+ )
290
+ }
@@ -0,0 +1,196 @@
1
+ import * as React from 'react'
2
+ import { DatePicker as AriaDatePicker, DateInput, DateSegment, Button, Dialog, I18nProvider } from 'react-aria-components'
3
+ import { AnimatedPopover } from './helpers/animated-popover'
4
+ import { CalendarDateTime, parseDateTime } from '@internationalized/date'
5
+ import { tw } from '../utils/tw'
6
+ import { FormFieldComponents } from './helpers/form-field'
7
+ import { Icon } from './icon'
8
+ import { HeadlessForm } from '@maestro-js/form'
9
+ import type { ValidationFunction, ValidationResult } from '@maestro-js/form'
10
+ import { CustomCalendar } from './helpers/calendar-month-year-picker'
11
+ import { dateFns, dateTimeFns, type Iso } from 'iso-fns'
12
+
13
+ export type DateTimePickerProps = {
14
+ // Form identification
15
+ name?: string
16
+ form?: string
17
+
18
+ // Display elements
19
+ label?: React.ReactNode
20
+ cornerHint?: React.ReactNode
21
+ helpText?: React.ReactNode
22
+ placeholder?: string
23
+ warningMessage?: React.ReactNode
24
+
25
+ // Validation and state
26
+ isRequired?: boolean
27
+ isDisabled?: boolean
28
+ isReadonly?: boolean
29
+ isInvalid?: boolean
30
+ validate?: ValidationFunction<Iso.DateTime | null>
31
+ errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
32
+
33
+ // Value management (ISO datetime string e.g. 2024-01-15T09:30:00)
34
+ value?: Iso.DateTime | null
35
+ defaultValue?: Iso.DateTime | null
36
+ onChange?: (value: Iso.DateTime | null) => void
37
+
38
+ // Date/Time specific
39
+ minValue?: Iso.DateTime
40
+ maxValue?: Iso.DateTime
41
+ use24Hour?: boolean
42
+ minuteStep?: number
43
+ locale?: string
44
+ shape?: 'rectangle' | 'oval'
45
+ }
46
+
47
+ export function DateTimePicker(props: DateTimePickerProps) {
48
+ const hiddenInputRef = React.useRef<HTMLInputElement>(null)
49
+ const helpTextId = React.useId()
50
+ const labelId = React.useId()
51
+
52
+ // Internal state for CalendarDateTime
53
+ const [value, _setValue] = React.useState(() => {
54
+ try {
55
+ const startingValue = props.value === undefined ? (props.defaultValue ?? null) : props.value
56
+ return startingValue ? parseDateTime(startingValue) : null
57
+ } catch {
58
+ return null
59
+ }
60
+ })
61
+
62
+ function setValue(dt: CalendarDateTime | null) {
63
+ _setValue(dt)
64
+ if (!dt || dateTimeFns.isValid(dt.toString())) {
65
+ props.onChange && props.onChange(dt ? (dt.toString() as Iso.DateTime) : null)
66
+ }
67
+ commitValidation()
68
+ }
69
+
70
+ // Sync controlled value
71
+ if (props.value !== undefined) {
72
+ const currentValueAsIso = value && dateTimeFns.isValid(value.toString()) ? value.toString() : null
73
+ const propValue = props.value && dateTimeFns.isValid(props.value) ? props.value : null
74
+ if ((!value || dateTimeFns.isValid(value.toString())) && propValue !== currentValueAsIso) {
75
+ try {
76
+ _setValue(propValue ? parseDateTime(propValue) : null)
77
+ } catch {
78
+ _setValue(null)
79
+ }
80
+ }
81
+ }
82
+
83
+ // Convert min/max values
84
+ const minDateTime = React.useMemo(() => {
85
+ if (!props.minValue) return undefined
86
+ try {
87
+ return parseDateTime(props.minValue)
88
+ } catch {
89
+ return undefined
90
+ }
91
+ }, [props.minValue])
92
+
93
+ const maxDateTime = React.useMemo(() => {
94
+ if (!props.maxValue) return undefined
95
+ try {
96
+ return parseDateTime(props.maxValue)
97
+ } catch {
98
+ return undefined
99
+ }
100
+ }, [props.maxValue])
101
+
102
+ // Form validation
103
+ const {
104
+ validationMessage: validationMessageRaw,
105
+ isInvalid: validationInvalid,
106
+ commitValidation
107
+ } = HeadlessForm.useValidation(
108
+ {
109
+ validate: props.validate,
110
+ value: value && dateTimeFns.isValid(value.toString()) ? (value.toString() as Iso.DateTime) : null,
111
+ name: props.name,
112
+ isRequired: props.isRequired,
113
+ form: props.form,
114
+ focus() {
115
+ // Focus handled by AriaDatePicker
116
+ }
117
+ },
118
+ hiddenInputRef as React.RefObject<HTMLInputElement>
119
+ )
120
+
121
+ const lessThanMin =
122
+ props.minValue && value && dateTimeFns.isValid(value.toString()) ? value.toString() < props.minValue : false
123
+ const greaterThanMax =
124
+ props.maxValue && value && dateTimeFns.isValid(value.toString()) ? value.toString() > props.maxValue : false
125
+
126
+ const validationMessage =
127
+ validationMessageRaw ||
128
+ (lessThanMin ? `Cannot be before ${dateTimeFns.format(props.minValue!, 'MM/dd/yyyy h:mm a')}` : '') ||
129
+ (greaterThanMax ? `Cannot be after ${dateTimeFns.format(props.maxValue!, 'MM/dd/yyyy h:mm a')}` : '')
130
+
131
+ const isInvalid = props.isInvalid || validationInvalid || lessThanMin || greaterThanMax
132
+ const hasWarning = Boolean(props.warningMessage) && !isInvalid
133
+
134
+ return (
135
+ <I18nProvider locale={props.locale ?? 'en'}>
136
+ <div className="relative">
137
+ {/* Hidden input for form submission */}
138
+ <HeadlessForm.HiddenInput ref={hiddenInputRef} name={props.name} value={value?.toString() ?? ''} form={props.form} />
139
+
140
+ <FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} labelId={labelId} />
141
+
142
+ <AriaDatePicker
143
+ value={value}
144
+ aria-labelledby={labelId}
145
+ aria-describedby={helpTextId}
146
+ onChange={setValue}
147
+ minValue={minDateTime}
148
+ maxValue={maxDateTime}
149
+ isDisabled={props.isDisabled}
150
+ isReadOnly={props.isReadonly}
151
+ isInvalid={isInvalid}
152
+ isRequired={props.isRequired}
153
+ granularity="minute"
154
+ hideTimeZone
155
+ hourCycle={props.use24Hour ? 24 : 12}
156
+ >
157
+ <FormFieldComponents.FieldGroup
158
+ isDisabled={props.isDisabled}
159
+ isReadonly={props.isReadonly}
160
+ isInvalid={isInvalid}
161
+ hasWarning={hasWarning}
162
+ shape={props.shape}
163
+ className="flex"
164
+ >
165
+ <DateInput className="flex-1 grow flex px-3 py-2 text-sm">
166
+ {(segment) => (
167
+ <DateSegment
168
+ segment={segment}
169
+ className={tw('px-0.5 tabular-nums outline-none rounded-sm', 'focus:bg-primary-500 focus:text-white')}
170
+ />
171
+ )}
172
+ </DateInput>
173
+ <Button className="flex items-center px-2 py-2">
174
+ <Icon name="calendar" className="h-4 w-4 text-neutral-400" />
175
+ </Button>
176
+ </FormFieldComponents.FieldGroup>
177
+
178
+ <AnimatedPopover>
179
+ <Dialog>
180
+ <CustomCalendar value={value} onChange={setValue} minValue={minDateTime} maxValue={maxDateTime} />
181
+ </Dialog>
182
+ </AnimatedPopover>
183
+ </AriaDatePicker>
184
+
185
+ <FormFieldComponents.FormFieldHelpText
186
+ id={helpTextId}
187
+ isInvalid={isInvalid}
188
+ validationMessage={validationMessage}
189
+ hasWarning={hasWarning}
190
+ warningMessage={props.warningMessage}
191
+ helpText={props.helpText}
192
+ />
193
+ </div>
194
+ </I18nProvider>
195
+ )
196
+ }
@@ -0,0 +1,97 @@
1
+ import React from 'react'
2
+ import { Modal, ModalOverlay, Dialog as AriaDialog, Heading } from 'react-aria-components'
3
+ import { tw } from '../utils/tw'
4
+ import { Button } from './button'
5
+
6
+ export type DialogProps = {
7
+ children?: React.ReactNode
8
+ title?: React.ReactNode
9
+ isOpen?: boolean
10
+ onOpenChange?: (isOpen: boolean) => void
11
+ size?: 'sm' | 'lg'
12
+ className?: string
13
+ isDismissable?: boolean
14
+ actions?: React.ReactNode
15
+ }
16
+
17
+ interface DialogContextValue {
18
+ close(): void
19
+ }
20
+
21
+ const DialogContext = React.createContext<DialogContextValue>({ close: () => undefined })
22
+
23
+ function Dialog_({
24
+ children,
25
+ title,
26
+ isOpen = false,
27
+ onOpenChange,
28
+ size = 'sm',
29
+ className,
30
+ isDismissable = false,
31
+ actions
32
+ }: DialogProps) {
33
+ return (
34
+ <ModalOverlay
35
+ isOpen={isOpen}
36
+ onOpenChange={onOpenChange}
37
+ isDismissable={isDismissable}
38
+ isKeyboardDismissDisabled={!isDismissable}
39
+ className={tw(
40
+ 'fixed inset-0 z-[2000] flex items-center justify-center bg-black/70 backdrop-blur-lg',
41
+ 'transition-opacity duration-300 ease-in-out',
42
+ 'data-[entering]:opacity-0',
43
+ 'data-[exiting]:opacity-0'
44
+ )}
45
+ >
46
+ <Modal
47
+ className={tw(
48
+ 'w-[calc(100%-4rem)] m-auto',
49
+ size === 'lg' ? 'max-w-[1080px]' : 'max-w-[640px]',
50
+ 'transition-all duration-300 ease-in-out',
51
+ 'data-[entering]:scale-95 data-[entering]:opacity-0',
52
+ 'data-[exiting]:scale-95 data-[exiting]:opacity-0'
53
+ )}
54
+ >
55
+ <AriaDialog
56
+ className={tw(
57
+ 'w-full max-h-[calc(100vh-6rem)] bg-white shadow-xl outline-none rounded-lg',
58
+ 'flex flex-col overflow-x-hidden overflow-y-auto',
59
+ className
60
+ )}
61
+ >
62
+ {({ close }) => (
63
+ <DialogContext.Provider value={{ close }}>
64
+ <div className="flex items-start justify-between gap-4 pt-6 px-6">
65
+ {title ? (
66
+ <Heading slot="title" className="flex-1 text-xl font-semibold text-neutral-900">
67
+ {title}
68
+ </Heading>
69
+ ) : (
70
+ <div />
71
+ )}
72
+ </div>
73
+
74
+ <div className="flex flex-col gap-3 px-6 pb-6">
75
+ <div className="">{children}</div>
76
+ <div className="flex gap-2 items-start">{actions}</div>
77
+ </div>
78
+ </DialogContext.Provider>
79
+ )}
80
+ </AriaDialog>
81
+ </Modal>
82
+ </ModalOverlay>
83
+ )
84
+ }
85
+
86
+ function CancelButton({ children = 'Cancel' }: { children?: React.ReactNode }) {
87
+ const context = React.useContext(DialogContext)
88
+ return (
89
+ <Button color="neutral" onClick={context.close} variant="soft" shape="oval">
90
+ {children}
91
+ </Button>
92
+ )
93
+ }
94
+
95
+ export const Dialog = Object.assign(Dialog_, {
96
+ CancelButton
97
+ })
@@ -0,0 +1,114 @@
1
+ import * as React from 'react'
2
+ import {
3
+ Disclosure as AriaDisclosure,
4
+ DisclosurePanel as AriaDisclosurePanel,
5
+ DisclosureGroup,
6
+ DisclosureStateContext,
7
+ Button,
8
+ Heading
9
+ } from 'react-aria-components'
10
+ import { tw } from '../utils/tw'
11
+ import { type ContainerSize } from './container'
12
+ import { Icon } from './icon'
13
+
14
+ export type DisclosureProps = {
15
+ id?: string | number
16
+ title: React.ReactNode
17
+ children: React.ReactNode
18
+ className?: string
19
+ size?: ContainerSize
20
+ isDisabled?: boolean
21
+ defaultOpen?: boolean
22
+ isOpen?: boolean
23
+ onOpenChange?: (isOpen: boolean) => void
24
+ }
25
+
26
+ function DisclosureRaw({
27
+ id,
28
+ title,
29
+ className,
30
+ defaultOpen,
31
+ isOpen,
32
+ onOpenChange,
33
+ children,
34
+ size = 'md',
35
+ isDisabled = false
36
+ }: DisclosureProps) {
37
+ const paddingX = size === 'lg' ? 'px-6' : size === 'md' ? 'px-3' : 'px-2'
38
+ const paddingBottom = size === 'lg' ? 'pb-6' : size === 'md' ? 'pb-3' : 'pb-2'
39
+
40
+ return (
41
+ <AriaDisclosure
42
+ id={id}
43
+ defaultExpanded={defaultOpen}
44
+ isExpanded={isOpen}
45
+ onExpandedChange={onOpenChange}
46
+ isDisabled={isDisabled}
47
+ >
48
+ <DisclosureHeader size={size} isDisabled={isDisabled} className={className}>
49
+ {title}
50
+ </DisclosureHeader>
51
+ <AriaDisclosurePanel className="h-(--disclosure-panel-height) motion-safe:transition-[height] duration-200 ease-in-out overflow-clip mt-1">
52
+ <div className={tw(paddingX, paddingBottom)}>{children}</div>
53
+ </AriaDisclosurePanel>
54
+ </AriaDisclosure>
55
+ )
56
+ }
57
+
58
+ function DisclosureHeader({
59
+ size,
60
+ isDisabled,
61
+ className,
62
+ children
63
+ }: {
64
+ size: ContainerSize
65
+ isDisabled: boolean
66
+ className?: string
67
+ children: React.ReactNode
68
+ }) {
69
+ const { isExpanded } = React.useContext(DisclosureStateContext)!
70
+
71
+ return (
72
+ <Heading>
73
+ <Button
74
+ slot="trigger"
75
+ className={tw(
76
+ 'flex w-full items-center justify-start text-left transition-colors gap-2 font-medium',
77
+ 'hover:bg-neutral-50',
78
+ size === 'lg' ? 'px-4 py-2.5' : size === 'md' ? 'px-2.5 py-1.5' : 'px-2 py-1',
79
+ 'rounded-lg',
80
+ isDisabled && 'opacity-50 cursor-not-allowed hover:bg-transparent',
81
+ className
82
+ )}
83
+ >
84
+ <Icon
85
+ name="chevron-right"
86
+ className={tw(
87
+ 'shrink-0 transition-transform duration-200 ease-in-out',
88
+ size === 'md' || size === 'lg' ? 'w-4 h-4' : 'w-3 h-3',
89
+ isExpanded && 'rotate-90',
90
+ isDisabled ? 'text-neutral-300' : 'text-neutral-500'
91
+ )}
92
+ />
93
+ <span
94
+ className={tw(
95
+ size === 'lg'
96
+ ? 'text-lg font-semibold'
97
+ : size === 'md'
98
+ ? 'text-base font-medium'
99
+ : 'text-sm font-medium',
100
+ isDisabled ? 'text-neutral-400' : 'text-neutral-700'
101
+ )}
102
+ >
103
+ {children}
104
+ </span>
105
+ </Button>
106
+ </Heading>
107
+ )
108
+ }
109
+
110
+ export const Disclosure = Object.assign(DisclosureRaw, {
111
+ Group: DisclosureGroup
112
+ })
113
+
114
+ Object.defineProperty(DisclosureRaw, 'name', { value: 'Disclosure' })