@oslokommune/punkt-react
Version:
React komponentbibliotek til Punkt, et designsystem laget av Oslo Origo
1,144 lines (1,052 loc) • 34.3 kB
text/typescript
import {
type ChangeEvent,
type FocusEvent,
type KeyboardEvent,
Children,
isValidElement,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import type { IPktComboboxOption, TPktComboboxDisplayValue } from 'shared-types/combobox'
import {
findOptionByValue,
filterOptionsBySearch,
buildFulltext,
isMaxSelectionReached,
parseValueToArray,
findTypeaheadMatches,
focusFirstOption,
focusFirstOrSelectedOption,
focusNextOption,
focusPreviousOption,
getSearchInfoMessage,
getInputKeyAction,
getInputValueAction,
getSingleValueForInput,
} from 'shared-utils/combobox'
import type { IPktCombobox, IComboboxState } from './types'
// Options sync helper (React-specific: immutable, returns new array)
function syncOptionsWithValues(options: IPktComboboxOption[], values: string[]): IPktComboboxOption[] {
return options.map((opt) => ({
...opt,
label: opt.label || opt.value,
fulltext: buildFulltext(opt),
selected: values.includes(opt.value),
}))
}
export function useComboboxState(props: IPktCombobox, ref: React.ForwardedRef<HTMLDivElement>): IComboboxState {
const {
id = '',
label,
value,
defaultValue,
options: optionsProp,
defaultOptions,
multiple = false,
maxlength,
typeahead = false,
includeSearch = false,
allowUserInput = false,
displayValueAs = 'label' as TPktComboboxDisplayValue,
tagPlacement,
searchPlaceholder,
placeholder,
disabled = false,
required = false,
fullwidth = false,
hasError = false,
errorMessage,
helptext,
helptextDropdown,
helptextDropdownButton,
optionalTag = false,
optionalText,
requiredTag = false,
requiredText,
tagText,
useWrapper = true,
inputSize = 'medium',
name,
className,
onChange,
onValueChange,
children,
} = props
// Identity
const inputId = `${id}-input`
const listboxId = `${id}-listbox`
const hasTextInput = typeahead || allowUserInput
// Refs
const inputRef = useRef<HTMLInputElement>(null)
const changeInputRef = useRef<HTMLInputElement>(null)
const triggerRef = useRef<HTMLDivElement>(null)
const listboxRef = useRef<HTMLDivElement>(null)
const wrapperRef = useRef<HTMLDivElement>(null)
// Focus the appropriate trigger: text input for editable, combobox div for select-only
const focusTrigger = useCallback(() => {
if (hasTextInput) {
inputRef.current?.focus()
} else {
triggerRef.current?.focus()
}
}, [hasTextInput])
// Parse <option> children into options
const optionsFromChildren = useMemo(() => {
const result: IPktComboboxOption[] = []
Children.forEach(children, (child) => {
if (isValidElement(child) && child.type === 'option') {
const childProps = child.props as {
value?: string
children?: string
selected?: boolean
disabled?: boolean
}
result.push({
value: childProps.value ?? (childProps.children as string) ?? '',
label: (childProps.children as string) ?? childProps.value ?? '',
selected: childProps.selected,
disabled: childProps.disabled,
})
}
})
return result
}, [children])
// Merge options from all sources
const baseOptions = useMemo(() => {
// defaultOptions take precedence, then optionsProp, then children
if (defaultOptions && defaultOptions.length > 0) {
return defaultOptions.map((opt) => ({ ...opt, fulltext: buildFulltext(opt) }))
}
if (optionsProp && optionsProp.length > 0) {
return optionsProp.map((opt) => ({ ...opt, fulltext: buildFulltext(opt) }))
}
if (optionsFromChildren.length > 0) {
return optionsFromChildren.map((opt) => ({ ...opt, fulltext: buildFulltext(opt) }))
}
return []
}, [defaultOptions, optionsProp, optionsFromChildren])
// Controlled/uncontrolled value
const isControlled = value !== undefined
const [internalValues, setInternalValues] = useState<string[]>(() => {
const initial = value ?? defaultValue
return parseValueToArray(initial as string | string[] | null | undefined, multiple)
})
// Store initial default values for form reset
const initialValuesRef = useRef(internalValues)
// Sync external controlled value
useEffect(() => {
if (isControlled) {
setInternalValues(parseValueToArray(value as string | string[] | null | undefined, multiple))
}
}, [value, isControlled, multiple])
const values = isControlled
? parseValueToArray(value as string | string[] | null | undefined, multiple)
: internalValues
// Options state (with selection tracking and user-added options)
const [userAddedOptions, setUserAddedOptions] = useState<IPktComboboxOption[]>([])
const options = useMemo(() => {
const merged = [
...userAddedOptions.filter((ua) => !baseOptions.some((bo) => bo.value === ua.value)),
...baseOptions,
]
return syncOptionsWithValues(merged, values)
}, [baseOptions, userAddedOptions, values])
// UI state
const [isOpen, setIsOpen] = useState(false)
const [searchValue, setSearchValue] = useState('')
const [inputValue, setInputValue] = useState('')
const [userInfoMessage, setUserInfoMessage] = useState('')
const [addValueText, setAddValueText] = useState<string | null>(null)
const [inputFocus, setInputFocus] = useState(false)
const [editingSingleValue, setEditingSingleValue] = useState(false)
const [focusedTagIndex, setFocusedTagIndex] = useState<number>(-1)
const suppressNextOpenRef = useRef(false)
// Derived state
const maxIsReached = isMaxSelectionReached(values.length, maxlength ?? null)
const hasCounter = multiple && maxlength != null
// Filter options by search (typeahead uses fulltext filtering, includeSearch uses same)
const filteredOptions = useMemo(() => {
if (!searchValue) return options
if (typeahead) return findTypeaheadMatches(options, searchValue).filtered
return filterOptionsBySearch(options, searchValue)
}, [options, searchValue, typeahead])
// Hidden input — dispatch native events for React synthetic event compat
const nativeInputValueSetter = useMemo(
() => Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set,
[],
)
const dispatchChange = useCallback(
(newValues: string[]) => {
const input = changeInputRef.current
if (!input || !nativeInputValueSetter) return
const stringVal = multiple ? newValues.join(',') : newValues[0] || ''
nativeInputValueSetter.call(input, stringVal)
input.dispatchEvent(new Event('input', { bubbles: true }))
},
[nativeInputValueSetter, multiple],
)
// Sync hidden input value
const formValue = multiple ? values.join(',') : values[0] || ''
useEffect(() => {
if (changeInputRef.current) {
changeInputRef.current.value = formValue
}
}, [formValue])
// Sync text input display value for single+typeahead when dropdown is closed
useEffect(() => {
if (isOpen || multiple || !inputRef.current) return
const displayValue = values[0] ? getSingleValueForInput(values[0], options, displayValueAs) : ''
if (inputRef.current.value !== displayValue) {
inputRef.current.value = displayValue
}
}, [values, options, displayValueAs, multiple, isOpen])
// Value update helper
const updateValues = useCallback(
(newValues: string[], notify = true) => {
if (!isControlled) {
setInternalValues(newValues)
}
if (notify) {
onValueChange?.(newValues)
dispatchChange(newValues)
}
},
[isControlled, onValueChange, dispatchChange],
)
// Selection operations
const setSelected = useCallback(
(optValue: string | null) => {
if (!optValue) return
if (values.includes(optValue)) return
if (multiple && isMaxSelectionReached(values.length, maxlength ?? null)) {
setUserInfoMessage('Maks antall valg nådd')
return
}
const newValues = multiple ? [...values, optValue] : [optValue]
updateValues(newValues)
},
[values, multiple, maxlength, updateValues],
)
const removeSelected = useCallback(
(optValue: string | null) => {
if (!optValue) return
const newValues = values.filter((v) => v !== optValue)
// Remove user-added option if it was deselected
const opt = findOptionByValue(options, optValue)
if (opt?.userAdded) {
setUserAddedOptions((prev) => prev.filter((o) => o.value !== optValue))
}
updateValues(newValues)
},
[values, options, updateValues],
)
const removeAllSelected = useCallback(() => {
setUserAddedOptions([])
updateValues([])
}, [updateValues])
const addNewUserValue = useCallback(
(val: string | null) => {
if (!val || val.trim() === '') return
let newValues: string[]
if (!multiple) {
newValues = [val]
} else {
if (findOptionByValue(options, val)) return
if (isMaxSelectionReached(values.length, maxlength ?? null)) return
newValues = [...values, val]
}
const newOption: IPktComboboxOption = {
value: val,
label: val,
userAdded: true,
selected: true,
}
setUserAddedOptions((prev) => [newOption, ...prev.filter((o) => o.value !== val)])
updateValues(newValues)
},
[multiple, options, values, maxlength, updateValues],
)
// Reset input field
// currentValue overrides stale closure reads of values[0] (React batches state updates,
// so values[0] may not reflect changes from setSelected/removeSelected in the same callback)
const resetInput = useCallback(
(shouldReset: boolean = true, currentValue?: string) => {
setAddValueText(null)
if (shouldReset && inputRef.current && inputRef.current.type !== 'hidden') {
setSearchValue('')
setInputValue('')
const val = currentValue !== undefined ? currentValue : values[0]
if (!multiple && val) {
inputRef.current.value = getSingleValueForInput(val, options, displayValueAs)
} else {
inputRef.current.value = ''
}
if (!multiple) {
setEditingSingleValue(false)
setUserInfoMessage('')
}
}
},
[multiple, values, options, displayValueAs],
)
// Core toggle logic (matches Elements toggleValue)
const toggleValue = useCallback(
(val: string | null) => {
if (disabled) return
setUserInfoMessage('')
setAddValueText(null)
const valueFromOptions = findOptionByValue(options, val)?.value || null
const isSelected = values.includes(val || valueFromOptions || '')
const isInOption = !!valueFromOptions
const isDisabled = options.find((o) => o.value === val)?.disabled || false
const isEmpty = !val?.trim()
const isSingle = !multiple
const isMultiple = multiple
const isMaxItemsReached = isMaxSelectionReached(values.length, maxlength ?? null)
let shouldOptionsBeOpen = false
let shouldResetInput = true
let newUserInfoMessage = ''
let newSearchValue = ''
// Track the resulting single value for resetInput (avoids stale closure reads)
let resultingSingleValue: string | undefined
if (isDisabled) return
// Not in list + allowUserInput
if (!isInOption && allowUserInput && !isEmpty) {
addNewUserValue(val)
newUserInfoMessage = 'Ny verdi lagt til'
shouldOptionsBeOpen = isMultiple
if (isSingle) resultingSingleValue = val || ''
}
// Not in list + no user input
else if (!isInOption && !allowUserInput) {
if (isSingle && values[0]) {
removeSelected(values[0])
}
shouldResetInput = false
shouldOptionsBeOpen = true
newUserInfoMessage = 'Ingen treff i søket'
}
// Already selected — deselect
else if (isSelected) {
removeSelected(valueFromOptions)
shouldOptionsBeOpen = true
resultingSingleValue = ''
// For single+typeahead: clear the input immediately so tab-out doesn't re-select
if (isSingle && hasTextInput && inputRef.current && inputRef.current.type !== 'hidden') {
inputRef.current.value = ''
}
}
// Empty + single — clear
else if (isEmpty && isSingle) {
removeAllSelected()
shouldOptionsBeOpen = true
resultingSingleValue = ''
}
// Single — replace
else if (isSingle) {
if (values[0]) removeSelected(values[0])
setSelected(valueFromOptions)
shouldOptionsBeOpen = false
resultingSingleValue = valueFromOptions || ''
}
// Multi with room
else if (isMultiple && !isMaxItemsReached) {
setSelected(valueFromOptions)
shouldOptionsBeOpen = true
}
// Multi at max
else if (isMultiple && isMaxItemsReached) {
newUserInfoMessage = 'Maks antall valg nådd'
shouldResetInput = false
newSearchValue = val || ''
}
// Fallback
else {
if (isSingle) removeAllSelected()
newUserInfoMessage = 'Ingen gyldig verdi valgt'
shouldResetInput = false
shouldOptionsBeOpen = true
newSearchValue = val || ''
resultingSingleValue = ''
}
setIsOpen(shouldOptionsBeOpen)
setUserInfoMessage(newUserInfoMessage)
setSearchValue(newSearchValue)
resetInput(shouldResetInput, resultingSingleValue)
if (!shouldOptionsBeOpen) {
if (isSingle && hasTextInput) {
// Suppress the next handleInputFocus from reopening the dropdown,
// then move focus back to the text input so screen readers
// announce the selected value instead of the browser window.
suppressNextOpenRef.current = true
}
window.setTimeout(() => {
focusTrigger()
}, 0)
}
},
[
disabled,
options,
values,
multiple,
maxlength,
allowUserInput,
addNewUserValue,
removeSelected,
removeAllSelected,
setSelected,
resetInput,
focusTrigger,
],
)
// Close and process input (used by focusout and outside click)
const closeAndProcessInput = useCallback(() => {
setInputFocus(false)
setAddValueText(null)
setUserInfoMessage('')
setSearchValue('')
if (inputRef.current && inputRef.current.type !== 'hidden') {
const inputText = inputRef.current.value
// Track the resulting value after processing (to avoid stale closure reads)
let resultingValue: string | undefined = values[0]
if (!multiple) {
if (!inputText) {
// Empty input — clear the selection
if (values[0]) removeSelected(values[0])
resultingValue = undefined
} else {
// Try to match input text to an option (by value or label)
const match = findOptionByValue(options, inputText)
if (match && match.value !== values[0]) {
// Input matches a different option — select it
if (values[0]) removeSelected(values[0])
setSelected(match.value)
resultingValue = match.value
} else if (!match && allowUserInput) {
// No match + allowUserInput — set as user value
if (values[0]) removeSelected(values[0])
addNewUserValue(inputText)
resultingValue = inputText
}
// No match, no allowUserInput — discard, keep previous selection
}
} else if (inputText !== '') {
// Multi: process typed text (add/select/remove)
const { action, value: actionValue } = getInputValueAction(inputText, values, options, allowUserInput, multiple)
switch (action) {
case 'addUserValue':
addNewUserValue(actionValue)
break
case 'selectOption':
setSelected(actionValue)
break
case 'removeValue':
removeSelected(actionValue)
break
}
}
// Restore input to display text of current selection (or clear)
if (!multiple && resultingValue) {
inputRef.current.value = getSingleValueForInput(resultingValue, options, displayValueAs)
} else {
inputRef.current.value = ''
}
}
setIsOpen(false)
setEditingSingleValue(false)
}, [values, options, allowUserInput, multiple, displayValueAs, addNewUserValue, setSelected, removeSelected])
// Check for matches while typing (info messages)
const checkForMatches = useCallback(
(currentInputValue: string) => {
const search = currentInputValue.trim().toLowerCase()
if (!search) {
setAddValueText(null)
setUserInfoMessage('')
if (!multiple && values[0]) {
removeSelected(values[0])
}
return
}
const result = getSearchInfoMessage(currentInputValue, values, options, allowUserInput)
setAddValueText(result.addValueText)
setUserInfoMessage(result.userInfoMessage)
},
[values, options, allowUserInput, multiple, removeSelected],
)
// Event handlers
const handleInputChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
if (disabled) return
const newInputValue = e.target.value
setInputValue(newInputValue)
setSearchValue(newInputValue)
checkForMatches(newInputValue)
if (typeahead) {
if (newInputValue) {
const { suggestion } = findTypeaheadMatches(options, newInputValue)
if (
suggestion?.label &&
inputRef.current &&
!(e.nativeEvent as InputEvent).inputType?.includes('deleteContent')
) {
inputRef.current.value = suggestion.label
window.setTimeout(
() => inputRef.current?.setSelectionRange(newInputValue.length, suggestion.label!.length),
0,
)
}
}
}
},
[disabled, typeahead, options, checkForMatches],
)
const handleInputKeydown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Backspace') {
const inputEmpty = !inputRef.current?.value
if (!searchValue && inputEmpty && multiple && values.length > 0) {
e.preventDefault()
const lastVal = values[values.length - 1]
removeSelected(lastVal)
}
return
}
if (e.key === 'ArrowLeft' && multiple && values.length > 0) {
const cursorPos = inputRef.current?.selectionStart ?? 0
if (cursorPos === 0 && !inputRef.current?.value) {
e.preventDefault()
setFocusedTagIndex(values.length - 1)
return
}
}
const action = getInputKeyAction(e.key, e.shiftKey, multiple)
if (!action) return
// When the dropdown is closed, let Tab move focus naturally.
if (e.key === 'Tab' && !isOpen) return
// For Tab/'focusListbox': only focus the listbox if it has focusable options.
// If the listbox is empty (no matches), close and let Tab move focus naturally.
if (action === 'focusListbox' && e.key === 'Tab') {
const hasFocusable = listboxRef.current?.querySelector(
'[role="option"]:not([data-disabled]), [data-type="new-option"]',
)
if (!hasFocusable) {
closeAndProcessInput()
return
}
}
e.preventDefault()
switch (action) {
case 'addValue': {
const input = inputRef.current?.value.trim() || ''
setSearchValue(input)
// If the typed value is already selected, don't toggle (which would deselect).
// Just reset the input and keep the "already selected" message.
if (input && values.includes(input)) {
resetInput(true)
setUserInfoMessage('Verdien er allerede valgt')
break
}
toggleValue(input)
break
}
case 'focusListbox':
if (listboxRef.current) {
if (!isOpen) setIsOpen(true)
focusFirstOrSelectedOption(listboxRef.current, {
disabled,
allowUserInput,
customUserInput: addValueText,
includeSearch,
})
}
break
case 'closeOptions':
setIsOpen(false)
// Don't refocus — the text input already has focus, and focusing
// it again would trigger handleInputFocus which reopens the dropdown
break
}
},
[
searchValue,
values,
multiple,
disabled,
allowUserInput,
addValueText,
includeSearch,
isOpen,
removeSelected,
toggleValue,
focusTrigger,
],
)
const handleInputFocus = useCallback(() => {
if (disabled) return
// After selecting a value in single+typeahead, focus returns to the input.
// Skip reopening the dropdown so screen readers announce the selected value.
if (suppressNextOpenRef.current) {
suppressNextOpenRef.current = false
setInputFocus(true)
return
}
if (!multiple && values[0] && inputRef.current && inputRef.current.type !== 'hidden') {
setEditingSingleValue(true)
inputRef.current.value = getSingleValueForInput(values[0], options, displayValueAs)
}
setInputFocus(true)
setSearchValue('')
setIsOpen(true)
}, [disabled, multiple, values, options, displayValueAs])
const handleInputBlur = useCallback(() => {
setInputFocus(false)
setEditingSingleValue(false)
}, [])
const handleFocusOut = useCallback(
(e: FocusEvent) => {
if (disabled || !isOpen) return
const related = e.relatedTarget as Element | null
const wrapper = wrapperRef.current
// Check if focus moved to another element within the combobox
if (wrapper && related && wrapper.contains(related)) return
closeAndProcessInput()
},
[disabled, isOpen, closeAndProcessInput],
)
const handleInputClick = useCallback(
(e: React.MouseEvent) => {
if (disabled) {
e.preventDefault()
e.stopPropagation()
return
}
if (hasTextInput) {
inputRef.current?.focus()
} else {
// Select-only: toggle the listbox
e.stopPropagation()
e.preventDefault()
const newOpen = !isOpen
setIsOpen(newOpen)
if (newOpen && listboxRef.current) {
focusFirstOrSelectedOption(listboxRef.current, {
disabled,
allowUserInput,
customUserInput: addValueText,
includeSearch,
})
}
}
},
[disabled, hasTextInput, isOpen, allowUserInput, addValueText, includeSearch],
)
const handlePlaceholderClick = useCallback(
(e: React.MouseEvent) => {
if (disabled) return
e.stopPropagation()
if (hasTextInput && inputRef.current) {
inputRef.current.focus()
setInputFocus(true)
} else {
// Select-only mode: toggle the listbox
setIsOpen((prev) => !prev)
}
},
[disabled, hasTextInput],
)
const handleSelectOnlyKeydown = useCallback(
(e: React.KeyboardEvent) => {
if (disabled) return
switch (e.key) {
case 'Enter':
case ' ':
case 'ArrowDown':
case 'ArrowUp':
e.preventDefault()
if (!isOpen) {
setIsOpen(true)
if (listboxRef.current) {
window.setTimeout(() => {
if (!listboxRef.current) return
focusFirstOrSelectedOption(listboxRef.current, {
disabled,
allowUserInput: allowUserInput && !maxIsReached,
customUserInput: addValueText,
includeSearch,
})
}, 0)
}
} else {
setIsOpen(false)
}
break
case 'Escape':
if (isOpen) {
e.preventDefault()
setIsOpen(false)
}
break
case 'Home':
case 'End':
e.preventDefault()
if (!isOpen) setIsOpen(true)
if (listboxRef.current) {
window.setTimeout(() => {
if (!listboxRef.current) return
focusFirstOrSelectedOption(listboxRef.current, {
disabled,
allowUserInput: allowUserInput && !maxIsReached,
customUserInput: addValueText,
includeSearch,
})
}, 0)
}
break
case 'ArrowLeft':
if (multiple && values.length > 0) {
e.preventDefault()
setFocusedTagIndex(values.length - 1)
}
break
case 'Backspace':
case 'Delete':
if (multiple && values.length > 0) {
e.preventDefault()
const lastVal = values[values.length - 1]
removeSelected(lastVal)
}
break
}
},
[disabled, isOpen, allowUserInput, maxIsReached, addValueText, includeSearch, multiple, values, removeSelected],
)
const handleOptionClick = useCallback(
(optValue: string) => {
toggleValue(optValue)
},
[toggleValue],
)
const handleOptionKeydown = useCallback(
(e: KeyboardEvent<HTMLLIElement>, optValue: string) => {
switch (e.key) {
case 'Enter':
case ' ':
e.preventDefault()
toggleValue(optValue)
break
case 'ArrowDown':
e.preventDefault()
focusNextOption(e.currentTarget)
break
case 'ArrowUp':
e.preventDefault()
if (listboxRef.current) {
focusPreviousOption(e.currentTarget, listboxRef.current, includeSearch)
}
break
case 'Escape':
e.preventDefault()
setIsOpen(false)
focusTrigger()
break
case 'Tab':
// Don't preventDefault — let Tab move focus naturally
closeAndProcessInput()
break
}
},
[toggleValue, includeSearch, focusTrigger, closeAndProcessInput],
)
const handleTagRemove = useCallback(
(optValue: string) => {
removeSelected(optValue)
setFocusedTagIndex(-1)
if (hasTextInput && inputRef.current) {
setInputFocus(true)
inputRef.current.focus()
}
},
[removeSelected, hasTextInput],
)
const handleTagKeydown = useCallback(
(e: React.KeyboardEvent, index: number) => {
e.stopPropagation()
switch (e.key) {
case 'ArrowLeft':
e.preventDefault()
if (index > 0) {
setFocusedTagIndex(index - 1)
}
break
case 'ArrowRight':
e.preventDefault()
if (index < values.length - 1) {
setFocusedTagIndex(index + 1)
} else {
setFocusedTagIndex(-1)
if (hasTextInput && inputRef.current) {
inputRef.current.focus()
} else {
triggerRef.current?.focus()
}
}
break
case 'Backspace':
case 'Delete':
e.preventDefault()
{
const val = values[index]
const nextIndex = index >= values.length - 1 ? index - 1 : index
removeSelected(val)
if (nextIndex >= 0) {
setFocusedTagIndex(nextIndex)
} else {
setFocusedTagIndex(-1)
if (hasTextInput && inputRef.current) {
inputRef.current.focus()
} else {
triggerRef.current?.focus()
}
}
}
break
case 'Tab':
// Let Tab move focus naturally, but reset roving tabindex
// so the next Tab into the combobox lands on the trigger, not a tag
setFocusedTagIndex(-1)
break
case 'Escape':
e.preventDefault()
setFocusedTagIndex(-1)
if (hasTextInput && inputRef.current) {
inputRef.current.focus()
} else {
triggerRef.current?.focus()
}
break
}
},
[values, hasTextInput, removeSelected],
)
const handleSearchInput = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.target.value)
checkForMatches(e.target.value)
},
[checkForMatches],
)
const handleSearchKeydown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
if (listboxRef.current) {
const firstOption = listboxRef.current.querySelector<HTMLElement>(
'[role="option"]:not([data-disabled]), [data-type="new-option"]',
)
firstOption?.focus()
}
break
case 'Tab':
// Don't preventDefault — let Tab move focus naturally
closeAndProcessInput()
break
case 'Escape':
e.preventDefault()
setIsOpen(false)
focusTrigger()
break
}
},
[focusTrigger, closeAndProcessInput],
)
const handleNewOptionClick = useCallback(
(optValue: string) => {
toggleValue(optValue)
},
[toggleValue],
)
const handleNewOptionKeydown = useCallback(
(e: KeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case 'Enter':
case ' ':
e.preventDefault()
toggleValue((e.currentTarget as HTMLElement).dataset.value || '')
break
case 'ArrowDown':
e.preventDefault()
if (listboxRef.current) {
focusFirstOption(listboxRef.current)
}
break
case 'ArrowUp':
e.preventDefault()
if (listboxRef.current) {
focusPreviousOption(e.currentTarget, listboxRef.current, includeSearch)
}
break
case 'Escape':
e.preventDefault()
setIsOpen(false)
focusTrigger()
break
}
},
[toggleValue, includeSearch, focusTrigger],
)
// Outside click handler
useEffect(() => {
if (!isOpen) return
const handleClick = (e: MouseEvent) => {
const target = e.target as Node
if (wrapperRef.current && !wrapperRef.current.contains(target)) {
closeAndProcessInput()
}
}
document.addEventListener('click', handleClick, true)
return () => {
document.removeEventListener('click', handleClick, true)
}
}, [isOpen, closeAndProcessInput])
// Form reset: listen for the parent <form>'s reset event and restore initial values
useEffect(() => {
const form = wrapperRef.current?.closest('form')
if (!form) return
const handleReset = () => {
// Use setTimeout so the reset event finishes before we update state
window.setTimeout(() => {
setInternalValues(initialValuesRef.current)
setUserAddedOptions([])
setInputValue('')
setSearchValue('')
setAddValueText(null)
setUserInfoMessage('')
if (changeInputRef.current) {
const resetVal = multiple ? initialValuesRef.current.join(',') : initialValuesRef.current[0] || ''
changeInputRef.current.value = resetVal
}
}, 0)
}
form.addEventListener('reset', handleReset)
return () => form.removeEventListener('reset', handleReset)
}, [multiple])
// Expose value getter/setter on ref for RHF register() compatibility.
// RHF reads ref.value via getFieldValue(field._f) — we read directly from the
// hidden input DOM element so the value is always fresh (not stale from a
// batched state update). RHF calls ref.value = x on mount and on reset.
useImperativeHandle(
ref,
() =>
({
get value() {
return changeInputRef.current?.value ?? ''
},
set value(newVal: string | string[]) {
const newValues = Array.isArray(newVal) ? newVal : newVal ? String(newVal).split(',').filter(Boolean) : []
if (!isControlled) {
setInternalValues(newValues)
}
if (changeInputRef.current) {
changeInputRef.current.value = newValues.join(',')
}
},
focus() {
if (hasTextInput) {
inputRef.current?.focus()
} else {
triggerRef.current?.focus()
}
},
blur() {
if (hasTextInput) {
inputRef.current?.blur()
} else {
triggerRef.current?.blur()
}
},
}) as unknown as HTMLDivElement,
[isControlled],
)
return {
// Identity
id,
inputId,
listboxId,
// Values
values,
inputValue,
searchValue,
// Options
options,
filteredOptions,
// UI state
isOpen,
userInfoMessage,
addValueText,
maxIsReached,
editingSingleValue,
inputFocus,
focusedTagIndex,
// Props passthrough
label,
multiple,
maxlength,
typeahead,
includeSearch,
allowUserInput,
displayValueAs,
tagPlacement,
searchPlaceholder,
placeholder,
disabled,
required,
fullwidth,
hasError,
errorMessage,
helptext,
helptextDropdown,
helptextDropdownButton,
optionalTag,
optionalText,
requiredTag,
requiredText,
tagText,
useWrapper,
inputSize,
name,
className,
// Derived
hasCounter,
// Refs
inputRef,
changeInputRef,
triggerRef,
listboxRef,
wrapperRef,
// Handlers
handleInputChange,
handleInputKeydown,
handleInputFocus,
handleInputBlur,
handleFocusOut,
handleInputClick,
handlePlaceholderClick,
handleSelectOnlyKeydown,
handleOptionClick,
handleOptionKeydown,
handleTagRemove,
handleTagKeydown,
handleSearchInput,
handleSearchKeydown,
handleNewOptionClick,
handleNewOptionKeydown,
// Form integration
onChange,
}
}