@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
154 lines (137 loc) • 6.01 kB
text/typescript
import { property, state } from 'lit/decorators.js'
import { Ref, createRef } from 'lit/directives/ref.js'
import { PktInputElement } from '@/base-elements/input-element'
import { PktOptionsSlotController } from '@/controllers/pkt-options-controller'
import { isMaxSelectionReached } from 'shared-utils/combobox/option-utils'
import specs from 'componentSpecs/combobox.json'
import type { IPktComboboxOption, TPktComboboxTagPlacement } from './combobox-types'
import PktListbox from '../listbox'
/**
* Base class for PktCombobox.
* Declares all reactive properties, state, refs, and simple helpers.
*/
export class ComboboxBase extends PktInputElement {
constructor() {
super()
this.optionsController = new PktOptionsSlotController(this)
}
// Props / Attributes
value: string | string[] = ''
options: IPktComboboxOption[] = []
defaultOptions: IPktComboboxOption[] = []
allowUserInput: boolean = false
typeahead: boolean = false
includeSearch: boolean = false
searchPlaceholder: string = ''
multiple: boolean = false
maxlength: number | null = null
displayValueAs: string =
specs.props.displayValueAs.default
tagPlacement: TPktComboboxTagPlacement | null = null
// Internal use only — syncs to _isOptionsOpen in updated(), but does not
// reliably open the listbox as a declarative attribute (requires focus state
// and populated _options). Not part of the public API / component spec.
// Used in tests to set initial open state without relying on focus or options population.
isOpen: boolean = false
// Internal state
override _options: IPktComboboxOption[] = []
protected _value: string[] = []
protected _isOptionsOpen = false
protected _userInfoMessage: string = ''
protected _addValueText: string | null = null
protected _maxIsReached: boolean = false
protected _search: string = ''
protected _inputFocus: boolean = false
protected _internalValueSync = false
protected _optionsFromSlot = false
protected _lastSlotGeneration = 0
/** When true, the next handleFocus call will not reopen the dropdown. */
protected _suppressNextOpen = false
// Refs
protected readonly inputRef: Ref<HTMLInputElement> = createRef()
protected readonly triggerRef: Ref<HTMLDivElement> = createRef()
protected readonly listboxRef: Ref<PktListbox> = createRef()
protected get _hasTextInput(): boolean {
return this.typeahead || this.allowUserInput
}
protected get _selectionDescription(): string | undefined {
if (!this.multiple || this._value.length === 0) return undefined
return `${this._value.length} valgt`
}
/**
* Focuses the appropriate trigger element after closing the listbox.
* Select-only: the combobox input div. Editable: the text input.
*/
protected focusTrigger(): void {
if (this._hasTextInput) {
this.inputRef.value?.focus()
} else {
this.triggerRef.value?.focus()
}
}
/**
* Parses the value prop into an internal string array.
*/
protected parseValue(): string[] {
if (Array.isArray(this.value)) {
return this.multiple ? this.value : this.value.length > 0 ? [this.value[0]] : []
}
if (this.value && this.multiple) {
return this.value.split(',')
}
if (this.value) {
return [this.value]
}
return []
}
/**
* Updates the _maxIsReached state flag.
*/
protected updateMaxReached(): void {
this._maxIsReached = isMaxSelectionReached(this._value.length, this.maxlength)
}
/**
* Syncs the public value property from internal _value state and dispatches
* events if the value content changed. Always sets this.value as a string
* to prevent array→string reflect cascades.
*/
protected syncValueAndDispatch(oldInternal: string[]): void {
const newInternal = this._value
// Sync public value as a string (avoids array→string attribute reflect cascade)
const newPublicStr = this.multiple ? newInternal.join(',') : newInternal[0] || ''
const currentPublicStr = Array.isArray(this.value)
? this.value.join(',')
: String(this.value || '')
if (newPublicStr !== currentPublicStr) {
this._internalValueSync = true
this.value = newPublicStr
}
// Dispatch events if value content changed
if (oldInternal?.join(',') !== newInternal.join(',')) {
const eventValue = this.multiple ? [...newInternal] : newInternal[0] || ''
this.onChange(eventValue)
} else if (newInternal.length === 0 && oldInternal && oldInternal.length > 0) {
this.clearInputValue()
}
}
/**
* Override onChange to skip the base class touched guard.
* The base class returns early on the first call (setting touched = true but not
* dispatching events). Combobox needs consistent event dispatch regardless of
* touched state.
*/
protected override onChange(value: string | string[]): void {
this.touched = true
super.onChange(value)
}
/**
* No-op override of the base class valueChanged.
* The base class version sets both this.value AND this._value, which creates
* an infinite _value → valueChanged → value → parseValue → _value loop.
* Combobox handles value sync and event dispatch in syncValueAndDispatch() instead.
*/
protected override valueChanged(): void {
// Intentionally empty — combobox manages value sync in updated()
}
}