import { html, nothing, PropertyValues } from 'lit' import { customElement, property, state } from 'lit/decorators.js' import { ifDefined } from 'lit/directives/if-defined.js' import strings from '@/translations/no.json' import { PktElement } from '@/base-elements/element' import { repeat } from 'lit/directives/repeat.js' import { classMap } from 'lit/directives/class-map.js' import type { IPktComboboxOption } from 'shared-types/combobox' import { uuidish } from 'shared-utils/utils' import { filterOptionsBySearch } from 'shared-utils/combobox/option-utils' import { isLetterOrSpace, createTypeaheadHandler, findTypeaheadOptionMatch, } from 'shared-utils/combobox/typeahead' import { focusAndScrollIntoView, getOptionElements, focusNextOption as navFocusNext, focusPreviousOption as navFocusPrev, focusFirstOption as navFocusFirst, focusLastOption as navFocusLast, focusFirstOrSelectedOption as navFocusFirstOrSelected, } from 'shared-utils/combobox/keyboard-navigation' declare global { interface HTMLElementTagNameMap { 'pkt-listbox': PktListbox } } export interface IPktListbox { options: IPktComboboxOption[] isOpen: boolean disabled: boolean includeSearch: boolean isMultiSelect: boolean allowUserInput: boolean maxIsReached: boolean customUserInput: string | null searchPlaceholder: string | null searchValue: string | null maxLength: number userMessage: string | null } export class PktListbox extends PktElement implements IPktListbox { @property({ type: String }) id: string = uuidish() @property({ type: String }) label: string | null = null @property({ type: Array }) options: IPktComboboxOption[] = [] @property({ type: Boolean, reflect: true, attribute: 'is-open' }) isOpen: boolean = false @property({ type: Boolean }) disabled: boolean = false @property({ type: Boolean, attribute: 'include-search' }) includeSearch: boolean = false @property({ type: Boolean, attribute: 'is-multi-select' }) isMultiSelect: boolean = false @property({ type: Boolean, attribute: 'allow-user-input' }) allowUserInput: boolean = false @property({ type: Boolean, attribute: 'max-is-reached' }) maxIsReached: boolean = false @property({ type: String, attribute: 'custom-user-input' }) customUserInput: string | null = null @property({ type: String, attribute: 'search-placeholder' }) searchPlaceholder: string | null = null @property({ type: String, attribute: 'search-value' }) searchValue: string | null = null @property({ type: Number, attribute: 'max-length' }) maxLength: number = 0 @property({ type: String, attribute: 'user-message' }) userMessage: string | null = null private _selectedOptions: number = 0 private typeahead = createTypeaheadHandler() @state() private _filteredOptions: IPktComboboxOption[] = [] // Lifecycle methods connectedCallback(): void { super.connectedCallback() if (this.includeSearch && !this.searchValue) { this.searchValue = '' } if (this.options.length > 0) { this.filterOptions() } this.setAttribute('tabindex', '-1') this.addEventListener('focus', this.focusFirstOrSelectedOption) } disconnectedCallback(): void { super.disconnectedCallback() this.typeahead.reset() } updated(changedProperties: PropertyValues) { if (changedProperties.has('options') || changedProperties.has('searchValue')) { this.filterOptions() } super.updated(changedProperties) } attributeChangedCallback(name: string, _old: string | null, value: string | null): void { if (name === 'options' || name === 'search-value') { this.filterOptions() } super.attributeChangedCallback(name, _old, value) } // Render methods private get _hasOptions(): boolean { return this._filteredOptions.length > 0 || this.options.length > 0 } render() { return html`
${this.renderSearch()} ${this.renderMaximumReachedBanner()} ${this.renderUserMessage()} ${this.renderEmptyMessage()} ${this.renderNewOptionBanner()}
${this.userMessage}
` } private renderCheckboxOrCheckIcon(option: IPktComboboxOption, index: number) { return this.isMultiSelect ? html` { e.stopPropagation() }} .checked=${option.selected} aria-labelledby=${this.id + '-option-label-' + index} ?disabled=${this.disabled || option.disabled || (this.maxIsReached && !option.selected)} /> ` : option.selected ? html`` : nothing } private renderEmptyMessage() { if (this.options.length > 0 || this._filteredOptions.length > 0 || this.userMessage) { return nothing } return html`
Tom liste
` } private renderList() { return html` ${repeat( this._filteredOptions, (option) => option.value, (option, index) => html`
  • { this.toggleOption(option) }} aria-selected=${option.selected ? 'true' : 'false'} @keydown=${this.handleOptionKeydown} class=${classMap({ 'pkt-listbox__option': true, 'pkt-listbox__option--selected': Boolean(!this.isMultiSelect && option.selected), 'pkt-listbox__option--checkBox': this.isMultiSelect, })} tabindex="${this.disabled || option.disabled ? '-1' : '0'}" data-index=${index} data-value=${option.value} data-selected=${option.selected ? 'true' : 'false'} ?data-disabled=${this.disabled || option.disabled || (this.maxIsReached && !option.selected)} aria-disabled=${this.disabled || option.disabled || (this.maxIsReached && !option.selected) ? 'true' : 'false'} role="option" id=${`${this.id}-${index}`} > ${this.renderCheckboxOrCheckIcon(option, index)} ${option.prefix ? html`${option.prefix}` : nothing} ${option.label || option.value} ${option.description ? html`${option.description}` : nothing}
  • `, )} ` } private renderNewOptionBanner() { return this.allowUserInput && this.customUserInput ? html`
    this.toggleOption({ value: this.customUserInput || '', })} @keydown=${this.handleOptionKeydown} > Legg til "${this.customUserInput}"
    ` : nothing } private renderMaximumReachedBanner() { this._selectedOptions = this.options.filter((option) => option.selected).length return this.isMultiSelect && this._selectedOptions > 0 && this.maxLength > 0 ? html`
    ${this._selectedOptions} av maks ${this.maxLength} mulige er valgt.
    ` : nothing } private renderUserMessage() { return this.userMessage ? html`
    ${this.userMessage}
    ` : nothing } private renderSearch() { return this.includeSearch ? html` ` : nothing } // Event handlers private handleSearchInput(e: InputEvent) { this.searchValue = (e.target as HTMLInputElement).value this.dispatchEvent( new CustomEvent('search', { detail: this.searchValue, bubbles: false, }), ) } private handleSearchKeydown(e: KeyboardEvent) { switch (e.key) { case 'Enter': e.preventDefault() break case 'ArrowUp': case 'Escape': this.closeOptions() e.preventDefault() break case 'ArrowDown': this.focusFirstOrSelectedOption() break case 'Tab': this.tabClose() break } } private handleOptionKeydown(e: KeyboardEvent) { const target = e.currentTarget as HTMLElement const value = target.dataset.value const itemType = target.dataset.type const isValueSelected = target.dataset.selected === 'true' if ( !getOptionElements(this).length && (!this.customUserInput || (!this.allowUserInput && this.customUserInput)) && itemType !== 'new-option' && itemType !== 'searchbox' ) { return } switch (e.key) { case ' ': case 'Enter': this.toggleOption(target) e.preventDefault() break case 'Backspace': if (value) { if (isValueSelected) { this.toggleOption(target) } else { this.closeOptions() } } e.preventDefault() break case 'Escape': this.closeOptions() e.preventDefault() break case 'Tab': // Don't preventDefault — let Tab move focus naturally this.tabClose() break case 'ArrowDown': if (e.altKey) { navFocusLast(this) } else { if (itemType === 'searchbox' || itemType === 'new-option') { navFocusFirst(this) } else { navFocusNext(target) } } e.preventDefault() break case 'ArrowUp': if (e.altKey) { navFocusFirst(this) } else { if (target.dataset.index === '0' && this.includeSearch) { const searchInput = this.querySelector('[role="searchbox"]') as HTMLElement searchInput && searchInput.focus() } else if (target.dataset.index === '0' && this.customUserInput) { const newOption = this.querySelector('[data-type="new-option"]') as HTMLElement newOption && newOption.focus() } else { navFocusPrev(target, this, this.includeSearch) } } e.preventDefault() break case 'Home': navFocusFirst(this) e.preventDefault() break case 'End': navFocusLast(this) e.preventDefault() break default: if ((e.metaKey || e.ctrlKey) && e.key === 'a') { this.selectAll() e.preventDefault() } if (isLetterOrSpace(e.key)) { this.handleTypeAhead(e.key) e.preventDefault() } break } } // Focus management methods (delegates to shared utils) focusFirstOrSelectedOption() { navFocusFirstOrSelected(this, { disabled: this.disabled, allowUserInput: this.allowUserInput, customUserInput: this.customUserInput, includeSearch: this.includeSearch, }) } // Event dispatching methods private toggleOption(option: IPktComboboxOption | HTMLElement) { const optionDisabled = option instanceof HTMLElement ? option.dataset.disabled : option.disabled if (this.disabled || optionDisabled) return const value = option instanceof HTMLElement ? option.dataset.value : option.value this.dispatchEvent( new CustomEvent('option-toggle', { detail: value, bubbles: false, }), ) } private selectAll() { this.dispatchEvent(new CustomEvent('select-all', { bubbles: false })) } private closeOptions() { this.dispatchEvent(new CustomEvent('close-options', { bubbles: false })) } private tabClose() { this.dispatchEvent(new CustomEvent('tab-close', { bubbles: false })) } // Filtering and typeahead methods filterOptions() { this._filteredOptions = filterOptionsBySearch(this.options, this.searchValue) } private handleTypeAhead(char: string) { const searchString = this.typeahead.append(char) const options = getOptionElements(this) const matchIndex = findTypeaheadOptionMatch(options, searchString) if (matchIndex >= 0) { focusAndScrollIntoView(options[matchIndex]) } } } try { customElement('pkt-listbox')(PktListbox) } catch (e) { console.warn('Forsøker å definere , men den er allerede definert') }