@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
420 lines (381 loc) • 12.1 kB
text/typescript
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
}
}