UNPKG

@oslokommune/punkt-elements

Version:

Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo

471 lines (431 loc) 14.9 kB
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` <div class=${classMap({ 'pkt-listbox': true, 'pkt-listbox__open': this.isOpen, 'pkt-txt-16-light': true, })} role=${ifDefined(this._hasOptions ? 'listbox' : undefined)} aria-multiselectable=${ifDefined( this._hasOptions && this.isMultiSelect ? 'true' : undefined, )} aria-label=${ifDefined(this._hasOptions ? (this.label ?? undefined) : undefined)} > <div class="pkt-listbox__banners"> ${this.renderSearch()} ${this.renderMaximumReachedBanner()} ${this.renderUserMessage()} ${this.renderEmptyMessage()} ${this.renderNewOptionBanner()} </div> <ul class="pkt-listbox__options" role="presentation"> ${this.renderList()} </ul> </div> <div aria-live="polite" class="pkt-visually-hidden">${this.userMessage}</div> ` } private renderCheckboxOrCheckIcon(option: IPktComboboxOption, index: number) { return this.isMultiSelect ? html` <input class="pkt-input-check__input-checkbox" type="checkbox" role="presentation" tabindex="-1" value=${option.value} @change=${(e: Event) => { e.stopPropagation() }} .checked=${option.selected} aria-labelledby=${this.id + '-option-label-' + index} ?disabled=${this.disabled || option.disabled || (this.maxIsReached && !option.selected)} /> ` : option.selected ? html`<pkt-icon name="check-big"></pkt-icon>` : nothing } private renderEmptyMessage() { if (this.options.length > 0 || this._filteredOptions.length > 0 || this.userMessage) { return nothing } return html`<div class="pkt-listbox__banner pkt-listbox__banner--empty"> <pkt-icon class="pkt-listbox__banner-icon" name="exclamation-mark-circle" size="large" ></pkt-icon> Tom liste </div>` } private renderList() { return html` ${repeat( this._filteredOptions, (option) => option.value, (option, index) => html` <li @click=${() => { 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)} <span class="pkt-listbox__option-label" id=${this.id + '-option-label-' + index}> ${option.prefix ? html`<span class="pkt-listbox__option-prefix">${option.prefix}</span>` : nothing} ${option.label || option.value} </span> ${option.description ? html`<span class="pkt-listbox__option-description pkt-txt-14-light" >${option.description}</span >` : nothing} </li> `, )} ` } private renderNewOptionBanner() { return this.allowUserInput && this.customUserInput ? html` <div class="pkt-listbox__banner pkt-listbox__banner--new-option pkt-listbox__option" data-type="new-option" data-value=${this.customUserInput} data-selected="false" tabindex="0" @click=${() => this.toggleOption({ value: this.customUserInput || '', })} @keydown=${this.handleOptionKeydown} > <pkt-icon class="pkt-listbox__banner-icon" name="plus-sign" size="large"></pkt-icon> Legg til "${this.customUserInput}" </div> ` : nothing } private renderMaximumReachedBanner() { this._selectedOptions = this.options.filter((option) => option.selected).length return this.isMultiSelect && this._selectedOptions > 0 && this.maxLength > 0 ? html` <div class="pkt-listbox__banner pkt-listbox__banner--maximum-reached"> ${this._selectedOptions} av maks ${this.maxLength} mulige er valgt. </div> ` : nothing } private renderUserMessage() { return this.userMessage ? html`<div class="pkt-listbox__banner pkt-listbox__banner--user-message"> <pkt-icon class="pkt-listbox__banner-icon" name="exclamation-mark-circle" size="large" ></pkt-icon> ${this.userMessage} </div>` : nothing } private renderSearch() { return this.includeSearch ? html` <div class="pkt-listbox__search"> <span class="pkt-listbox__search-icon"> <pkt-icon name="magnifying-glass-small" size="large"></pkt-icon> </span> <input class="pkt-txt-16-light" type="text" aria-label="Søk i listen" form="" placeholder=${this.searchPlaceholder || strings.forms.search.placeholder} @input=${this.handleSearchInput} @keydown=${this.handleSearchKeydown} .value=${this.searchValue} data-type="searchbox" ?disabled=${this.disabled} ?readonly=${this.disabled} role="searchbox" /> </div> ` : 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 <pkt-listbox>, men den er allerede definert') }