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