@oslokommune/punkt-react 15.4.6 → 16.0.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/CHANGELOG.md +74 -0
- package/dist/index.d.ts +38 -15
- package/dist/punkt-react.es.js +12025 -10664
- package/dist/punkt-react.umd.js +562 -549
- package/package.json +5 -5
- package/src/components/accordion/Accordion.test.tsx +3 -2
- package/src/components/alert/Alert.test.tsx +2 -1
- package/src/components/backlink/BackLink.test.tsx +2 -1
- package/src/components/button/Button.test.tsx +4 -3
- package/src/components/calendar/Calendar.interaction.test.tsx +2 -1
- package/src/components/checkbox/Checkbox.test.tsx +2 -1
- package/src/components/combobox/Combobox.accessibility.test.tsx +277 -0
- package/src/components/combobox/Combobox.core.test.tsx +469 -0
- package/src/components/combobox/Combobox.interaction.test.tsx +607 -0
- package/src/components/combobox/Combobox.selection.test.tsx +548 -0
- package/src/components/combobox/Combobox.tsx +59 -54
- package/src/components/combobox/ComboboxInput.tsx +140 -0
- package/src/components/combobox/ComboboxTags.tsx +110 -0
- package/src/components/combobox/Listbox.tsx +172 -0
- package/src/components/combobox/types.ts +145 -0
- package/src/components/combobox/useComboboxState.ts +1141 -0
- package/src/components/datepicker/Datepicker.accessibility.test.tsx +5 -4
- package/src/components/datepicker/Datepicker.input.test.tsx +3 -2
- package/src/components/datepicker/Datepicker.selection.test.tsx +8 -8
- package/src/components/datepicker/Datepicker.validation.test.tsx +2 -1
- package/src/components/radio/RadioButton.test.tsx +3 -2
- package/src/components/searchinput/SearchInput.test.tsx +6 -5
- package/src/components/tabs/Tabs.test.tsx +13 -12
- package/src/components/tag/Tag.test.tsx +2 -1
|
@@ -0,0 +1,1141 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ChangeEvent,
|
|
3
|
+
type FocusEvent,
|
|
4
|
+
type KeyboardEvent,
|
|
5
|
+
Children,
|
|
6
|
+
isValidElement,
|
|
7
|
+
useCallback,
|
|
8
|
+
useEffect,
|
|
9
|
+
useImperativeHandle,
|
|
10
|
+
useMemo,
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
13
|
+
} from 'react'
|
|
14
|
+
import type { IPktComboboxOption, TPktComboboxDisplayValue } from 'shared-types/combobox'
|
|
15
|
+
import {
|
|
16
|
+
findOptionByValue,
|
|
17
|
+
filterOptionsBySearch,
|
|
18
|
+
buildFulltext,
|
|
19
|
+
isMaxSelectionReached,
|
|
20
|
+
parseValueToArray,
|
|
21
|
+
findTypeaheadMatches,
|
|
22
|
+
focusFirstOption,
|
|
23
|
+
focusFirstOrSelectedOption,
|
|
24
|
+
focusNextOption,
|
|
25
|
+
focusPreviousOption,
|
|
26
|
+
getSearchInfoMessage,
|
|
27
|
+
getInputKeyAction,
|
|
28
|
+
getInputValueAction,
|
|
29
|
+
getSingleValueForInput,
|
|
30
|
+
} from 'shared-utils/combobox'
|
|
31
|
+
|
|
32
|
+
import type { IPktCombobox, IComboboxState } from './types'
|
|
33
|
+
|
|
34
|
+
// Options sync helper (React-specific: immutable, returns new array)
|
|
35
|
+
function syncOptionsWithValues(options: IPktComboboxOption[], values: string[]): IPktComboboxOption[] {
|
|
36
|
+
return options.map((opt) => ({
|
|
37
|
+
...opt,
|
|
38
|
+
label: opt.label || opt.value,
|
|
39
|
+
fulltext: buildFulltext(opt),
|
|
40
|
+
selected: values.includes(opt.value),
|
|
41
|
+
}))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function useComboboxState(props: IPktCombobox, ref: React.ForwardedRef<HTMLDivElement>): IComboboxState {
|
|
45
|
+
const {
|
|
46
|
+
id = '',
|
|
47
|
+
label,
|
|
48
|
+
value,
|
|
49
|
+
defaultValue,
|
|
50
|
+
options: optionsProp,
|
|
51
|
+
defaultOptions,
|
|
52
|
+
multiple = false,
|
|
53
|
+
maxlength,
|
|
54
|
+
typeahead = false,
|
|
55
|
+
includeSearch = false,
|
|
56
|
+
allowUserInput = false,
|
|
57
|
+
displayValueAs = 'label' as TPktComboboxDisplayValue,
|
|
58
|
+
tagPlacement,
|
|
59
|
+
searchPlaceholder,
|
|
60
|
+
placeholder,
|
|
61
|
+
disabled = false,
|
|
62
|
+
required = false,
|
|
63
|
+
fullwidth = false,
|
|
64
|
+
hasError = false,
|
|
65
|
+
errorMessage,
|
|
66
|
+
helptext,
|
|
67
|
+
helptextDropdown,
|
|
68
|
+
helptextDropdownButton,
|
|
69
|
+
optionalTag = false,
|
|
70
|
+
optionalText,
|
|
71
|
+
requiredTag = false,
|
|
72
|
+
requiredText,
|
|
73
|
+
tagText,
|
|
74
|
+
useWrapper = true,
|
|
75
|
+
name,
|
|
76
|
+
className,
|
|
77
|
+
onChange,
|
|
78
|
+
onValueChange,
|
|
79
|
+
children,
|
|
80
|
+
} = props
|
|
81
|
+
|
|
82
|
+
// Identity
|
|
83
|
+
const inputId = `${id}-input`
|
|
84
|
+
const listboxId = `${id}-listbox`
|
|
85
|
+
const hasTextInput = typeahead || allowUserInput
|
|
86
|
+
|
|
87
|
+
// Refs
|
|
88
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
89
|
+
const changeInputRef = useRef<HTMLInputElement>(null)
|
|
90
|
+
const triggerRef = useRef<HTMLDivElement>(null)
|
|
91
|
+
const listboxRef = useRef<HTMLDivElement>(null)
|
|
92
|
+
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
93
|
+
|
|
94
|
+
// Focus the appropriate trigger: text input for editable, combobox div for select-only
|
|
95
|
+
const focusTrigger = useCallback(() => {
|
|
96
|
+
if (hasTextInput) {
|
|
97
|
+
inputRef.current?.focus()
|
|
98
|
+
} else {
|
|
99
|
+
triggerRef.current?.focus()
|
|
100
|
+
}
|
|
101
|
+
}, [hasTextInput])
|
|
102
|
+
|
|
103
|
+
// Parse <option> children into options
|
|
104
|
+
const optionsFromChildren = useMemo(() => {
|
|
105
|
+
const result: IPktComboboxOption[] = []
|
|
106
|
+
Children.forEach(children, (child) => {
|
|
107
|
+
if (isValidElement(child) && child.type === 'option') {
|
|
108
|
+
const childProps = child.props as {
|
|
109
|
+
value?: string
|
|
110
|
+
children?: string
|
|
111
|
+
selected?: boolean
|
|
112
|
+
disabled?: boolean
|
|
113
|
+
}
|
|
114
|
+
result.push({
|
|
115
|
+
value: childProps.value ?? (childProps.children as string) ?? '',
|
|
116
|
+
label: (childProps.children as string) ?? childProps.value ?? '',
|
|
117
|
+
selected: childProps.selected,
|
|
118
|
+
disabled: childProps.disabled,
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
return result
|
|
123
|
+
}, [children])
|
|
124
|
+
|
|
125
|
+
// Merge options from all sources
|
|
126
|
+
const baseOptions = useMemo(() => {
|
|
127
|
+
// defaultOptions take precedence, then optionsProp, then children
|
|
128
|
+
if (defaultOptions && defaultOptions.length > 0) {
|
|
129
|
+
return defaultOptions.map((opt) => ({ ...opt, fulltext: buildFulltext(opt) }))
|
|
130
|
+
}
|
|
131
|
+
if (optionsProp && optionsProp.length > 0) {
|
|
132
|
+
return optionsProp.map((opt) => ({ ...opt, fulltext: buildFulltext(opt) }))
|
|
133
|
+
}
|
|
134
|
+
if (optionsFromChildren.length > 0) {
|
|
135
|
+
return optionsFromChildren.map((opt) => ({ ...opt, fulltext: buildFulltext(opt) }))
|
|
136
|
+
}
|
|
137
|
+
return []
|
|
138
|
+
}, [defaultOptions, optionsProp, optionsFromChildren])
|
|
139
|
+
|
|
140
|
+
// Controlled/uncontrolled value
|
|
141
|
+
const isControlled = value !== undefined
|
|
142
|
+
|
|
143
|
+
const [internalValues, setInternalValues] = useState<string[]>(() => {
|
|
144
|
+
const initial = value ?? defaultValue
|
|
145
|
+
return parseValueToArray(initial as string | string[] | null | undefined, multiple)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
// Store initial default values for form reset
|
|
149
|
+
const initialValuesRef = useRef(internalValues)
|
|
150
|
+
|
|
151
|
+
// Sync external controlled value
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
if (isControlled) {
|
|
154
|
+
setInternalValues(parseValueToArray(value as string | string[] | null | undefined, multiple))
|
|
155
|
+
}
|
|
156
|
+
}, [value, isControlled, multiple])
|
|
157
|
+
|
|
158
|
+
const values = isControlled
|
|
159
|
+
? parseValueToArray(value as string | string[] | null | undefined, multiple)
|
|
160
|
+
: internalValues
|
|
161
|
+
|
|
162
|
+
// Options state (with selection tracking and user-added options)
|
|
163
|
+
const [userAddedOptions, setUserAddedOptions] = useState<IPktComboboxOption[]>([])
|
|
164
|
+
|
|
165
|
+
const options = useMemo(() => {
|
|
166
|
+
const merged = [
|
|
167
|
+
...userAddedOptions.filter((ua) => !baseOptions.some((bo) => bo.value === ua.value)),
|
|
168
|
+
...baseOptions,
|
|
169
|
+
]
|
|
170
|
+
return syncOptionsWithValues(merged, values)
|
|
171
|
+
}, [baseOptions, userAddedOptions, values])
|
|
172
|
+
|
|
173
|
+
// UI state
|
|
174
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
175
|
+
const [searchValue, setSearchValue] = useState('')
|
|
176
|
+
const [inputValue, setInputValue] = useState('')
|
|
177
|
+
const [userInfoMessage, setUserInfoMessage] = useState('')
|
|
178
|
+
const [addValueText, setAddValueText] = useState<string | null>(null)
|
|
179
|
+
const [inputFocus, setInputFocus] = useState(false)
|
|
180
|
+
const [editingSingleValue, setEditingSingleValue] = useState(false)
|
|
181
|
+
const [focusedTagIndex, setFocusedTagIndex] = useState<number>(-1)
|
|
182
|
+
const suppressNextOpenRef = useRef(false)
|
|
183
|
+
|
|
184
|
+
// Derived state
|
|
185
|
+
const maxIsReached = isMaxSelectionReached(values.length, maxlength ?? null)
|
|
186
|
+
const hasCounter = multiple && maxlength != null
|
|
187
|
+
|
|
188
|
+
// Filter options by search (typeahead uses fulltext filtering, includeSearch uses same)
|
|
189
|
+
const filteredOptions = useMemo(() => {
|
|
190
|
+
if (!searchValue) return options
|
|
191
|
+
if (typeahead) return findTypeaheadMatches(options, searchValue).filtered
|
|
192
|
+
return filterOptionsBySearch(options, searchValue)
|
|
193
|
+
}, [options, searchValue, typeahead])
|
|
194
|
+
|
|
195
|
+
// Hidden input — dispatch native events for React synthetic event compat
|
|
196
|
+
const nativeInputValueSetter = useMemo(
|
|
197
|
+
() => Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set,
|
|
198
|
+
[],
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
const dispatchChange = useCallback(
|
|
202
|
+
(newValues: string[]) => {
|
|
203
|
+
const input = changeInputRef.current
|
|
204
|
+
if (!input || !nativeInputValueSetter) return
|
|
205
|
+
const stringVal = multiple ? newValues.join(',') : newValues[0] || ''
|
|
206
|
+
nativeInputValueSetter.call(input, stringVal)
|
|
207
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
208
|
+
},
|
|
209
|
+
[nativeInputValueSetter, multiple],
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
// Sync hidden input value
|
|
213
|
+
const formValue = multiple ? values.join(',') : values[0] || ''
|
|
214
|
+
useEffect(() => {
|
|
215
|
+
if (changeInputRef.current) {
|
|
216
|
+
changeInputRef.current.value = formValue
|
|
217
|
+
}
|
|
218
|
+
}, [formValue])
|
|
219
|
+
|
|
220
|
+
// Sync text input display value for single+typeahead when dropdown is closed
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
if (isOpen || multiple || !inputRef.current) return
|
|
223
|
+
const displayValue = values[0] ? getSingleValueForInput(values[0], options, displayValueAs) : ''
|
|
224
|
+
if (inputRef.current.value !== displayValue) {
|
|
225
|
+
inputRef.current.value = displayValue
|
|
226
|
+
}
|
|
227
|
+
}, [values, options, displayValueAs, multiple, isOpen])
|
|
228
|
+
|
|
229
|
+
// Value update helper
|
|
230
|
+
const updateValues = useCallback(
|
|
231
|
+
(newValues: string[], notify = true) => {
|
|
232
|
+
if (!isControlled) {
|
|
233
|
+
setInternalValues(newValues)
|
|
234
|
+
}
|
|
235
|
+
if (notify) {
|
|
236
|
+
onValueChange?.(newValues)
|
|
237
|
+
dispatchChange(newValues)
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
[isControlled, onValueChange, dispatchChange],
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
// Selection operations
|
|
244
|
+
const setSelected = useCallback(
|
|
245
|
+
(optValue: string | null) => {
|
|
246
|
+
if (!optValue) return
|
|
247
|
+
if (values.includes(optValue)) return
|
|
248
|
+
if (multiple && isMaxSelectionReached(values.length, maxlength ?? null)) {
|
|
249
|
+
setUserInfoMessage('Maks antall valg nådd')
|
|
250
|
+
return
|
|
251
|
+
}
|
|
252
|
+
const newValues = multiple ? [...values, optValue] : [optValue]
|
|
253
|
+
updateValues(newValues)
|
|
254
|
+
},
|
|
255
|
+
[values, multiple, maxlength, updateValues],
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
const removeSelected = useCallback(
|
|
259
|
+
(optValue: string | null) => {
|
|
260
|
+
if (!optValue) return
|
|
261
|
+
const newValues = values.filter((v) => v !== optValue)
|
|
262
|
+
// Remove user-added option if it was deselected
|
|
263
|
+
const opt = findOptionByValue(options, optValue)
|
|
264
|
+
if (opt?.userAdded) {
|
|
265
|
+
setUserAddedOptions((prev) => prev.filter((o) => o.value !== optValue))
|
|
266
|
+
}
|
|
267
|
+
updateValues(newValues)
|
|
268
|
+
},
|
|
269
|
+
[values, options, updateValues],
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
const removeAllSelected = useCallback(() => {
|
|
273
|
+
setUserAddedOptions([])
|
|
274
|
+
updateValues([])
|
|
275
|
+
}, [updateValues])
|
|
276
|
+
|
|
277
|
+
const addNewUserValue = useCallback(
|
|
278
|
+
(val: string | null) => {
|
|
279
|
+
if (!val || val.trim() === '') return
|
|
280
|
+
|
|
281
|
+
let newValues: string[]
|
|
282
|
+
if (!multiple) {
|
|
283
|
+
newValues = [val]
|
|
284
|
+
} else {
|
|
285
|
+
if (findOptionByValue(options, val)) return
|
|
286
|
+
if (isMaxSelectionReached(values.length, maxlength ?? null)) return
|
|
287
|
+
newValues = [...values, val]
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
const newOption: IPktComboboxOption = {
|
|
291
|
+
value: val,
|
|
292
|
+
label: val,
|
|
293
|
+
userAdded: true,
|
|
294
|
+
selected: true,
|
|
295
|
+
}
|
|
296
|
+
setUserAddedOptions((prev) => [newOption, ...prev.filter((o) => o.value !== val)])
|
|
297
|
+
updateValues(newValues)
|
|
298
|
+
},
|
|
299
|
+
[multiple, options, values, maxlength, updateValues],
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
// Reset input field
|
|
303
|
+
// currentValue overrides stale closure reads of values[0] (React batches state updates,
|
|
304
|
+
// so values[0] may not reflect changes from setSelected/removeSelected in the same callback)
|
|
305
|
+
const resetInput = useCallback(
|
|
306
|
+
(shouldReset: boolean = true, currentValue?: string) => {
|
|
307
|
+
setAddValueText(null)
|
|
308
|
+
if (shouldReset && inputRef.current && inputRef.current.type !== 'hidden') {
|
|
309
|
+
setSearchValue('')
|
|
310
|
+
setInputValue('')
|
|
311
|
+
const val = currentValue !== undefined ? currentValue : values[0]
|
|
312
|
+
if (!multiple && val) {
|
|
313
|
+
inputRef.current.value = getSingleValueForInput(val, options, displayValueAs)
|
|
314
|
+
} else {
|
|
315
|
+
inputRef.current.value = ''
|
|
316
|
+
}
|
|
317
|
+
if (!multiple) {
|
|
318
|
+
setEditingSingleValue(false)
|
|
319
|
+
setUserInfoMessage('')
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
[multiple, values, options, displayValueAs],
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
// Core toggle logic (matches Elements toggleValue)
|
|
327
|
+
const toggleValue = useCallback(
|
|
328
|
+
(val: string | null) => {
|
|
329
|
+
if (disabled) return
|
|
330
|
+
|
|
331
|
+
setUserInfoMessage('')
|
|
332
|
+
setAddValueText(null)
|
|
333
|
+
|
|
334
|
+
const valueFromOptions = findOptionByValue(options, val)?.value || null
|
|
335
|
+
const isSelected = values.includes(val || valueFromOptions || '')
|
|
336
|
+
const isInOption = !!valueFromOptions
|
|
337
|
+
const isDisabled = options.find((o) => o.value === val)?.disabled || false
|
|
338
|
+
const isEmpty = !val?.trim()
|
|
339
|
+
const isSingle = !multiple
|
|
340
|
+
const isMultiple = multiple
|
|
341
|
+
const isMaxItemsReached = isMaxSelectionReached(values.length, maxlength ?? null)
|
|
342
|
+
|
|
343
|
+
let shouldOptionsBeOpen = false
|
|
344
|
+
let shouldResetInput = true
|
|
345
|
+
let newUserInfoMessage = ''
|
|
346
|
+
let newSearchValue = ''
|
|
347
|
+
// Track the resulting single value for resetInput (avoids stale closure reads)
|
|
348
|
+
let resultingSingleValue: string | undefined
|
|
349
|
+
|
|
350
|
+
if (isDisabled) return
|
|
351
|
+
|
|
352
|
+
// Not in list + allowUserInput
|
|
353
|
+
if (!isInOption && allowUserInput && !isEmpty) {
|
|
354
|
+
addNewUserValue(val)
|
|
355
|
+
newUserInfoMessage = 'Ny verdi lagt til'
|
|
356
|
+
shouldOptionsBeOpen = isMultiple
|
|
357
|
+
if (isSingle) resultingSingleValue = val || ''
|
|
358
|
+
}
|
|
359
|
+
// Not in list + no user input
|
|
360
|
+
else if (!isInOption && !allowUserInput) {
|
|
361
|
+
if (isSingle && values[0]) {
|
|
362
|
+
removeSelected(values[0])
|
|
363
|
+
}
|
|
364
|
+
shouldResetInput = false
|
|
365
|
+
shouldOptionsBeOpen = true
|
|
366
|
+
newUserInfoMessage = 'Ingen treff i søket'
|
|
367
|
+
}
|
|
368
|
+
// Already selected — deselect
|
|
369
|
+
else if (isSelected) {
|
|
370
|
+
removeSelected(valueFromOptions)
|
|
371
|
+
shouldOptionsBeOpen = true
|
|
372
|
+
resultingSingleValue = ''
|
|
373
|
+
// For single+typeahead: clear the input immediately so tab-out doesn't re-select
|
|
374
|
+
if (isSingle && hasTextInput && inputRef.current && inputRef.current.type !== 'hidden') {
|
|
375
|
+
inputRef.current.value = ''
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
// Empty + single — clear
|
|
379
|
+
else if (isEmpty && isSingle) {
|
|
380
|
+
removeAllSelected()
|
|
381
|
+
shouldOptionsBeOpen = true
|
|
382
|
+
resultingSingleValue = ''
|
|
383
|
+
}
|
|
384
|
+
// Single — replace
|
|
385
|
+
else if (isSingle) {
|
|
386
|
+
if (values[0]) removeSelected(values[0])
|
|
387
|
+
setSelected(valueFromOptions)
|
|
388
|
+
shouldOptionsBeOpen = false
|
|
389
|
+
resultingSingleValue = valueFromOptions || ''
|
|
390
|
+
}
|
|
391
|
+
// Multi with room
|
|
392
|
+
else if (isMultiple && !isMaxItemsReached) {
|
|
393
|
+
setSelected(valueFromOptions)
|
|
394
|
+
shouldOptionsBeOpen = true
|
|
395
|
+
}
|
|
396
|
+
// Multi at max
|
|
397
|
+
else if (isMultiple && isMaxItemsReached) {
|
|
398
|
+
newUserInfoMessage = 'Maks antall valg nådd'
|
|
399
|
+
shouldResetInput = false
|
|
400
|
+
newSearchValue = val || ''
|
|
401
|
+
}
|
|
402
|
+
// Fallback
|
|
403
|
+
else {
|
|
404
|
+
if (isSingle) removeAllSelected()
|
|
405
|
+
newUserInfoMessage = 'Ingen gyldig verdi valgt'
|
|
406
|
+
shouldResetInput = false
|
|
407
|
+
shouldOptionsBeOpen = true
|
|
408
|
+
newSearchValue = val || ''
|
|
409
|
+
resultingSingleValue = ''
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
setIsOpen(shouldOptionsBeOpen)
|
|
413
|
+
setUserInfoMessage(newUserInfoMessage)
|
|
414
|
+
setSearchValue(newSearchValue)
|
|
415
|
+
resetInput(shouldResetInput, resultingSingleValue)
|
|
416
|
+
|
|
417
|
+
if (!shouldOptionsBeOpen) {
|
|
418
|
+
if (isSingle && hasTextInput) {
|
|
419
|
+
// Suppress the next handleInputFocus from reopening the dropdown,
|
|
420
|
+
// then move focus back to the text input so screen readers
|
|
421
|
+
// announce the selected value instead of the browser window.
|
|
422
|
+
suppressNextOpenRef.current = true
|
|
423
|
+
}
|
|
424
|
+
window.setTimeout(() => {
|
|
425
|
+
focusTrigger()
|
|
426
|
+
}, 0)
|
|
427
|
+
}
|
|
428
|
+
},
|
|
429
|
+
[
|
|
430
|
+
disabled,
|
|
431
|
+
options,
|
|
432
|
+
values,
|
|
433
|
+
multiple,
|
|
434
|
+
maxlength,
|
|
435
|
+
allowUserInput,
|
|
436
|
+
addNewUserValue,
|
|
437
|
+
removeSelected,
|
|
438
|
+
removeAllSelected,
|
|
439
|
+
setSelected,
|
|
440
|
+
resetInput,
|
|
441
|
+
focusTrigger,
|
|
442
|
+
],
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
// Close and process input (used by focusout and outside click)
|
|
446
|
+
const closeAndProcessInput = useCallback(() => {
|
|
447
|
+
setInputFocus(false)
|
|
448
|
+
setAddValueText(null)
|
|
449
|
+
setUserInfoMessage('')
|
|
450
|
+
setSearchValue('')
|
|
451
|
+
|
|
452
|
+
if (inputRef.current && inputRef.current.type !== 'hidden') {
|
|
453
|
+
const inputText = inputRef.current.value
|
|
454
|
+
// Track the resulting value after processing (to avoid stale closure reads)
|
|
455
|
+
let resultingValue: string | undefined = values[0]
|
|
456
|
+
|
|
457
|
+
if (!multiple) {
|
|
458
|
+
if (!inputText) {
|
|
459
|
+
// Empty input — clear the selection
|
|
460
|
+
if (values[0]) removeSelected(values[0])
|
|
461
|
+
resultingValue = undefined
|
|
462
|
+
} else {
|
|
463
|
+
// Try to match input text to an option (by value or label)
|
|
464
|
+
const match = findOptionByValue(options, inputText)
|
|
465
|
+
if (match && match.value !== values[0]) {
|
|
466
|
+
// Input matches a different option — select it
|
|
467
|
+
if (values[0]) removeSelected(values[0])
|
|
468
|
+
setSelected(match.value)
|
|
469
|
+
resultingValue = match.value
|
|
470
|
+
} else if (!match && allowUserInput) {
|
|
471
|
+
// No match + allowUserInput — set as user value
|
|
472
|
+
if (values[0]) removeSelected(values[0])
|
|
473
|
+
addNewUserValue(inputText)
|
|
474
|
+
resultingValue = inputText
|
|
475
|
+
}
|
|
476
|
+
// No match, no allowUserInput — discard, keep previous selection
|
|
477
|
+
}
|
|
478
|
+
} else if (inputText !== '') {
|
|
479
|
+
// Multi: process typed text (add/select/remove)
|
|
480
|
+
const { action, value: actionValue } = getInputValueAction(inputText, values, options, allowUserInput, multiple)
|
|
481
|
+
switch (action) {
|
|
482
|
+
case 'addUserValue':
|
|
483
|
+
addNewUserValue(actionValue)
|
|
484
|
+
break
|
|
485
|
+
case 'selectOption':
|
|
486
|
+
setSelected(actionValue)
|
|
487
|
+
break
|
|
488
|
+
case 'removeValue':
|
|
489
|
+
removeSelected(actionValue)
|
|
490
|
+
break
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Restore input to display text of current selection (or clear)
|
|
495
|
+
if (!multiple && resultingValue) {
|
|
496
|
+
inputRef.current.value = getSingleValueForInput(resultingValue, options, displayValueAs)
|
|
497
|
+
} else {
|
|
498
|
+
inputRef.current.value = ''
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
setIsOpen(false)
|
|
502
|
+
setEditingSingleValue(false)
|
|
503
|
+
}, [values, options, allowUserInput, multiple, displayValueAs, addNewUserValue, setSelected, removeSelected])
|
|
504
|
+
|
|
505
|
+
// Check for matches while typing (info messages)
|
|
506
|
+
const checkForMatches = useCallback(
|
|
507
|
+
(currentInputValue: string) => {
|
|
508
|
+
const search = currentInputValue.trim().toLowerCase()
|
|
509
|
+
|
|
510
|
+
if (!search) {
|
|
511
|
+
setAddValueText(null)
|
|
512
|
+
setUserInfoMessage('')
|
|
513
|
+
if (!multiple && values[0]) {
|
|
514
|
+
removeSelected(values[0])
|
|
515
|
+
}
|
|
516
|
+
return
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const result = getSearchInfoMessage(currentInputValue, values, options, allowUserInput)
|
|
520
|
+
setAddValueText(result.addValueText)
|
|
521
|
+
setUserInfoMessage(result.userInfoMessage)
|
|
522
|
+
},
|
|
523
|
+
[values, options, allowUserInput, multiple, removeSelected],
|
|
524
|
+
)
|
|
525
|
+
|
|
526
|
+
// Event handlers
|
|
527
|
+
|
|
528
|
+
const handleInputChange = useCallback(
|
|
529
|
+
(e: ChangeEvent<HTMLInputElement>) => {
|
|
530
|
+
if (disabled) return
|
|
531
|
+
|
|
532
|
+
const newInputValue = e.target.value
|
|
533
|
+
setInputValue(newInputValue)
|
|
534
|
+
setSearchValue(newInputValue)
|
|
535
|
+
checkForMatches(newInputValue)
|
|
536
|
+
|
|
537
|
+
if (typeahead) {
|
|
538
|
+
if (newInputValue) {
|
|
539
|
+
const { suggestion } = findTypeaheadMatches(options, newInputValue)
|
|
540
|
+
if (
|
|
541
|
+
suggestion?.label &&
|
|
542
|
+
inputRef.current &&
|
|
543
|
+
!(e.nativeEvent as InputEvent).inputType?.includes('deleteContent')
|
|
544
|
+
) {
|
|
545
|
+
inputRef.current.value = suggestion.label
|
|
546
|
+
window.setTimeout(
|
|
547
|
+
() => inputRef.current?.setSelectionRange(newInputValue.length, suggestion.label!.length),
|
|
548
|
+
0,
|
|
549
|
+
)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
},
|
|
554
|
+
[disabled, typeahead, options, checkForMatches],
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
const handleInputKeydown = useCallback(
|
|
558
|
+
(e: KeyboardEvent<HTMLInputElement>) => {
|
|
559
|
+
if (e.key === 'Backspace') {
|
|
560
|
+
const inputEmpty = !inputRef.current?.value
|
|
561
|
+
if (!searchValue && inputEmpty && multiple && values.length > 0) {
|
|
562
|
+
e.preventDefault()
|
|
563
|
+
const lastVal = values[values.length - 1]
|
|
564
|
+
removeSelected(lastVal)
|
|
565
|
+
}
|
|
566
|
+
return
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
if (e.key === 'ArrowLeft' && multiple && values.length > 0) {
|
|
570
|
+
const cursorPos = inputRef.current?.selectionStart ?? 0
|
|
571
|
+
if (cursorPos === 0 && !inputRef.current?.value) {
|
|
572
|
+
e.preventDefault()
|
|
573
|
+
setFocusedTagIndex(values.length - 1)
|
|
574
|
+
return
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const action = getInputKeyAction(e.key, e.shiftKey, multiple)
|
|
579
|
+
if (!action) return
|
|
580
|
+
|
|
581
|
+
// When the dropdown is closed, let Tab move focus naturally.
|
|
582
|
+
if (e.key === 'Tab' && !isOpen) return
|
|
583
|
+
|
|
584
|
+
// For Tab/'focusListbox': only focus the listbox if it has focusable options.
|
|
585
|
+
// If the listbox is empty (no matches), close and let Tab move focus naturally.
|
|
586
|
+
if (action === 'focusListbox' && e.key === 'Tab') {
|
|
587
|
+
const hasFocusable = listboxRef.current?.querySelector(
|
|
588
|
+
'[role="option"]:not([data-disabled]), [data-type="new-option"]',
|
|
589
|
+
)
|
|
590
|
+
if (!hasFocusable) {
|
|
591
|
+
closeAndProcessInput()
|
|
592
|
+
return
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
e.preventDefault()
|
|
597
|
+
switch (action) {
|
|
598
|
+
case 'addValue': {
|
|
599
|
+
const input = inputRef.current?.value.trim() || ''
|
|
600
|
+
setSearchValue(input)
|
|
601
|
+
// If the typed value is already selected, don't toggle (which would deselect).
|
|
602
|
+
// Just reset the input and keep the "already selected" message.
|
|
603
|
+
if (input && values.includes(input)) {
|
|
604
|
+
resetInput(true)
|
|
605
|
+
setUserInfoMessage('Verdien er allerede valgt')
|
|
606
|
+
break
|
|
607
|
+
}
|
|
608
|
+
toggleValue(input)
|
|
609
|
+
break
|
|
610
|
+
}
|
|
611
|
+
case 'focusListbox':
|
|
612
|
+
if (listboxRef.current) {
|
|
613
|
+
if (!isOpen) setIsOpen(true)
|
|
614
|
+
focusFirstOrSelectedOption(listboxRef.current, {
|
|
615
|
+
disabled,
|
|
616
|
+
allowUserInput,
|
|
617
|
+
customUserInput: addValueText,
|
|
618
|
+
includeSearch,
|
|
619
|
+
})
|
|
620
|
+
}
|
|
621
|
+
break
|
|
622
|
+
case 'closeOptions':
|
|
623
|
+
setIsOpen(false)
|
|
624
|
+
// Don't refocus — the text input already has focus, and focusing
|
|
625
|
+
// it again would trigger handleInputFocus which reopens the dropdown
|
|
626
|
+
break
|
|
627
|
+
}
|
|
628
|
+
},
|
|
629
|
+
[
|
|
630
|
+
searchValue,
|
|
631
|
+
values,
|
|
632
|
+
multiple,
|
|
633
|
+
disabled,
|
|
634
|
+
allowUserInput,
|
|
635
|
+
addValueText,
|
|
636
|
+
includeSearch,
|
|
637
|
+
isOpen,
|
|
638
|
+
removeSelected,
|
|
639
|
+
toggleValue,
|
|
640
|
+
focusTrigger,
|
|
641
|
+
],
|
|
642
|
+
)
|
|
643
|
+
|
|
644
|
+
const handleInputFocus = useCallback(() => {
|
|
645
|
+
if (disabled) return
|
|
646
|
+
|
|
647
|
+
// After selecting a value in single+typeahead, focus returns to the input.
|
|
648
|
+
// Skip reopening the dropdown so screen readers announce the selected value.
|
|
649
|
+
if (suppressNextOpenRef.current) {
|
|
650
|
+
suppressNextOpenRef.current = false
|
|
651
|
+
setInputFocus(true)
|
|
652
|
+
return
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (!multiple && values[0] && inputRef.current && inputRef.current.type !== 'hidden') {
|
|
656
|
+
setEditingSingleValue(true)
|
|
657
|
+
inputRef.current.value = getSingleValueForInput(values[0], options, displayValueAs)
|
|
658
|
+
}
|
|
659
|
+
setInputFocus(true)
|
|
660
|
+
setSearchValue('')
|
|
661
|
+
setIsOpen(true)
|
|
662
|
+
}, [disabled, multiple, values, options, displayValueAs])
|
|
663
|
+
|
|
664
|
+
const handleInputBlur = useCallback(() => {
|
|
665
|
+
setInputFocus(false)
|
|
666
|
+
setEditingSingleValue(false)
|
|
667
|
+
}, [])
|
|
668
|
+
|
|
669
|
+
const handleFocusOut = useCallback(
|
|
670
|
+
(e: FocusEvent) => {
|
|
671
|
+
if (disabled || !isOpen) return
|
|
672
|
+
|
|
673
|
+
const related = e.relatedTarget as Element | null
|
|
674
|
+
const wrapper = wrapperRef.current
|
|
675
|
+
|
|
676
|
+
// Check if focus moved to another element within the combobox
|
|
677
|
+
if (wrapper && related && wrapper.contains(related)) return
|
|
678
|
+
|
|
679
|
+
closeAndProcessInput()
|
|
680
|
+
},
|
|
681
|
+
[disabled, isOpen, closeAndProcessInput],
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
const handleInputClick = useCallback(
|
|
685
|
+
(e: React.MouseEvent) => {
|
|
686
|
+
if (disabled) {
|
|
687
|
+
e.preventDefault()
|
|
688
|
+
e.stopPropagation()
|
|
689
|
+
return
|
|
690
|
+
}
|
|
691
|
+
if (hasTextInput) {
|
|
692
|
+
inputRef.current?.focus()
|
|
693
|
+
} else {
|
|
694
|
+
// Select-only: toggle the listbox
|
|
695
|
+
e.stopPropagation()
|
|
696
|
+
e.preventDefault()
|
|
697
|
+
const newOpen = !isOpen
|
|
698
|
+
setIsOpen(newOpen)
|
|
699
|
+
if (newOpen && listboxRef.current) {
|
|
700
|
+
focusFirstOrSelectedOption(listboxRef.current, {
|
|
701
|
+
disabled,
|
|
702
|
+
allowUserInput,
|
|
703
|
+
customUserInput: addValueText,
|
|
704
|
+
includeSearch,
|
|
705
|
+
})
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
[disabled, hasTextInput, isOpen, allowUserInput, addValueText, includeSearch],
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
const handlePlaceholderClick = useCallback(
|
|
713
|
+
(e: React.MouseEvent) => {
|
|
714
|
+
if (disabled) return
|
|
715
|
+
e.stopPropagation()
|
|
716
|
+
if (hasTextInput && inputRef.current) {
|
|
717
|
+
inputRef.current.focus()
|
|
718
|
+
setInputFocus(true)
|
|
719
|
+
} else {
|
|
720
|
+
// Select-only mode: toggle the listbox
|
|
721
|
+
setIsOpen((prev) => !prev)
|
|
722
|
+
}
|
|
723
|
+
},
|
|
724
|
+
[disabled, hasTextInput],
|
|
725
|
+
)
|
|
726
|
+
|
|
727
|
+
const handleSelectOnlyKeydown = useCallback(
|
|
728
|
+
(e: React.KeyboardEvent) => {
|
|
729
|
+
if (disabled) return
|
|
730
|
+
|
|
731
|
+
switch (e.key) {
|
|
732
|
+
case 'Enter':
|
|
733
|
+
case ' ':
|
|
734
|
+
case 'ArrowDown':
|
|
735
|
+
case 'ArrowUp':
|
|
736
|
+
e.preventDefault()
|
|
737
|
+
if (!isOpen) {
|
|
738
|
+
setIsOpen(true)
|
|
739
|
+
if (listboxRef.current) {
|
|
740
|
+
window.setTimeout(() => {
|
|
741
|
+
if (!listboxRef.current) return
|
|
742
|
+
focusFirstOrSelectedOption(listboxRef.current, {
|
|
743
|
+
disabled,
|
|
744
|
+
allowUserInput: allowUserInput && !maxIsReached,
|
|
745
|
+
customUserInput: addValueText,
|
|
746
|
+
includeSearch,
|
|
747
|
+
})
|
|
748
|
+
}, 0)
|
|
749
|
+
}
|
|
750
|
+
} else {
|
|
751
|
+
setIsOpen(false)
|
|
752
|
+
}
|
|
753
|
+
break
|
|
754
|
+
case 'Escape':
|
|
755
|
+
if (isOpen) {
|
|
756
|
+
e.preventDefault()
|
|
757
|
+
setIsOpen(false)
|
|
758
|
+
}
|
|
759
|
+
break
|
|
760
|
+
case 'Home':
|
|
761
|
+
case 'End':
|
|
762
|
+
e.preventDefault()
|
|
763
|
+
if (!isOpen) setIsOpen(true)
|
|
764
|
+
if (listboxRef.current) {
|
|
765
|
+
window.setTimeout(() => {
|
|
766
|
+
if (!listboxRef.current) return
|
|
767
|
+
focusFirstOrSelectedOption(listboxRef.current, {
|
|
768
|
+
disabled,
|
|
769
|
+
allowUserInput: allowUserInput && !maxIsReached,
|
|
770
|
+
customUserInput: addValueText,
|
|
771
|
+
includeSearch,
|
|
772
|
+
})
|
|
773
|
+
}, 0)
|
|
774
|
+
}
|
|
775
|
+
break
|
|
776
|
+
case 'ArrowLeft':
|
|
777
|
+
if (multiple && values.length > 0) {
|
|
778
|
+
e.preventDefault()
|
|
779
|
+
setFocusedTagIndex(values.length - 1)
|
|
780
|
+
}
|
|
781
|
+
break
|
|
782
|
+
case 'Backspace':
|
|
783
|
+
case 'Delete':
|
|
784
|
+
if (multiple && values.length > 0) {
|
|
785
|
+
e.preventDefault()
|
|
786
|
+
const lastVal = values[values.length - 1]
|
|
787
|
+
removeSelected(lastVal)
|
|
788
|
+
}
|
|
789
|
+
break
|
|
790
|
+
}
|
|
791
|
+
},
|
|
792
|
+
[disabled, isOpen, allowUserInput, maxIsReached, addValueText, includeSearch, multiple, values, removeSelected],
|
|
793
|
+
)
|
|
794
|
+
|
|
795
|
+
const handleOptionClick = useCallback(
|
|
796
|
+
(optValue: string) => {
|
|
797
|
+
toggleValue(optValue)
|
|
798
|
+
},
|
|
799
|
+
[toggleValue],
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
const handleOptionKeydown = useCallback(
|
|
803
|
+
(e: KeyboardEvent<HTMLLIElement>, optValue: string) => {
|
|
804
|
+
switch (e.key) {
|
|
805
|
+
case 'Enter':
|
|
806
|
+
case ' ':
|
|
807
|
+
e.preventDefault()
|
|
808
|
+
toggleValue(optValue)
|
|
809
|
+
break
|
|
810
|
+
case 'ArrowDown':
|
|
811
|
+
e.preventDefault()
|
|
812
|
+
focusNextOption(e.currentTarget)
|
|
813
|
+
break
|
|
814
|
+
case 'ArrowUp':
|
|
815
|
+
e.preventDefault()
|
|
816
|
+
if (listboxRef.current) {
|
|
817
|
+
focusPreviousOption(e.currentTarget, listboxRef.current, includeSearch)
|
|
818
|
+
}
|
|
819
|
+
break
|
|
820
|
+
case 'Escape':
|
|
821
|
+
e.preventDefault()
|
|
822
|
+
setIsOpen(false)
|
|
823
|
+
focusTrigger()
|
|
824
|
+
break
|
|
825
|
+
case 'Tab':
|
|
826
|
+
// Don't preventDefault — let Tab move focus naturally
|
|
827
|
+
closeAndProcessInput()
|
|
828
|
+
break
|
|
829
|
+
}
|
|
830
|
+
},
|
|
831
|
+
[toggleValue, includeSearch, focusTrigger, closeAndProcessInput],
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
const handleTagRemove = useCallback(
|
|
835
|
+
(optValue: string) => {
|
|
836
|
+
removeSelected(optValue)
|
|
837
|
+
setFocusedTagIndex(-1)
|
|
838
|
+
if (hasTextInput && inputRef.current) {
|
|
839
|
+
setInputFocus(true)
|
|
840
|
+
inputRef.current.focus()
|
|
841
|
+
}
|
|
842
|
+
},
|
|
843
|
+
[removeSelected, hasTextInput],
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
const handleTagKeydown = useCallback(
|
|
847
|
+
(e: React.KeyboardEvent, index: number) => {
|
|
848
|
+
e.stopPropagation()
|
|
849
|
+
switch (e.key) {
|
|
850
|
+
case 'ArrowLeft':
|
|
851
|
+
e.preventDefault()
|
|
852
|
+
if (index > 0) {
|
|
853
|
+
setFocusedTagIndex(index - 1)
|
|
854
|
+
}
|
|
855
|
+
break
|
|
856
|
+
case 'ArrowRight':
|
|
857
|
+
e.preventDefault()
|
|
858
|
+
if (index < values.length - 1) {
|
|
859
|
+
setFocusedTagIndex(index + 1)
|
|
860
|
+
} else {
|
|
861
|
+
setFocusedTagIndex(-1)
|
|
862
|
+
if (hasTextInput && inputRef.current) {
|
|
863
|
+
inputRef.current.focus()
|
|
864
|
+
} else {
|
|
865
|
+
triggerRef.current?.focus()
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
break
|
|
869
|
+
case 'Backspace':
|
|
870
|
+
case 'Delete':
|
|
871
|
+
e.preventDefault()
|
|
872
|
+
{
|
|
873
|
+
const val = values[index]
|
|
874
|
+
const nextIndex = index >= values.length - 1 ? index - 1 : index
|
|
875
|
+
removeSelected(val)
|
|
876
|
+
if (nextIndex >= 0) {
|
|
877
|
+
setFocusedTagIndex(nextIndex)
|
|
878
|
+
} else {
|
|
879
|
+
setFocusedTagIndex(-1)
|
|
880
|
+
if (hasTextInput && inputRef.current) {
|
|
881
|
+
inputRef.current.focus()
|
|
882
|
+
} else {
|
|
883
|
+
triggerRef.current?.focus()
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
break
|
|
888
|
+
case 'Tab':
|
|
889
|
+
// Let Tab move focus naturally, but reset roving tabindex
|
|
890
|
+
// so the next Tab into the combobox lands on the trigger, not a tag
|
|
891
|
+
setFocusedTagIndex(-1)
|
|
892
|
+
break
|
|
893
|
+
case 'Escape':
|
|
894
|
+
e.preventDefault()
|
|
895
|
+
setFocusedTagIndex(-1)
|
|
896
|
+
if (hasTextInput && inputRef.current) {
|
|
897
|
+
inputRef.current.focus()
|
|
898
|
+
} else {
|
|
899
|
+
triggerRef.current?.focus()
|
|
900
|
+
}
|
|
901
|
+
break
|
|
902
|
+
}
|
|
903
|
+
},
|
|
904
|
+
[values, hasTextInput, removeSelected],
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
const handleSearchInput = useCallback(
|
|
908
|
+
(e: ChangeEvent<HTMLInputElement>) => {
|
|
909
|
+
setSearchValue(e.target.value)
|
|
910
|
+
checkForMatches(e.target.value)
|
|
911
|
+
},
|
|
912
|
+
[checkForMatches],
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
const handleSearchKeydown = useCallback(
|
|
916
|
+
(e: KeyboardEvent<HTMLInputElement>) => {
|
|
917
|
+
switch (e.key) {
|
|
918
|
+
case 'ArrowDown':
|
|
919
|
+
e.preventDefault()
|
|
920
|
+
if (listboxRef.current) {
|
|
921
|
+
const firstOption = listboxRef.current.querySelector<HTMLElement>(
|
|
922
|
+
'[role="option"]:not([data-disabled]), [data-type="new-option"]',
|
|
923
|
+
)
|
|
924
|
+
firstOption?.focus()
|
|
925
|
+
}
|
|
926
|
+
break
|
|
927
|
+
case 'Tab':
|
|
928
|
+
// Don't preventDefault — let Tab move focus naturally
|
|
929
|
+
closeAndProcessInput()
|
|
930
|
+
break
|
|
931
|
+
case 'Escape':
|
|
932
|
+
e.preventDefault()
|
|
933
|
+
setIsOpen(false)
|
|
934
|
+
focusTrigger()
|
|
935
|
+
break
|
|
936
|
+
}
|
|
937
|
+
},
|
|
938
|
+
[focusTrigger, closeAndProcessInput],
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
const handleNewOptionClick = useCallback(
|
|
942
|
+
(optValue: string) => {
|
|
943
|
+
toggleValue(optValue)
|
|
944
|
+
},
|
|
945
|
+
[toggleValue],
|
|
946
|
+
)
|
|
947
|
+
|
|
948
|
+
const handleNewOptionKeydown = useCallback(
|
|
949
|
+
(e: KeyboardEvent<HTMLDivElement>) => {
|
|
950
|
+
switch (e.key) {
|
|
951
|
+
case 'Enter':
|
|
952
|
+
case ' ':
|
|
953
|
+
e.preventDefault()
|
|
954
|
+
toggleValue((e.currentTarget as HTMLElement).dataset.value || '')
|
|
955
|
+
break
|
|
956
|
+
case 'ArrowDown':
|
|
957
|
+
e.preventDefault()
|
|
958
|
+
if (listboxRef.current) {
|
|
959
|
+
focusFirstOption(listboxRef.current)
|
|
960
|
+
}
|
|
961
|
+
break
|
|
962
|
+
case 'ArrowUp':
|
|
963
|
+
e.preventDefault()
|
|
964
|
+
if (listboxRef.current) {
|
|
965
|
+
focusPreviousOption(e.currentTarget, listboxRef.current, includeSearch)
|
|
966
|
+
}
|
|
967
|
+
break
|
|
968
|
+
case 'Escape':
|
|
969
|
+
e.preventDefault()
|
|
970
|
+
setIsOpen(false)
|
|
971
|
+
focusTrigger()
|
|
972
|
+
break
|
|
973
|
+
}
|
|
974
|
+
},
|
|
975
|
+
[toggleValue, includeSearch, focusTrigger],
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
// Outside click handler
|
|
979
|
+
useEffect(() => {
|
|
980
|
+
if (!isOpen) return
|
|
981
|
+
|
|
982
|
+
const handleClick = (e: MouseEvent) => {
|
|
983
|
+
const target = e.target as Node
|
|
984
|
+
if (wrapperRef.current && !wrapperRef.current.contains(target)) {
|
|
985
|
+
closeAndProcessInput()
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
document.addEventListener('click', handleClick, true)
|
|
990
|
+
return () => {
|
|
991
|
+
document.removeEventListener('click', handleClick, true)
|
|
992
|
+
}
|
|
993
|
+
}, [isOpen, closeAndProcessInput])
|
|
994
|
+
|
|
995
|
+
// Form reset: listen for the parent <form>'s reset event and restore initial values
|
|
996
|
+
useEffect(() => {
|
|
997
|
+
const form = wrapperRef.current?.closest('form')
|
|
998
|
+
if (!form) return
|
|
999
|
+
|
|
1000
|
+
const handleReset = () => {
|
|
1001
|
+
// Use setTimeout so the reset event finishes before we update state
|
|
1002
|
+
window.setTimeout(() => {
|
|
1003
|
+
setInternalValues(initialValuesRef.current)
|
|
1004
|
+
setUserAddedOptions([])
|
|
1005
|
+
setInputValue('')
|
|
1006
|
+
setSearchValue('')
|
|
1007
|
+
setAddValueText(null)
|
|
1008
|
+
setUserInfoMessage('')
|
|
1009
|
+
if (changeInputRef.current) {
|
|
1010
|
+
const resetVal = multiple ? initialValuesRef.current.join(',') : initialValuesRef.current[0] || ''
|
|
1011
|
+
changeInputRef.current.value = resetVal
|
|
1012
|
+
}
|
|
1013
|
+
}, 0)
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
form.addEventListener('reset', handleReset)
|
|
1017
|
+
return () => form.removeEventListener('reset', handleReset)
|
|
1018
|
+
}, [multiple])
|
|
1019
|
+
|
|
1020
|
+
// Expose value getter/setter on ref for RHF register() compatibility.
|
|
1021
|
+
// RHF reads ref.value via getFieldValue(field._f) — we read directly from the
|
|
1022
|
+
// hidden input DOM element so the value is always fresh (not stale from a
|
|
1023
|
+
// batched state update). RHF calls ref.value = x on mount and on reset.
|
|
1024
|
+
useImperativeHandle(
|
|
1025
|
+
ref,
|
|
1026
|
+
() =>
|
|
1027
|
+
({
|
|
1028
|
+
get value() {
|
|
1029
|
+
return changeInputRef.current?.value ?? ''
|
|
1030
|
+
},
|
|
1031
|
+
set value(newVal: string | string[]) {
|
|
1032
|
+
const newValues = Array.isArray(newVal) ? newVal : newVal ? String(newVal).split(',').filter(Boolean) : []
|
|
1033
|
+
if (!isControlled) {
|
|
1034
|
+
setInternalValues(newValues)
|
|
1035
|
+
}
|
|
1036
|
+
if (changeInputRef.current) {
|
|
1037
|
+
changeInputRef.current.value = newValues.join(',')
|
|
1038
|
+
}
|
|
1039
|
+
},
|
|
1040
|
+
focus() {
|
|
1041
|
+
if (hasTextInput) {
|
|
1042
|
+
inputRef.current?.focus()
|
|
1043
|
+
} else {
|
|
1044
|
+
triggerRef.current?.focus()
|
|
1045
|
+
}
|
|
1046
|
+
},
|
|
1047
|
+
blur() {
|
|
1048
|
+
if (hasTextInput) {
|
|
1049
|
+
inputRef.current?.blur()
|
|
1050
|
+
} else {
|
|
1051
|
+
triggerRef.current?.blur()
|
|
1052
|
+
}
|
|
1053
|
+
},
|
|
1054
|
+
}) as unknown as HTMLDivElement,
|
|
1055
|
+
[isControlled],
|
|
1056
|
+
)
|
|
1057
|
+
|
|
1058
|
+
return {
|
|
1059
|
+
// Identity
|
|
1060
|
+
id,
|
|
1061
|
+
inputId,
|
|
1062
|
+
listboxId,
|
|
1063
|
+
|
|
1064
|
+
// Values
|
|
1065
|
+
values,
|
|
1066
|
+
inputValue,
|
|
1067
|
+
searchValue,
|
|
1068
|
+
|
|
1069
|
+
// Options
|
|
1070
|
+
options,
|
|
1071
|
+
filteredOptions,
|
|
1072
|
+
|
|
1073
|
+
// UI state
|
|
1074
|
+
isOpen,
|
|
1075
|
+
userInfoMessage,
|
|
1076
|
+
addValueText,
|
|
1077
|
+
maxIsReached,
|
|
1078
|
+
editingSingleValue,
|
|
1079
|
+
inputFocus,
|
|
1080
|
+
focusedTagIndex,
|
|
1081
|
+
|
|
1082
|
+
// Props passthrough
|
|
1083
|
+
label,
|
|
1084
|
+
multiple,
|
|
1085
|
+
maxlength,
|
|
1086
|
+
typeahead,
|
|
1087
|
+
includeSearch,
|
|
1088
|
+
allowUserInput,
|
|
1089
|
+
displayValueAs,
|
|
1090
|
+
tagPlacement,
|
|
1091
|
+
searchPlaceholder,
|
|
1092
|
+
placeholder,
|
|
1093
|
+
disabled,
|
|
1094
|
+
required,
|
|
1095
|
+
fullwidth,
|
|
1096
|
+
hasError,
|
|
1097
|
+
errorMessage,
|
|
1098
|
+
helptext,
|
|
1099
|
+
helptextDropdown,
|
|
1100
|
+
helptextDropdownButton,
|
|
1101
|
+
optionalTag,
|
|
1102
|
+
optionalText,
|
|
1103
|
+
requiredTag,
|
|
1104
|
+
requiredText,
|
|
1105
|
+
tagText,
|
|
1106
|
+
useWrapper,
|
|
1107
|
+
name,
|
|
1108
|
+
className,
|
|
1109
|
+
|
|
1110
|
+
// Derived
|
|
1111
|
+
hasCounter,
|
|
1112
|
+
|
|
1113
|
+
// Refs
|
|
1114
|
+
inputRef,
|
|
1115
|
+
changeInputRef,
|
|
1116
|
+
triggerRef,
|
|
1117
|
+
listboxRef,
|
|
1118
|
+
wrapperRef,
|
|
1119
|
+
|
|
1120
|
+
// Handlers
|
|
1121
|
+
handleInputChange,
|
|
1122
|
+
handleInputKeydown,
|
|
1123
|
+
handleInputFocus,
|
|
1124
|
+
handleInputBlur,
|
|
1125
|
+
handleFocusOut,
|
|
1126
|
+
handleInputClick,
|
|
1127
|
+
handlePlaceholderClick,
|
|
1128
|
+
handleSelectOnlyKeydown,
|
|
1129
|
+
handleOptionClick,
|
|
1130
|
+
handleOptionKeydown,
|
|
1131
|
+
handleTagRemove,
|
|
1132
|
+
handleTagKeydown,
|
|
1133
|
+
handleSearchInput,
|
|
1134
|
+
handleSearchKeydown,
|
|
1135
|
+
handleNewOptionClick,
|
|
1136
|
+
handleNewOptionKeydown,
|
|
1137
|
+
|
|
1138
|
+
// Form integration
|
|
1139
|
+
onChange,
|
|
1140
|
+
}
|
|
1141
|
+
}
|