UNPKG

@oslokommune/punkt-elements

Version:

Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo

506 lines (462 loc) 19.3 kB
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<PropertyKey, unknown>): 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.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` <pkt-input-wrapper .label=${this.label} .helptext=${this.helptext} .helptextDropdown=${ifDefined(this.helptextDropdown)} .helptextDropdownButton=${ifDefined(this.helptextDropdownButton)} ?fullwidth=${this.fullwidth} ?hasError=${this.hasError} ?inline=${this.inline} ?disabled=${this.disabled} .errorMessage=${this.errorMessage} ?optionalTag=${this.optionalTag} .optionalText=${this.optionalText} ?requiredTag=${this.requiredTag} .requiredText=${this.requiredText} .tagText=${this.tagText} useWrapper=${this.useWrapper} .forId=${this._hasTextInput ? this.id + '-input' : this.id + '-combobox'} ?hasFieldset=${!this._hasTextInput} class="pkt-combobox__wrapper" @labelClick=${this.handleInputClick} > <div class="pkt-contents" slot="helptext">${slotContent(this, 'helptext')}</div> <div class="pkt-combobox" @focusout=${this.handleFocusOut}> <div class=${classMap({ 'pkt-combobox__input': true, 'pkt-combobox__input--fullwidth': this.fullwidth, 'pkt-combobox__input--open': this._isOptionsOpen, 'pkt-combobox__input--error': this.hasError, 'pkt-combobox__input--disabled': this.disabled, })} id=${ifDefined(!this._hasTextInput ? `${this.id}-combobox` : undefined)} role=${ifDefined(!this._hasTextInput ? 'combobox' : undefined)} aria-expanded=${ifDefined( !this._hasTextInput ? (this._isOptionsOpen ? 'true' : 'false') : undefined, )} aria-controls=${ifDefined(!this._hasTextInput ? `${this.id}-listbox` : undefined)} aria-haspopup=${ifDefined(!this._hasTextInput ? 'listbox' : undefined)} aria-labelledby=${ifDefined( !this._hasTextInput ? `${this.id}-combobox-label` : undefined, )} aria-activedescendant=${ifDefined( !this._hasTextInput && 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)} tabindex=${!this._hasTextInput ? (this.disabled ? '-1' : '0') : '-1'} @click=${this.handleInputClick} @keydown=${!this._hasTextInput ? this.handleSelectOnlyKeydown : nothing} ${ref(this.triggerRef)} > ${!this._hasTextInput && this.placeholder && (!this._value.length || (this.multiple && this.tagPlacement == 'outside')) && !this._inputFocus ? html`<span class="pkt-combobox__placeholder" @click=${this.handlePlaceholderClick} >${this.placeholder}</span >` : this.tagPlacement !== 'outside' ? this.renderSingleOrMultipleValues() : nothing} ${this.renderInputField()} <pkt-icon class=${classMap({ 'pkt-combobox__arrow-icon': true, 'pkt-combobox__arrow-icon--open': this._isOptionsOpen, })} name="chevron-thin-down" aria-hidden="true" ></pkt-icon> </div> <pkt-listbox id="${this.id}-listbox" .options=${this._options} .isOpen=${this._isOptionsOpen} .searchPlaceholder=${this.searchPlaceholder} .label="Liste: ${this.label || ''}" ?include-search=${this.includeSearch} ?is-multi-select=${this.multiple} ?allow-user-input=${this.allowUserInput && !this._maxIsReached} ?max-is-reached=${this._maxIsReached} .customUserInput=${ifDefined(this._addValueText)} .userMessage=${this._userInfoMessage} @search=${this.handleSearch} @option-toggle=${this.handleOptionToggled} @select-all=${this.addAllOptions} @close-options=${() => (this._isOptionsOpen = false)} @tab-close=${() => this.closeAndProcessInput()} .searchValue=${this._search || null} .maxLength=${this.maxlength || 0} ${ref(this.listboxRef)} ></pkt-listbox> </div> ${this.tagPlacement === 'outside' && this.multiple ? html`<div class="pkt-combobox__tags-outside"> ${this.renderSingleOrMultipleValues()} </div>` : nothing} </pkt-input-wrapper> ` } private renderInputField() { return this.typeahead || this.allowUserInput ? html` <div class="pkt-combobox__input-div combobox__input"> <input type="text" id="${this.id}-input" name=${(this.name || this.id) + '-input'} placeholder=${ifDefined( !this._value.length || (this.multiple && this.tagPlacement === 'outside') ? this.placeholder : undefined, )} @input=${this.handleInput} @change=${(e: Event) => { 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)} /> </div> ` : html` <input type="hidden" id="${this.id}-input" name=${(this.name || this.id) + '-input'} .value=${this._value.join(',')} ${ref(this.inputRef)} /> ` } 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` <ul role="list" class="pkt-combobox__tag-list"> ${repeat( this._value, (value: string) => value, (value: string, index: number) => { const option = findOptionByValue(this.options, value) const tagSkinColor = option?.tagSkinColor return html` <li role="listitem" @click=${isOutside ? nothing : (e: MouseEvent) => e.stopPropagation()} @mousedown=${isOutside ? nothing : (e: MouseEvent) => e.preventDefault()} > <pkt-tag skin=${tagSkinColor || 'blue-dark'} ?closeTag=${!this.disabled} .buttonTabindex=${isOutside ? undefined : -1} @close=${() => this.handleTagRemove(value)} @keydown=${isOutside ? nothing : (e: KeyboardEvent) => this.handleTagKeydown(e, index)} > ${this.renderValueTag(option)} </pkt-tag> </li> ` }, )} </ul> ` return isSingleValueDisplay ? singleValueContent : multipleValuesContent } private renderValueTag(option: IPktComboboxOption | null) { if (!option) return '' const displayAs = this.displayValueAs as TPktComboboxDisplayValue switch (displayAs) { case 'prefixAndValue': return html`<span class="pkt-combobox__value" data-focusfix=${this.id} >${option.prefix || ''} ${option.value}</span >` case 'value': return html`<span class="pkt-combobox__value" data-focusfix=${this.id} >${option.value}</span >` case 'label': default: return html`<span class="pkt-combobox__value" data-focusfix=${this.id} >${option.label || option.value}</span >` } } } export default PktCombobox try { customElement('pkt-combobox')(PktCombobox) } catch (e) { console.warn('Forsøker å definere <pkt-combobox>, men den er allerede definert') }