UNPKG

@oslokommune/punkt-elements

Version:

Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo

917 lines (815 loc) 30.2 kB
import { html, nothing, PropertyValues } from 'lit' import { ifDefined } from 'lit/directives/if-defined.js' import { customElement, property, state } from 'lit/decorators.js' import { Ref, createRef, ref } from 'lit/directives/ref.js' import { classMap } from 'lit/directives/class-map.js' import { repeat } from 'lit/directives/repeat.js' import { PktInputElement } from '@/base-elements/input-element' import { PktOptionsSlotController } from '@/controllers/pkt-options-controller' import { PktSlotController } from '@/controllers/pkt-slot-controller' import specs from 'componentSpecs/combobox.json' import '../input-wrapper' import '../icon' import '../tag' import '../listbox' import PktListbox from '../listbox' import { TTagSkin } from '../tag' export interface IPktComboboxOption { description?: string disabled?: boolean fulltext?: string label?: string prefix?: string selected?: boolean tagSkinColor?: TTagSkin userAdded?: boolean value: string } 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[] } export type TPktComboboxTagPlacement = 'inside' | 'outside' declare global { interface HTMLElementTagNameMap { 'pkt-combobox': PktCombobox & HTMLSelectElement } } @customElement('pkt-combobox') export class PktCombobox extends PktInputElement implements IPktCombobox { private helptextSlot: Ref<HTMLElement> = createRef() constructor() { super() this.optionsController = new PktOptionsSlotController(this) this.slotController = new PktSlotController(this, this.helptextSlot) this.slotController.skipOptions = true } // Props / Attributes @property({ type: String, reflect: true }) value: string | string[] = '' @property({ type: Array }) options: IPktComboboxOption[] = [] @property({ type: Array }) defaultOptions: IPktComboboxOption[] = [] @property({ type: Boolean }) allowUserInput: boolean = false @property({ type: Boolean }) typeahead: boolean = false @property({ type: Boolean }) includeSearch: boolean = false @property({ type: String }) searchPlaceholder: string = '' @property({ type: Boolean }) multiple: boolean = false @property({ type: Number }) maxlength: number | null = null @property({ type: String }) displayValueAs: string = specs.props.displayValueAs.default @property({ type: String }) tagPlacement: TPktComboboxTagPlacement | null = null // State @state() private _options: IPktComboboxOption[] = [] @state() private _isOptionsOpen = false @state() private _value: string[] = [] @state() private _userInfoMessage: string = '' @state() private _addValueText: string | null = null @state() private _maxIsReached: boolean = false @state() private _search: string = '' @state() private _inputFocus: boolean = false @state() private _editingSingleValue: boolean = false // Refs inputRef: Ref<HTMLInputElement> = createRef() arrowRef: Ref<HTMLButtonElement> = createRef() listboxRef: Ref<PktListbox> = createRef() focusRef: Ref<HTMLElement> = createRef() optionTagRef: Ref<HTMLElement> = createRef() // Lifecycle methods connectedCallback(): void { super.connectedCallback() document && document.body.addEventListener('click', (e: MouseEvent) => { if (this._isOptionsOpen && !this.contains(e.target as Node)) { this.handleFocusOut(e) } }) 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 = [...this.options] } // If options are provided via the options slot, we need to extract them if (this.optionsController.nodes.length) { const options: IPktComboboxOption[] = [] this.optionsController.nodes.forEach((node: Element) => { if (!node.textContent && !node.getAttribute('value')) return null const option: IPktComboboxOption = { value: node.getAttribute('value') || node.textContent || '', label: node.textContent || node.getAttribute('value') || '', } if (node.getAttribute('data-prefix')) { option.prefix = node.getAttribute('data-prefix') || undefined } if (node.getAttribute('tagskincolor')) { option.tagSkinColor = node.getAttribute('tagskincolor') as TTagSkin } if (node.getAttribute('description')) { option.description = node.getAttribute('description') || undefined } option.fulltext = option.value + option.label + (option.prefix || '') options.push(option) }) if (options.length) { this.options = [...options] this._options = [...options] } } } updated(changedProperties: PropertyValues): void { if (changedProperties.has('_value')) { this.valueChanged(this._value, changedProperties.get('_value') as string[]) } if (changedProperties.has('value')) { this._value = Array.isArray(this.value) ? this.value : this.value ? this.value.split(',') : [] if (!this.multiple && this._value.length > 1) { this._value = [this._value[0]] } this.isMaxItemsReached() } // If defaultOptions changed, update options (preserving userAdded) if (changedProperties.has('defaultOptions') && this.defaultOptions.length) { const userAdded = this.options?.filter((opt) => opt.userAdded) || [] this.options = [...userAdded, ...JSON.parse(JSON.stringify(this.defaultOptions))] this._options = [...this.options] } if (changedProperties.has('options') && this.options.length) { // If options change, we need to update _options, but we need to preserve userAdded values const userAddedValues = this._options.filter((option) => option.userAdded) // Filter out userAddedValues that are overridden by this.options const filteredUserAdded = userAddedValues.filter( (userOpt) => !this.options.some((opt) => opt.value === userOpt.value), ) // Merge, giving precedence to this.options this._options = [...filteredUserAdded, ...this.options] this._options.forEach((option) => { if (option.value && !option.label) { option.label = option.value } if (option.selected && !this._value.includes(option.value)) { const oldValue = [...this._value] this._value = [...this._value, option.value] this.valueChanged(this._value, oldValue) } option.fulltext = option.value + option.label + (option.prefix || '') option.selected = option.selected || this._value.includes(option.value) }) } if (changedProperties.has('_search')) { this.dispatchEvent( new CustomEvent('search', { detail: this._search, bubbles: false, }), ) } super.updated(changedProperties) } attributeChangedCallback(name: string, _old: string | null, value: string | null): void { if (name === 'value') { this._value = Array.isArray(this.value) ? this.value : this.value ? this.value.split(',') : [] if (!this.multiple && this._value.length > 1) { this._value = [this._value[0]] } } if (name === 'options') { this._options = this.options this._options.forEach((option) => { if (option.value && !option.label) { option.label = option.value } if (option.selected && !this._value.includes(option.value)) { this._value = [...this._value, option.value] } option.fulltext = option.value + option.label + (option.prefix || '') }) 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.allowUserInput || this.typeahead ? this.id + '-input' : this.id + '-arrow'} class="pkt-combobox__wrapper" @labelClick=${this.handleInputClick} > <div class="pkt-contents" ${ref(this.helptextSlot)} name="helptext" slot="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, })} tabindex="-1" @click=${this.handleInputClick} > ${this.placeholder && (!this._value.length || (this.multiple && this.tagPlacement == 'outside')) && !this._inputFocus ? html`<span class="pkt-combobox__placeholder">${this.placeholder}</span>` : this.tagPlacement !== 'outside' ? this.renderSingleOrMultipleValues() : nothing} ${this.renderInputField()} <div class="pkt-btn pkt-btn--tertiary pkt-combobox__arrow" @click=${this.handleArrowClick} @keydown=${this.handleArrowClick} id="${this.id}-arrow" ${ref(this.arrowRef)} aria-expanded=${this._isOptionsOpen} aria-controls="${this.id}-listbox" aria-haspopup="listbox" aria-label="Åpne liste" ?disabled=${this.disabled} ?data-disabled=${this.disabled} role="button" tabindex="${this.disabled ? '-1' : '0'}" > <pkt-icon class=${classMap({ 'pkt-combobox__arrow-icon': true, 'pkt-combobox__arrow-icon--open': this._isOptionsOpen, })} name="chevron-thin-down" ></pkt-icon> </div> <div ${ref(this.focusRef)} tabindex="-1" @keydown=${this.handleArrowClick}></div> </div> <pkt-listbox id="${this.id}-listbox" .options=${this._options} .isOpen=${this._isOptionsOpen} .searchPlaceholder=${this.searchPlaceholder} .label="Liste: ${this.label || ''}" ?includeSearch=${this.includeSearch} ?isMultiSelect=${this.multiple} ?allowUserInput=${this.allowUserInput && !this._maxIsReached} ?maxIsReached=${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)} .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> ` } 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'} @input=${this.handleInput} @keydown=${this.handleInputKeydown} @focus=${this.handleFocus} @blur=${this.handleBlur} autocomplete="off" role="combobox" aria-label=${ifDefined(this.label)} aria-autocomplete=${this.typeahead ? 'both' : 'list'} aria-controls="${this.id}-listbox" aria-multiselectable=${ifDefined(this.multiple ? 'true' : undefined)} aria-activedescendant=${ifDefined( this._value[0] && !!this.findValueInOptions(this._value[0]) ? `${this.id}-listbox-${this.findIndexInOptions(this._value[0])}` : 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)} /> ` } renderSingleOrMultipleValues() { const isSingleValueDisplay = !this.multiple // enkeltverdi som tekst const singleValueContent = !this._editingSingleValue ? this.renderValueTag(this.findValueInOptions(this._value[0])) : null // Multiple vises som tags const multipleValuesContent = repeat( this._value, (value: string) => value, (value: string) => { const option = this.findValueInOptions(value) const tagSkinColor = this.options.find((o) => o.value === value)?.tagSkinColor return html` <pkt-tag skin=${tagSkinColor || 'blue-dark'} ?closeTag=${!this.disabled} @close=${() => this.handleTagRemove(value)} > ${this.renderValueTag(option)} </pkt-tag> ` }, ) return isSingleValueDisplay ? singleValueContent : multipleValuesContent } renderValueTag(option: IPktComboboxOption | null) { if (!option) return '' switch (this.displayValueAs) { case 'prefixAndValue': return html`<span data-focusfix=${this.id}>${option.prefix || ''} ${option.value}</span>` case 'value': return html`<span data-focusfix=${this.id}>${option.value}</span>` case 'label': default: return html`<span data-focusfix=${this.id}>${option.label || option.value}</span>` } } // Event handlers 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) { this._options = this.options.filter((option) => option.fulltext?.toLowerCase().includes(this._search.toLowerCase()), ) if (e.inputType !== 'deleteContentBackward') { const matchingOptions = this._options.filter( (option) => !option.selected && option.label?.toLowerCase().startsWith(this._search.toLowerCase()), ) if ( matchingOptions.length > 0 && this.inputRef.value && this.inputRef.value.type !== 'hidden' ) { const match = matchingOptions[0] if (match?.label) { input.value = match.label window.setTimeout( () => input.setSelectionRange(this._search.length, input.value.length), 0, ) input.selectionDirection = 'backward' } } } } else { this._options = [...this.options] } } } private handleFocus(): void { if (this.disabled) return if ( !this.multiple && this._value[0] && this.inputRef.value && this.inputRef.value.type !== 'hidden' ) { const option = this.findValueInOptions(this._value[0]) this._editingSingleValue = true this.inputRef.value.value = this.displayValueAs === 'label' && option?.label ? option.label : this._value[0] } this._inputFocus = true this._search = '' this._options = [...this.options] this._isOptionsOpen = true this.onFocus() this.requestUpdate() // Ensure the UI updates } private handleFocusOut(e: FocusEvent): void { if (this.disabled) return // Triggered when focus completely leaves the combobox and its children if ( (e.relatedTarget as Element)?.closest('pkt-combobox')?.id !== this.id && (e.relatedTarget as Element)?.closest('pkt-combobox')?.id !== this.id && (e.target as Element)?.getAttribute('data-focusfix') !== this.id && e.relatedTarget !== this.focusRef.value && e.relatedTarget !== this.inputRef.value && e.relatedTarget !== this.arrowRef.value && this._isOptionsOpen ) { this._inputFocus = false this._addValueText = null this._userInfoMessage = '' this._search = '' // If value in text input, check if it should be added if ( this.inputRef.value && this.inputRef.value.type !== 'hidden' && this.inputRef.value.value !== '' ) { const val = this.inputRef.value.value const valInOptions = this.findValueInOptions(val) if (!this._value.includes(val) && !valInOptions) { if (this.allowUserInput) { this.addNewUserValue(val) } else if (!this.multiple) { this.removeValue(this._value[0]) } } else if (valInOptions && !this._value.includes(valInOptions.value)) { this.setSelected(valInOptions.value) } this.inputRef.value.value = '' } this._isOptionsOpen = false this.onBlur() } } private handleBlur(): void { this._inputFocus = false this._editingSingleValue = false this.onBlur() } private handleInputClick(e: MouseEvent): void { if (this.disabled) return if ( e.currentTarget && e.currentTarget !== this.arrowRef.value && this.inputRef.value?.type !== 'hidden' ) { this.inputRef.value?.focus() } else { this.handleArrowClick(e) } } private handleArrowClick(e: MouseEvent | KeyboardEvent): void { if (this.disabled) return if (e instanceof KeyboardEvent && e.key) { if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'ArrowDown') return } e.stopImmediatePropagation() e.preventDefault() this._isOptionsOpen = !this._isOptionsOpen if (this._isOptionsOpen) { this.listboxRef.value?.focusFirstOrSelectedOption() } else { this.arrowRef.value?.focus() } } private handleOptionToggled(e: CustomEvent) { this.toggleValue(e.detail) } private handleSearch(e: CustomEvent) { e.stopPropagation() this._search = e.detail.toLowerCase() } private handleInputKeydown(e: KeyboardEvent): void { switch (e.key) { case ',': case 'Enter': e.preventDefault() this.addValue() break case 'Backspace': if (!this._search && this.inputRef.value?.type === 'hidden') this.removeLastValue(e) break case 'Tab': case 'ArrowDown': if (!e.shiftKey) { this.listboxRef.value?.focusFirstOrSelectedOption() e.preventDefault() } break case 'Escape': this._isOptionsOpen = false this.arrowRef.value?.focus() // Return focus to the button e.preventDefault() break default: break } } private handleTagRemove(value: string | null): void { this.removeSelected(value) } private blurInput(): void { if (this.inputRef.value && this.inputRef.value.matches(':focus')) { this.inputRef.value.blur() } } private checkForMatches() { // sjekker om verdiene bruker skriver inn finnes, er valgt eller kan legges til //setter riktig infomelding til bruker const inputValue = this.inputRef.value?.value || this._search || '' const searchValue = inputValue.trim().toLowerCase() || '' if (!searchValue) { if (!this.multiple && this._value[0]) { this.removeValue(this._value[0]) } this.resetComboboxInput(false) return } const matchedValues = this._value.find((value) => value.toLowerCase() === searchValue) const matchedOptions: IPktComboboxOption[] = this._options.filter( (option) => option.label?.toLowerCase().includes(searchValue) ?? false, ) const matchedOption = matchedOptions.find( (option) => option.label?.toLowerCase() === searchValue || option.value.toLowerCase() === searchValue, ) // sett riktig infomelding til bruker switch (true) { case (matchedOptions.length === 0 || !matchedOption) && this.allowUserInput: this._addValueText = inputValue this._userInfoMessage = '' break case matchedOptions.length === 0 && !this.allowUserInput: this._addValueText = null this._userInfoMessage = 'Ingen match i søket' break case !!matchedValues: this._addValueText = null this._userInfoMessage = 'Verdien er allerede valgt' break case matchedOptions.length > 1: this._addValueText = null this._userInfoMessage = '' break default: this._addValueText = null this._userInfoMessage = '' // Default for å fjerne melding } } private findValueInOptions(value: string | null): IPktComboboxOption | null { return ( this.options.find((option) => { return option.value === value || option.label === value }) || null ) } private findIndexInOptions(value: string | null): number { return this._options.findIndex((option) => { return option.value === value || option.label === value }) } private isMaxItemsReached(): boolean { const isReached = this.maxlength !== null && this._value.length >= this.maxlength if (!isReached) { this._maxIsReached = false } else { this._maxIsReached = true } return isReached } public toggleValue(value: string | null): void { if (this.disabled) return this.touched = true this._userInfoMessage = '' this._addValueText = null const valueFromOptions: string | null = this.findValueInOptions(value)?.value || null const isSelected: boolean = this._value.includes(value || valueFromOptions || '') const isInOption: boolean = !!valueFromOptions const isDisabled: boolean = this._options.find((o) => o.value === value)?.disabled || false const isEmpty: boolean = !value?.trim() const isSingle: boolean = !this.multiple const isMultiple: boolean = this.multiple const isMaxItemsReached: boolean = this.isMaxItemsReached() let shouldOptionsBeOpen: boolean = false let shouldResetInput: boolean = true let userInfoMessage: string | null = '' let searchValue: string | null = '' if (isDisabled) return // Dersom ikke i listen og allowUserInput er true if (!isInOption && this.allowUserInput && !isEmpty) { this.addNewUserValue(value) userInfoMessage = 'Ny verdi lagt til' shouldOptionsBeOpen = !isMultiple } // Dersom ikke i listen men allowUserInput er false else if (!isInOption && !this.allowUserInput) { if (isSingle && this._value[0]) { this.removeValue(this._value[0]) } shouldResetInput = false shouldOptionsBeOpen = true userInfoMessage = 'Ingen treff i søket' } // Dersom verdien er valgt allerede else if (isSelected) { this.removeValue(valueFromOptions) shouldOptionsBeOpen = true } // Dersom verdien er en tom streng, og det er enkeltvalg else if (isEmpty && isSingle) { this.removeAllSelected() shouldOptionsBeOpen = true } // Dersom det er enkeltvalg else if (isSingle) { this._value[0] && this.removeSelected(this._value[0]) this.setSelected(valueFromOptions) shouldOptionsBeOpen = false if (this.inputRef.value && this.inputRef.value.type !== 'hidden') { this.inputRef.value.value = '' this.inputRef.value.blur() } } // Dersom det er flervalg og mulig å legge til fler else if (isMultiple && !isMaxItemsReached) { this.setSelected(valueFromOptions) shouldOptionsBeOpen = true } // Dersom det er flervalg og maks antall er nådd else if (isMultiple && isMaxItemsReached) { this._userInfoMessage = 'Maks antall valg nådd' shouldResetInput = false searchValue = value } // Dersom ingen av de over passer else { isSingle && this.removeAllSelected() this._userInfoMessage = 'Ingen gyldig verdi valgt' shouldResetInput = false shouldOptionsBeOpen = true searchValue = value } this._isOptionsOpen = shouldOptionsBeOpen if (!shouldOptionsBeOpen) { window.setTimeout(() => { this.focusRef.value?.focus() }, 0) } this._userInfoMessage = userInfoMessage this._search = searchValue || '' this.resetComboboxInput(shouldResetInput) isMultiple && this.isMaxItemsReached() } private setSelected(value: string | null): void { if (this._value.includes(value as string)) return if (this.multiple && this.isMaxItemsReached()) { this._userInfoMessage = 'Maks antall valg nådd' return } !this.multiple && this.removeAllSelected() this._value = value ? [...this._value, value] : this._value this._options = this._options.map((option) => { if (option.value === value) { option.selected = true } return option }) this.resetComboboxInput(true) } private removeSelected(value: string | null): void { if (!value) return this._value = this._value.filter((v) => v !== value) const _opt = this.findValueInOptions(value) if (_opt) { _opt.selected = false if (_opt.userAdded) { this._options = [...this._options.filter((o) => o.value !== value)] this.options = [...this.options.filter((o) => o.value !== value)] } else { this._options = [...this._options, _opt] } } else if (!value && !this.multiple) { this._options = this._options.map((option) => { option.selected = false return option }) } } private addAllOptions(): void { if (!this.multiple) return if (this.maxlength && this._options.length > this.maxlength) { this._userInfoMessage = 'For mange valgt' return } this._value = this._options.map((option) => option.value) this._options = this._options.map((option) => { option.selected = true return option }) this.requestUpdate() } private removeAllSelected(): void { this._value = [] this._options = this._options.map((option) => { option.selected = false return option }) this._options = this._options.filter((option) => !option.userAdded) this.requestUpdate() } private addValue(): void { const input = this.inputRef.value?.value.trim() || '' this._search = input this.toggleValue(input) } private removeValue(value: string | null): void { this._value = this.multiple ? this._value.filter((v) => v !== value) : [] this.removeSelected(value) } private addNewUserValue(value: string | null): void { if (!value || value.trim() === '') return if (!this.multiple) { this._value[0] && this.removeSelected(this._value[0]) this._value = [value] this._isOptionsOpen = false this.blurInput() } else if (!this.findValueInOptions(value)) { if (this.isMaxItemsReached()) return this._value = [...this._value, value] } const newOption: IPktComboboxOption = { value, label: value, userAdded: true } this.options = [newOption, ...this.options] this._options = [newOption, ...this._options] this.setSelected(value) this.requestUpdate() } private resetComboboxInput(shouldResetInput: boolean = true): void { this._addValueText = null if (this.inputRef.value && this.inputRef.value.type !== 'hidden' && shouldResetInput) { this._search = '' if (this.multiple) { this.inputRef.value.value = '' } else { const option = this.findValueInOptions(this._value[0]) window.setTimeout(() => { if (!this.inputRef.value || this.inputRef.value.type === 'hidden') return this.inputRef.value.value = this.displayValueAs === 'label' && option?.label ? option.label : this._value[0] || '' }, 0) this._userInfoMessage = '' } } this._options = [...this.options] } private removeLastValue(e: Event): void { if (this._value.length === 0) return e.preventDefault() const val = this._value[this._value.length - 1] val && this.removeSelected(val) this.isMaxItemsReached() } }