UNPKG

@oslokommune/punkt-elements

Version:

Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo

420 lines (381 loc) 12.1 kB
import { findOptionByValue, findTypeaheadMatches } from 'shared-utils/combobox/option-utils' import { getInputKeyAction, getInputValueAction, checkForMatches, getSingleValueForInput, } from 'shared-utils/combobox/input-utils' import { ComboboxValue } from './combobox-value' import type { PktTag } from '../tag' /** * Event handler layer for PktCombobox. * Handles user interactions: input, focus, keyboard, clicks, tags. */ export class ComboboxHandlers extends ComboboxValue { protected handleInput(e: InputEvent): void { e.stopPropagation() e.stopImmediatePropagation() if (this.disabled) return this.touched = true const input = e.target as HTMLInputElement this._search = input.value this.checkForMatches() if (this.typeahead) { if (this._search) { const { filtered, suggestion } = findTypeaheadMatches(this.options, this._search) this._options = filtered if ( e.inputType !== 'deleteContentBackward' && suggestion?.label && this.inputRef.value && this.inputRef.value.type !== 'hidden' ) { input.value = suggestion.label window.setTimeout( () => input.setSelectionRange(this._search.length, input.value.length), 0, ) input.selectionDirection = 'backward' } } else { this._options = [...this.options] } } } protected handleFocus(): void { if (this.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 (this._suppressNextOpen) { this._suppressNextOpen = false this._inputFocus = true this.requestUpdate() return } if ( !this.multiple && this._value[0] && this.inputRef.value && this.inputRef.value.type !== 'hidden' ) { this.inputRef.value.value = getSingleValueForInput( this._value[0], this.options, this.displayValueAs, ) } this._inputFocus = true this._search = '' this._options = [...this.options] this._isOptionsOpen = true this.onFocus() this.requestUpdate() } protected handleFocusOut(e: FocusEvent): void { if (this.disabled || !this._isOptionsOpen) return const related = e.relatedTarget as Element | null const isFocusInsideCombobox = related?.closest('pkt-combobox')?.id === this.id || (e.target as Element)?.getAttribute('data-focusfix') === this.id || related === this.inputRef.value || related === this.triggerRef.value if (!isFocusInsideCombobox) { this.closeAndProcessInput() } } /** * Shared close logic used by both focusout and outside-click handlers. * Processes any pending input value, then closes the dropdown. */ protected closeAndProcessInput(): void { this._inputFocus = false this._addValueText = null this._userInfoMessage = '' this._search = '' if (this.inputRef.value && this.inputRef.value.type !== 'hidden') { const inputText = this.inputRef.value.value if (!this.multiple) { if (!inputText) { // Empty input — clear the selection if (this._value[0]) this.removeSelected(this._value[0]) } else { // Try to match input text to an option (by value or label) const match = findOptionByValue(this.options, inputText) if (match && match.value !== this._value[0]) { // Input matches a different option — select it this._value[0] && this.removeSelected(this._value[0]) this.setSelected(match.value) } else if (!match && this.allowUserInput) { // No match + allowUserInput — set as user value this._value[0] && this.removeSelected(this._value[0]) this.addNewUserValue(inputText) } // No match, no allowUserInput — discard, keep previous selection } } else if (inputText !== '') { // Multi: process typed text (add/select/remove) const { action, value } = getInputValueAction( inputText, this._value, this.options, this.allowUserInput, this.multiple, ) switch (action) { case 'addUserValue': this.addNewUserValue(value) break case 'selectOption': this.setSelected(value) break case 'removeValue': this.removeValue(value) break } } // Restore input to display text of current selection (or clear) if (!this.multiple && this._value[0]) { this.inputRef.value.value = getSingleValueForInput( this._value[0], this.options, this.displayValueAs, ) } else { this.inputRef.value.value = '' } } this._isOptionsOpen = false this.onBlur() } protected handleBlur(): void { this._inputFocus = false this.onBlur() } protected handleInputClick(e: MouseEvent): void { if (this.disabled) { e.preventDefault() e.stopImmediatePropagation() return } if (this._hasTextInput) { this.inputRef.value?.focus() this.requestUpdate() } else { // Select-only: toggle the listbox e.stopImmediatePropagation() e.preventDefault() this._isOptionsOpen = !this._isOptionsOpen if (this._isOptionsOpen) { this.listboxRef.value?.focusFirstOrSelectedOption() } } } protected handlePlaceholderClick(e: MouseEvent): void { if (this.disabled) return e.stopPropagation() if (this._hasTextInput && this.inputRef.value) { this.inputRef.value.focus() this._inputFocus = true this.requestUpdate() } else { this._isOptionsOpen = !this._isOptionsOpen this.requestUpdate() } } protected handleSelectOnlyKeydown(e: KeyboardEvent): void { if (this.disabled) return switch (e.key) { case 'Enter': case ' ': case 'ArrowDown': case 'ArrowUp': e.preventDefault() if (!this._isOptionsOpen) { this._isOptionsOpen = true this.listboxRef.value?.focusFirstOrSelectedOption() } else { this._isOptionsOpen = false } break case 'Escape': if (this._isOptionsOpen) { e.preventDefault() this._isOptionsOpen = false } break case 'Home': case 'End': e.preventDefault() if (!this._isOptionsOpen) { this._isOptionsOpen = true } this.listboxRef.value?.focusFirstOrSelectedOption() break case 'ArrowLeft': if (this.multiple && this._value.length > 0) { e.preventDefault() this.focusTag(this._value.length - 1) } break case 'Backspace': case 'Delete': if (this.multiple && this._value.length > 0) { e.preventDefault() this.removeSelected(this._value[this._value.length - 1]) } break } } protected handleOptionToggled(e: CustomEvent) { this.toggleValue(e.detail) } protected handleSearch(e: CustomEvent) { e.stopPropagation() this._search = e.detail.toLowerCase() this.checkForMatches() } protected handleInputKeydown(e: KeyboardEvent): void { // Backspace has special DOM-dependent conditions if (e.key === 'Backspace') { const inputEmpty = !this.inputRef.value?.value if (!this._search && inputEmpty && this.multiple && this._value.length > 0) this.removeLastValue(e) return } if (e.key === 'ArrowLeft' && this.multiple && this._value.length > 0) { const input = this.inputRef.value if (input && input.selectionStart === 0 && !input.value) { e.preventDefault() this.focusTag(this._value.length - 1) return } } const action = getInputKeyAction(e.key, e.shiftKey, this.multiple) if (!action) return // When the dropdown is closed, let Tab move focus naturally. if (e.key === 'Tab' && !this._isOptionsOpen) 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 = this.listboxRef.value?.querySelector( '[role="option"]:not([data-disabled]), [data-type="new-option"]', ) if (!hasFocusable) { this.closeAndProcessInput() return } } e.preventDefault() switch (action) { case 'addValue': this.addValue() break case 'focusListbox': this.listboxRef.value?.focusFirstOrSelectedOption() break case 'closeOptions': this._isOptionsOpen = false // Don't refocus — the text input already has focus, and focusing // it again would trigger handleFocus which reopens the dropdown break } } protected handleTagRemove(value: string | null): void { this.removeSelected(value) if (this._hasTextInput && this.inputRef.value) { this._inputFocus = true this.inputRef.value.focus() this.requestUpdate() } } protected getInsideTags(): HTMLElement[] { return Array.from( this.querySelectorAll<HTMLElement>('.pkt-combobox__input .pkt-combobox__tag-list pkt-tag'), ) } protected focusTag(index: number): void { const tags = this.getInsideTags() tags.forEach((tag, i) => { ;(tag as PktTag).buttonTabindex = i === index ? 0 : -1 }) // Focus the button inside the target pkt-tag const btn = tags[index]?.querySelector('button') btn?.focus() } protected resetTagTabindices(): void { const tags = this.getInsideTags() tags.forEach((tag) => { ;(tag as PktTag).buttonTabindex = -1 }) } protected handleTagKeydown(e: KeyboardEvent, index: number): void { e.stopPropagation() const returnFocusToTrigger = () => { this.resetTagTabindices() if (this._hasTextInput && this.inputRef.value) { this.inputRef.value.focus() } else { this.triggerRef.value?.focus() } } switch (e.key) { case 'ArrowLeft': e.preventDefault() if (index > 0) { this.focusTag(index - 1) } break case 'ArrowRight': e.preventDefault() if (index < this._value.length - 1) { this.focusTag(index + 1) } else { returnFocusToTrigger() } break case 'Backspace': case 'Delete': e.preventDefault() { const val = this._value[index] const nextIndex = index >= this._value.length - 1 ? index - 1 : index this.removeSelected(val) if (nextIndex >= 0) { this.requestUpdate() this.updateComplete.then(() => this.focusTag(nextIndex)) } else { returnFocusToTrigger() } } 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 this.resetTagTabindices() break case 'Escape': e.preventDefault() returnFocusToTrigger() break } } protected checkForMatches() { const inputValue = this.inputRef.value?.value || this._search || '' const result = checkForMatches( inputValue, this._value, this.options, this.allowUserInput, this.multiple, ) if (result.shouldRemoveValue) { this.removeValue(this._value[0]) } if (result.shouldResetInput) { this.resetComboboxInput(false) return } this._addValueText = result.addValueText this._userInfoMessage = result.userInfoMessage } }