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