@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,314 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { ComboBox as AriaComboBox, ListBoxItem } from 'react-aria-components'
|
|
3
|
+
import { AnimatedPopover } from './helpers/animated-popover'
|
|
4
|
+
import type { Key } from 'react-aria-components'
|
|
5
|
+
import { HeadlessForm } from '@maestro-js/form'
|
|
6
|
+
import type { ValidationFunction, ValidationResult } from '@maestro-js/form'
|
|
7
|
+
import { FormFieldComponents } from './helpers/form-field'
|
|
8
|
+
import { matchSorter } from 'match-sorter'
|
|
9
|
+
|
|
10
|
+
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export type AutosuggestOption = {
|
|
13
|
+
value: string
|
|
14
|
+
label: string
|
|
15
|
+
secondary?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type AutosuggestProps<T extends AutosuggestOption = AutosuggestOption> = {
|
|
19
|
+
// Form identification
|
|
20
|
+
name?: string
|
|
21
|
+
form?: string
|
|
22
|
+
|
|
23
|
+
// Display elements
|
|
24
|
+
label?: React.ReactNode
|
|
25
|
+
cornerHint?: React.ReactNode
|
|
26
|
+
helpText?: React.ReactNode
|
|
27
|
+
placeholder?: string
|
|
28
|
+
warningMessage?: React.ReactNode
|
|
29
|
+
|
|
30
|
+
// Validation and state
|
|
31
|
+
isRequired?: boolean
|
|
32
|
+
isDisabled?: boolean
|
|
33
|
+
isReadonly?: boolean
|
|
34
|
+
isInvalid?: boolean
|
|
35
|
+
validate?: ValidationFunction<string | null>
|
|
36
|
+
errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
|
|
37
|
+
|
|
38
|
+
// Value management
|
|
39
|
+
value?: string | null
|
|
40
|
+
defaultValue?: string | null
|
|
41
|
+
onChange?: (value: string | null) => void
|
|
42
|
+
onCustomValue?: (value: string) => void
|
|
43
|
+
|
|
44
|
+
// Options
|
|
45
|
+
options: T[]
|
|
46
|
+
renderOption?: (props: { option: T; isCustom: boolean; index: number }) => React.ReactNode
|
|
47
|
+
|
|
48
|
+
// Filtering
|
|
49
|
+
filterFunction?: ((items: readonly T[], value: string) => T[]) | (keyof T)[]
|
|
50
|
+
|
|
51
|
+
// Loading
|
|
52
|
+
isLoading?: boolean
|
|
53
|
+
|
|
54
|
+
// Input
|
|
55
|
+
inputAutocomplete?: React.ComponentProps<'input'>['autoComplete']
|
|
56
|
+
id?: string
|
|
57
|
+
|
|
58
|
+
// Shape
|
|
59
|
+
shape?: 'rectangle' | 'oval'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Component ──────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
const CUSTOM_VALUE_KEY = '__autosuggest_custom__'
|
|
65
|
+
|
|
66
|
+
export function Autosuggest<T extends AutosuggestOption = AutosuggestOption>(props: AutosuggestProps<T>) {
|
|
67
|
+
const hiddenInputRef = React.useRef<HTMLInputElement>(null)
|
|
68
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
69
|
+
const helpTextId = React.useId()
|
|
70
|
+
const labelId = React.useId()
|
|
71
|
+
|
|
72
|
+
// Controlled state for the selected value
|
|
73
|
+
const [selectedValue, setSelectedValue] = HeadlessForm.useControlledState(
|
|
74
|
+
props.value,
|
|
75
|
+
props.defaultValue ?? null,
|
|
76
|
+
props.onChange
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
// Form validation
|
|
80
|
+
const {
|
|
81
|
+
validationMessage,
|
|
82
|
+
isInvalid: validationInvalid,
|
|
83
|
+
commitValidation
|
|
84
|
+
} = HeadlessForm.useValidation(
|
|
85
|
+
{
|
|
86
|
+
validate: props.validate,
|
|
87
|
+
value: selectedValue,
|
|
88
|
+
name: props.name,
|
|
89
|
+
isRequired: props.isRequired,
|
|
90
|
+
form: props.form,
|
|
91
|
+
focus() {
|
|
92
|
+
inputRef.current?.focus()
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
hiddenInputRef
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
const isInvalid = props.isInvalid || validationInvalid
|
|
99
|
+
const hasWarning = Boolean(props.warningMessage) && !isInvalid
|
|
100
|
+
|
|
101
|
+
// Find if selected value matches an option
|
|
102
|
+
const selectedOption = React.useMemo(() => {
|
|
103
|
+
return props.options.find((option) => option.value === selectedValue) ?? null
|
|
104
|
+
}, [props.options, selectedValue])
|
|
105
|
+
|
|
106
|
+
// Input value state (what's displayed in the input)
|
|
107
|
+
const [inputValue, setInputValue] = React.useState(() => {
|
|
108
|
+
const initial = props.value ?? props.defaultValue ?? null
|
|
109
|
+
if (initial != null) {
|
|
110
|
+
const option = props.options.find((o) => o.value === initial)
|
|
111
|
+
return option?.label ?? initial
|
|
112
|
+
}
|
|
113
|
+
return ''
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// Sync inputValue when external props.value changes
|
|
117
|
+
const lastPropsValue = React.useRef(props.value)
|
|
118
|
+
React.useEffect(() => {
|
|
119
|
+
if (props.value !== lastPropsValue.current) {
|
|
120
|
+
lastPropsValue.current = props.value
|
|
121
|
+
if (props.value != null) {
|
|
122
|
+
const option = props.options.find((o) => o.value === props.value)
|
|
123
|
+
setInputValue(option?.label ?? props.value)
|
|
124
|
+
} else {
|
|
125
|
+
setInputValue('')
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}, [props.value, props.options])
|
|
129
|
+
|
|
130
|
+
// Filter options based on input value
|
|
131
|
+
const filteredOptions = React.useMemo(() => {
|
|
132
|
+
if (props.isLoading) return []
|
|
133
|
+
|
|
134
|
+
// When input matches the selected option's label, show all options (not filtered)
|
|
135
|
+
const isShowingSelectedLabel = selectedOption && inputValue === selectedOption.label
|
|
136
|
+
const searchTerm = isShowingSelectedLabel ? '' : inputValue
|
|
137
|
+
|
|
138
|
+
const filterFn = props.filterFunction
|
|
139
|
+
let filtered: T[]
|
|
140
|
+
|
|
141
|
+
if (typeof filterFn === 'function') {
|
|
142
|
+
filtered = filterFn(props.options, searchTerm).slice(0, 50)
|
|
143
|
+
} else if (Array.isArray(filterFn)) {
|
|
144
|
+
filtered = matchSorter(props.options, searchTerm, {
|
|
145
|
+
keys: filterFn as string[]
|
|
146
|
+
}).slice(0, 50)
|
|
147
|
+
} else {
|
|
148
|
+
filtered = defaultFilterFunction(props.options, searchTerm).slice(0, 50)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return filtered
|
|
152
|
+
}, [props.options, inputValue, props.filterFunction, props.isLoading, selectedOption])
|
|
153
|
+
|
|
154
|
+
// Check if we should show a custom value option
|
|
155
|
+
const showCustomOption = React.useMemo(() => {
|
|
156
|
+
if (!inputValue || props.isLoading) return false
|
|
157
|
+
return !filteredOptions.some((option) => option.label === inputValue || option.value === inputValue)
|
|
158
|
+
}, [inputValue, filteredOptions, props.isLoading])
|
|
159
|
+
|
|
160
|
+
// Items for React Aria (with id for key management), including custom option
|
|
161
|
+
const items = React.useMemo(() => {
|
|
162
|
+
const mapped = filteredOptions.map((option, index) => ({
|
|
163
|
+
...option,
|
|
164
|
+
index,
|
|
165
|
+
id: option.value,
|
|
166
|
+
isCustom: false
|
|
167
|
+
}))
|
|
168
|
+
|
|
169
|
+
if (showCustomOption) {
|
|
170
|
+
return [
|
|
171
|
+
{ value: inputValue, label: inputValue, index: -1, id: CUSTOM_VALUE_KEY, isCustom: true } as T & {
|
|
172
|
+
index: number
|
|
173
|
+
id: string
|
|
174
|
+
isCustom: boolean
|
|
175
|
+
},
|
|
176
|
+
...mapped
|
|
177
|
+
]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return mapped
|
|
181
|
+
}, [filteredOptions, showCustomOption, inputValue])
|
|
182
|
+
|
|
183
|
+
// Selected key for React Aria
|
|
184
|
+
const selectedKey = selectedValue != null ? (selectedOption ? selectedOption.value : CUSTOM_VALUE_KEY) : null
|
|
185
|
+
|
|
186
|
+
const handleSelectionChange = (key: Key | null) => {
|
|
187
|
+
if (key === null) {
|
|
188
|
+
// Don't clear - keep the custom value in input
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (key === CUSTOM_VALUE_KEY) {
|
|
193
|
+
// Custom value selected
|
|
194
|
+
setSelectedValue(inputValue)
|
|
195
|
+
props.onCustomValue?.(inputValue)
|
|
196
|
+
} else {
|
|
197
|
+
const option = props.options.find((o) => o.value === String(key))
|
|
198
|
+
setSelectedValue(option?.value ?? null)
|
|
199
|
+
setInputValue(option?.label ?? '')
|
|
200
|
+
}
|
|
201
|
+
commitValidation()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const handleInputChange = (value: string) => {
|
|
205
|
+
setInputValue(value)
|
|
206
|
+
// Update selected value to the input value (allows custom values)
|
|
207
|
+
setSelectedValue(value || null)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const handleBlur = () => {
|
|
211
|
+
// Keep the custom value if entered
|
|
212
|
+
if (inputValue && !selectedOption) {
|
|
213
|
+
setSelectedValue(inputValue)
|
|
214
|
+
props.onCustomValue?.(inputValue)
|
|
215
|
+
}
|
|
216
|
+
commitValidation()
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return (
|
|
220
|
+
<div className="relative">
|
|
221
|
+
{/* Hidden input for form submission */}
|
|
222
|
+
<HeadlessForm.HiddenInput ref={hiddenInputRef} name={props.name} value={selectedValue ?? ''} form={props.form} />
|
|
223
|
+
|
|
224
|
+
<FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} labelId={labelId} />
|
|
225
|
+
|
|
226
|
+
<AriaComboBox
|
|
227
|
+
aria-labelledby={props.label ? labelId : undefined}
|
|
228
|
+
aria-label={!props.label ? 'Autosuggest' : undefined}
|
|
229
|
+
selectedKey={selectedKey}
|
|
230
|
+
onSelectionChange={handleSelectionChange}
|
|
231
|
+
inputValue={inputValue}
|
|
232
|
+
onInputChange={handleInputChange}
|
|
233
|
+
items={items}
|
|
234
|
+
isDisabled={props.isDisabled || props.isReadonly}
|
|
235
|
+
isInvalid={isInvalid}
|
|
236
|
+
isRequired={props.isRequired}
|
|
237
|
+
allowsCustomValue
|
|
238
|
+
allowsEmptyCollection
|
|
239
|
+
menuTrigger="focus"
|
|
240
|
+
onBlur={handleBlur}
|
|
241
|
+
>
|
|
242
|
+
{/* Input and button wrapper */}
|
|
243
|
+
<FormFieldComponents.FieldGroup
|
|
244
|
+
isDisabled={props.isDisabled}
|
|
245
|
+
isReadonly={props.isReadonly}
|
|
246
|
+
isInvalid={isInvalid}
|
|
247
|
+
hasWarning={hasWarning}
|
|
248
|
+
shape={props.shape}
|
|
249
|
+
className="flex w-full items-center cursor-default"
|
|
250
|
+
>
|
|
251
|
+
<FormFieldComponents.FieldInput
|
|
252
|
+
ref={inputRef}
|
|
253
|
+
autoComplete={props.inputAutocomplete ?? 'off'}
|
|
254
|
+
className="pr-1"
|
|
255
|
+
placeholder={props.placeholder ?? undefined}
|
|
256
|
+
aria-describedby={helpTextId}
|
|
257
|
+
id={props.id}
|
|
258
|
+
/>
|
|
259
|
+
<FormFieldComponents.ChevronButton />
|
|
260
|
+
</FormFieldComponents.FieldGroup>
|
|
261
|
+
|
|
262
|
+
{/* Dropdown */}
|
|
263
|
+
<AnimatedPopover offset={4} className="w-(--trigger-width)">
|
|
264
|
+
<FormFieldComponents.DropdownListBox
|
|
265
|
+
renderEmptyState={() => (
|
|
266
|
+
<div className="px-3 py-2 text-neutral-500 text-sm">
|
|
267
|
+
{props.isLoading ? 'Loading...' : inputValue ? 'No matching suggestions' : 'No options available'}
|
|
268
|
+
</div>
|
|
269
|
+
)}
|
|
270
|
+
>
|
|
271
|
+
{(item) => {
|
|
272
|
+
const option = item as T & { index: number; id: string; isCustom: boolean }
|
|
273
|
+
if (props.renderOption) {
|
|
274
|
+
return (
|
|
275
|
+
<ListBoxItem id={option.id} textValue={option.label} className="outline-none">
|
|
276
|
+
{props.renderOption({ option, isCustom: option.isCustom, index: option.index })}
|
|
277
|
+
</ListBoxItem>
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return (
|
|
282
|
+
<FormFieldComponents.DropdownItem
|
|
283
|
+
id={option.id}
|
|
284
|
+
textValue={option.label}
|
|
285
|
+
label={option.label}
|
|
286
|
+
secondary={option.secondary}
|
|
287
|
+
customValuePrefix={option.isCustom ? 'Use' : undefined}
|
|
288
|
+
/>
|
|
289
|
+
)
|
|
290
|
+
}}
|
|
291
|
+
</FormFieldComponents.DropdownListBox>
|
|
292
|
+
</AnimatedPopover>
|
|
293
|
+
</AriaComboBox>
|
|
294
|
+
|
|
295
|
+
<FormFieldComponents.FormFieldHelpText
|
|
296
|
+
id={helpTextId}
|
|
297
|
+
isInvalid={isInvalid}
|
|
298
|
+
validationMessage={validationMessage}
|
|
299
|
+
hasWarning={hasWarning}
|
|
300
|
+
warningMessage={props.warningMessage}
|
|
301
|
+
helpText={props.helpText}
|
|
302
|
+
/>
|
|
303
|
+
</div>
|
|
304
|
+
)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
function defaultFilterFunction<T extends AutosuggestOption>(options: readonly T[], inputValue: string): T[] {
|
|
310
|
+
if (!inputValue) return [...options]
|
|
311
|
+
return matchSorter(options, inputValue, {
|
|
312
|
+
keys: ['label', 'secondary']
|
|
313
|
+
})
|
|
314
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { tw } from '../utils/tw'
|
|
3
|
+
|
|
4
|
+
export type AvatarShape = 'circle' | 'rounded' | 'square'
|
|
5
|
+
|
|
6
|
+
export type AvatarProps = {
|
|
7
|
+
alt?: string
|
|
8
|
+
children?: React.ReactNode
|
|
9
|
+
className?: string
|
|
10
|
+
src?: string
|
|
11
|
+
shape?: AvatarShape
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
|
|
15
|
+
({ alt, children, className, src, shape = 'circle' }, ref) => {
|
|
16
|
+
const [imgError, setImgError] = React.useState(false)
|
|
17
|
+
|
|
18
|
+
const showImage = src && !imgError
|
|
19
|
+
const childrenToRender = children || (alt && stringAvatar(alt))
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
ref={ref}
|
|
24
|
+
className={tw(
|
|
25
|
+
'size-10 relative inline-flex items-center justify-center overflow-hidden font-medium text-white select-none',
|
|
26
|
+
!showImage && 'bg-neutral-300',
|
|
27
|
+
shape === 'circle' && 'rounded-full',
|
|
28
|
+
shape === 'rounded' && 'rounded',
|
|
29
|
+
shape === 'square' && 'rounded-none',
|
|
30
|
+
className
|
|
31
|
+
)}
|
|
32
|
+
>
|
|
33
|
+
{showImage ? (
|
|
34
|
+
<img className="size-full absolute inset-0 object-cover" alt={alt} src={src} onError={() => setImgError(true)} />
|
|
35
|
+
) : null}
|
|
36
|
+
{!showImage && childrenToRender ? <span>{childrenToRender}</span> : null}
|
|
37
|
+
</div>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
Avatar.displayName = 'Avatar'
|
|
43
|
+
|
|
44
|
+
function stringAvatar(name: string) {
|
|
45
|
+
const names = name.split(' ')
|
|
46
|
+
const first = names[0]
|
|
47
|
+
const last = names[names.length - 1]
|
|
48
|
+
|
|
49
|
+
if (!first) return ''
|
|
50
|
+
|
|
51
|
+
const initials = names.length === 1 ? first[0] ?? '' : `${first[0] ?? ''}${last?.[0] ?? ''}`
|
|
52
|
+
|
|
53
|
+
return initials.toUpperCase()
|
|
54
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { Select, type SelectProps, type SelectOption } from './select'
|
|
3
|
+
|
|
4
|
+
export type BooleanSelectProps = Omit<SelectProps, 'options' | 'value' | 'defaultValue' | 'onChange'> & {
|
|
5
|
+
// Value management
|
|
6
|
+
value?: boolean | null
|
|
7
|
+
defaultValue?: boolean | null
|
|
8
|
+
onChange?: (value: boolean | null) => void
|
|
9
|
+
|
|
10
|
+
// Customizable labels
|
|
11
|
+
trueLabel?: string
|
|
12
|
+
falseLabel?: string
|
|
13
|
+
nullLabel?: string
|
|
14
|
+
includeNull?: boolean
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function BooleanSelect({
|
|
18
|
+
trueLabel = 'Yes',
|
|
19
|
+
falseLabel = 'No',
|
|
20
|
+
nullLabel = 'Not Set',
|
|
21
|
+
includeNull = false,
|
|
22
|
+
value,
|
|
23
|
+
defaultValue,
|
|
24
|
+
onChange,
|
|
25
|
+
...restProps
|
|
26
|
+
}: BooleanSelectProps) {
|
|
27
|
+
const options: SelectOption[] = [
|
|
28
|
+
...(includeNull ? [{ value: 'null', label: nullLabel }] : []),
|
|
29
|
+
{ value: 'true', label: trueLabel },
|
|
30
|
+
{ value: 'false', label: falseLabel }
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
// Convert boolean value to string for Select component
|
|
34
|
+
const stringValue = value === null ? 'null' : value === undefined ? undefined : String(value)
|
|
35
|
+
const stringDefaultValue = defaultValue === null ? 'null' : defaultValue === undefined ? undefined : String(defaultValue)
|
|
36
|
+
|
|
37
|
+
// Convert string value back to boolean for onChange
|
|
38
|
+
const handleChange = (val: string | number | null) => {
|
|
39
|
+
if (!onChange) return
|
|
40
|
+
if (val === 'null' || val === null) onChange(null)
|
|
41
|
+
else if (val === 'true') onChange(true)
|
|
42
|
+
else if (val === 'false') onChange(false)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<Select {...restProps} options={options} value={stringValue} defaultValue={stringDefaultValue} onChange={handleChange} />
|
|
47
|
+
)
|
|
48
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { mergeProps } from '@react-aria/utils'
|
|
3
|
+
import { tw } from '../utils/tw'
|
|
4
|
+
|
|
5
|
+
export type ButtonGroupOrientation = 'horizontal' | 'vertical'
|
|
6
|
+
export type ButtonGroupAlignment = 'start' | 'center' | 'end'
|
|
7
|
+
export type ButtonGroupSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
|
8
|
+
|
|
9
|
+
export type ButtonGroupProps = {
|
|
10
|
+
orientation?: ButtonGroupOrientation
|
|
11
|
+
alignment?: ButtonGroupAlignment
|
|
12
|
+
size?: ButtonGroupSize
|
|
13
|
+
className?: string
|
|
14
|
+
children?: React.ReactNode
|
|
15
|
+
'aria-label'?: string
|
|
16
|
+
'aria-labelledby'?: string
|
|
17
|
+
'aria-describedby'?: string
|
|
18
|
+
fullWidth?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const ButtonGroupContext = React.createContext<{
|
|
22
|
+
orientation?: ButtonGroupOrientation
|
|
23
|
+
alignment?: ButtonGroupAlignment
|
|
24
|
+
size?: ButtonGroupSize
|
|
25
|
+
fullWidth?: boolean
|
|
26
|
+
className?: string
|
|
27
|
+
}>({})
|
|
28
|
+
|
|
29
|
+
const sizeGapClasses: Record<ButtonGroupSize, string> = {
|
|
30
|
+
xl: 'gap-4',
|
|
31
|
+
lg: 'gap-3',
|
|
32
|
+
md: 'gap-2',
|
|
33
|
+
sm: 'gap-1',
|
|
34
|
+
xs: 'gap-0.5'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const alignmentClasses: Record<ButtonGroupAlignment, string> = {
|
|
38
|
+
start: 'justify-start',
|
|
39
|
+
center: 'justify-center',
|
|
40
|
+
end: 'justify-end'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function ButtonGroup(inputProps: ButtonGroupProps) {
|
|
44
|
+
const context = React.useContext(ButtonGroupContext)
|
|
45
|
+
const {
|
|
46
|
+
orientation = 'horizontal',
|
|
47
|
+
alignment = 'end',
|
|
48
|
+
size = 'md',
|
|
49
|
+
className,
|
|
50
|
+
children,
|
|
51
|
+
fullWidth,
|
|
52
|
+
...ariaProps
|
|
53
|
+
} = mergeProps(React.useContext(ButtonGroupContext), inputProps)
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<div
|
|
57
|
+
role="group"
|
|
58
|
+
aria-label={ariaProps['aria-label']}
|
|
59
|
+
aria-labelledby={ariaProps['aria-labelledby']}
|
|
60
|
+
aria-describedby={ariaProps['aria-describedby']}
|
|
61
|
+
className={tw(
|
|
62
|
+
'inline-flex w-fit',
|
|
63
|
+
sizeGapClasses[size],
|
|
64
|
+
orientation === 'horizontal' ? 'flex-row items-center flex-wrap' : 'flex-col',
|
|
65
|
+
alignmentClasses[alignment],
|
|
66
|
+
fullWidth && 'w-full',
|
|
67
|
+
className
|
|
68
|
+
)}
|
|
69
|
+
>
|
|
70
|
+
{children}
|
|
71
|
+
</div>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
ButtonGroup.displayName = 'ButtonGroup'
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { Link } from 'react-router'
|
|
3
|
+
import { usePress } from '@react-aria/interactions'
|
|
4
|
+
import { mergeProps } from '@react-aria/utils'
|
|
5
|
+
import { Spinner } from './spinner'
|
|
6
|
+
import {
|
|
7
|
+
getButtonClasses,
|
|
8
|
+
type ButtonColor,
|
|
9
|
+
type ButtonShape,
|
|
10
|
+
type ButtonSize,
|
|
11
|
+
type ButtonVariant
|
|
12
|
+
} from './helpers/get-button-classes'
|
|
13
|
+
import { useSpinDelay } from '../utils/use-spin-delay'
|
|
14
|
+
import { tw } from '../utils/tw'
|
|
15
|
+
import { ButtonContext } from './helpers/button-context'
|
|
16
|
+
|
|
17
|
+
export type ButtonLinkProps = React.ComponentPropsWithoutRef<typeof Link> & {
|
|
18
|
+
size?: ButtonSize
|
|
19
|
+
variant?: ButtonVariant
|
|
20
|
+
color?: ButtonColor
|
|
21
|
+
shape?: ButtonShape
|
|
22
|
+
disabled?: boolean
|
|
23
|
+
fullWidth?: boolean
|
|
24
|
+
isPending?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const ButtonLink = React.forwardRef<HTMLAnchorElement, ButtonLinkProps>(
|
|
28
|
+
({ disabled, fullWidth, isPending: isPendingProp, children, onClick, className, ...propsRaw }, ref) => {
|
|
29
|
+
const {
|
|
30
|
+
size = 'md',
|
|
31
|
+
variant = 'contained',
|
|
32
|
+
color = 'primary',
|
|
33
|
+
shape = 'oval',
|
|
34
|
+
...props
|
|
35
|
+
} = mergeProps(propsRaw, React.useContext(ButtonContext))
|
|
36
|
+
const { isPressed, pressProps } = usePress({ isDisabled: disabled })
|
|
37
|
+
|
|
38
|
+
const isPending = isPendingProp ?? false
|
|
39
|
+
const showSpinner = useSpinDelay(isPending, { delay: 500, minDuration: 200 })
|
|
40
|
+
|
|
41
|
+
const handleClick = disabled || isPending ? (e: React.MouseEvent<HTMLAnchorElement>) => e.preventDefault() : onClick
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<Link
|
|
45
|
+
{...mergeProps(props, pressProps, { onClick: handleClick })}
|
|
46
|
+
ref={ref}
|
|
47
|
+
data-pending={isPending || undefined}
|
|
48
|
+
data-disabled={disabled || undefined}
|
|
49
|
+
data-pressed={(isPressed && !disabled) || undefined}
|
|
50
|
+
aria-disabled={disabled || undefined}
|
|
51
|
+
className={getButtonClasses({
|
|
52
|
+
size,
|
|
53
|
+
variant,
|
|
54
|
+
color,
|
|
55
|
+
shape,
|
|
56
|
+
className: tw(fullWidth && 'w-full', className)
|
|
57
|
+
})}
|
|
58
|
+
>
|
|
59
|
+
<span className={tw('inline-flex items-center gap-2', showSpinner && 'opacity-0')}>{children}</span>
|
|
60
|
+
<span
|
|
61
|
+
aria-hidden={!showSpinner ? true : undefined}
|
|
62
|
+
className={tw('absolute inset-0 flex items-center justify-center', !showSpinner && 'opacity-0')}
|
|
63
|
+
>
|
|
64
|
+
<Spinner className="size-4" />
|
|
65
|
+
</span>
|
|
66
|
+
</Link>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
ButtonLink.displayName = 'ButtonLink'
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { Spinner } from './spinner'
|
|
3
|
+
import { flushSync } from 'react-dom'
|
|
4
|
+
import { composeRenderProps } from 'react-aria-components'
|
|
5
|
+
import {
|
|
6
|
+
getButtonClasses,
|
|
7
|
+
type ButtonColor,
|
|
8
|
+
type ButtonShape,
|
|
9
|
+
type ButtonSize,
|
|
10
|
+
type ButtonVariant
|
|
11
|
+
} from './helpers/get-button-classes'
|
|
12
|
+
import { HeadlessButton } from './helpers/headless-button'
|
|
13
|
+
import { useSpinDelay } from '../utils/use-spin-delay'
|
|
14
|
+
import { usePreventDefault } from '../utils/use-prevent-default'
|
|
15
|
+
import { composeTailwindRenderProps, tw } from '../utils/tw'
|
|
16
|
+
import { useStableAccessor } from '../utils/use-stable-accessor'
|
|
17
|
+
import { mergeProps } from '@react-aria/utils'
|
|
18
|
+
import { ButtonContext } from './helpers/button-context'
|
|
19
|
+
|
|
20
|
+
// --- Public types ---
|
|
21
|
+
|
|
22
|
+
export type ButtonProps = {
|
|
23
|
+
variant?: ButtonVariant
|
|
24
|
+
color?: ButtonColor
|
|
25
|
+
size?: ButtonSize
|
|
26
|
+
shape?: ButtonShape
|
|
27
|
+
isToggled?: boolean
|
|
28
|
+
fullWidth?: boolean
|
|
29
|
+
onActionSucceeded?: () => void
|
|
30
|
+
onActionCompleted?: () => void
|
|
31
|
+
disabled?: boolean
|
|
32
|
+
onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void
|
|
33
|
+
className?: string
|
|
34
|
+
children?: React.ReactNode
|
|
35
|
+
value?: string | undefined
|
|
36
|
+
isPending?: boolean
|
|
37
|
+
} & Partial<Pick<React.ComponentProps<'button'>, 'type' | 'role' | 'name' | 'form' | 'id' | 'title' | 'aria-label'>>
|
|
38
|
+
|
|
39
|
+
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
40
|
+
({ isToggled, onActionSucceeded, onActionCompleted, isPending: isPendingProp, ...propsRaw }, ref) => {
|
|
41
|
+
const {
|
|
42
|
+
variant = 'contained',
|
|
43
|
+
color = 'primary',
|
|
44
|
+
size = 'md',
|
|
45
|
+
shape = 'oval',
|
|
46
|
+
fullWidth,
|
|
47
|
+
...props
|
|
48
|
+
} = mergeProps(propsRaw, React.useContext(ButtonContext))
|
|
49
|
+
|
|
50
|
+
const { isClicking, onClick: isClickingHandler } = useIsClicking(props.onClick)
|
|
51
|
+
|
|
52
|
+
const isPending = isPendingProp || isClicking
|
|
53
|
+
const isPendingSmoothed = useSpinDelay(isPending, { delay: 0, minDuration: 50 })
|
|
54
|
+
const showSpinner = useSpinDelay(isPending, { delay: 500, minDuration: 200 })
|
|
55
|
+
|
|
56
|
+
const getOnActionCompleted = useStableAccessor(onActionCompleted)
|
|
57
|
+
const getOnActionSucceeded = useStableAccessor(onActionSucceeded)
|
|
58
|
+
const wasPending = React.useRef(false)
|
|
59
|
+
React.useEffect(() => {
|
|
60
|
+
if (isPendingSmoothed !== wasPending.current) {
|
|
61
|
+
wasPending.current = isPendingSmoothed
|
|
62
|
+
if (!isPendingSmoothed) {
|
|
63
|
+
getOnActionCompleted()?.()
|
|
64
|
+
getOnActionSucceeded()?.()
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}, [isPendingSmoothed, getOnActionCompleted, getOnActionSucceeded])
|
|
68
|
+
|
|
69
|
+
const onClick = usePreventDefault(isClickingHandler, showSpinner || isClicking)
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<HeadlessButton
|
|
73
|
+
{...props}
|
|
74
|
+
ref={ref}
|
|
75
|
+
data-pending={isPending}
|
|
76
|
+
onClick={onClick}
|
|
77
|
+
className={composeTailwindRenderProps(
|
|
78
|
+
props.className,
|
|
79
|
+
tw(getButtonClasses({ variant, color, size, shape, isToggled }), fullWidth && 'w-full')
|
|
80
|
+
)}
|
|
81
|
+
>
|
|
82
|
+
{composeRenderProps(props.children, (children) => (
|
|
83
|
+
<>
|
|
84
|
+
<span className={tw('inline-flex items-center gap-2', showSpinner && 'opacity-0')}>{children}</span>
|
|
85
|
+
<span
|
|
86
|
+
aria-hidden={!showSpinner ? true : undefined}
|
|
87
|
+
className={tw('absolute inset-0 flex items-center justify-center', !showSpinner && 'opacity-0')}
|
|
88
|
+
>
|
|
89
|
+
<Spinner className="size-4" />
|
|
90
|
+
</span>
|
|
91
|
+
</>
|
|
92
|
+
))}
|
|
93
|
+
</HeadlessButton>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
Button.displayName = 'Button'
|
|
99
|
+
|
|
100
|
+
function useIsClicking<C extends ((event: React.MouseEvent<any, MouseEvent>) => unknown) | undefined>(
|
|
101
|
+
onClickRaw: C
|
|
102
|
+
): { onClick: C; isClicking: boolean } {
|
|
103
|
+
type Event = C extends (event: infer E) => unknown ? E : never
|
|
104
|
+
|
|
105
|
+
const [isClicking, setIsClicking] = React.useState<boolean>(false)
|
|
106
|
+
|
|
107
|
+
const onClickRawStabilized = useStableAccessor(onClickRaw)
|
|
108
|
+
|
|
109
|
+
const onClickDebounced = React.useCallback(
|
|
110
|
+
(event: Event) => {
|
|
111
|
+
try {
|
|
112
|
+
const onClickInner = onClickRawStabilized()
|
|
113
|
+
flushSync(() => setIsClicking(true))
|
|
114
|
+
const result = onClickInner ? onClickInner(event) : null
|
|
115
|
+
Promise.resolve(result).finally(() => {
|
|
116
|
+
setIsClicking(false)
|
|
117
|
+
})
|
|
118
|
+
return result
|
|
119
|
+
} catch (e) {
|
|
120
|
+
setIsClicking(false)
|
|
121
|
+
throw e
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
[onClickRawStabilized, setIsClicking]
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if (!onClickRaw) {
|
|
128
|
+
return { onClick: undefined as C, isClicking }
|
|
129
|
+
} else {
|
|
130
|
+
return { onClick: onClickDebounced as C, isClicking }
|
|
131
|
+
}
|
|
132
|
+
}
|