@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
package/scripts/build.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'fs/promises'
|
|
2
|
+
import { basename, dirname, resolve } from 'path'
|
|
3
|
+
import { fileURLToPath } from 'url'
|
|
4
|
+
|
|
5
|
+
interface RegistryFile {
|
|
6
|
+
path: string
|
|
7
|
+
type: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface RegistryEntry {
|
|
11
|
+
name: string
|
|
12
|
+
files: RegistryFile[]
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface OutputFile {
|
|
16
|
+
name: string
|
|
17
|
+
content: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface OutputEntry {
|
|
21
|
+
name: string
|
|
22
|
+
files: OutputFile[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
|
26
|
+
|
|
27
|
+
const registry: RegistryEntry[] = JSON.parse(await readFile(resolve(packageRoot, 'registry.json'), 'utf-8'))
|
|
28
|
+
|
|
29
|
+
const output: OutputEntry[] = await Promise.all(
|
|
30
|
+
registry.map(async (entry) => {
|
|
31
|
+
const files = await Promise.all(
|
|
32
|
+
entry.files.map(async (file) => ({
|
|
33
|
+
name: `${file.type}/${basename(file.path)}`,
|
|
34
|
+
content: await readFile(resolve(packageRoot, file.path), 'utf-8')
|
|
35
|
+
}))
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
name: entry.name,
|
|
40
|
+
files
|
|
41
|
+
}
|
|
42
|
+
})
|
|
43
|
+
)
|
|
44
|
+
writeFile('./dist/components.json', JSON.stringify(output))
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { Dialog, Modal, ModalOverlay, Heading } from 'react-aria-components'
|
|
3
|
+
import { tw } from '../utils/tw'
|
|
4
|
+
import { Icon } from './icon'
|
|
5
|
+
import { Button } from './button'
|
|
6
|
+
import { ButtonGroupContext } from './button-group'
|
|
7
|
+
import { ButtonContext } from './helpers/button-context'
|
|
8
|
+
|
|
9
|
+
export type AlertDialogProps = {
|
|
10
|
+
title: string
|
|
11
|
+
description?: string
|
|
12
|
+
variant?: 'info' | 'positive' | 'neutral' | 'negative' | 'notice'
|
|
13
|
+
icon?: React.ReactNode
|
|
14
|
+
children?: React.ReactNode | ((props: { close(): void }) => React.ReactNode)
|
|
15
|
+
isOpen?: boolean
|
|
16
|
+
onOpenChange?: (isOpen: boolean) => void
|
|
17
|
+
isDismissable?: boolean
|
|
18
|
+
className?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const variantStyles = {
|
|
22
|
+
info: {
|
|
23
|
+
container: 'border-info-500/20',
|
|
24
|
+
icon: 'text-info-500',
|
|
25
|
+
iconBg: 'bg-info-500/10',
|
|
26
|
+
header: 'text-info-500'
|
|
27
|
+
},
|
|
28
|
+
positive: {
|
|
29
|
+
container: 'border-positive-200',
|
|
30
|
+
icon: 'text-positive-600',
|
|
31
|
+
iconBg: 'bg-positive-100',
|
|
32
|
+
header: 'text-positive-800'
|
|
33
|
+
},
|
|
34
|
+
notice: {
|
|
35
|
+
container: 'border-notice-200',
|
|
36
|
+
icon: 'text-notice-600',
|
|
37
|
+
iconBg: 'bg-notice-100',
|
|
38
|
+
header: 'text-notice-800'
|
|
39
|
+
},
|
|
40
|
+
negative: {
|
|
41
|
+
container: 'border-negative-200',
|
|
42
|
+
icon: 'text-negative-600',
|
|
43
|
+
iconBg: 'bg-negative-100',
|
|
44
|
+
header: 'text-negative-800'
|
|
45
|
+
},
|
|
46
|
+
neutral: {
|
|
47
|
+
container: 'border-neutral-200',
|
|
48
|
+
icon: 'text-neutral-600',
|
|
49
|
+
iconBg: 'bg-neutral-100',
|
|
50
|
+
header: 'text-neutral-700'
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const defaultIconNames = {
|
|
55
|
+
info: 'information-circle',
|
|
56
|
+
positive: 'check',
|
|
57
|
+
neutral: 'information-circle',
|
|
58
|
+
negative: 'x-mark',
|
|
59
|
+
notice: 'exclamation-triangle'
|
|
60
|
+
} as const
|
|
61
|
+
|
|
62
|
+
interface AlertDialogContextValue {
|
|
63
|
+
close(): void
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const AlertDialogContext = React.createContext<AlertDialogContextValue>({ close: () => undefined })
|
|
67
|
+
|
|
68
|
+
function AlertDialog_({
|
|
69
|
+
description,
|
|
70
|
+
title,
|
|
71
|
+
variant = 'info',
|
|
72
|
+
icon,
|
|
73
|
+
isOpen,
|
|
74
|
+
onOpenChange,
|
|
75
|
+
isDismissable = true,
|
|
76
|
+
className,
|
|
77
|
+
children
|
|
78
|
+
}: AlertDialogProps) {
|
|
79
|
+
const displayIcon = icon !== undefined ? icon : <Icon name={defaultIconNames[variant]} className="h-8 w-8" />
|
|
80
|
+
const styles = variantStyles[variant]
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<ModalOverlay
|
|
84
|
+
isOpen={isOpen}
|
|
85
|
+
onOpenChange={onOpenChange}
|
|
86
|
+
isDismissable={isDismissable}
|
|
87
|
+
isKeyboardDismissDisabled={!isDismissable}
|
|
88
|
+
className={tw(
|
|
89
|
+
'fixed inset-0 z-[4000] flex items-center justify-center bg-black/70 backdrop-blur-lg',
|
|
90
|
+
'transition-opacity duration-200 ease-out',
|
|
91
|
+
'data-[entering]:opacity-0',
|
|
92
|
+
'data-[exiting]:opacity-0'
|
|
93
|
+
)}
|
|
94
|
+
>
|
|
95
|
+
<Modal
|
|
96
|
+
className={tw(
|
|
97
|
+
'w-[calc(100%-2rem)] max-w-sm',
|
|
98
|
+
'transition-all duration-200 ease-out',
|
|
99
|
+
'data-[entering]:scale-95 data-[entering]:opacity-0 data-[entering]:translate-y-2',
|
|
100
|
+
'data-[exiting]:scale-95 data-[exiting]:opacity-0 data-[exiting]:translate-y-2'
|
|
101
|
+
)}
|
|
102
|
+
>
|
|
103
|
+
<Dialog className={tw('outline-none bg-white rounded-lg shadow-2xl w-full', className)} role="alertdialog">
|
|
104
|
+
{({ close }) => (
|
|
105
|
+
<AlertDialogContext.Provider value={{ close }}>
|
|
106
|
+
<ButtonGroupContext.Provider
|
|
107
|
+
value={{ orientation: 'horizontal', alignment: 'center', fullWidth: true, className: 'flex-nowrap' }}
|
|
108
|
+
>
|
|
109
|
+
<ButtonContext.Provider value={{ shape: 'rectangle', variant: 'soft', fullWidth: true }}>
|
|
110
|
+
<div className={tw('border rounded-lg p-6', styles.container)}>
|
|
111
|
+
<div className="flex flex-col items-center text-center">
|
|
112
|
+
{displayIcon && (
|
|
113
|
+
<div className={tw('w-12 h-12 rounded-xl flex items-center justify-center mb-4', styles.iconBg)}>
|
|
114
|
+
<div className={tw(styles.icon)}>{displayIcon}</div>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
<Heading
|
|
118
|
+
slot="title"
|
|
119
|
+
className={tw('text-xl font-semibold', description ? 'mb-2' : 'mb-5', styles.header)}
|
|
120
|
+
>
|
|
121
|
+
{title}
|
|
122
|
+
</Heading>
|
|
123
|
+
{description && <div className="text-sm text-neutral-600 mb-5">{description}</div>}
|
|
124
|
+
{typeof children === 'function' ? children({ close }) : children}
|
|
125
|
+
</div>
|
|
126
|
+
</div>
|
|
127
|
+
</ButtonContext.Provider>
|
|
128
|
+
</ButtonGroupContext.Provider>
|
|
129
|
+
</AlertDialogContext.Provider>
|
|
130
|
+
)}
|
|
131
|
+
</Dialog>
|
|
132
|
+
</Modal>
|
|
133
|
+
</ModalOverlay>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
AlertDialog_.displayName = 'AlertDialog'
|
|
138
|
+
|
|
139
|
+
function CancelButton({ children = 'Cancel' }: { children?: React.ReactNode }) {
|
|
140
|
+
const context = React.useContext(AlertDialogContext)
|
|
141
|
+
return (
|
|
142
|
+
<Button color="neutral" onClick={context.close} variant="soft" shape="rectangle">
|
|
143
|
+
{children}
|
|
144
|
+
</Button>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const AlertDialog = Object.assign(AlertDialog_, {
|
|
149
|
+
CancelButton
|
|
150
|
+
})
|
|
@@ -0,0 +1,288 @@
|
|
|
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 { getColor, stringToNumber } from '../utils/colors'
|
|
8
|
+
import { FormFieldComponents } from './helpers/form-field'
|
|
9
|
+
import { matchSorter } from 'match-sorter'
|
|
10
|
+
|
|
11
|
+
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export type AutocompleteOption = {
|
|
14
|
+
value: string | number
|
|
15
|
+
label: string
|
|
16
|
+
secondary?: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type AutocompleteProps<T extends AutocompleteOption = AutocompleteOption> = {
|
|
20
|
+
// Form identification
|
|
21
|
+
name?: string
|
|
22
|
+
form?: string
|
|
23
|
+
|
|
24
|
+
// Display elements
|
|
25
|
+
label?: React.ReactNode
|
|
26
|
+
cornerHint?: React.ReactNode
|
|
27
|
+
helpText?: React.ReactNode
|
|
28
|
+
placeholder?: string
|
|
29
|
+
warningMessage?: React.ReactNode
|
|
30
|
+
|
|
31
|
+
// Validation and state
|
|
32
|
+
isRequired?: boolean
|
|
33
|
+
isDisabled?: boolean
|
|
34
|
+
isReadonly?: boolean
|
|
35
|
+
isInvalid?: boolean
|
|
36
|
+
validate?: ValidationFunction<T['value'] | null>
|
|
37
|
+
errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
|
|
38
|
+
|
|
39
|
+
// Value management
|
|
40
|
+
value?: T['value'] | null
|
|
41
|
+
defaultValue?: T['value'] | null
|
|
42
|
+
onChange?: (value: T['value'] | null) => void
|
|
43
|
+
|
|
44
|
+
// Options
|
|
45
|
+
options: T[]
|
|
46
|
+
renderOption?: (props: { option: T; colored: boolean; index: number }) => React.ReactNode
|
|
47
|
+
|
|
48
|
+
colored?: boolean
|
|
49
|
+
|
|
50
|
+
// Filtering
|
|
51
|
+
filterFunction?: ((items: readonly T[], value: string) => T[]) | (keyof T)[]
|
|
52
|
+
|
|
53
|
+
// Loading
|
|
54
|
+
isLoading?: boolean
|
|
55
|
+
|
|
56
|
+
// Input
|
|
57
|
+
inputAutocomplete?: React.ComponentProps<'input'>['autoComplete']
|
|
58
|
+
id?: string
|
|
59
|
+
shape?: 'rectangle' | 'oval'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Component ──────────────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
export function Autocomplete<T extends AutocompleteOption = AutocompleteOption>(props: AutocompleteProps<T>) {
|
|
65
|
+
const hiddenInputRef = React.useRef<HTMLInputElement>(null)
|
|
66
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
67
|
+
const helpTextId = React.useId()
|
|
68
|
+
const labelId = React.useId()
|
|
69
|
+
|
|
70
|
+
// Controlled state for the selected value
|
|
71
|
+
const [selectedValue, setSelectedValue] = HeadlessForm.useControlledState(
|
|
72
|
+
props.value,
|
|
73
|
+
props.defaultValue ?? null,
|
|
74
|
+
props.onChange
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
// Form validation
|
|
78
|
+
const {
|
|
79
|
+
validationMessage,
|
|
80
|
+
isInvalid: validationInvalid,
|
|
81
|
+
commitValidation
|
|
82
|
+
} = HeadlessForm.useValidation(
|
|
83
|
+
{
|
|
84
|
+
validate: props.validate,
|
|
85
|
+
value: selectedValue,
|
|
86
|
+
name: props.name,
|
|
87
|
+
isRequired: props.isRequired,
|
|
88
|
+
form: props.form,
|
|
89
|
+
focus() {
|
|
90
|
+
inputRef.current?.focus()
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
hiddenInputRef
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
const isInvalid = props.isInvalid || validationInvalid
|
|
97
|
+
const hasWarning = Boolean(props.warningMessage) && !isInvalid
|
|
98
|
+
|
|
99
|
+
// Find the selected option
|
|
100
|
+
const selectedOption = React.useMemo(() => {
|
|
101
|
+
return props.options.find((option) => option.value === selectedValue) ?? null
|
|
102
|
+
}, [props.options, selectedValue])
|
|
103
|
+
|
|
104
|
+
// Input value state (what's displayed in the input)
|
|
105
|
+
const [inputValue, setInputValue] = React.useState(() => {
|
|
106
|
+
const initial = props.value ?? props.defaultValue ?? null
|
|
107
|
+
if (initial != null) {
|
|
108
|
+
const option = props.options.find((o) => o.value === initial)
|
|
109
|
+
return option?.label ?? ''
|
|
110
|
+
}
|
|
111
|
+
return ''
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
// Sync inputValue when external props.value changes
|
|
115
|
+
const lastPropsValue = React.useRef(props.value)
|
|
116
|
+
React.useEffect(() => {
|
|
117
|
+
if (props.value !== lastPropsValue.current) {
|
|
118
|
+
lastPropsValue.current = props.value
|
|
119
|
+
if (props.value != null) {
|
|
120
|
+
const option = props.options.find((o) => o.value === props.value)
|
|
121
|
+
setInputValue(option?.label ?? '')
|
|
122
|
+
} else {
|
|
123
|
+
setInputValue('')
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}, [props.value, props.options])
|
|
127
|
+
|
|
128
|
+
// Filter options based on input value
|
|
129
|
+
const filteredOptions = React.useMemo(() => {
|
|
130
|
+
if (props.isLoading) return []
|
|
131
|
+
|
|
132
|
+
// When input matches the selected option's label, show all options (not filtered)
|
|
133
|
+
const isShowingSelectedLabel = selectedOption && inputValue === selectedOption.label
|
|
134
|
+
const searchTerm = isShowingSelectedLabel ? '' : inputValue
|
|
135
|
+
|
|
136
|
+
const filterFn = props.filterFunction
|
|
137
|
+
let filtered: T[]
|
|
138
|
+
|
|
139
|
+
if (typeof filterFn === 'function') {
|
|
140
|
+
filtered = filterFn(props.options, searchTerm).slice(0, 50)
|
|
141
|
+
} else if (Array.isArray(filterFn)) {
|
|
142
|
+
filtered = matchSorter(props.options, searchTerm, {
|
|
143
|
+
keys: filterFn as string[]
|
|
144
|
+
}).slice(0, 50)
|
|
145
|
+
} else {
|
|
146
|
+
filtered = defaultFilterFunction(props.options, searchTerm).slice(0, 50)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Ensure selected option is always included
|
|
150
|
+
if (selectedOption && !filtered.some((o) => o.value === selectedOption.value)) {
|
|
151
|
+
filtered = [selectedOption, ...filtered]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return filtered
|
|
155
|
+
}, [props.options, inputValue, props.filterFunction, props.isLoading, selectedOption])
|
|
156
|
+
|
|
157
|
+
// Items for React Aria (with id for key management)
|
|
158
|
+
const items = React.useMemo(
|
|
159
|
+
() => filteredOptions.map((option, index) => ({ ...option, index, id: String(option.value) })),
|
|
160
|
+
[filteredOptions]
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
// Selected key for React Aria
|
|
164
|
+
const selectedKey = selectedValue != null ? String(selectedValue) : null
|
|
165
|
+
|
|
166
|
+
const handleSelectionChange = (key: Key | null) => {
|
|
167
|
+
if (key === null) {
|
|
168
|
+
setSelectedValue(null)
|
|
169
|
+
setInputValue('')
|
|
170
|
+
} else {
|
|
171
|
+
const option = props.options.find((o) => String(o.value) === String(key))
|
|
172
|
+
setSelectedValue(option?.value ?? null)
|
|
173
|
+
setInputValue(option?.label ?? '')
|
|
174
|
+
}
|
|
175
|
+
commitValidation()
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const handleInputChange = (value: string) => {
|
|
179
|
+
setInputValue(value)
|
|
180
|
+
// Clear selection when input doesn't match any option label
|
|
181
|
+
const matchingOption = props.options.find((o) => o.label === value)
|
|
182
|
+
if (!matchingOption) {
|
|
183
|
+
setSelectedValue(null)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<div className="relative">
|
|
189
|
+
{/* Hidden input for form submission */}
|
|
190
|
+
<HeadlessForm.HiddenInput ref={hiddenInputRef} name={props.name} value={selectedValue ?? ''} form={props.form} />
|
|
191
|
+
|
|
192
|
+
<FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} labelId={labelId} />
|
|
193
|
+
|
|
194
|
+
<AriaComboBox
|
|
195
|
+
aria-labelledby={props.label ? labelId : undefined}
|
|
196
|
+
aria-label={!props.label ? 'Autocomplete' : undefined}
|
|
197
|
+
selectedKey={selectedKey}
|
|
198
|
+
onSelectionChange={handleSelectionChange}
|
|
199
|
+
inputValue={inputValue}
|
|
200
|
+
onInputChange={handleInputChange}
|
|
201
|
+
items={items}
|
|
202
|
+
isDisabled={props.isDisabled || props.isReadonly}
|
|
203
|
+
isInvalid={isInvalid}
|
|
204
|
+
isRequired={props.isRequired}
|
|
205
|
+
allowsEmptyCollection
|
|
206
|
+
menuTrigger="focus"
|
|
207
|
+
onBlur={() => commitValidation()}
|
|
208
|
+
>
|
|
209
|
+
{/* Input and button wrapper */}
|
|
210
|
+
<FormFieldComponents.FieldGroup
|
|
211
|
+
isDisabled={props.isDisabled}
|
|
212
|
+
isReadonly={props.isReadonly}
|
|
213
|
+
isInvalid={isInvalid}
|
|
214
|
+
hasWarning={hasWarning}
|
|
215
|
+
shape={props.shape}
|
|
216
|
+
className="flex w-full items-center cursor-default"
|
|
217
|
+
>
|
|
218
|
+
{props.colored && selectedOption ? (
|
|
219
|
+
<div
|
|
220
|
+
className="absolute left-0 top-0 w-[3px] h-full"
|
|
221
|
+
style={{ backgroundColor: getColor(stringToNumber(String(selectedOption.value))) }}
|
|
222
|
+
/>
|
|
223
|
+
) : null}
|
|
224
|
+
<FormFieldComponents.FieldInput
|
|
225
|
+
ref={inputRef}
|
|
226
|
+
autoComplete={props.inputAutocomplete ?? 'off'}
|
|
227
|
+
className="pr-1"
|
|
228
|
+
placeholder={props.placeholder ?? undefined}
|
|
229
|
+
aria-describedby={helpTextId}
|
|
230
|
+
id={props.id}
|
|
231
|
+
/>
|
|
232
|
+
<FormFieldComponents.ChevronButton />
|
|
233
|
+
</FormFieldComponents.FieldGroup>
|
|
234
|
+
|
|
235
|
+
{/* Dropdown */}
|
|
236
|
+
<AnimatedPopover offset={4} className="w-(--trigger-width)">
|
|
237
|
+
<FormFieldComponents.DropdownListBox
|
|
238
|
+
renderEmptyState={() => (
|
|
239
|
+
<div className="px-3 py-2 text-neutral-500 text-sm">
|
|
240
|
+
{props.isLoading ? 'Loading...' : inputValue ? 'No results found' : 'No options available'}
|
|
241
|
+
</div>
|
|
242
|
+
)}
|
|
243
|
+
>
|
|
244
|
+
{(item) => {
|
|
245
|
+
const option = item as T & { index: number; id: string }
|
|
246
|
+
if (props.renderOption) {
|
|
247
|
+
return (
|
|
248
|
+
<ListBoxItem id={option.id} textValue={option.label} className="outline-none">
|
|
249
|
+
{props.renderOption({ option, colored: props.colored ?? false, index: option.index })}
|
|
250
|
+
</ListBoxItem>
|
|
251
|
+
)
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const color = props.colored ? getColor(stringToNumber(String(option.value))) : null
|
|
255
|
+
return (
|
|
256
|
+
<FormFieldComponents.DropdownItem
|
|
257
|
+
id={option.id}
|
|
258
|
+
textValue={option.label}
|
|
259
|
+
label={option.label}
|
|
260
|
+
secondary={option.secondary}
|
|
261
|
+
color={color}
|
|
262
|
+
/>
|
|
263
|
+
)
|
|
264
|
+
}}
|
|
265
|
+
</FormFieldComponents.DropdownListBox>
|
|
266
|
+
</AnimatedPopover>
|
|
267
|
+
</AriaComboBox>
|
|
268
|
+
|
|
269
|
+
<FormFieldComponents.FormFieldHelpText
|
|
270
|
+
id={helpTextId}
|
|
271
|
+
isInvalid={isInvalid}
|
|
272
|
+
validationMessage={validationMessage}
|
|
273
|
+
hasWarning={hasWarning}
|
|
274
|
+
warningMessage={props.warningMessage}
|
|
275
|
+
helpText={props.helpText}
|
|
276
|
+
/>
|
|
277
|
+
</div>
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
function defaultFilterFunction<T extends AutocompleteOption>(options: readonly T[], inputValue: string): T[] {
|
|
284
|
+
if (!inputValue) return [...options]
|
|
285
|
+
return matchSorter(options, inputValue, {
|
|
286
|
+
keys: ['label', 'secondary']
|
|
287
|
+
})
|
|
288
|
+
}
|