UNPKG

@oslokommune/punkt-elements

Version:

Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo

154 lines (137 loc) 6.01 kB
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 @property({ type: String, reflect: true }) value: string | string[] = '' @property({ type: Array }) options: IPktComboboxOption[] = [] @property({ type: Array, attribute: 'default-options' }) defaultOptions: IPktComboboxOption[] = [] @property({ type: Boolean, attribute: 'allow-user-input' }) allowUserInput: boolean = false @property({ type: Boolean }) typeahead: boolean = false @property({ type: Boolean, attribute: 'include-search' }) includeSearch: boolean = false @property({ type: String, attribute: 'search-placeholder' }) searchPlaceholder: string = '' @property({ type: Boolean }) multiple: boolean = false @property({ type: Number }) maxlength: number | null = null @property({ type: String, attribute: 'display-value-as' }) displayValueAs: string = specs.props.displayValueAs.default @property({ type: String, attribute: 'tag-placement' }) 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. @property({ type: Boolean, attribute: 'open' }) isOpen: boolean = false // Internal state @state() override _options: IPktComboboxOption[] = [] @state() protected _value: string[] = [] @state() protected _isOptionsOpen = false @state() protected _userInfoMessage: string = '' @state() protected _addValueText: string | null = null @state() protected _maxIsReached: boolean = false @state() protected _search: string = '' @state() 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() } }