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