UNPKG

@oslokommune/punkt-react

Version:

React komponentbibliotek til Punkt, et designsystem laget av Oslo Origo

1,144 lines (1,052 loc) 34.3 kB
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, } }