@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,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">–</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' })
|