@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,151 @@
1
+ import * as React from 'react'
2
+ import { Tabs as AriaTabs, TabList as AriaTabList, Tab as AriaTab, TabPanel as AriaTabPanel } from 'react-aria-components'
3
+ import type { Key } from 'react-aria-components'
4
+ import { tw } from '../utils/tw'
5
+ import {
6
+ TAB_BASE_CLASSES,
7
+ TAB_DISABLED_CLASSES,
8
+ TAB_INACTIVE_CLASSES,
9
+ TAB_OVAL_BASE_CLASSES,
10
+ TAB_OVAL_SELECTED_CLASSES,
11
+ tabColorMap,
12
+ tabListContainerClasses,
13
+ tabListInnerClasses,
14
+ getIndicatorClasses,
15
+ useAnimatedIndicator
16
+ } from '../utils/use-tab-indicator'
17
+ import type { TabColor, TabVariant } from '../utils/use-tab-indicator'
18
+
19
+ // --- Types ---
20
+
21
+ export type TabGroupProps = {
22
+ children: React.ReactNode
23
+ className?: string
24
+ defaultSelectedKey?: Key
25
+ selectedKey?: Key
26
+ onSelectionChange?: (key: Key) => void
27
+ keyboardActivation?: 'automatic' | 'manual'
28
+ orientation?: 'horizontal' | 'vertical'
29
+ isDisabled?: boolean
30
+ }
31
+
32
+ export type TabListProps = {
33
+ children: React.ReactNode
34
+ className?: string
35
+ color?: TabColor
36
+ variant?: TabVariant
37
+ }
38
+
39
+ export type TabProps = {
40
+ id: Key
41
+ children: React.ReactNode
42
+ className?: string
43
+ isDisabled?: boolean
44
+ }
45
+
46
+ export type TabPanelsProps = {
47
+ children: React.ReactNode
48
+ className?: string
49
+ }
50
+
51
+ export type TabPanelProps = {
52
+ id: Key
53
+ children: React.ReactNode
54
+ className?: string
55
+ shouldForceMount?: boolean
56
+ }
57
+
58
+ // --- TabGroup ---
59
+
60
+ function TabGroup({ children, className, ...props }: TabGroupProps) {
61
+ return (
62
+ <AriaTabs {...props} className={tw('w-full', className)}>
63
+ {children}
64
+ </AriaTabs>
65
+ )
66
+ }
67
+
68
+ TabGroup.displayName = 'TabGroup'
69
+
70
+ // --- TabList ---
71
+
72
+ const TabListContext = React.createContext<{ color: TabColor; variant: TabVariant }>({ color: 'primary', variant: 'underline' })
73
+
74
+ function TabList({ children, className, color = 'primary', variant = 'underline' }: TabListProps) {
75
+ const containerRef = React.useRef<HTMLDivElement>(null)
76
+ const indicatorRef = useAnimatedIndicator(containerRef, '[data-selected]', 'data-selected')
77
+
78
+ return (
79
+ <TabListContext.Provider value={{ color, variant }}>
80
+ <div ref={containerRef} className={tabListContainerClasses[variant]}>
81
+ <AriaTabList className={tw(tabListInnerClasses[variant], className)}>{children}</AriaTabList>
82
+ <div
83
+ ref={indicatorRef}
84
+ className={getIndicatorClasses(variant, color)}
85
+ style={{ opacity: 0 }}
86
+ />
87
+ </div>
88
+ </TabListContext.Provider>
89
+ )
90
+ }
91
+
92
+ TabList.displayName = 'TabList'
93
+
94
+ // --- Tab ---
95
+
96
+ function TabItem({ children, className, ...props }: TabProps) {
97
+ const { color, variant } = React.useContext(TabListContext)
98
+ const colors = tabColorMap[color]
99
+ const focusClasses = `outline-none data-[focus-visible]:outline-2 data-[focus-visible]:outline-offset-2 data-[focus-visible]:${colors.outline}`
100
+
101
+ return (
102
+ <AriaTab
103
+ {...props}
104
+ className={({ isSelected, isDisabled }) =>
105
+ tw(
106
+ variant === 'oval' ? TAB_OVAL_BASE_CLASSES : TAB_BASE_CLASSES,
107
+ focusClasses,
108
+ isSelected
109
+ ? (variant === 'oval' ? TAB_OVAL_SELECTED_CLASSES : `${colors.text} font-bold`)
110
+ : `${TAB_INACTIVE_CLASSES} data-[hovered]:text-neutral-700`,
111
+ isDisabled && TAB_DISABLED_CLASSES,
112
+ className
113
+ )
114
+ }
115
+ >
116
+ {children}
117
+ </AriaTab>
118
+ )
119
+ }
120
+
121
+ TabItem.displayName = 'Tab'
122
+
123
+ // --- TabPanels ---
124
+
125
+ function TabPanels({ children, className }: TabPanelsProps) {
126
+ return <div className={tw('mt-2', className)}>{children}</div>
127
+ }
128
+
129
+ TabPanels.displayName = 'TabPanels'
130
+
131
+ // --- TabPanel ---
132
+
133
+ function TabPanelItem({ children, className, ...props }: TabPanelProps) {
134
+ return (
135
+ <AriaTabPanel {...props} className={className}>
136
+ {children}
137
+ </AriaTabPanel>
138
+ )
139
+ }
140
+
141
+ TabPanelItem.displayName = 'TabPanel'
142
+
143
+ // --- Export ---
144
+
145
+ export const Tabs = {
146
+ Group: TabGroup,
147
+ List: TabList,
148
+ Tab: TabItem,
149
+ Panels: TabPanels,
150
+ Panel: TabPanelItem
151
+ }
@@ -0,0 +1,250 @@
1
+ import * as React from 'react'
2
+ import { HeadlessForm } from '@maestro-js/form'
3
+ import type { ValidationFunction } from '@maestro-js/form'
4
+ import { tw } from '../utils/tw'
5
+ import { FormFieldComponents } from './helpers/form-field'
6
+ import { Icon } from './icon'
7
+
8
+ export type TagFieldProps = {
9
+ // Form identification
10
+ name?: string
11
+ form?: string
12
+
13
+ // Display elements
14
+ label?: React.ReactNode
15
+ cornerHint?: React.ReactNode
16
+ helpText?: React.ReactNode
17
+ placeholder?: string
18
+ warningMessage?: React.ReactNode
19
+
20
+ // Validation and state
21
+ isRequired?: boolean
22
+ isDisabled?: boolean
23
+ isInvalid?: boolean
24
+ validate?: ValidationFunction<string[]>
25
+
26
+ // Value management
27
+ value?: string[]
28
+ defaultValue?: string[]
29
+ onChange?: (value: string[]) => void
30
+
31
+ // Tag specific
32
+ maxTags?: number
33
+ hideTagCount?: boolean
34
+ allowDuplicates?: boolean
35
+ separator?: string | RegExp | (string | RegExp)[]
36
+ validateTag?: (tag: string) => boolean
37
+ transformTag?: (tag: string) => string
38
+
39
+ // Custom rendering
40
+ renderTag?: (tag: string, index: number, onRemove: () => void, isDisabled?: boolean) => React.ReactNode
41
+ shape?: 'rectangle' | 'oval'
42
+ }
43
+
44
+ export function TagField(props: TagFieldProps) {
45
+ const inputRef = React.useRef<HTMLInputElement>(null)
46
+ const hiddenInputRef = React.useRef<HTMLInputElement>(null)
47
+ const helpTextId = React.useId()
48
+ const inputId = React.useId()
49
+ const [query, setQuery] = React.useState('')
50
+
51
+ // Controlled state management
52
+ const [tags, setTags] = HeadlessForm.useControlledState(props.value, props.defaultValue ?? [], props.onChange)
53
+
54
+ // Form validation
55
+ const {
56
+ validationMessage,
57
+ isInvalid: validationInvalid,
58
+ commitValidation
59
+ } = HeadlessForm.useValidation(
60
+ {
61
+ validate: props.validate,
62
+ value: tags,
63
+ name: props.name,
64
+ isRequired: props.isRequired,
65
+ form: props.form,
66
+ focus() {
67
+ inputRef.current?.focus()
68
+ }
69
+ },
70
+ hiddenInputRef
71
+ )
72
+
73
+ const isInvalid = props.isInvalid || validationInvalid
74
+ const hasWarning = Boolean(props.warningMessage) && !isInvalid
75
+
76
+ const separators = React.useMemo(() => {
77
+ const sep = props.separator ?? [',', '\n']
78
+ return Array.isArray(sep) ? sep : [sep]
79
+ }, [props.separator])
80
+
81
+ const addTag = React.useCallback(
82
+ (tag: string) => {
83
+ const trimmed = tag.trim()
84
+ if (!trimmed) return
85
+
86
+ const final = props.transformTag ? props.transformTag(trimmed) : trimmed
87
+
88
+ if (props.validateTag && !props.validateTag(final)) return
89
+ if (!props.allowDuplicates && tags.includes(final)) return
90
+ if (props.maxTags !== undefined && tags.length >= props.maxTags) return
91
+
92
+ setTags([...tags, final])
93
+ setQuery('')
94
+ commitValidation()
95
+ },
96
+ [tags, setTags, commitValidation, props.transformTag, props.validateTag, props.allowDuplicates, props.maxTags]
97
+ )
98
+
99
+ const removeTag = React.useCallback(
100
+ (index: number) => {
101
+ setTags(tags.filter((_, i) => i !== index))
102
+ commitValidation()
103
+ },
104
+ [tags, setTags, commitValidation]
105
+ )
106
+
107
+ const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
108
+ const value = e.target.value
109
+
110
+ for (const sep of separators) {
111
+ const matches = typeof sep === 'string' ? value.includes(sep) : sep.test(value)
112
+ if (matches) {
113
+ const parts = typeof sep === 'string' ? value.split(sep) : value.split(sep)
114
+ for (const part of parts) {
115
+ if (part.trim()) addTag(part)
116
+ }
117
+ setQuery('')
118
+ return
119
+ }
120
+ }
121
+
122
+ setQuery(value)
123
+ }
124
+
125
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
126
+ if (e.key === 'Enter') {
127
+ e.preventDefault()
128
+ if (query) addTag(query)
129
+ } else if (e.key === 'Backspace' && !query && tags.length > 0) {
130
+ removeTag(tags.length - 1)
131
+ }
132
+ }
133
+
134
+ const atMax = props.maxTags !== undefined && tags.length >= props.maxTags
135
+ const isDisabled = props.isDisabled || atMax
136
+
137
+ return (
138
+ <div className="relative">
139
+ {/* Hidden inputs for form submission */}
140
+ {tags.map((tag, i) => (
141
+ <HeadlessForm.HiddenInput
142
+ key={i}
143
+ ref={i === 0 ? hiddenInputRef : undefined}
144
+ name={props.name}
145
+ value={tag}
146
+ form={props.form}
147
+ />
148
+ ))}
149
+ {tags.length === 0 && <HeadlessForm.HiddenInput ref={hiddenInputRef} name={props.name} value="" form={props.form} />}
150
+
151
+ <FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} htmlFor={inputId} />
152
+
153
+ <FormFieldComponents.FieldGroup isDisabled={props.isDisabled} isInvalid={isInvalid} hasWarning={hasWarning} shape={props.shape} onClick={() => inputRef.current?.focus()}>
154
+ <div className="flex flex-wrap items-center gap-1.5 px-3 py-1.5 min-h-[38px] w-full">
155
+ {/* Display existing tags */}
156
+ {tags.map((tag, index) =>
157
+ props.renderTag ? (
158
+ <React.Fragment key={index}>
159
+ {props.renderTag(tag, index, () => removeTag(index), props.isDisabled)}
160
+ </React.Fragment>
161
+ ) : (
162
+ <Tag
163
+ key={index}
164
+ onRemove={!props.isDisabled ? () => removeTag(index) : undefined}
165
+ isDisabled={props.isDisabled}
166
+ >
167
+ {tag}
168
+ </Tag>
169
+ )
170
+ )}
171
+
172
+ {/* Text input for adding tags */}
173
+ <input
174
+ id={inputId}
175
+ ref={inputRef}
176
+ className={tw(
177
+ 'flex-1 min-w-[120px] bg-transparent p-0 border-0 focus:ring-0 focus:outline-none text-sm text-neutral-900 placeholder:text-neutral-400',
178
+ props.isDisabled && 'cursor-not-allowed'
179
+ )}
180
+ value={query}
181
+ onChange={handleInputChange}
182
+ onKeyDown={handleKeyDown}
183
+ onBlur={() => {
184
+ if (query) addTag(query)
185
+ commitValidation()
186
+ }}
187
+ placeholder={
188
+ tags.length === 0 ? (props.placeholder ?? 'Add tags...') : atMax ? `Maximum ${props.maxTags} tags` : ''
189
+ }
190
+ disabled={isDisabled}
191
+ aria-invalid={isInvalid || undefined}
192
+ aria-describedby={helpTextId}
193
+ autoComplete="off"
194
+ />
195
+ </div>
196
+ </FormFieldComponents.FieldGroup>
197
+
198
+ {/* Tag count */}
199
+ {!props.hideTagCount && props.maxTags !== undefined && (
200
+ <div className="mt-1 text-xs text-neutral-500">
201
+ {tags.length} / {props.maxTags} tags
202
+ </div>
203
+ )}
204
+
205
+ <FormFieldComponents.FormFieldHelpText
206
+ id={helpTextId}
207
+ isInvalid={isInvalid}
208
+ validationMessage={validationMessage}
209
+ hasWarning={hasWarning}
210
+ warningMessage={props.warningMessage}
211
+ helpText={props.helpText}
212
+ />
213
+ </div>
214
+ )
215
+ }
216
+
217
+ function Tag({
218
+ children,
219
+ onRemove,
220
+ isDisabled
221
+ }: {
222
+ children: React.ReactNode
223
+ onRemove?: () => void
224
+ isDisabled?: boolean
225
+ }) {
226
+ return (
227
+ <span
228
+ className={tw(
229
+ 'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-normal bg-primary-500/10 text-primary-500',
230
+ isDisabled && 'opacity-50'
231
+ )}
232
+ >
233
+ <span className="truncate">{children}</span>
234
+ {onRemove && (
235
+ <button
236
+ type="button"
237
+ onClick={(e) => {
238
+ e.stopPropagation()
239
+ onRemove()
240
+ }}
241
+ className="inline-flex items-center justify-center h-4 w-4 -m-0.5 p-0.5 rounded-full translate-x-1 hover:bg-black/10"
242
+ aria-label="Remove"
243
+ tabIndex={-1}
244
+ >
245
+ <Icon name="x-mark" className="h-2 w-2" aria-hidden="true" />
246
+ </button>
247
+ )}
248
+ </span>
249
+ )
250
+ }
@@ -0,0 +1,148 @@
1
+ import * as React from 'react'
2
+ import { HeadlessForm } from '@maestro-js/form'
3
+ import type { ValidationFunction } from '@maestro-js/form'
4
+ import { tw } from '../utils/tw'
5
+ import { FormFieldComponents } from './helpers/form-field'
6
+
7
+ export type TextAreaProps = {
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<string>
25
+
26
+ // Value management
27
+ value?: string
28
+ defaultValue?: string
29
+ onChange?: (value: string) => void
30
+
31
+ // TextArea specific
32
+ rows?: number
33
+ minRows?: number
34
+ maxRows?: number
35
+ autoResize?: boolean
36
+ maxLength?: number
37
+ resize?: 'none' | 'both' | 'horizontal' | 'vertical'
38
+ shape?: 'rectangle' | 'oval'
39
+ }
40
+
41
+ export function TextArea(props: TextAreaProps) {
42
+ const autoResize = props.autoResize ?? true
43
+ const hiddenInputRef = React.useRef<HTMLInputElement>(null)
44
+ const textAreaRef = React.useRef<HTMLTextAreaElement>(null)
45
+ const helpTextId = React.useId()
46
+ const textAreaId = React.useId()
47
+
48
+ // Controlled state management
49
+ const [value, setValue] = HeadlessForm.useControlledState(props.value, props.defaultValue ?? '', props.onChange)
50
+
51
+ // Form validation
52
+ const {
53
+ validationMessage,
54
+ isInvalid: validationInvalid,
55
+ commitValidation
56
+ } = HeadlessForm.useValidation(
57
+ {
58
+ validate: props.validate,
59
+ value,
60
+ name: props.name,
61
+ isRequired: props.isRequired,
62
+ form: props.form,
63
+ focus() {
64
+ textAreaRef.current?.focus()
65
+ }
66
+ },
67
+ hiddenInputRef
68
+ )
69
+
70
+ const isInvalid = props.isInvalid || validationInvalid
71
+ const hasWarning = Boolean(props.warningMessage) && !isInvalid
72
+
73
+ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
74
+ setValue(e.target.value)
75
+ }
76
+
77
+ return (
78
+ <div className="relative">
79
+ {/* Hidden input for form submission */}
80
+ <HeadlessForm.HiddenInput ref={hiddenInputRef} name={props.name} value={value} form={props.form} />
81
+
82
+ <FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} htmlFor={textAreaId} />
83
+
84
+ {/* TextArea wrapper with state-based styling */}
85
+ <FormFieldComponents.FieldGroup
86
+ isDisabled={props.isDisabled}
87
+ isReadonly={props.isReadonly}
88
+ isInvalid={isInvalid}
89
+ hasWarning={hasWarning}
90
+ shape={props.shape}
91
+ className={autoResize ? 'grid' : undefined}
92
+ >
93
+ {/* Auto-expand helper element */}
94
+ {autoResize && (
95
+ <p
96
+ className={tw(
97
+ 'whitespace-pre-wrap overflow-hidden col-span-full row-span-full px-3 py-2 text-sm text-transparent pointer-events-none',
98
+ 'border-0 m-0'
99
+ )}
100
+ style={{
101
+ WebkitLineClamp: props.maxRows || undefined,
102
+ WebkitBoxOrient: props.maxRows ? 'vertical' : undefined,
103
+ display: props.maxRows ? '-webkit-box' : undefined,
104
+ minHeight: props.minRows ? `${props.minRows * 1.5}rem` : undefined
105
+ }}
106
+ aria-hidden="true"
107
+ >
108
+ {value ? value + ' ' : ' '}
109
+ </p>
110
+ )}
111
+
112
+ <textarea
113
+ ref={textAreaRef}
114
+ id={textAreaId}
115
+ className={tw(
116
+ 'w-full bg-transparent px-3 py-2 border-0 focus:ring-0 focus:outline-none text-sm text-neutral-900 placeholder:text-neutral-400',
117
+ autoResize && 'resize-none overflow-hidden col-span-full row-span-full',
118
+ (props.isDisabled || props.isReadonly) && 'cursor-not-allowed',
119
+ !autoResize && props.resize === 'none' && 'resize-none',
120
+ !autoResize && props.resize === 'both' && 'resize',
121
+ !autoResize && props.resize === 'horizontal' && 'resize-x',
122
+ !autoResize && props.resize === 'vertical' && 'resize-y',
123
+ !autoResize && !props.resize && 'resize-y'
124
+ )}
125
+ value={value}
126
+ onChange={handleChange}
127
+ onBlur={commitValidation}
128
+ placeholder={props.placeholder}
129
+ disabled={props.isDisabled}
130
+ readOnly={props.isReadonly}
131
+ maxLength={props.maxLength}
132
+ rows={!autoResize ? props.rows || props.minRows || 3 : props.minRows || 3}
133
+ aria-invalid={isInvalid || undefined}
134
+ aria-describedby={helpTextId}
135
+ />
136
+ </FormFieldComponents.FieldGroup>
137
+
138
+ <FormFieldComponents.FormFieldHelpText
139
+ id={helpTextId}
140
+ isInvalid={isInvalid}
141
+ validationMessage={validationMessage}
142
+ hasWarning={hasWarning}
143
+ warningMessage={props.warningMessage}
144
+ helpText={props.helpText}
145
+ />
146
+ </div>
147
+ )
148
+ }
@@ -0,0 +1,144 @@
1
+ import * as React from 'react'
2
+ import { HeadlessForm } from '@maestro-js/form'
3
+ import type { ValidationFunction } from '@maestro-js/form'
4
+ import { tw } from '../utils/tw'
5
+ import { FormFieldComponents } from './helpers/form-field'
6
+ import { HeadlessButton } from './helpers/headless-button'
7
+
8
+ export type TextFieldProps = {
9
+ // Form identification
10
+ name?: string
11
+ form?: string
12
+
13
+ // Display elements
14
+ label?: React.ReactNode
15
+ cornerHint?: React.ReactNode
16
+ helpText?: React.ReactNode
17
+ placeholder?: string
18
+ warningMessage?: React.ReactNode
19
+
20
+ // Validation and state
21
+ isRequired?: boolean
22
+ isDisabled?: boolean
23
+ isReadonly?: boolean
24
+ isInvalid?: boolean
25
+ validate?: ValidationFunction<string>
26
+
27
+ // Value management
28
+ value?: string
29
+ defaultValue?: string
30
+ onChange?: (value: string) => void
31
+ onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>
32
+
33
+ // Input type
34
+ type?: 'text' | 'email' | 'password' | 'search' | 'tel' | 'url'
35
+ shape?: 'rectangle' | 'oval'
36
+
37
+ // Additional input props
38
+ autoComplete?: string
39
+ autoFocus?: boolean
40
+ maxLength?: number
41
+ minLength?: number
42
+ pattern?: string
43
+ inputMode?: React.HTMLAttributes<HTMLInputElement>['inputMode']
44
+
45
+ trailingInlineAddon?: React.ReactNode
46
+ }
47
+
48
+ export function TextField(props: TextFieldProps) {
49
+ const inputRef = React.useRef<HTMLInputElement>(null)
50
+ const helpTextId = React.useId()
51
+ const inputId = React.useId()
52
+
53
+ // Controlled state management
54
+ const [value, setValue] = HeadlessForm.useControlledState(props.value, props.defaultValue ?? '', props.onChange)
55
+
56
+ // Form validation
57
+ const {
58
+ validationMessage,
59
+ isInvalid: validationInvalid,
60
+ commitValidation
61
+ } = HeadlessForm.useValidation(
62
+ {
63
+ validate: props.validate,
64
+ value,
65
+ name: props.name,
66
+ isRequired: props.isRequired,
67
+ form: props.form,
68
+ focus() {
69
+ inputRef.current?.focus()
70
+ }
71
+ },
72
+ inputRef
73
+ )
74
+
75
+ const isInvalid = props.isInvalid || validationInvalid
76
+ const hasWarning = Boolean(props.warningMessage) && !isInvalid
77
+
78
+ const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
79
+ setValue(e.target.value)
80
+ }
81
+
82
+ return (
83
+ <div className="relative">
84
+ {/* Hidden input for form submission */}
85
+ <HeadlessForm.HiddenInput ref={inputRef} name={props.name} value={value} form={props.form} />
86
+
87
+ <FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} htmlFor={inputId} />
88
+
89
+ {/* Input wrapper with state-based styling */}
90
+ <FormFieldComponents.FieldGroup
91
+ isDisabled={props.isDisabled}
92
+ isReadonly={props.isReadonly}
93
+ isInvalid={isInvalid}
94
+ hasWarning={hasWarning}
95
+ shape={props.shape}
96
+ className="flex"
97
+ >
98
+ <FormFieldComponents.FieldInput
99
+ id={inputId}
100
+ type={props.type ?? 'text'}
101
+ value={value}
102
+ onChange={handleChange}
103
+ onKeyDown={props.onKeyDown}
104
+ onBlur={commitValidation}
105
+ placeholder={props.placeholder}
106
+ disabled={props.isDisabled}
107
+ readOnly={props.isReadonly}
108
+ autoComplete={props.autoComplete ?? 'off'}
109
+ autoFocus={props.autoFocus}
110
+ maxLength={props.maxLength}
111
+ minLength={props.minLength}
112
+ pattern={props.pattern}
113
+ inputMode={props.inputMode}
114
+ aria-invalid={isInvalid || undefined}
115
+ aria-describedby={helpTextId}
116
+ />
117
+ {props.trailingInlineAddon}
118
+ </FormFieldComponents.FieldGroup>
119
+
120
+ <FormFieldComponents.FormFieldHelpText
121
+ id={helpTextId}
122
+ isInvalid={isInvalid}
123
+ validationMessage={validationMessage}
124
+ hasWarning={hasWarning}
125
+ warningMessage={props.warningMessage}
126
+ helpText={props.helpText}
127
+ />
128
+ </div>
129
+ )
130
+ }
131
+
132
+ export function TrailingInlineAddonButton({ className, ...props }: React.ComponentProps<typeof HeadlessButton>) {
133
+ return (
134
+ <HeadlessButton
135
+ {...props}
136
+ className={(a) =>
137
+ tw(
138
+ 'inline-flex items-center inset-y-0 right-0 px-3 text-neutral-500 hover:bg-neutral-50 rounded-l sm:text-sm',
139
+ typeof className === 'function' ? className(a) : className
140
+ )
141
+ }
142
+ />
143
+ )
144
+ }