@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,183 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { HeadlessForm } from '@maestro-js/form'
|
|
3
|
+
import type { ValidationFunction } from '@maestro-js/form'
|
|
4
|
+
import { FormFieldComponents } from './helpers/form-field'
|
|
5
|
+
import { useNumberInput } from '../utils/use-number-input'
|
|
6
|
+
|
|
7
|
+
export type CurrencyFieldProps = {
|
|
8
|
+
// Form identification
|
|
9
|
+
name?: string
|
|
10
|
+
form?: string
|
|
11
|
+
|
|
12
|
+
// Display elements
|
|
13
|
+
label?: React.ReactNode
|
|
14
|
+
cornerHint?: React.ReactNode
|
|
15
|
+
helpText?: React.ReactNode
|
|
16
|
+
placeholder?: string
|
|
17
|
+
warningMessage?: React.ReactNode
|
|
18
|
+
|
|
19
|
+
// Validation and state
|
|
20
|
+
isRequired?: boolean
|
|
21
|
+
isDisabled?: boolean
|
|
22
|
+
isReadonly?: boolean
|
|
23
|
+
isInvalid?: boolean
|
|
24
|
+
validate?: ValidationFunction<number>
|
|
25
|
+
errorMessage?: React.ReactNode | ((v: import('@maestro-js/form').ValidationResult) => React.ReactNode)
|
|
26
|
+
|
|
27
|
+
// Value management (in the unit specified by `units`)
|
|
28
|
+
value?: number | null
|
|
29
|
+
defaultValue?: number | null
|
|
30
|
+
onChange?: (value: number | null) => void
|
|
31
|
+
|
|
32
|
+
// Number specific
|
|
33
|
+
min?: number | null
|
|
34
|
+
max?: number | null
|
|
35
|
+
autoComplete?: string | null
|
|
36
|
+
autoFocus?: boolean
|
|
37
|
+
|
|
38
|
+
// Currency specific
|
|
39
|
+
units: 'dollars' | 'cents'
|
|
40
|
+
shape?: 'rectangle' | 'oval'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const currencyFormatOptions: Intl.NumberFormatOptions = {
|
|
44
|
+
style: 'currency',
|
|
45
|
+
currency: 'USD',
|
|
46
|
+
minimumFractionDigits: 2,
|
|
47
|
+
maximumFractionDigits: 2
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function CurrencyField(props: CurrencyFieldProps) {
|
|
51
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
52
|
+
const hiddenInputRef = React.useRef<HTMLInputElement>(null)
|
|
53
|
+
|
|
54
|
+
const helpTextId = React.useId()
|
|
55
|
+
const inputId = React.useId()
|
|
56
|
+
|
|
57
|
+
const isCents = props.units === 'cents'
|
|
58
|
+
|
|
59
|
+
// Stable ref for onChange to avoid unnecessary re-renders in useNumberInput
|
|
60
|
+
const onChangeRef = React.useRef(props.onChange)
|
|
61
|
+
onChangeRef.current = props.onChange
|
|
62
|
+
|
|
63
|
+
const handleChange = React.useCallback(
|
|
64
|
+
(newValue: number | null) => {
|
|
65
|
+
onChangeRef.current?.(isCents && newValue !== null ? dollarsToCents(newValue) : newValue)
|
|
66
|
+
},
|
|
67
|
+
[isCents]
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
const displayValue = isCents && props.value != null ? centsToDollars(props.value) : props.value
|
|
71
|
+
const displayDefaultValue = isCents && props.defaultValue != null ? centsToDollars(props.defaultValue) : props.defaultValue
|
|
72
|
+
const displayMin = isCents && props.min != null ? centsToDollars(props.min) : props.min
|
|
73
|
+
const displayMax = isCents && props.max != null ? centsToDollars(props.max) : props.max
|
|
74
|
+
|
|
75
|
+
const numberInput = useNumberInput({
|
|
76
|
+
value: displayValue ?? undefined,
|
|
77
|
+
defaultValue: displayDefaultValue ?? undefined,
|
|
78
|
+
onChange: handleChange,
|
|
79
|
+
minValue: displayMin ?? undefined,
|
|
80
|
+
maxValue: displayMax ?? undefined,
|
|
81
|
+
step: 0.01,
|
|
82
|
+
formatOptions: currencyFormatOptions,
|
|
83
|
+
locale: 'en-US',
|
|
84
|
+
isDisabled: props.isDisabled,
|
|
85
|
+
isReadOnly: props.isReadonly ?? false
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
HeadlessForm.useReset(inputRef, numberInput.numberValue, numberInput.setNumberValue)
|
|
89
|
+
|
|
90
|
+
// Validation value should be in the original unit
|
|
91
|
+
const validationValue = isCents ? dollarsToCents(numberInput.numberValue) : numberInput.numberValue
|
|
92
|
+
|
|
93
|
+
const {
|
|
94
|
+
validationMessage,
|
|
95
|
+
isInvalid: validationInvalid,
|
|
96
|
+
commitValidation
|
|
97
|
+
} = HeadlessForm.useValidation(
|
|
98
|
+
{
|
|
99
|
+
validate: props.validate,
|
|
100
|
+
value: validationValue!,
|
|
101
|
+
name: props.name,
|
|
102
|
+
isRequired: props.isRequired,
|
|
103
|
+
form: props.form,
|
|
104
|
+
focus() {
|
|
105
|
+
inputRef.current?.focus()
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
hiddenInputRef
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const isInvalid = props.isInvalid || validationInvalid
|
|
112
|
+
const hasWarning = Boolean(props.warningMessage) && !isInvalid
|
|
113
|
+
|
|
114
|
+
// Hidden input value in the original unit
|
|
115
|
+
const hiddenValue = isCents ? dollarsToCents(numberInput.numberValue) : numberInput.numberValue
|
|
116
|
+
|
|
117
|
+
const handleBlur = () => {
|
|
118
|
+
numberInput.commit(inputRef.current?.value)
|
|
119
|
+
commitValidation()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return (
|
|
123
|
+
<div className="relative">
|
|
124
|
+
<FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} htmlFor={inputId} />
|
|
125
|
+
|
|
126
|
+
{/* Hidden input for form submission */}
|
|
127
|
+
{props.isDisabled ? null : (
|
|
128
|
+
<HeadlessForm.HiddenInput
|
|
129
|
+
ref={hiddenInputRef}
|
|
130
|
+
form={props.form}
|
|
131
|
+
name={props.name}
|
|
132
|
+
value={hiddenValue ?? ''}
|
|
133
|
+
type="number"
|
|
134
|
+
min={numberInput.minValue}
|
|
135
|
+
max={numberInput.maxValue}
|
|
136
|
+
/>
|
|
137
|
+
)}
|
|
138
|
+
|
|
139
|
+
<FormFieldComponents.FieldGroup
|
|
140
|
+
isDisabled={props.isDisabled}
|
|
141
|
+
isReadonly={props.isReadonly}
|
|
142
|
+
isInvalid={isInvalid}
|
|
143
|
+
hasWarning={hasWarning}
|
|
144
|
+
shape={props.shape}
|
|
145
|
+
className="flex"
|
|
146
|
+
>
|
|
147
|
+
<FormFieldComponents.FieldInput
|
|
148
|
+
ref={inputRef}
|
|
149
|
+
id={inputId}
|
|
150
|
+
type="text"
|
|
151
|
+
value={numberInput.inputValue}
|
|
152
|
+
onChange={(e) => numberInput.setInputValue(e.currentTarget.value)}
|
|
153
|
+
onBlur={handleBlur}
|
|
154
|
+
placeholder={props.placeholder}
|
|
155
|
+
disabled={props.isDisabled}
|
|
156
|
+
readOnly={props.isReadonly}
|
|
157
|
+
autoComplete={props.autoComplete ?? undefined}
|
|
158
|
+
autoFocus={props.autoFocus}
|
|
159
|
+
aria-invalid={isInvalid || undefined}
|
|
160
|
+
aria-describedby={helpTextId}
|
|
161
|
+
/>
|
|
162
|
+
</FormFieldComponents.FieldGroup>
|
|
163
|
+
|
|
164
|
+
<FormFieldComponents.FormFieldHelpText
|
|
165
|
+
id={helpTextId}
|
|
166
|
+
isInvalid={isInvalid}
|
|
167
|
+
validationMessage={validationMessage}
|
|
168
|
+
hasWarning={hasWarning}
|
|
169
|
+
warningMessage={props.warningMessage}
|
|
170
|
+
helpText={props.helpText}
|
|
171
|
+
/>
|
|
172
|
+
</div>
|
|
173
|
+
)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function centsToDollars(cents: number): number {
|
|
177
|
+
return cents / 100
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function dollarsToCents(dollars: number | null): number | null {
|
|
181
|
+
if (dollars == null) return null
|
|
182
|
+
return Math.round(dollars * 100)
|
|
183
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { DateField, DateInput as AriaDateInput, DateSegment, I18nProvider } from 'react-aria-components'
|
|
3
|
+
import { CalendarDate, parseDate } from '@internationalized/date'
|
|
4
|
+
import { tw } from '../utils/tw'
|
|
5
|
+
import { FormFieldComponents } from './helpers/form-field'
|
|
6
|
+
import { HeadlessForm } from '@maestro-js/form'
|
|
7
|
+
import type { ValidationFunction, ValidationResult } from '@maestro-js/form'
|
|
8
|
+
import { dateFns, type Iso } from 'iso-fns'
|
|
9
|
+
|
|
10
|
+
export type DateInputProps = {
|
|
11
|
+
// Form identification
|
|
12
|
+
name?: string
|
|
13
|
+
form?: string
|
|
14
|
+
|
|
15
|
+
// Display elements
|
|
16
|
+
label?: React.ReactNode
|
|
17
|
+
cornerHint?: React.ReactNode
|
|
18
|
+
helpText?: React.ReactNode
|
|
19
|
+
placeholder?: string
|
|
20
|
+
warningMessage?: React.ReactNode
|
|
21
|
+
|
|
22
|
+
// Validation and state
|
|
23
|
+
isRequired?: boolean
|
|
24
|
+
isDisabled?: boolean
|
|
25
|
+
isReadonly?: boolean
|
|
26
|
+
isInvalid?: boolean
|
|
27
|
+
validate?: ValidationFunction<Iso.Date | null>
|
|
28
|
+
errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
|
|
29
|
+
|
|
30
|
+
// Value management (ISO date string YYYY-MM-DD)
|
|
31
|
+
value?: Iso.Date | null
|
|
32
|
+
defaultValue?: Iso.Date | null
|
|
33
|
+
onChange?: (value: Iso.Date | null) => void
|
|
34
|
+
|
|
35
|
+
// Date specific (ISO date strings)
|
|
36
|
+
minValue?: Iso.Date
|
|
37
|
+
maxValue?: Iso.Date
|
|
38
|
+
isDateUnavailable?: (date: Iso.Date) => boolean
|
|
39
|
+
|
|
40
|
+
// Locale
|
|
41
|
+
locale?: string
|
|
42
|
+
shape?: 'rectangle' | 'oval'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function DateInput(props: DateInputProps) {
|
|
46
|
+
const hiddenInputRef = React.useRef<HTMLInputElement>(null)
|
|
47
|
+
const helpTextId = React.useId()
|
|
48
|
+
const labelId = React.useId()
|
|
49
|
+
|
|
50
|
+
// Internal state for CalendarDate
|
|
51
|
+
const [value, _setValue] = React.useState(() => {
|
|
52
|
+
try {
|
|
53
|
+
const startingDate = props.value === undefined ? (props.defaultValue ?? null) : props.value
|
|
54
|
+
return startingDate ? parseDate(startingDate) : null
|
|
55
|
+
} catch {
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
function setValue(d: CalendarDate | null) {
|
|
61
|
+
_setValue(d)
|
|
62
|
+
if (!d || dateFns.isValid(d.toString())) {
|
|
63
|
+
props.onChange && props.onChange(d ? (d.toString() as Iso.Date) : null)
|
|
64
|
+
}
|
|
65
|
+
commitValidation()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Sync controlled value
|
|
69
|
+
if (props.value !== undefined) {
|
|
70
|
+
const currentValueAsIso = value && dateFns.isValid(value.toString()) ? value.toString() : null
|
|
71
|
+
const propValue = props.value && dateFns.isValid(props.value) ? props.value : null
|
|
72
|
+
if ((!value || dateFns.isValid(value.toString())) && propValue !== currentValueAsIso) {
|
|
73
|
+
try {
|
|
74
|
+
_setValue(propValue ? parseDate(propValue) : null)
|
|
75
|
+
} catch {
|
|
76
|
+
_setValue(null)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Convert min/max dates
|
|
82
|
+
const minDate = React.useMemo(() => {
|
|
83
|
+
if (!props.minValue) return undefined
|
|
84
|
+
try {
|
|
85
|
+
return parseDate(props.minValue)
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
}, [props.minValue])
|
|
90
|
+
|
|
91
|
+
const maxDate = React.useMemo(() => {
|
|
92
|
+
if (!props.maxValue) return undefined
|
|
93
|
+
try {
|
|
94
|
+
return parseDate(props.maxValue)
|
|
95
|
+
} catch {
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
}, [props.maxValue])
|
|
99
|
+
|
|
100
|
+
// Form validation
|
|
101
|
+
const {
|
|
102
|
+
validationMessage: validationMessageRaw,
|
|
103
|
+
isInvalid: validationInvalid,
|
|
104
|
+
commitValidation
|
|
105
|
+
} = HeadlessForm.useValidation(
|
|
106
|
+
{
|
|
107
|
+
validate: props.validate,
|
|
108
|
+
value: value && dateFns.isValid(value.toString()) ? (value.toString() as Iso.Date) : null,
|
|
109
|
+
name: props.name,
|
|
110
|
+
isRequired: props.isRequired,
|
|
111
|
+
form: props.form,
|
|
112
|
+
focus() {
|
|
113
|
+
// Focus handled by DateField
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
hiddenInputRef as React.RefObject<HTMLInputElement>
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const lessThanMin =
|
|
120
|
+
props.minValue && value && dateFns.isValid(value.toString()) ? value.toString() < props.minValue : false
|
|
121
|
+
const greaterThanMax =
|
|
122
|
+
props.maxValue && value && dateFns.isValid(value.toString()) ? value.toString() > props.maxValue : false
|
|
123
|
+
|
|
124
|
+
const validationMessage =
|
|
125
|
+
validationMessageRaw ||
|
|
126
|
+
(lessThanMin ? `Cannot be less than ${dateFns.format(props.minValue!, 'MM/dd/yyyy')}` : '') ||
|
|
127
|
+
(greaterThanMax ? `Cannot be greater than ${dateFns.format(props.maxValue!, 'MM/dd/yyyy')}` : '')
|
|
128
|
+
|
|
129
|
+
const isInvalid = props.isInvalid || validationInvalid || lessThanMin || greaterThanMax
|
|
130
|
+
const hasWarning = Boolean(props.warningMessage) && !isInvalid
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
<I18nProvider locale={props.locale ?? 'en'}>
|
|
134
|
+
<div className="relative">
|
|
135
|
+
{/* Hidden input for form submission */}
|
|
136
|
+
<HeadlessForm.HiddenInput ref={hiddenInputRef} name={props.name} value={value?.toString() ?? ''} form={props.form} />
|
|
137
|
+
|
|
138
|
+
<FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} labelId={labelId} />
|
|
139
|
+
|
|
140
|
+
{/* Date input */}
|
|
141
|
+
<DateField
|
|
142
|
+
aria-labelledby={labelId}
|
|
143
|
+
aria-describedby={helpTextId}
|
|
144
|
+
value={value}
|
|
145
|
+
onChange={setValue}
|
|
146
|
+
minValue={minDate}
|
|
147
|
+
maxValue={maxDate}
|
|
148
|
+
isDisabled={props.isDisabled}
|
|
149
|
+
isReadOnly={props.isReadonly}
|
|
150
|
+
isInvalid={isInvalid}
|
|
151
|
+
isRequired={props.isRequired}
|
|
152
|
+
isDateUnavailable={
|
|
153
|
+
props.isDateUnavailable ? (date) => props.isDateUnavailable!(date.toString() as Iso.Date) : undefined
|
|
154
|
+
}
|
|
155
|
+
>
|
|
156
|
+
<FormFieldComponents.FieldGroup
|
|
157
|
+
isDisabled={props.isDisabled}
|
|
158
|
+
isReadonly={props.isReadonly}
|
|
159
|
+
isInvalid={isInvalid}
|
|
160
|
+
hasWarning={hasWarning}
|
|
161
|
+
shape={props.shape}
|
|
162
|
+
>
|
|
163
|
+
<AriaDateInput className="flex px-3 py-2 text-sm">
|
|
164
|
+
{(segment) => (
|
|
165
|
+
<DateSegment
|
|
166
|
+
segment={segment}
|
|
167
|
+
className={tw(
|
|
168
|
+
'px-0.5 tabular-nums outline-none rounded-sm',
|
|
169
|
+
'focus:bg-primary-500 focus:text-white',
|
|
170
|
+
segment.isPlaceholder && 'text-neutral-400'
|
|
171
|
+
)}
|
|
172
|
+
/>
|
|
173
|
+
)}
|
|
174
|
+
</AriaDateInput>
|
|
175
|
+
</FormFieldComponents.FieldGroup>
|
|
176
|
+
</DateField>
|
|
177
|
+
|
|
178
|
+
<FormFieldComponents.FormFieldHelpText
|
|
179
|
+
id={helpTextId}
|
|
180
|
+
isInvalid={isInvalid}
|
|
181
|
+
validationMessage={validationMessage}
|
|
182
|
+
hasWarning={hasWarning}
|
|
183
|
+
warningMessage={props.warningMessage}
|
|
184
|
+
helpText={props.helpText}
|
|
185
|
+
/>
|
|
186
|
+
</div>
|
|
187
|
+
</I18nProvider>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
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 { CalendarDate, parseDate } 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, type Iso } from 'iso-fns'
|
|
12
|
+
|
|
13
|
+
export type DatePickerProps = {
|
|
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.Date | null>
|
|
31
|
+
errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
|
|
32
|
+
|
|
33
|
+
// Value management (ISO date string YYYY-MM-DD)
|
|
34
|
+
value?: Iso.Date | null
|
|
35
|
+
defaultValue?: Iso.Date | null
|
|
36
|
+
onChange?: (value: Iso.Date | null) => void
|
|
37
|
+
|
|
38
|
+
// Date specific (ISO date strings)
|
|
39
|
+
minValue?: Iso.Date
|
|
40
|
+
maxValue?: Iso.Date
|
|
41
|
+
isDateUnavailable?: (date: Iso.Date) => boolean
|
|
42
|
+
|
|
43
|
+
// Calendar display
|
|
44
|
+
locale?: string
|
|
45
|
+
|
|
46
|
+
// Input shape
|
|
47
|
+
shape?: 'rectangle' | 'oval'
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function DatePicker_(props: DatePickerProps) {
|
|
51
|
+
const hiddenInputRef = React.useRef<HTMLInputElement>(null)
|
|
52
|
+
const helpTextId = React.useId()
|
|
53
|
+
const labelId = React.useId()
|
|
54
|
+
|
|
55
|
+
// Internal state for CalendarDate
|
|
56
|
+
const [value, _setValue] = React.useState(() => {
|
|
57
|
+
try {
|
|
58
|
+
const startingDate = props.value === undefined ? (props.defaultValue ?? null) : props.value
|
|
59
|
+
return startingDate ? parseDate(startingDate) : null
|
|
60
|
+
} catch {
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
function setValue(d: CalendarDate | null) {
|
|
66
|
+
_setValue(d)
|
|
67
|
+
if (!d || dateFns.isValid(d.toString())) {
|
|
68
|
+
props.onChange && props.onChange(d ? (d.toString() as Iso.Date) : null)
|
|
69
|
+
}
|
|
70
|
+
commitValidation()
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Sync controlled value
|
|
74
|
+
if (props.value !== undefined) {
|
|
75
|
+
const currentValueAsIso = value && dateFns.isValid(value.toString()) ? value.toString() : null
|
|
76
|
+
const propValue = props.value && dateFns.isValid(props.value) ? props.value : null
|
|
77
|
+
if ((!value || dateFns.isValid(value.toString())) && propValue !== currentValueAsIso) {
|
|
78
|
+
try {
|
|
79
|
+
_setValue(propValue ? parseDate(propValue) : null)
|
|
80
|
+
} catch {
|
|
81
|
+
_setValue(null)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Convert min/max dates
|
|
87
|
+
const minDate = React.useMemo(() => {
|
|
88
|
+
if (!props.minValue) return undefined
|
|
89
|
+
try {
|
|
90
|
+
return parseDate(props.minValue)
|
|
91
|
+
} catch {
|
|
92
|
+
return undefined
|
|
93
|
+
}
|
|
94
|
+
}, [props.minValue])
|
|
95
|
+
|
|
96
|
+
const maxDate = React.useMemo(() => {
|
|
97
|
+
if (!props.maxValue) return undefined
|
|
98
|
+
try {
|
|
99
|
+
return parseDate(props.maxValue)
|
|
100
|
+
} catch {
|
|
101
|
+
return undefined
|
|
102
|
+
}
|
|
103
|
+
}, [props.maxValue])
|
|
104
|
+
|
|
105
|
+
// Form validation
|
|
106
|
+
const {
|
|
107
|
+
validationMessage: validationMessageRaw,
|
|
108
|
+
isInvalid: validationInvalid,
|
|
109
|
+
commitValidation
|
|
110
|
+
} = HeadlessForm.useValidation(
|
|
111
|
+
{
|
|
112
|
+
validate: props.validate,
|
|
113
|
+
value: value && dateFns.isValid(value.toString()) ? (value.toString() as Iso.Date) : null,
|
|
114
|
+
name: props.name,
|
|
115
|
+
isRequired: props.isRequired,
|
|
116
|
+
form: props.form,
|
|
117
|
+
focus() {
|
|
118
|
+
// Focus handled by AriaDatePicker
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
hiddenInputRef as React.RefObject<HTMLInputElement>
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
const lessThanMin =
|
|
125
|
+
props.minValue && value && dateFns.isValid(value.toString()) ? value.toString() < props.minValue : false
|
|
126
|
+
const greaterThanMax =
|
|
127
|
+
props.maxValue && value && dateFns.isValid(value.toString()) ? value.toString() > props.maxValue : false
|
|
128
|
+
|
|
129
|
+
const validationMessage =
|
|
130
|
+
validationMessageRaw ||
|
|
131
|
+
(lessThanMin ? `Cannot be less than ${dateFns.format(props.minValue!, 'MM/dd/yyyy')}` : '') ||
|
|
132
|
+
(greaterThanMax ? `Cannot be greater than ${dateFns.format(props.maxValue!, 'MM/dd/yyyy')}` : '')
|
|
133
|
+
|
|
134
|
+
const isInvalid = props.isInvalid || validationInvalid || lessThanMin || greaterThanMax
|
|
135
|
+
const hasWarning = Boolean(props.warningMessage) && !isInvalid
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<I18nProvider locale={props.locale ?? 'en'}>
|
|
139
|
+
<div className="relative">
|
|
140
|
+
{/* Hidden input for form submission */}
|
|
141
|
+
<HeadlessForm.HiddenInput ref={hiddenInputRef} name={props.name} value={value?.toString() ?? ''} form={props.form} />
|
|
142
|
+
|
|
143
|
+
<FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} labelId={labelId} />
|
|
144
|
+
|
|
145
|
+
<AriaDatePicker
|
|
146
|
+
value={value}
|
|
147
|
+
aria-labelledby={labelId}
|
|
148
|
+
onChange={setValue}
|
|
149
|
+
minValue={minDate}
|
|
150
|
+
maxValue={maxDate}
|
|
151
|
+
isDateUnavailable={
|
|
152
|
+
props.isDateUnavailable ? (date) => props.isDateUnavailable!(date.toString() as Iso.Date) : undefined
|
|
153
|
+
}
|
|
154
|
+
isDisabled={props.isDisabled}
|
|
155
|
+
isReadOnly={props.isReadonly}
|
|
156
|
+
isInvalid={isInvalid}
|
|
157
|
+
isRequired={props.isRequired}
|
|
158
|
+
>
|
|
159
|
+
<FormFieldComponents.FieldGroup
|
|
160
|
+
isDisabled={props.isDisabled}
|
|
161
|
+
isReadonly={props.isReadonly}
|
|
162
|
+
isInvalid={isInvalid}
|
|
163
|
+
hasWarning={hasWarning}
|
|
164
|
+
shape={props.shape}
|
|
165
|
+
className="flex"
|
|
166
|
+
>
|
|
167
|
+
<DateInput className="flex-1 grow flex px-3 py-2 text-sm">
|
|
168
|
+
{(segment) => (
|
|
169
|
+
<DateSegment
|
|
170
|
+
segment={segment}
|
|
171
|
+
className={tw('px-0.5 tabular-nums outline-none rounded-sm', 'focus:bg-primary-500 focus:text-white')}
|
|
172
|
+
/>
|
|
173
|
+
)}
|
|
174
|
+
</DateInput>
|
|
175
|
+
<Button className="flex items-center px-2 py-2">
|
|
176
|
+
<Icon name="calendar" className="h-4 w-4 text-neutral-400" />
|
|
177
|
+
</Button>
|
|
178
|
+
</FormFieldComponents.FieldGroup>
|
|
179
|
+
|
|
180
|
+
<AnimatedPopover>
|
|
181
|
+
<Dialog>
|
|
182
|
+
<CustomCalendar
|
|
183
|
+
value={value}
|
|
184
|
+
onChange={setValue}
|
|
185
|
+
minValue={minDate}
|
|
186
|
+
maxValue={maxDate}
|
|
187
|
+
isDateUnavailable={
|
|
188
|
+
props.isDateUnavailable ? (date) => props.isDateUnavailable!(date.toString() as Iso.Date) : undefined
|
|
189
|
+
}
|
|
190
|
+
/>
|
|
191
|
+
</Dialog>
|
|
192
|
+
</AnimatedPopover>
|
|
193
|
+
</AriaDatePicker>
|
|
194
|
+
|
|
195
|
+
<FormFieldComponents.FormFieldHelpText
|
|
196
|
+
id={helpTextId}
|
|
197
|
+
isInvalid={isInvalid}
|
|
198
|
+
validationMessage={validationMessage}
|
|
199
|
+
hasWarning={hasWarning}
|
|
200
|
+
warningMessage={props.warningMessage}
|
|
201
|
+
helpText={props.helpText}
|
|
202
|
+
/>
|
|
203
|
+
</div>
|
|
204
|
+
</I18nProvider>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const today = dateFns.now()
|
|
209
|
+
export const DatePicker = Object.assign(DatePicker_, {
|
|
210
|
+
isDateInPast: (date: Iso.Date) => dateFns.isBefore(date, today)
|
|
211
|
+
})
|