import { html, nothing, PropertyValues } from 'lit' import { ifDefined } from 'lit/directives/if-defined.js' import { customElement } from 'lit/decorators.js' import { ref } from 'lit/directives/ref.js' import { classMap } from 'lit/directives/class-map.js' import { repeat } from 'lit/directives/repeat.js' import type { IPktComboboxOption, TPktComboboxTagPlacement, TPktComboboxDisplayValue, } from './combobox-types' import { findOptionByValue, findOptionIndex } from 'shared-utils/combobox/option-utils' import { getSingleValueForInput } from 'shared-utils/combobox/input-utils' import { slotUtils, optionStateUtils } from './combobox-utils' import { slotContent } from '@/directives/slot-content' import { ComboboxHandlers } from './combobox-handlers' import '../input-wrapper' import '../icon' import '../tag' import '../listbox' // Re-export types for backward compatibility export type { IPktComboboxOption, TPktComboboxTagPlacement } from './combobox-types' export interface IPktCombobox { allowUserInput?: boolean typeahead?: boolean disabled?: boolean displayValueAs?: string errorMessage?: string fullwidth?: boolean hasError?: boolean helptext?: string | null helptextDropdown?: string | null helptextDropdownButton?: string | null id?: string includeSearch?: boolean label?: string | null maxlength?: number | null minlength?: number | null multiple?: boolean name?: string optionalTag?: boolean optionalText?: string options?: IPktComboboxOption[] defaultOptions?: IPktComboboxOption[] placeholder?: string | null requiredTag?: boolean requiredText?: string searchPlaceholder?: string tagPlacement?: TPktComboboxTagPlacement | null tagText?: string | null value?: string | string[] isOpen?: boolean } declare global { interface HTMLElementTagNameMap { 'pkt-combobox': PktCombobox & HTMLSelectElement } } export class PktCombobox extends ComboboxHandlers implements IPktCombobox { // Bound handler for body click — stored for cleanup in disconnectedCallback private handleBodyClick = (e: MouseEvent) => { if (this._isOptionsOpen && !this.contains(e.target as Node)) { this.closeAndProcessInput() } } // Lifecycle methods connectedCallback(): void { super.connectedCallback() document?.body.addEventListener('click', this.handleBodyClick) this._options = [] // Deep clone defaultOptions into options, preserving userAdded options if (this.defaultOptions && this.defaultOptions.length) { const userAdded = this.options?.filter((opt) => opt.userAdded) || [] this.options = [...userAdded, ...JSON.parse(JSON.stringify(this.defaultOptions))] this._options = Array.isArray(this.options) ? [...this.options] : [] } // If options are provided via the options slot, we need to extract them if (this.optionsController?.nodes && this.optionsController.nodes.length) { const parsedOptions = slotUtils.parseOptionsFromSlot(this.optionsController.nodes) if (parsedOptions.length) { this.options = [...parsedOptions] this._options = [...parsedOptions] this._optionsFromSlot = true this._lastSlotGeneration = this.optionsController.generation } } } protected willUpdate(changedProperties: Map): void { // Re-parse slot options when the controller detects mutations. // The controller increments its generation counter on each mutation, but // doesn't set any reactive properties — so we detect the change here. if (this._optionsFromSlot && this.optionsController) { const currentGen = this.optionsController.generation if (currentGen !== this._lastSlotGeneration) { this._lastSlotGeneration = currentGen const parsedOptions = slotUtils.parseOptionsFromSlot(this.optionsController.nodes) const userAdded = this._options.filter((o) => o.userAdded) this.options = [...userAdded, ...parsedOptions] } } super.willUpdate(changedProperties) } disconnectedCallback(): void { super.disconnectedCallback() document?.body.removeEventListener('click', this.handleBodyClick) } firstUpdated(changedProperties: PropertyValues): void { // Apply defaultValue before the base class firstUpdated, which calls // valueChanged(defaultValue) — a no-op in combobox. Setting this.value // here lets updated() handle the sync via the normal value-change path. if (this.defaultValue !== null && !this.value) { this.value = this.defaultValue } super.firstUpdated(changedProperties) } updated(changedProperties: PropertyValues): void { if (changedProperties.has('isOpen')) { this._isOptionsOpen = this.isOpen } // Handle value and _value changes. // Three cases: // 1. value changed from our own syncValueAndDispatch (internal sync) — skip value handler, // but still process concurrent _value changes // 2. value changed externally — sync _value from value, dispatch events // 3. Only _value changed — sync value from _value, dispatch events const valueChanged = changedProperties.has('value') const internalChanged = changedProperties.has('_value') const isInternalSync = valueChanged && this._internalValueSync if (isInternalSync) { this._internalValueSync = false if (internalChanged) { this.syncValueAndDispatch(changedProperties.get('_value') as string[]) } } else if (valueChanged) { const oldInternal = [...this._value] const newInternal = this.parseValue() if (newInternal.join(',') !== this._value.join(',')) { this._value = newInternal this.options.forEach((o) => (o.selected = newInternal.includes(o.value))) this._options.forEach((o) => (o.selected = newInternal.includes(o.value))) this._options = [...this._options] } this.updateMaxReached() this.syncValueAndDispatch(oldInternal) } else if (internalChanged) { this.syncValueAndDispatch(changedProperties.get('_value') as string[]) } // If defaultOptions changed, update options (preserving userAdded) if (changedProperties.has('defaultOptions') && this.defaultOptions.length) { const userAdded = (Array.isArray(this.options) ? this.options : []).filter((opt) => opt.userAdded) || [] this.options = [...userAdded, ...JSON.parse(JSON.stringify(this.defaultOptions))] this._options = Array.isArray(this.options) ? [...this.options] : [] } if (changedProperties.has('options')) { const prevOptions = (changedProperties.get('options') as IPktComboboxOption[]) || this._options || [] const mergedOptions = optionStateUtils.mergeWithUserAdded(this.options, prevOptions) this._options = mergedOptions if (mergedOptions.length > this.options.length) { this.options = mergedOptions } const syncResult = optionStateUtils.syncOptionsWithValues(this._options, this._value) this._options = syncResult.options if (syncResult.newValues.length > this._value.length) { const oldValue = [...this._value] this._value = syncResult.newValues this.syncValueAndDispatch(oldValue) } } if (changedProperties.has('_search')) { this.dispatchEvent( new CustomEvent('search', { detail: this._search, bubbles: false, }), ) } // Sync text input display value for single+typeahead when dropdown is closed if ( !this._isOptionsOpen && !this.multiple && this._hasTextInput && this.inputRef.value && this.inputRef.value.type !== 'hidden' ) { const displayValue = this._value[0] ? getSingleValueForInput(this._value[0], this.options, this.displayValueAs) : '' if (this.inputRef.value.value !== displayValue) { this.inputRef.value.value = displayValue } } super.updated(changedProperties) } /** * Override form reset to properly restore combobox state. * The base class deselects all options and sets value/defaultValue, but * combobox needs to re-sync _options with the restored values and clean up * user-added options and UI state. */ protected override formResetCallback(): void { this.touched = false // Restore value from defaultValue (set by base class firstUpdated from // the initial value attribute, per MDN HTMLInputElement.defaultValue) const resetValue = this.defaultValue || (this.multiple ? '' : '') this.value = resetValue this._value = this.parseValue() // Remove user-added options, then re-sync selection state with restored _value. // We must create new arrays because the base class mutates option objects in place. this._options = this._options .filter((o) => !o.userAdded) .map((o) => ({ ...o, selected: this._value.includes(o.value) })) this.options = this.options .filter((o) => !o.userAdded) .map((o) => ({ ...o, selected: this._value.includes(o.value) })) // Reset UI state this._search = '' this._isOptionsOpen = false this._userInfoMessage = '' this._addValueText = null this._inputFocus = false this.updateMaxReached() if (this.inputRef.value && this.inputRef.value.type !== 'hidden') { this.inputRef.value.value = '' } this.internals.setFormValue('') this.internals.ariaInvalid = 'false' this.requestUpdate() } attributeChangedCallback(name: string, _old: string | null, value: string | null): void { // Don't set _value here for 'value' changes — this.value hasn't been updated yet // (super.attributeChangedCallback does that). Let updated() handle the sync. if (name === 'options') { this._options = Array.isArray(this.options) ? [...this.options] : [] const syncResult = optionStateUtils.syncOptionsWithValues(this._options, this._value) this._options = syncResult.options if (syncResult.newValues.length > this._value.length) { this._value = syncResult.newValues } this._search = '' } super.attributeChangedCallback(name, _old, value) } // Render methods render() { return html`
${slotContent(this, 'helptext')}
${!this._hasTextInput && this.placeholder && (!this._value.length || (this.multiple && this.tagPlacement == 'outside')) && !this._inputFocus ? html`${this.placeholder}` : this.tagPlacement !== 'outside' ? this.renderSingleOrMultipleValues() : nothing} ${this.renderInputField()}
(this._isOptionsOpen = false)} @tab-close=${() => this.closeAndProcessInput()} .searchValue=${this._search || null} .maxLength=${this.maxlength || 0} ${ref(this.listboxRef)} >
${this.tagPlacement === 'outside' && this.multiple ? html`
${this.renderSingleOrMultipleValues()}
` : nothing}
` } private renderInputField() { return this.typeahead || this.allowUserInput ? html`
{ e.stopPropagation() e.stopImmediatePropagation() }} @keydown=${this.handleInputKeydown} @focus=${this.handleFocus} @blur=${this.handleBlur} autocomplete="off" role="combobox" aria-expanded=${this._isOptionsOpen ? 'true' : 'false'} aria-label=${ifDefined(this.label)} aria-autocomplete=${this.typeahead ? 'both' : this.allowUserInput ? 'list' : 'none'} aria-controls="${this.id}-listbox" aria-activedescendant=${ifDefined( this._value[0] && !!findOptionByValue(this.options, this._value[0]) ? `${this.id}-listbox-${findOptionIndex(this._options, this._value[0])}` : undefined, )} aria-description=${ifDefined(this._selectionDescription || undefined)} ${ref(this.inputRef)} />
` : html` ` } private renderSingleOrMultipleValues() { // Single select with text input: value is shown in the input field, not as a span if (!this.multiple && this._hasTextInput) return nothing const isSingleValueDisplay = !this.multiple // Single value displayed as text (select-only mode) const singleValueContent = this.renderValueTag(findOptionByValue(this.options, this._value[0])) // Multiple values displayed as tags, wrapped in a list for accessibility const isOutside = this.tagPlacement === 'outside' const multipleValuesContent = html` ` return isSingleValueDisplay ? singleValueContent : multipleValuesContent } private renderValueTag(option: IPktComboboxOption | null) { if (!option) return '' const displayAs = this.displayValueAs as TPktComboboxDisplayValue switch (displayAs) { case 'prefixAndValue': return html`${option.prefix || ''} ${option.value}` case 'value': return html`${option.value}` case 'label': default: return html`${option.label || option.value}` } } } export default PktCombobox try { customElement('pkt-combobox')(PktCombobox) } catch (e) { console.warn('Forsøker å definere , men den er allerede definert') }