@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,198 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { TimeField, DateInput, DateSegment, I18nProvider } from 'react-aria-components'
|
|
3
|
+
import { Time, parseTime } 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 { timeFns, type Iso } from 'iso-fns'
|
|
9
|
+
|
|
10
|
+
export type TimeInputProps = {
|
|
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.Time | null>
|
|
28
|
+
errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
|
|
29
|
+
|
|
30
|
+
// Value management (IsoTimeString format HH:mm:ss)
|
|
31
|
+
value?: Iso.Time | null
|
|
32
|
+
defaultValue?: Iso.Time | null
|
|
33
|
+
onChange?: (value: Iso.Time | null) => void
|
|
34
|
+
|
|
35
|
+
// Time specific
|
|
36
|
+
use24Hour?: boolean
|
|
37
|
+
minuteStep?: number
|
|
38
|
+
minTime?: Iso.Time
|
|
39
|
+
maxTime?: Iso.Time
|
|
40
|
+
|
|
41
|
+
// Locale
|
|
42
|
+
locale?: string
|
|
43
|
+
shape?: 'rectangle' | 'oval'
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function TimeInput(props: TimeInputProps) {
|
|
47
|
+
const hiddenInputRef = React.useRef<HTMLInputElement>(null)
|
|
48
|
+
const helpTextId = React.useId()
|
|
49
|
+
const labelId = React.useId()
|
|
50
|
+
|
|
51
|
+
// Internal state for Time object
|
|
52
|
+
const [value, _setValue] = React.useState<Time | null>(() => {
|
|
53
|
+
try {
|
|
54
|
+
const startingTime = props.value === undefined ? (props.defaultValue ?? null) : props.value
|
|
55
|
+
return startingTime ? parseTime(startingTime) : null
|
|
56
|
+
} catch {
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
function setValue(t: Time | null) {
|
|
62
|
+
_setValue(t)
|
|
63
|
+
const isoTime = t ? (t.toString() as Iso.Time) : null
|
|
64
|
+
props.onChange?.(isoTime)
|
|
65
|
+
commitValidation()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Sync controlled value
|
|
69
|
+
if (props.value !== undefined) {
|
|
70
|
+
const currentValueAsIso = value && isValidTimeString(value.toString()) ? value.toString() : null
|
|
71
|
+
const propValue = props.value && isValidTimeString(props.value) ? props.value : null
|
|
72
|
+
if ((!value || isValidTimeString(value.toString())) && propValue !== currentValueAsIso) {
|
|
73
|
+
try {
|
|
74
|
+
_setValue(propValue ? parseTime(propValue) : null)
|
|
75
|
+
} catch {
|
|
76
|
+
_setValue(null)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Convert min/max times
|
|
82
|
+
const minTime = React.useMemo(() => {
|
|
83
|
+
if (!props.minTime) return undefined
|
|
84
|
+
try {
|
|
85
|
+
return parseTime(props.minTime)
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined
|
|
88
|
+
}
|
|
89
|
+
}, [props.minTime])
|
|
90
|
+
|
|
91
|
+
const maxTime = React.useMemo(() => {
|
|
92
|
+
if (!props.maxTime) return undefined
|
|
93
|
+
try {
|
|
94
|
+
return parseTime(props.maxTime)
|
|
95
|
+
} catch {
|
|
96
|
+
return undefined
|
|
97
|
+
}
|
|
98
|
+
}, [props.maxTime])
|
|
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 && isValidTimeString(value.toString()) ? (value.toString() as Iso.Time) : null,
|
|
109
|
+
name: props.name,
|
|
110
|
+
isRequired: props.isRequired,
|
|
111
|
+
form: props.form,
|
|
112
|
+
focus() {
|
|
113
|
+
// Focus handled by TimeField
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
hiddenInputRef as React.RefObject<HTMLInputElement>
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
const lessThanMin =
|
|
120
|
+
props.minTime && value && isValidTimeString(value.toString()) ? value.toString() < props.minTime : false
|
|
121
|
+
const greaterThanMax =
|
|
122
|
+
props.maxTime && value && isValidTimeString(value.toString()) ? value.toString() > props.maxTime : false
|
|
123
|
+
|
|
124
|
+
const validationMessage =
|
|
125
|
+
validationMessageRaw ||
|
|
126
|
+
(lessThanMin ? `Cannot be before ${formatTimeString(props.minTime!, 'h:mm a')}` : '') ||
|
|
127
|
+
(greaterThanMax ? `Cannot be after ${formatTimeString(props.maxTime!, 'h:mm a')}` : '')
|
|
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
|
+
{/* Time field */}
|
|
141
|
+
<TimeField
|
|
142
|
+
aria-labelledby={labelId}
|
|
143
|
+
aria-describedby={helpTextId}
|
|
144
|
+
value={value}
|
|
145
|
+
onChange={setValue}
|
|
146
|
+
isRequired={props.isRequired}
|
|
147
|
+
isDisabled={props.isDisabled}
|
|
148
|
+
isReadOnly={props.isReadonly}
|
|
149
|
+
isInvalid={isInvalid}
|
|
150
|
+
minValue={minTime}
|
|
151
|
+
maxValue={maxTime}
|
|
152
|
+
hourCycle={props.use24Hour ? 24 : 12}
|
|
153
|
+
granularity="minute"
|
|
154
|
+
>
|
|
155
|
+
<FormFieldComponents.FieldGroup
|
|
156
|
+
isDisabled={props.isDisabled}
|
|
157
|
+
isReadonly={props.isReadonly}
|
|
158
|
+
isInvalid={isInvalid}
|
|
159
|
+
hasWarning={hasWarning}
|
|
160
|
+
shape={props.shape}
|
|
161
|
+
>
|
|
162
|
+
<DateInput className="flex w-full bg-transparent px-3 py-2 text-sm">
|
|
163
|
+
{(segment) => (
|
|
164
|
+
<DateSegment
|
|
165
|
+
segment={segment}
|
|
166
|
+
className={tw(
|
|
167
|
+
'px-0.5 tabular-nums outline-none rounded-sm',
|
|
168
|
+
'focus:bg-primary-500 focus:text-white',
|
|
169
|
+
segment.isPlaceholder && 'text-neutral-400'
|
|
170
|
+
)}
|
|
171
|
+
/>
|
|
172
|
+
)}
|
|
173
|
+
</DateInput>
|
|
174
|
+
</FormFieldComponents.FieldGroup>
|
|
175
|
+
</TimeField>
|
|
176
|
+
|
|
177
|
+
<FormFieldComponents.FormFieldHelpText
|
|
178
|
+
id={helpTextId}
|
|
179
|
+
isInvalid={isInvalid}
|
|
180
|
+
validationMessage={validationMessage}
|
|
181
|
+
hasWarning={hasWarning}
|
|
182
|
+
warningMessage={props.warningMessage}
|
|
183
|
+
helpText={props.helpText}
|
|
184
|
+
/>
|
|
185
|
+
</div>
|
|
186
|
+
</I18nProvider>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Check if a string is a valid HH:mm:ss time string */
|
|
191
|
+
function isValidTimeString(timeStr: string): boolean {
|
|
192
|
+
return timeFns.isValid(timeStr)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Format an HH:mm:ss time string using a date-fns format pattern */
|
|
196
|
+
function formatTimeString(timeStr: Iso.Time, fmt: string): string {
|
|
197
|
+
return timeFns.format(timeStr, fmt)
|
|
198
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { flushSync } from 'react-dom'
|
|
3
|
+
import {
|
|
4
|
+
Text,
|
|
5
|
+
UNSTABLE_Toast as AriaToast,
|
|
6
|
+
UNSTABLE_ToastContent as ToastContent,
|
|
7
|
+
UNSTABLE_ToastQueue as ToastQueue,
|
|
8
|
+
UNSTABLE_ToastRegion as AriaToastRegion
|
|
9
|
+
} from 'react-aria-components'
|
|
10
|
+
import { tw } from '../utils/tw'
|
|
11
|
+
import { Icon } from './icon'
|
|
12
|
+
import { HeadlessButton } from './helpers/headless-button'
|
|
13
|
+
|
|
14
|
+
export interface Toast {
|
|
15
|
+
title: string
|
|
16
|
+
description?: string
|
|
17
|
+
variant?: 'info' | 'positive' | 'warning' | 'negative' | 'notice'
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const queue = new ToastQueue<Toast>({
|
|
21
|
+
wrapUpdate(fn) {
|
|
22
|
+
if ('startViewTransition' in document) {
|
|
23
|
+
document.startViewTransition(() => {
|
|
24
|
+
flushSync(fn)
|
|
25
|
+
})
|
|
26
|
+
} else {
|
|
27
|
+
fn()
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export function showToast(
|
|
33
|
+
toast: Toast,
|
|
34
|
+
options?: {
|
|
35
|
+
/** Handler that is called when the toast is closed, either by the user or after a timeout. */
|
|
36
|
+
onClose?: () => void
|
|
37
|
+
/** A timeout to automatically close the toast after, in milliseconds. */
|
|
38
|
+
timeout?: number | null
|
|
39
|
+
}
|
|
40
|
+
) {
|
|
41
|
+
return queue.add(toast, {
|
|
42
|
+
...options,
|
|
43
|
+
timeout: typeof options?.timeout === 'undefined' ? 5_000 : (options?.timeout ?? undefined)
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function ToastRegion({ toast }: { toast?: Toast | null }) {
|
|
48
|
+
return (
|
|
49
|
+
<>
|
|
50
|
+
<style>
|
|
51
|
+
{`
|
|
52
|
+
::view-transition-new(.toast):only-child {
|
|
53
|
+
animation: maestro-toast-slide-in 400ms;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
::view-transition-old(.toast):only-child {
|
|
57
|
+
animation: maestro-toast-slide-out 400ms;
|
|
58
|
+
animation-fill-mode: forwards;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
@keyframes maestro-toast-slide-out {
|
|
62
|
+
to {
|
|
63
|
+
translate: 0 100%;
|
|
64
|
+
opacity: 0;
|
|
65
|
+
visibility: hidden;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@keyframes maestro-toast-slide-in {
|
|
70
|
+
from {
|
|
71
|
+
translate: 0 100%;
|
|
72
|
+
opacity: 0;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
`}
|
|
76
|
+
</style>
|
|
77
|
+
<AriaToastRegion
|
|
78
|
+
className="fixed right-4 bottom-4 sm:right-6 sm:bottom-6 z-[6000] flex flex-col gap-2 pointer-events-none outline-none"
|
|
79
|
+
queue={queue}
|
|
80
|
+
>
|
|
81
|
+
{({ toast }) => <ToastItem toast={toast} />}
|
|
82
|
+
</AriaToastRegion>
|
|
83
|
+
{toast ? <DeclaredToast toast={toast} /> : null}
|
|
84
|
+
</>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
ToastRegion.displayName = 'ToastRegion'
|
|
89
|
+
|
|
90
|
+
// --- Internal Components ---
|
|
91
|
+
|
|
92
|
+
function ToastItem({ toast }: { toast: { content: Toast; key: string } }) {
|
|
93
|
+
const variant = toast.content.variant || 'info'
|
|
94
|
+
const hasDescription = !!toast.content.description
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<AriaToast
|
|
98
|
+
toast={toast}
|
|
99
|
+
style={{ viewTransitionName: toast.key } as React.CSSProperties}
|
|
100
|
+
className={tw(
|
|
101
|
+
'pointer-events-auto',
|
|
102
|
+
'flex gap-3 rounded-md border px-4 py-3 shadow-lg',
|
|
103
|
+
'min-w-[300px] max-w-md',
|
|
104
|
+
'[view-transition-class:toast]',
|
|
105
|
+
hasDescription ? 'items-start' : 'items-center',
|
|
106
|
+
variantClasses[variant]
|
|
107
|
+
)}
|
|
108
|
+
>
|
|
109
|
+
<Icon name={variantIconNames[variant]} className={tw('h-5 w-5 shrink-0', hasDescription && 'mt-0.5')} />
|
|
110
|
+
<ToastContent className="flex flex-1 flex-col min-w-0">
|
|
111
|
+
<Text slot="title" className="font-semibold text-sm">
|
|
112
|
+
{toast.content.title}
|
|
113
|
+
</Text>
|
|
114
|
+
{toast.content.description && (
|
|
115
|
+
<Text slot="description" className="mt-1 text-sm">
|
|
116
|
+
{toast.content.description}
|
|
117
|
+
</Text>
|
|
118
|
+
)}
|
|
119
|
+
</ToastContent>
|
|
120
|
+
<HeadlessButton
|
|
121
|
+
onClick={() => queue.close(toast.key)}
|
|
122
|
+
className={tw(
|
|
123
|
+
'flex-shrink-0 rounded p-1 transition-colors',
|
|
124
|
+
'hover:bg-black/5 focus:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2',
|
|
125
|
+
variantCloseOutlineClasses[variant]
|
|
126
|
+
)}
|
|
127
|
+
>
|
|
128
|
+
<Icon name="x-mark" className="h-5 w-5" />
|
|
129
|
+
</HeadlessButton>
|
|
130
|
+
</AriaToast>
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function DeclaredToast({
|
|
135
|
+
toast,
|
|
136
|
+
options
|
|
137
|
+
}: {
|
|
138
|
+
toast: Toast
|
|
139
|
+
options?: {
|
|
140
|
+
onClose?: () => void
|
|
141
|
+
timeout?: number | null
|
|
142
|
+
}
|
|
143
|
+
}) {
|
|
144
|
+
React.useEffect(() => {
|
|
145
|
+
setTimeout(() => {
|
|
146
|
+
showToast(toast, options)
|
|
147
|
+
}, 0)
|
|
148
|
+
}, [toast, options])
|
|
149
|
+
return null
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// --- Variant Maps ---
|
|
153
|
+
|
|
154
|
+
const variantClasses = {
|
|
155
|
+
info: 'bg-info-500/10 text-info-500 border-info-500/20',
|
|
156
|
+
positive: 'bg-positive-50 text-positive-800 border-positive-200',
|
|
157
|
+
warning: 'bg-notice-50 text-notice-800 border-notice-200',
|
|
158
|
+
negative: 'bg-negative-50 text-negative-800 border-negative-200',
|
|
159
|
+
notice: 'bg-neutral-100 text-neutral-700 border-neutral-200'
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const variantIconNames = {
|
|
163
|
+
info: 'information-circle',
|
|
164
|
+
positive: 'check',
|
|
165
|
+
warning: 'exclamation-triangle',
|
|
166
|
+
negative: 'x-mark',
|
|
167
|
+
notice: 'information-circle'
|
|
168
|
+
} as const
|
|
169
|
+
|
|
170
|
+
const variantCloseOutlineClasses = {
|
|
171
|
+
info: 'focus-visible:outline-info-500',
|
|
172
|
+
positive: 'focus-visible:outline-positive-600',
|
|
173
|
+
warning: 'focus-visible:outline-notice-600',
|
|
174
|
+
negative: 'focus-visible:outline-negative-600',
|
|
175
|
+
notice: 'focus-visible:outline-neutral-400'
|
|
176
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { RadioGroup, Radio } from 'react-aria-components'
|
|
3
|
+
import { tw } from '../utils/tw'
|
|
4
|
+
import type { ButtonSize } from './helpers/get-button-classes'
|
|
5
|
+
|
|
6
|
+
// --- Types ---
|
|
7
|
+
|
|
8
|
+
export type ToggleButtonGroupSize = ButtonSize
|
|
9
|
+
|
|
10
|
+
export type ToggleButtonGroupProps = {
|
|
11
|
+
value?: string | null
|
|
12
|
+
defaultValue?: string | null
|
|
13
|
+
onChange?: (value: string) => void
|
|
14
|
+
orientation?: 'horizontal' | 'vertical'
|
|
15
|
+
className?: string
|
|
16
|
+
isDisabled?: boolean
|
|
17
|
+
name?: string
|
|
18
|
+
children: React.ReactNode
|
|
19
|
+
'aria-label'?: string
|
|
20
|
+
'aria-labelledby'?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// --- ToggleButtonGroup ---
|
|
24
|
+
|
|
25
|
+
export function ToggleButtonGroup({ orientation = 'horizontal', className, children, ...props }: ToggleButtonGroupProps) {
|
|
26
|
+
return (
|
|
27
|
+
<RadioGroup
|
|
28
|
+
{...props}
|
|
29
|
+
orientation={orientation}
|
|
30
|
+
className={tw(
|
|
31
|
+
'inline-flex',
|
|
32
|
+
orientation === 'horizontal'
|
|
33
|
+
? ['flex-row', '[&>*:first-child]:rounded-l [&>*:last-child]:rounded-r', '[&>*:not(:first-child)]:-ml-px']
|
|
34
|
+
: ['flex-col', '[&>*:first-child]:rounded-t [&>*:last-child]:rounded-b', '[&>*:not(:first-child)]:-mt-px'],
|
|
35
|
+
className
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
{children}
|
|
39
|
+
</RadioGroup>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
ToggleButtonGroup.displayName = 'ToggleButtonGroup'
|
|
44
|
+
|
|
45
|
+
// --- ToggleButton ---
|
|
46
|
+
|
|
47
|
+
export type ToggleButtonProps = {
|
|
48
|
+
value: string
|
|
49
|
+
children: React.ReactNode
|
|
50
|
+
isDisabled?: boolean
|
|
51
|
+
className?: string
|
|
52
|
+
'aria-label'?: string
|
|
53
|
+
size?: ToggleButtonGroupSize
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function ToggleButton({ children, className, size = 'md', ...props }: ToggleButtonProps) {
|
|
57
|
+
return (
|
|
58
|
+
<Radio {...props} className={({ isSelected }) => tw(getToggleButtonClasses({ size, isSelected }), className)}>
|
|
59
|
+
{children}
|
|
60
|
+
</Radio>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
ToggleButton.displayName = 'ToggleButton'
|
|
65
|
+
|
|
66
|
+
// --- Styling ---
|
|
67
|
+
|
|
68
|
+
function getToggleButtonClasses({ size = 'md', isSelected }: { size?: ButtonSize; isSelected?: boolean }) {
|
|
69
|
+
return tw(
|
|
70
|
+
'relative rounded-none cursor-default select-none',
|
|
71
|
+
'font-medium transition-all',
|
|
72
|
+
'data-[focus-visible]:outline data-[focus-visible]:outline-2 data-[focus-visible]:outline-offset-2',
|
|
73
|
+
'data-[disabled]:bg-neutral-50 data-[disabled]:ring-neutral-50 data-[disabled]:cursor-not-allowed data-[disabled]:text-neutral-400',
|
|
74
|
+
|
|
75
|
+
// Size
|
|
76
|
+
size === 'xs' && 'px-3.5 py-1.5 text-xs gap-x-1 min-w-6',
|
|
77
|
+
size === 'sm' && 'px-4 py-1.5 text-sm gap-x-1.5 min-w-7',
|
|
78
|
+
size === 'md' && 'px-5 py-2 text-sm gap-x-2 min-w-8',
|
|
79
|
+
size === 'lg' && 'px-6 py-2.5 text-base gap-x-2 min-w-9',
|
|
80
|
+
size === 'xl' && 'px-8 py-3 text-base gap-x-2.5 min-w-10',
|
|
81
|
+
|
|
82
|
+
// Unselected
|
|
83
|
+
!isSelected && 'bg-white ring-1 ring-inset ring-neutral-300',
|
|
84
|
+
!isSelected && 'data-[hovered]:bg-neutral-50 data-[hovered]:ring-neutral-400',
|
|
85
|
+
!isSelected && 'text-neutral-700 data-[focus-visible]:outline-neutral-400',
|
|
86
|
+
|
|
87
|
+
// Selected
|
|
88
|
+
isSelected && 'bg-primary-500 text-white ring-1 ring-inset ring-primary-700',
|
|
89
|
+
isSelected && 'data-[hovered]:bg-primary-600 data-[hovered]:ring-primary-700',
|
|
90
|
+
isSelected && 'data-[focus-visible]:outline-primary-500',
|
|
91
|
+
|
|
92
|
+
'inline-flex justify-center items-center gap-x-2'
|
|
93
|
+
)
|
|
94
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { DateField, DateInput as AriaDateInput, DateSegment, I18nProvider } from 'react-aria-components'
|
|
3
|
+
import { CalendarDate } 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 { yearMonthFns } from 'iso-fns'
|
|
9
|
+
|
|
10
|
+
/** ISO year-month string in YYYY-MM format */
|
|
11
|
+
export type YearMonth = string
|
|
12
|
+
|
|
13
|
+
export type YearMonthInputProps = {
|
|
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<YearMonth | null>
|
|
31
|
+
errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
|
|
32
|
+
|
|
33
|
+
// Value management (ISO year-month string YYYY-MM)
|
|
34
|
+
value?: YearMonth | null
|
|
35
|
+
defaultValue?: YearMonth | null
|
|
36
|
+
onChange?: (value: YearMonth | null) => void
|
|
37
|
+
|
|
38
|
+
// Locale
|
|
39
|
+
locale?: string
|
|
40
|
+
shape?: 'rectangle' | 'oval'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function YearMonthInput(props: YearMonthInputProps) {
|
|
44
|
+
const hiddenInputRef = React.useRef<HTMLInputElement>(null)
|
|
45
|
+
const helpTextId = React.useId()
|
|
46
|
+
const labelId = React.useId()
|
|
47
|
+
|
|
48
|
+
// Internal state for CalendarDate
|
|
49
|
+
const [value, _setValue] = React.useState(() => {
|
|
50
|
+
try {
|
|
51
|
+
const startingDate = props.value === undefined ? (props.defaultValue ?? null) : props.value
|
|
52
|
+
return startingDate ? parseYearMonth(startingDate) : null
|
|
53
|
+
} catch {
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
function setValue(d: CalendarDate | null) {
|
|
59
|
+
_setValue(d)
|
|
60
|
+
const yearMonth = formatYearMonth(d)
|
|
61
|
+
props.onChange?.(yearMonth)
|
|
62
|
+
commitValidation()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Sync controlled value
|
|
66
|
+
if (props.value !== undefined) {
|
|
67
|
+
const currentYearMonth = formatYearMonth(value)
|
|
68
|
+
if (props.value !== currentYearMonth) {
|
|
69
|
+
try {
|
|
70
|
+
_setValue(props.value ? parseYearMonth(props.value) : null)
|
|
71
|
+
} catch {
|
|
72
|
+
_setValue(null)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Form validation
|
|
78
|
+
const {
|
|
79
|
+
validationMessage,
|
|
80
|
+
isInvalid: validationInvalid,
|
|
81
|
+
commitValidation
|
|
82
|
+
} = HeadlessForm.useValidation(
|
|
83
|
+
{
|
|
84
|
+
validate: props.validate,
|
|
85
|
+
value: formatYearMonth(value),
|
|
86
|
+
name: props.name,
|
|
87
|
+
isRequired: props.isRequired,
|
|
88
|
+
form: props.form,
|
|
89
|
+
focus() {
|
|
90
|
+
// Focus handled by DateField
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
hiddenInputRef as React.RefObject<HTMLInputElement>
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
const isInvalid = props.isInvalid || validationInvalid
|
|
97
|
+
const hasWarning = Boolean(props.warningMessage) && !isInvalid
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<I18nProvider locale={props.locale ?? 'en'}>
|
|
101
|
+
<div className="relative">
|
|
102
|
+
{/* Hidden input for form submission */}
|
|
103
|
+
<HeadlessForm.HiddenInput
|
|
104
|
+
ref={hiddenInputRef}
|
|
105
|
+
name={props.name}
|
|
106
|
+
value={formatYearMonth(value) ?? ''}
|
|
107
|
+
form={props.form}
|
|
108
|
+
/>
|
|
109
|
+
|
|
110
|
+
<FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} labelId={labelId} />
|
|
111
|
+
|
|
112
|
+
{/* Year/Month input using DateField */}
|
|
113
|
+
<DateField
|
|
114
|
+
aria-labelledby={labelId}
|
|
115
|
+
aria-describedby={helpTextId}
|
|
116
|
+
value={value}
|
|
117
|
+
onChange={setValue}
|
|
118
|
+
isDisabled={props.isDisabled}
|
|
119
|
+
isReadOnly={props.isReadonly}
|
|
120
|
+
isInvalid={isInvalid}
|
|
121
|
+
isRequired={props.isRequired}
|
|
122
|
+
granularity="day"
|
|
123
|
+
hideTimeZone
|
|
124
|
+
>
|
|
125
|
+
<FormFieldComponents.FieldGroup
|
|
126
|
+
isDisabled={props.isDisabled}
|
|
127
|
+
isReadonly={props.isReadonly}
|
|
128
|
+
isInvalid={isInvalid}
|
|
129
|
+
hasWarning={hasWarning}
|
|
130
|
+
shape={props.shape}
|
|
131
|
+
>
|
|
132
|
+
<AriaDateInput className="flex px-3 py-2 text-sm [&>[data-literal]:not(:has(~[data-literal]))]:hidden">
|
|
133
|
+
{(segment) => {
|
|
134
|
+
// Hide day segment
|
|
135
|
+
const isHidden = segment.type === 'day'
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<DateSegment
|
|
139
|
+
segment={segment}
|
|
140
|
+
data-literal={segment.type === 'literal' ? true : undefined}
|
|
141
|
+
className={tw(
|
|
142
|
+
'px-0.5 tabular-nums outline-none rounded-sm',
|
|
143
|
+
'focus:bg-primary-500 focus:text-white',
|
|
144
|
+
segment.isPlaceholder && 'text-neutral-400',
|
|
145
|
+
isHidden && 'hidden'
|
|
146
|
+
)}
|
|
147
|
+
/>
|
|
148
|
+
)
|
|
149
|
+
}}
|
|
150
|
+
</AriaDateInput>
|
|
151
|
+
</FormFieldComponents.FieldGroup>
|
|
152
|
+
</DateField>
|
|
153
|
+
|
|
154
|
+
<FormFieldComponents.FormFieldHelpText
|
|
155
|
+
id={helpTextId}
|
|
156
|
+
isInvalid={isInvalid}
|
|
157
|
+
validationMessage={validationMessage}
|
|
158
|
+
hasWarning={hasWarning}
|
|
159
|
+
warningMessage={props.warningMessage}
|
|
160
|
+
helpText={props.helpText}
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
</I18nProvider>
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseYearMonth(yearMonth: YearMonth): CalendarDate | null {
|
|
168
|
+
const match = yearMonth.match(/^(\d{4})-(\d{2})$/)
|
|
169
|
+
if (!match || match.length < 3) return null
|
|
170
|
+
const year = parseInt(match[1]!, 10)
|
|
171
|
+
const month = parseInt(match[2]!, 10)
|
|
172
|
+
// Use day 1 as placeholder for the CalendarDate
|
|
173
|
+
return new CalendarDate(year, month, 1)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function formatYearMonth(date: CalendarDate | null): YearMonth | null {
|
|
177
|
+
if (!date) return null
|
|
178
|
+
const year = date.year.toString().padStart(4, '0')
|
|
179
|
+
const month = date.month.toString().padStart(2, '0')
|
|
180
|
+
const yearMonth = `${year}-${month}`
|
|
181
|
+
return isValidYearMonth(yearMonth) ? yearMonth : null
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Check if a string is a valid YYYY-MM year-month string */
|
|
185
|
+
function isValidYearMonth(yearMonth: string): boolean {
|
|
186
|
+
return yearMonthFns.isValid(yearMonth)
|
|
187
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export const COLORS = [
|
|
2
|
+
'#f43f5e',
|
|
3
|
+
'#3b82f6',
|
|
4
|
+
'#22c55e',
|
|
5
|
+
'#EAB308',
|
|
6
|
+
'#8b5cf6',
|
|
7
|
+
'#191970',
|
|
8
|
+
'#f97316',
|
|
9
|
+
'#6b7280',
|
|
10
|
+
'#ec4899',
|
|
11
|
+
'#06b6d4',
|
|
12
|
+
'#84cc16',
|
|
13
|
+
'#64748b',
|
|
14
|
+
'#ffff00',
|
|
15
|
+
'#6366f1',
|
|
16
|
+
'#ff6347',
|
|
17
|
+
'#737373',
|
|
18
|
+
'#14b8a6',
|
|
19
|
+
'#f59e0b',
|
|
20
|
+
'#0ea5e9',
|
|
21
|
+
'#ef4444',
|
|
22
|
+
'#78716c',
|
|
23
|
+
'#00ffff',
|
|
24
|
+
'#d946ef',
|
|
25
|
+
'#10b981',
|
|
26
|
+
'#b22222',
|
|
27
|
+
'#40e0d0',
|
|
28
|
+
'#71717a',
|
|
29
|
+
'#a855f7',
|
|
30
|
+
'#7fff00'
|
|
31
|
+
] as const
|
|
32
|
+
|
|
33
|
+
export function stringToNumber(str: string) {
|
|
34
|
+
let h = 0x811c9dc5 // FNV offset basis
|
|
35
|
+
for (const b of new TextEncoder().encode(str)) {
|
|
36
|
+
h ^= b
|
|
37
|
+
h = Math.imul(h, 0x01000193) // 16777619
|
|
38
|
+
}
|
|
39
|
+
return h >>> 0 // unsigned 32-bit
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getColor(seed: number) {
|
|
43
|
+
return COLORS[seed % COLORS.length]
|
|
44
|
+
}
|