UNPKG

@oslokommune/punkt-elements

Version:

Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo

475 lines (428 loc) 14.6 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 { IPktComboboxOption } from '@/components/combobox/combobox' import { uuidish } from '@/utils/stringutils' 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 } @customElement('pkt-listbox') 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 }) isOpen: boolean = false @property({ type: Boolean }) disabled: boolean = false @property({ type: Boolean }) includeSearch: boolean = false @property({ type: Boolean }) isMultiSelect: boolean = false @property({ type: Boolean }) allowUserInput: boolean = false @property({ type: Boolean }) maxIsReached: boolean = false @property({ type: String }) customUserInput: string | null = null @property({ type: String }) searchPlaceholder: string | null = null @property({ type: String }) searchValue: string | null = null @property({ type: Number }) maxLength: number = 0 @property({ type: String }) userMessage: string | null = null private _selectedOptions: number = 0 @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) } 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 === 'searchValue' || name === 'search-value') { this.filterOptions() } super.attributeChangedCallback(name, _old, value) } // Render methods render() { return html` <div class=${classMap({ 'pkt-listbox': true, 'pkt-listbox__open': this.isOpen, 'pkt-txt-16-light': true, })} role="listbox" aria-label=${ifDefined(this.label)} > <div class="pkt-listbox__banners"> ${this.renderMaximumReachedBanner()} ${this.renderUserMessage()} ${this.renderNewOptionBanner()} ${this.renderSearch()} </div> <ul class="pkt-listbox__options" role="presentation"> ${this.renderList()} </ul> </div> ` } 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} .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 } 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)} 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> `, )} ` } 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 } 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 } 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 } 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 handleSearchInput(e: InputEvent) { this.searchValue = (e.target as HTMLInputElement).value this.dispatchEvent( new CustomEvent('search', { detail: this.searchValue, bubbles: false, }), ) } handleSearchKeydown(e: KeyboardEvent) { switch (e.key) { case 'Enter': e.preventDefault() break case 'ArrowUp': case 'Escape': this.closeOptions() e.preventDefault() break case 'ArrowDown': case 'Tab': this.focusFirstOrSelectedOption() break } } 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 ( !this.getOptionElements().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': case 'Tab': this.closeOptions() break case 'ArrowDown': if (e.altKey) { this.focusLastOption() } else { if (itemType === 'searchbox' || itemType === 'new-option') { this.focusFirstOption() } else { this.focusNextOption(target) } } e.preventDefault() break case 'ArrowUp': if (e.altKey) { this.focusFirstOption() } 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 { this.focusPreviousOption(target) } } e.preventDefault() break case 'Home': this.focusFirstOption() e.preventDefault() break case 'End': this.focusLastOption() e.preventDefault() break default: if ((e.metaKey || e.ctrlKey) && e.key === 'a') { this.selectAll() e.preventDefault() } if (this.isLetterOrSpace(e.key)) { this.handleTypeAhead(e.key) e.preventDefault() } break } } // Focus management methods focusAndScrollIntoView(el: HTMLElement) { el.scrollIntoView({ block: 'nearest' }) window.setTimeout(() => el.focus(), 0) } focusNextOption(target: HTMLElement) { const nextOption = target.nextElementSibling as HTMLElement nextOption && this.focusAndScrollIntoView(nextOption) } focusPreviousOption(target: HTMLElement) { const previousOption = target.previousElementSibling as HTMLElement if (target.dataset.index === '0' && this.includeSearch) { const searchInput = this.querySelector('[role="searchbox"]') as HTMLElement searchInput && this.focusAndScrollIntoView(searchInput) } else if (previousOption) { this.focusAndScrollIntoView(previousOption) } } focusFirstOption() { const firstOption = this.getOptionElements()[0] firstOption && this.focusAndScrollIntoView(firstOption) } focusLastOption() { const lastOption = this.getOptionElements().pop() lastOption && this.focusAndScrollIntoView(lastOption) } focusFirstOrSelectedOption() { if (this.disabled) return const selectedOption = this.getOptionElements().find( (option) => option.dataset.selected === 'true', ) if (this.allowUserInput && this.customUserInput) { const newOption = this.querySelector('[data-type="new-option"]') as HTMLElement this.focusAndScrollIntoView(newOption) } else if (selectedOption) { this.focusAndScrollIntoView(selectedOption) } else if (this.includeSearch && !(document.activeElement instanceof HTMLInputElement)) { const searchInput = this.querySelector('[role="searchbox"]') as HTMLElement window.setTimeout(() => searchInput.focus(), 0) } else { this.focusFirstOption() } } // Event dispatching methods 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, }), ) } selectAll() { this.dispatchEvent(new CustomEvent('select-all', { bubbles: false })) } closeOptions() { this.dispatchEvent(new CustomEvent('close-options', { bubbles: false })) } // Filtering and typeahead methods filterOptions() { if (this.searchValue) { this._filteredOptions = this.options.filter((option) => { const fulltext = option.label + option.value return fulltext.toLowerCase().includes(this.searchValue?.toLowerCase() || '') }) } else { this._filteredOptions = [...this.options] } } isLetterOrSpace(char: string): boolean { return /^[\p{L} ]$/u.test(char) } handleTypeAhead(char: string) { this.typeAheadString += char.toLowerCase() if (this.typeAheadTimeout) { clearTimeout(this.typeAheadTimeout) } this.typeAheadTimeout = window.setTimeout(() => { this.typeAheadString = '' }, 500) const options = this.getOptionElements() const match = options.find((option) => option.textContent?.trim().toLowerCase().startsWith(this.typeAheadString), ) match && this.focusAndScrollIntoView(match) } // DOM helper methods getOptionElements() { if (!this._filteredOptions.length) { return [] } return Array.from( this.querySelectorAll('[role="option"]:not([data-disabled])') || [], ) as HTMLElement[] } }