@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,322 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { ComboBox as AriaComboBox, Input, ListBox, 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 { tw } from '../utils/tw'
|
|
8
|
+
import { getColor, stringToNumber } from '../utils/colors'
|
|
9
|
+
import { FormFieldComponents } from './helpers/form-field'
|
|
10
|
+
import { matchSorter } from 'match-sorter'
|
|
11
|
+
import { Icon } from './icon'
|
|
12
|
+
import { useElementSize } from '../utils/use-element-size'
|
|
13
|
+
import { composeRefs } from '../utils/compose-refs'
|
|
14
|
+
|
|
15
|
+
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export type MulticompleteOption = {
|
|
18
|
+
value: string | number
|
|
19
|
+
label: string
|
|
20
|
+
secondary?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type MulticompleteProps<T extends MulticompleteOption = MulticompleteOption> = {
|
|
24
|
+
// Form identification
|
|
25
|
+
name?: string
|
|
26
|
+
form?: string
|
|
27
|
+
|
|
28
|
+
// Display elements
|
|
29
|
+
label?: React.ReactNode
|
|
30
|
+
cornerHint?: React.ReactNode
|
|
31
|
+
helpText?: React.ReactNode
|
|
32
|
+
placeholder?: string
|
|
33
|
+
warningMessage?: React.ReactNode
|
|
34
|
+
|
|
35
|
+
// Validation and state
|
|
36
|
+
isRequired?: boolean
|
|
37
|
+
isDisabled?: boolean
|
|
38
|
+
isReadonly?: boolean
|
|
39
|
+
isInvalid?: boolean
|
|
40
|
+
validate?: ValidationFunction<T['value'][]>
|
|
41
|
+
errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
|
|
42
|
+
|
|
43
|
+
// Value management
|
|
44
|
+
value?: T['value'][]
|
|
45
|
+
defaultValue?: T['value'][]
|
|
46
|
+
onChange?: (value: T['value'][]) => void
|
|
47
|
+
|
|
48
|
+
// Options
|
|
49
|
+
options: T[]
|
|
50
|
+
renderOption?: (props: { option: T; colored: boolean; index: number }) => React.ReactNode
|
|
51
|
+
|
|
52
|
+
colored?: boolean
|
|
53
|
+
|
|
54
|
+
// Filtering
|
|
55
|
+
filterFunction?: ((items: readonly T[], value: string) => T[]) | (keyof T)[]
|
|
56
|
+
|
|
57
|
+
// Loading
|
|
58
|
+
isLoading?: boolean
|
|
59
|
+
|
|
60
|
+
// Input
|
|
61
|
+
inputAutocomplete?: React.ComponentProps<'input'>['autoComplete']
|
|
62
|
+
id?: string
|
|
63
|
+
shape?: 'rectangle' | 'oval'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── Component ──────────────────────────────────────────────────────────────────
|
|
67
|
+
|
|
68
|
+
const EMPTY_ARR: never[] = []
|
|
69
|
+
|
|
70
|
+
export function Multicomplete<T extends MulticompleteOption = MulticompleteOption>(props: MulticompleteProps<T>) {
|
|
71
|
+
const hiddenInputRef = React.useRef<HTMLInputElement>(null)
|
|
72
|
+
const inputRef = React.useRef<HTMLInputElement>(null)
|
|
73
|
+
const fieldGroupRef = React.useRef<HTMLDivElement>(null)
|
|
74
|
+
const [fieldGroupEl, setFieldGroupEl] = React.useState<HTMLDivElement | null>(null)
|
|
75
|
+
const fieldGroupSize = useElementSize(fieldGroupEl)
|
|
76
|
+
const helpTextId = React.useId()
|
|
77
|
+
const labelId = React.useId()
|
|
78
|
+
const [query, setQuery] = React.useState('')
|
|
79
|
+
|
|
80
|
+
// Controlled state management (array of values)
|
|
81
|
+
const [selectedValues, setSelectedValues] = HeadlessForm.useControlledState(
|
|
82
|
+
props.value,
|
|
83
|
+
props.defaultValue ?? EMPTY_ARR,
|
|
84
|
+
props.onChange
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
// Form validation
|
|
88
|
+
const {
|
|
89
|
+
validationMessage,
|
|
90
|
+
isInvalid: validationInvalid,
|
|
91
|
+
commitValidation
|
|
92
|
+
} = HeadlessForm.useValidation(
|
|
93
|
+
{
|
|
94
|
+
validate: props.validate,
|
|
95
|
+
value: selectedValues,
|
|
96
|
+
name: props.name,
|
|
97
|
+
isRequired: props.isRequired,
|
|
98
|
+
form: props.form,
|
|
99
|
+
focus() {
|
|
100
|
+
inputRef.current?.focus()
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
hiddenInputRef
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
const isInvalid = props.isInvalid || validationInvalid
|
|
107
|
+
const hasWarning = Boolean(props.warningMessage) && !isInvalid
|
|
108
|
+
const isInteractive = !props.isDisabled && !props.isReadonly
|
|
109
|
+
|
|
110
|
+
// Find selected options
|
|
111
|
+
const selectedOptions = React.useMemo(() => {
|
|
112
|
+
return props.options.filter((option) => selectedValues.includes(option.value))
|
|
113
|
+
}, [props.options, selectedValues])
|
|
114
|
+
|
|
115
|
+
// Filter unselected options based on query
|
|
116
|
+
const availableOptions = React.useMemo(() => {
|
|
117
|
+
if (props.isLoading) return []
|
|
118
|
+
|
|
119
|
+
const unselected = props.options.filter((option) => !selectedValues.includes(option.value))
|
|
120
|
+
const filterFn = props.filterFunction
|
|
121
|
+
|
|
122
|
+
if (typeof filterFn === 'function') {
|
|
123
|
+
return filterFn(unselected, query).slice(0, 50)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (Array.isArray(filterFn)) {
|
|
127
|
+
return matchSorter(unselected, query, {
|
|
128
|
+
keys: filterFn as string[]
|
|
129
|
+
}).slice(0, 50)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return defaultFilterFunction(unselected, query).slice(0, 50)
|
|
133
|
+
}, [props.options, selectedValues, query, props.filterFunction, props.isLoading])
|
|
134
|
+
|
|
135
|
+
// Items for React Aria (with id for key management)
|
|
136
|
+
const items = React.useMemo(
|
|
137
|
+
() => availableOptions.map((option, index) => ({ ...option, index, id: String(option.value) })),
|
|
138
|
+
[availableOptions]
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
const handleRemoveOption = (value: T['value']) => {
|
|
142
|
+
const newValues = selectedValues.filter((v) => v !== value)
|
|
143
|
+
setSelectedValues(newValues)
|
|
144
|
+
commitValidation()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const handleSelectionChange = (key: Key | null) => {
|
|
148
|
+
if (key === null) return
|
|
149
|
+
const option = props.options.find((o) => String(o.value) === String(key))
|
|
150
|
+
if (option && !selectedValues.includes(option.value)) {
|
|
151
|
+
setSelectedValues([...selectedValues, option.value])
|
|
152
|
+
commitValidation()
|
|
153
|
+
}
|
|
154
|
+
setQuery('')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
158
|
+
if (e.key === 'Backspace' && !query && selectedOptions.length > 0) {
|
|
159
|
+
e.preventDefault()
|
|
160
|
+
handleRemoveOption(selectedOptions[selectedOptions.length - 1]!.value)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return (
|
|
165
|
+
<div className="relative">
|
|
166
|
+
{/* Hidden inputs for form submission */}
|
|
167
|
+
<HeadlessForm.HiddenInput
|
|
168
|
+
ref={hiddenInputRef}
|
|
169
|
+
name={props.name}
|
|
170
|
+
value={JSON.stringify(selectedValues)}
|
|
171
|
+
form={props.form}
|
|
172
|
+
/>
|
|
173
|
+
|
|
174
|
+
<FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} labelId={labelId} />
|
|
175
|
+
|
|
176
|
+
<AriaComboBox
|
|
177
|
+
aria-labelledby={props.label ? labelId : undefined}
|
|
178
|
+
aria-label={!props.label ? 'Multicomplete' : undefined}
|
|
179
|
+
selectedKey={null}
|
|
180
|
+
onSelectionChange={handleSelectionChange}
|
|
181
|
+
inputValue={query}
|
|
182
|
+
onInputChange={setQuery}
|
|
183
|
+
items={items}
|
|
184
|
+
isDisabled={props.isDisabled || props.isReadonly}
|
|
185
|
+
isInvalid={isInvalid}
|
|
186
|
+
allowsEmptyCollection
|
|
187
|
+
menuTrigger="focus"
|
|
188
|
+
onBlur={() => commitValidation()}
|
|
189
|
+
onKeyDown={handleKeyDown}
|
|
190
|
+
>
|
|
191
|
+
{/* Input container with chips */}
|
|
192
|
+
<FormFieldComponents.FieldGroup
|
|
193
|
+
ref={composeRefs(fieldGroupRef, setFieldGroupEl)}
|
|
194
|
+
isDisabled={props.isDisabled}
|
|
195
|
+
isReadonly={props.isReadonly}
|
|
196
|
+
isInvalid={isInvalid}
|
|
197
|
+
hasWarning={hasWarning}
|
|
198
|
+
shape={props.shape}
|
|
199
|
+
className="flex"
|
|
200
|
+
>
|
|
201
|
+
<div className="flex flex-wrap items-center gap-1.5 px-3 py-1.5 w-full">
|
|
202
|
+
{/* Display selected chips */}
|
|
203
|
+
{selectedOptions.map((option) => {
|
|
204
|
+
const chipColor = props.colored ? getColor(stringToNumber(String(option.value))) : undefined
|
|
205
|
+
return (
|
|
206
|
+
<span
|
|
207
|
+
key={option.value}
|
|
208
|
+
className={tw(
|
|
209
|
+
'inline-flex items-center rounded-full font-normal transition-colors max-w-full px-2 py-0.5 text-xs',
|
|
210
|
+
!chipColor && 'bg-primary-500/10 text-primary-500',
|
|
211
|
+
props.isDisabled && 'opacity-50'
|
|
212
|
+
)}
|
|
213
|
+
style={
|
|
214
|
+
chipColor
|
|
215
|
+
? {
|
|
216
|
+
backgroundColor: `color-mix(in srgb, ${chipColor} 15%, white)`,
|
|
217
|
+
color: `color-mix(in srgb, ${chipColor} 50%, black)`
|
|
218
|
+
}
|
|
219
|
+
: undefined
|
|
220
|
+
}
|
|
221
|
+
>
|
|
222
|
+
<span className="truncate">{option.label}</span>
|
|
223
|
+
{isInteractive && (
|
|
224
|
+
<button
|
|
225
|
+
type="button"
|
|
226
|
+
onClick={(e) => {
|
|
227
|
+
e.stopPropagation()
|
|
228
|
+
handleRemoveOption(option.value)
|
|
229
|
+
}}
|
|
230
|
+
className="inline-flex items-center justify-center rounded-full transition-colors translate-x-1 h-4 w-4 -m-0.5 p-0.5 hover:bg-black/10"
|
|
231
|
+
aria-label={`Remove ${option.label}`}
|
|
232
|
+
tabIndex={-1}
|
|
233
|
+
>
|
|
234
|
+
<Icon name="x-mark" className="h-2 w-2" />
|
|
235
|
+
</button>
|
|
236
|
+
)}
|
|
237
|
+
</span>
|
|
238
|
+
)
|
|
239
|
+
})}
|
|
240
|
+
|
|
241
|
+
{/* Search input */}
|
|
242
|
+
<Input
|
|
243
|
+
ref={inputRef}
|
|
244
|
+
autoComplete={props.inputAutocomplete ?? 'off'}
|
|
245
|
+
className="flex-1 min-w-[120px] px-0 bg-transparent border-0 focus:ring-0 focus:outline-none text-sm text-neutral-900 placeholder:text-neutral-400 py-0.5 disabled:cursor-not-allowed"
|
|
246
|
+
placeholder={selectedOptions.length === 0 ? (props.placeholder ?? 'Search to add...') : ''}
|
|
247
|
+
aria-describedby={helpTextId}
|
|
248
|
+
id={props.id}
|
|
249
|
+
/>
|
|
250
|
+
</div>
|
|
251
|
+
<FormFieldComponents.ChevronButton className="py-0" />
|
|
252
|
+
</FormFieldComponents.FieldGroup>
|
|
253
|
+
|
|
254
|
+
{/* Options dropdown */}
|
|
255
|
+
<AnimatedPopover
|
|
256
|
+
triggerRef={fieldGroupRef}
|
|
257
|
+
offset={4}
|
|
258
|
+
style={{ width: fieldGroupSize.width, minWidth: fieldGroupSize.width }}
|
|
259
|
+
>
|
|
260
|
+
<div
|
|
261
|
+
className={tw(
|
|
262
|
+
'overflow-hidden rounded bg-white border border-neutral-200 shadow-lg text-sm w-full focus:outline-none'
|
|
263
|
+
)}
|
|
264
|
+
>
|
|
265
|
+
<ListBox
|
|
266
|
+
aria-label={props.label ? String(props.label) : 'Multicomplete'}
|
|
267
|
+
className="max-h-60 overflow-y-auto focus:outline-none"
|
|
268
|
+
renderEmptyState={() => (
|
|
269
|
+
<div className="px-3 py-2 text-neutral-500 text-sm">
|
|
270
|
+
{props.isLoading ? 'Loading...' : query ? 'No results found' : 'No options available'}
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
>
|
|
274
|
+
{(item) => {
|
|
275
|
+
const option = item as T & { index: number; id: string }
|
|
276
|
+
const color = props.colored ? getColor(stringToNumber(String(option.value))) : null
|
|
277
|
+
|
|
278
|
+
if (props.renderOption) {
|
|
279
|
+
return (
|
|
280
|
+
<ListBoxItem id={option.id} textValue={option.label} className="outline-none">
|
|
281
|
+
{props.renderOption({ option, colored: props.colored ?? false, index: option.index })}
|
|
282
|
+
</ListBoxItem>
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<FormFieldComponents.DropdownItem
|
|
288
|
+
id={option.id}
|
|
289
|
+
textValue={option.label}
|
|
290
|
+
label={option.label}
|
|
291
|
+
secondary={option.secondary}
|
|
292
|
+
color={color}
|
|
293
|
+
showCheckIcon={false}
|
|
294
|
+
className="px-3"
|
|
295
|
+
/>
|
|
296
|
+
)
|
|
297
|
+
}}
|
|
298
|
+
</ListBox>
|
|
299
|
+
</div>
|
|
300
|
+
</AnimatedPopover>
|
|
301
|
+
</AriaComboBox>
|
|
302
|
+
|
|
303
|
+
<FormFieldComponents.FormFieldHelpText
|
|
304
|
+
id={helpTextId}
|
|
305
|
+
isInvalid={isInvalid}
|
|
306
|
+
validationMessage={validationMessage}
|
|
307
|
+
hasWarning={hasWarning}
|
|
308
|
+
warningMessage={props.warningMessage}
|
|
309
|
+
helpText={props.helpText}
|
|
310
|
+
/>
|
|
311
|
+
</div>
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
function defaultFilterFunction<T extends MulticompleteOption>(options: readonly T[], inputValue: string): T[] {
|
|
318
|
+
if (!inputValue) return [...options]
|
|
319
|
+
return matchSorter(options, inputValue, {
|
|
320
|
+
keys: ['label', 'secondary']
|
|
321
|
+
})
|
|
322
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { ListBox, ListBoxItem } from 'react-aria-components'
|
|
3
|
+
import { AnimatedPopover } from './helpers/animated-popover'
|
|
4
|
+
import type { Selection } from 'react-aria-components'
|
|
5
|
+
import { HeadlessForm } from '@maestro-js/form'
|
|
6
|
+
import type { ValidationFunction, ValidationResult } from '@maestro-js/form'
|
|
7
|
+
import { tw } from '../utils/tw'
|
|
8
|
+
import { getColor, stringToNumber } from '../utils/colors'
|
|
9
|
+
import { FormFieldComponents } from './helpers/form-field'
|
|
10
|
+
import { useElementSize } from '../utils/use-element-size'
|
|
11
|
+
import { composeRefs } from '../utils/compose-refs'
|
|
12
|
+
import { Icon } from './icon'
|
|
13
|
+
|
|
14
|
+
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
export type MultiselectOption = {
|
|
17
|
+
value: string | number
|
|
18
|
+
label: string
|
|
19
|
+
secondary?: string
|
|
20
|
+
isDisabled?: boolean
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type MultiselectProps<T extends MultiselectOption = MultiselectOption> = {
|
|
24
|
+
// Form identification
|
|
25
|
+
name?: string
|
|
26
|
+
form?: string
|
|
27
|
+
|
|
28
|
+
// Display elements
|
|
29
|
+
label?: React.ReactNode
|
|
30
|
+
cornerHint?: React.ReactNode
|
|
31
|
+
helpText?: React.ReactNode
|
|
32
|
+
placeholder?: string
|
|
33
|
+
warningMessage?: React.ReactNode
|
|
34
|
+
|
|
35
|
+
// Validation and state
|
|
36
|
+
isRequired?: boolean
|
|
37
|
+
isDisabled?: boolean
|
|
38
|
+
isInvalid?: boolean
|
|
39
|
+
validate?: ValidationFunction<T['value'][]>
|
|
40
|
+
errorMessage?: React.ReactNode | ((v: ValidationResult) => React.ReactNode)
|
|
41
|
+
|
|
42
|
+
// Value management
|
|
43
|
+
value?: T['value'][]
|
|
44
|
+
defaultValue?: T['value'][]
|
|
45
|
+
onChange?: (value: T['value'][]) => void
|
|
46
|
+
|
|
47
|
+
// Options
|
|
48
|
+
options: T[]
|
|
49
|
+
showSelectAll?: boolean
|
|
50
|
+
renderOption?: (props: { option: T; selected: boolean; colored: boolean; index: number }) => React.ReactNode
|
|
51
|
+
|
|
52
|
+
colored?: boolean
|
|
53
|
+
shape?: 'rectangle' | 'oval'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ── Component ──────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export function Multiselect<T extends MultiselectOption = MultiselectOption>(props: MultiselectProps<T>) {
|
|
59
|
+
const hiddenInputRef = React.useRef<HTMLInputElement>(null)
|
|
60
|
+
const buttonRef = React.useRef<HTMLButtonElement>(null)
|
|
61
|
+
const [containerRef, setContainerRef] = React.useState<HTMLButtonElement | null>(null)
|
|
62
|
+
const helpTextId = React.useId()
|
|
63
|
+
const labelId = React.useId()
|
|
64
|
+
const [isOpen, setIsOpen] = React.useState(false)
|
|
65
|
+
|
|
66
|
+
// Controlled state management
|
|
67
|
+
const [selectedValues, setSelectedValues] = HeadlessForm.useControlledState(
|
|
68
|
+
props.value,
|
|
69
|
+
props.defaultValue ?? [],
|
|
70
|
+
props.onChange
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
// Form validation
|
|
74
|
+
const {
|
|
75
|
+
validationMessage,
|
|
76
|
+
isInvalid: validationInvalid,
|
|
77
|
+
commitValidation
|
|
78
|
+
} = HeadlessForm.useValidation(
|
|
79
|
+
{
|
|
80
|
+
validate: props.validate,
|
|
81
|
+
value: selectedValues,
|
|
82
|
+
name: props.name,
|
|
83
|
+
isRequired: props.isRequired,
|
|
84
|
+
form: props.form,
|
|
85
|
+
focus() {
|
|
86
|
+
buttonRef.current?.focus()
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
hiddenInputRef
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const isInvalid = props.isInvalid || validationInvalid
|
|
93
|
+
const hasWarning = Boolean(props.warningMessage) && !isInvalid
|
|
94
|
+
|
|
95
|
+
// Find selected options
|
|
96
|
+
const selectedOptions = React.useMemo(() => {
|
|
97
|
+
return props.options.filter((option) => selectedValues.includes(option.value))
|
|
98
|
+
}, [props.options, selectedValues])
|
|
99
|
+
|
|
100
|
+
const containerSize = useElementSize(containerRef)
|
|
101
|
+
|
|
102
|
+
const handleRemoveOption = (option: T) => {
|
|
103
|
+
const newValues = selectedValues.filter((v) => v !== option.value)
|
|
104
|
+
setSelectedValues(newValues)
|
|
105
|
+
commitValidation()
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const handleSelectAll = () => {
|
|
109
|
+
const availableOptions = props.options.filter((o) => !o.isDisabled)
|
|
110
|
+
if (selectedValues.length === availableOptions.length) {
|
|
111
|
+
setSelectedValues([])
|
|
112
|
+
} else {
|
|
113
|
+
setSelectedValues(availableOptions.map((o) => o.value))
|
|
114
|
+
}
|
|
115
|
+
commitValidation()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Convert selectedValues to a Set<string> for React Aria
|
|
119
|
+
const selectedKeys = React.useMemo(() => {
|
|
120
|
+
return new Set(selectedValues.map((v) => String(v)))
|
|
121
|
+
}, [selectedValues])
|
|
122
|
+
|
|
123
|
+
const handleSelectionChange = (keys: Selection) => {
|
|
124
|
+
if (keys === 'all') {
|
|
125
|
+
const availableOptions = props.options.filter((o) => !o.isDisabled)
|
|
126
|
+
setSelectedValues(availableOptions.map((o) => o.value))
|
|
127
|
+
} else {
|
|
128
|
+
const keySet = keys as Set<string>
|
|
129
|
+
const newValues = props.options.filter((o) => keySet.has(String(o.value))).map((o) => o.value)
|
|
130
|
+
setSelectedValues(newValues)
|
|
131
|
+
}
|
|
132
|
+
commitValidation()
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<div className="relative">
|
|
137
|
+
{/* Hidden inputs for form submission */}
|
|
138
|
+
{selectedValues.map((value, i) => (
|
|
139
|
+
<HeadlessForm.HiddenInput
|
|
140
|
+
key={i}
|
|
141
|
+
ref={i === 0 ? hiddenInputRef : undefined}
|
|
142
|
+
name={props.name}
|
|
143
|
+
value={value}
|
|
144
|
+
form={props.form}
|
|
145
|
+
/>
|
|
146
|
+
))}
|
|
147
|
+
{selectedValues.length === 0 && (
|
|
148
|
+
<HeadlessForm.HiddenInput ref={hiddenInputRef} name={props.name} value="[]" form={props.form} />
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
<FormFieldComponents.LabelRow label={props.label} cornerHint={props.cornerHint} labelId={labelId} />
|
|
152
|
+
|
|
153
|
+
{/* Trigger button */}
|
|
154
|
+
<button
|
|
155
|
+
ref={composeRefs(buttonRef, setContainerRef)}
|
|
156
|
+
type="button"
|
|
157
|
+
role="combobox"
|
|
158
|
+
aria-expanded={isOpen}
|
|
159
|
+
aria-haspopup="listbox"
|
|
160
|
+
aria-labelledby={props.label ? labelId : undefined}
|
|
161
|
+
aria-label={!props.label ? 'Multiselect' : undefined}
|
|
162
|
+
aria-invalid={isInvalid || undefined}
|
|
163
|
+
aria-describedby={helpTextId}
|
|
164
|
+
disabled={props.isDisabled}
|
|
165
|
+
onClick={() => {
|
|
166
|
+
if (!props.isDisabled) setIsOpen((prev) => !prev)
|
|
167
|
+
}}
|
|
168
|
+
onBlur={() => {
|
|
169
|
+
if (!isOpen) {
|
|
170
|
+
commitValidation()
|
|
171
|
+
}
|
|
172
|
+
}}
|
|
173
|
+
className={tw(
|
|
174
|
+
'relative overflow-hidden border transition-all duration-200 max-w-96',
|
|
175
|
+
props.shape === 'oval' ? 'rounded-full' : 'rounded',
|
|
176
|
+
'focus:outline-none focus:ring-1',
|
|
177
|
+
props.isDisabled && 'border-neutral-100 bg-neutral-50 text-neutral-400 cursor-not-allowed',
|
|
178
|
+
isInvalid && 'border-negative-200 bg-negative-50 focus:border-negative-500 focus:ring-negative-500/20',
|
|
179
|
+
hasWarning && 'border-notice-200 bg-notice-50 focus:border-notice-500 focus:ring-notice-500/20',
|
|
180
|
+
!isInvalid &&
|
|
181
|
+
!hasWarning &&
|
|
182
|
+
!props.isDisabled &&
|
|
183
|
+
'border-neutral-200 hover:border-neutral-300 focus:border-primary-500 focus:ring-primary-500/20',
|
|
184
|
+
'w-full cursor-default bg-white py-2 pl-3 pr-10 text-left'
|
|
185
|
+
)}
|
|
186
|
+
>
|
|
187
|
+
<div className="block text-sm min-h-5">
|
|
188
|
+
{selectedOptions.length === 0 ? (
|
|
189
|
+
<span className="text-neutral-300 italic">{props.placeholder ?? 'Select options'}</span>
|
|
190
|
+
) : (
|
|
191
|
+
<div className="flex flex-wrap gap-1.5">
|
|
192
|
+
{selectedOptions.map((option) => {
|
|
193
|
+
const chipColor = props.colored ? getColor(stringToNumber(String(option.value))) : undefined
|
|
194
|
+
return (
|
|
195
|
+
<span
|
|
196
|
+
key={option.value}
|
|
197
|
+
className={tw(
|
|
198
|
+
'inline-flex items-center rounded-full font-normal transition-colors max-w-full px-2 py-0.5 text-xs',
|
|
199
|
+
!chipColor && 'bg-primary-500/10 text-primary-500',
|
|
200
|
+
props.isDisabled && 'opacity-50'
|
|
201
|
+
)}
|
|
202
|
+
style={
|
|
203
|
+
chipColor
|
|
204
|
+
? {
|
|
205
|
+
backgroundColor: `color-mix(in srgb, ${chipColor} 15%, white)`,
|
|
206
|
+
color: `color-mix(in srgb, ${chipColor} 50%, black)`
|
|
207
|
+
}
|
|
208
|
+
: undefined
|
|
209
|
+
}
|
|
210
|
+
>
|
|
211
|
+
<span className="truncate">{option.label}</span>
|
|
212
|
+
{!props.isDisabled && (
|
|
213
|
+
<button
|
|
214
|
+
type="button"
|
|
215
|
+
onClick={(e) => {
|
|
216
|
+
e.stopPropagation()
|
|
217
|
+
handleRemoveOption(option)
|
|
218
|
+
}}
|
|
219
|
+
className="inline-flex items-center justify-center rounded-full transition-colors translate-x-1 h-4 w-4 -m-0.5 p-0.5 hover:bg-black/10"
|
|
220
|
+
aria-label={`Remove ${option.label}`}
|
|
221
|
+
tabIndex={-1}
|
|
222
|
+
>
|
|
223
|
+
<Icon name="x-mark" className="h-2 w-2" />
|
|
224
|
+
</button>
|
|
225
|
+
)}
|
|
226
|
+
</span>
|
|
227
|
+
)
|
|
228
|
+
})}
|
|
229
|
+
</div>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
|
|
233
|
+
<Icon name="chevron-up-down" className="h-4 w-4 text-neutral-400" />
|
|
234
|
+
</span>
|
|
235
|
+
</button>
|
|
236
|
+
|
|
237
|
+
{/* Options dropdown */}
|
|
238
|
+
<AnimatedPopover
|
|
239
|
+
triggerRef={buttonRef}
|
|
240
|
+
isNonModal
|
|
241
|
+
isOpen={isOpen && !props.isDisabled}
|
|
242
|
+
onOpenChange={(open) => {
|
|
243
|
+
setIsOpen(open)
|
|
244
|
+
if (!open) commitValidation()
|
|
245
|
+
}}
|
|
246
|
+
placement="bottom start"
|
|
247
|
+
offset={4}
|
|
248
|
+
style={{ width: containerSize.width, minWidth: containerSize.width }}
|
|
249
|
+
>
|
|
250
|
+
<div
|
|
251
|
+
className={tw(
|
|
252
|
+
'overflow-hidden rounded bg-white border border-neutral-200 shadow-lg text-sm w-full focus:outline-none'
|
|
253
|
+
)}
|
|
254
|
+
>
|
|
255
|
+
{props.showSelectAll && (
|
|
256
|
+
<button
|
|
257
|
+
type="button"
|
|
258
|
+
onClick={handleSelectAll}
|
|
259
|
+
className="w-full px-3 py-2 text-left hover:bg-neutral-50 border-b border-neutral-100 text-sm font-medium"
|
|
260
|
+
>
|
|
261
|
+
{selectedValues.length === props.options.filter((o) => !o.isDisabled).length ? 'Deselect All' : 'Select All'}
|
|
262
|
+
</button>
|
|
263
|
+
)}
|
|
264
|
+
<ListBox
|
|
265
|
+
aria-label={props.label ? String(props.label) : 'Multiselect'}
|
|
266
|
+
selectionMode="multiple"
|
|
267
|
+
selectedKeys={selectedKeys}
|
|
268
|
+
onSelectionChange={handleSelectionChange}
|
|
269
|
+
className="max-h-60 overflow-y-auto focus:outline-none"
|
|
270
|
+
items={props.options.map((option, index) => ({ ...option, index, id: String(option.value) }))}
|
|
271
|
+
renderEmptyState={() => <div className="px-3 py-2 text-neutral-500 text-sm">No options available</div>}
|
|
272
|
+
>
|
|
273
|
+
{(item) => {
|
|
274
|
+
const option = item as T & { index: number; id: string }
|
|
275
|
+
const color = props.colored ? getColor(stringToNumber(String(option.value))) : null
|
|
276
|
+
|
|
277
|
+
if (props.renderOption) {
|
|
278
|
+
return (
|
|
279
|
+
<ListBoxItem
|
|
280
|
+
id={option.id}
|
|
281
|
+
textValue={option.label}
|
|
282
|
+
isDisabled={option.isDisabled}
|
|
283
|
+
className="outline-none"
|
|
284
|
+
>
|
|
285
|
+
{({ isSelected }) => (
|
|
286
|
+
<>
|
|
287
|
+
{props.renderOption!({
|
|
288
|
+
option,
|
|
289
|
+
selected: isSelected,
|
|
290
|
+
colored: props.colored ?? false,
|
|
291
|
+
index: option.index
|
|
292
|
+
})}
|
|
293
|
+
</>
|
|
294
|
+
)}
|
|
295
|
+
</ListBoxItem>
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return (
|
|
300
|
+
<FormFieldComponents.DropdownItem
|
|
301
|
+
id={option.id}
|
|
302
|
+
textValue={option.label}
|
|
303
|
+
label={option.label}
|
|
304
|
+
secondary={option.secondary}
|
|
305
|
+
isDisabled={option.isDisabled}
|
|
306
|
+
color={color}
|
|
307
|
+
className="px-3"
|
|
308
|
+
/>
|
|
309
|
+
)
|
|
310
|
+
}}
|
|
311
|
+
</ListBox>
|
|
312
|
+
</div>
|
|
313
|
+
</AnimatedPopover>
|
|
314
|
+
|
|
315
|
+
<FormFieldComponents.FormFieldHelpText
|
|
316
|
+
id={helpTextId}
|
|
317
|
+
isInvalid={isInvalid}
|
|
318
|
+
validationMessage={validationMessage}
|
|
319
|
+
hasWarning={hasWarning}
|
|
320
|
+
warningMessage={props.warningMessage}
|
|
321
|
+
helpText={props.helpText}
|
|
322
|
+
/>
|
|
323
|
+
</div>
|
|
324
|
+
)
|
|
325
|
+
}
|