@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.
Files changed (29) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/index.d.ts +38 -15
  3. package/dist/punkt-react.es.js +12025 -10664
  4. package/dist/punkt-react.umd.js +562 -549
  5. package/package.json +5 -5
  6. package/src/components/accordion/Accordion.test.tsx +3 -2
  7. package/src/components/alert/Alert.test.tsx +2 -1
  8. package/src/components/backlink/BackLink.test.tsx +2 -1
  9. package/src/components/button/Button.test.tsx +4 -3
  10. package/src/components/calendar/Calendar.interaction.test.tsx +2 -1
  11. package/src/components/checkbox/Checkbox.test.tsx +2 -1
  12. package/src/components/combobox/Combobox.accessibility.test.tsx +277 -0
  13. package/src/components/combobox/Combobox.core.test.tsx +469 -0
  14. package/src/components/combobox/Combobox.interaction.test.tsx +607 -0
  15. package/src/components/combobox/Combobox.selection.test.tsx +548 -0
  16. package/src/components/combobox/Combobox.tsx +59 -54
  17. package/src/components/combobox/ComboboxInput.tsx +140 -0
  18. package/src/components/combobox/ComboboxTags.tsx +110 -0
  19. package/src/components/combobox/Listbox.tsx +172 -0
  20. package/src/components/combobox/types.ts +145 -0
  21. package/src/components/combobox/useComboboxState.ts +1141 -0
  22. package/src/components/datepicker/Datepicker.accessibility.test.tsx +5 -4
  23. package/src/components/datepicker/Datepicker.input.test.tsx +3 -2
  24. package/src/components/datepicker/Datepicker.selection.test.tsx +8 -8
  25. package/src/components/datepicker/Datepicker.validation.test.tsx +2 -1
  26. package/src/components/radio/RadioButton.test.tsx +3 -2
  27. package/src/components/searchinput/SearchInput.test.tsx +6 -5
  28. package/src/components/tabs/Tabs.test.tsx +13 -12
  29. 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
+ }