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