UNPKG

@oslokommune/punkt-elements

Version:

Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo

268 lines (241 loc) 9.04 kB
import { html, PropertyValues } from 'lit' import { customElement, property } from 'lit/decorators.js' import { Ref, createRef, ref } from 'lit/directives/ref.js' import { ifDefined } from 'lit/directives/if-defined.js' import { PktOptionsInputElement } from '@/base-elements/options-input-element' import { PktOptionsSlotController } from '@/controllers/pkt-options-controller' import { slotContent } from '@/directives/slot-content' import '@/components/input-wrapper' export type TSelectOption = { value: string label: string selected?: boolean disabled?: boolean hidden?: boolean } export interface IPktSelect { options: TSelectOption[] value: string } /** * Pkt Select is a wrapper for the native select element using the pkt-input-wrapper component. * * The component will prioritize options passed as a prop over options passed as children if both are provided. * This is to allow for dynamic options that might change in the case of both children/slot and props are provided. * * @slot (default) - Options to be rendered as children * @prop {TSelectOption[]} options - Options to be rendered as children * * */ declare global { interface HTMLElementTagNameMap { 'pkt-select': PktSelect & HTMLSelectElement } } export class PktSelect extends PktOptionsInputElement<{}, TSelectOption> implements IPktSelect { inputRef: Ref<HTMLSelectElement> = createRef() @property({ type: String }) value: string = '' public selectedIndex: number | null = -1 public selectedOptions: HTMLCollectionOf<HTMLOptionElement> | undefined = undefined constructor() { super() this.optionsController = new PktOptionsSlotController(this) } connectedCallback(): void { super.connectedCallback() // Parse options from props or slots this.parseOptions() // Set initial value from selected option this._options.forEach((option) => { if (option.selected && !this.value) { this.value = option.value } }) } // Support native Select method `add` public add(item: HTMLOptionElement, before?: HTMLOptionElement | number) { const newOption = { value: item.value || item.text, label: item.text || item.value, selected: item.selected, disabled: item.disabled, } // Add to our internal options array at the correct position if (before === undefined) { this._options.push(newOption) } else if (typeof before === 'number') { this._options.splice(before, 0, newOption) } else { // If before is an HTMLOptionElement, find its index in our options const beforeValue = before.value || before.text const beforeIndex = this._options.findIndex((opt) => opt.value === beforeValue) if (beforeIndex >= 0) { this._options.splice(beforeIndex, 0, newOption) } else { this._options.push(newOption) } } if (item.selected) { this.value = item.value || item.text this.selectedIndex = this._options.findIndex((opt) => opt.value === this.value) } this.requestUpdate() } // Support native Select method `remove` public remove(item?: number) { if (typeof item === 'number') { if (this.selectedIndex === item) { this.value = this._options[0]?.value || '' } this._options.splice(item, 1) this.requestUpdate() } } // Support native Select method `item` public item(index: number) { return this.inputRef.value?.item(index) } // Support native Select method `namedItem` public namedItem(name: string) { return this.inputRef.value?.namedItem(name) } // Support native Select method `showPicker` public showPicker() { if (this.inputRef.value && 'showPicker' in this.inputRef.value) { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- showPicker is not in TypeScript's HTMLSelectElement type yet ;(this.inputRef.value as any).showPicker() } } public attributeChangedCallback(name: string, _old: string, value: string): void { if (name === 'options') { this._options = value ? JSON.parse(value) : [] } if (name === 'value' && this.value !== _old) { this.selectedIndex = this.touched ? this.returnNumberOrNull(this.inputRef.value?.selectedIndex) : this._options.findIndex((option) => option.value === value) this.selectedOptions = this.inputRef.value?.selectedOptions this.valueChanged(value, _old) } super.attributeChangedCallback(name, _old, value) } update(changedProperties: PropertyValues) { super.update(changedProperties) if (changedProperties.has('_optionsProp') && this._optionsProp.length > 0) { this._options = this._optionsProp this.requestUpdate('_options') // If no value is set and we have options, set to first option if (!this.value && this._options.length > 0) { // eslint-disable-next-line lit/no-property-change-update -- Initial value setup is intentional this.value = this._options[0].value this.selectedIndex = 0 } } if (changedProperties.has('value') && this.value !== changedProperties.get('value')) { this.selectedIndex = this.touched ? this.returnNumberOrNull(this.inputRef.value?.selectedIndex) : this._options.findIndex((option) => option.value === this.value) this.selectedOptions = this.inputRef.value?.selectedOptions this.valueChanged(this.value, changedProperties.get('value')) } if (changedProperties.has('id')) { !this.name && this.id && (this.name = this.id) } } protected firstUpdated(_changedProperties: PropertyValues): void { super.firstUpdated(_changedProperties) if (this._optionsProp.length) { this._options = this._optionsProp } // If no options are selected, set value and selectedIndex to the first option if (!this.value && this._options.length > 0) { this.value = this._options[0].value this.selectedIndex = 0 } else { this.selectedIndex = this._options.findIndex((option) => option.value === this.value) } this.selectedOptions = this.inputRef.value?.selectedOptions } render() { const inputClass = `pkt-input ${this.fullwidth ? 'pkt-input--fullwidth' : ''}` return html` <pkt-input-wrapper ?counter=${this.counter} ?disabled=${this.disabled} ?hasError=${this.hasError} ?hasFieldset=${this.hasFieldset} ?inline=${this.inline} ?optionalTag=${this.optionalTag} ?requiredTag=${this.requiredTag} useWrapper=${this.useWrapper} ariaDescribedBy=${ifDefined(this.ariaDescribedBy)} class="pkt-select" errorMessage=${ifDefined(this.errorMessage)} forId=${this.id + '-input'} helptext=${ifDefined(this.helptext)} helptextDropdown=${ifDefined(this.helptextDropdown)} helptextDropdownButton=${ifDefined(this.helptextDropdownButton)} label=${ifDefined(this.label)} optionalText=${ifDefined(this.optionalText)} requiredText=${ifDefined(this.requiredText)} tagText=${ifDefined(this.tagText)} > <select class=${inputClass} aria-invalid=${this.hasError} aria-errormessage=${`${this.id}-error`} aria-labelledby=${ifDefined(this.ariaLabelledby)} ?disabled=${this.disabled} id=${this.id + '-input'} name=${(this.name || this.id) + '-input'} value=${this.value} @change=${(e: Event) => { this.touched = true this.value = (e.target as HTMLSelectElement).value e.stopImmediatePropagation() }} @input=${(e: Event) => { this.onInput() e.stopImmediatePropagation() }} @focus=${(e: FocusEvent) => { this.onFocus() e.stopImmediatePropagation() }} @blur=${(e: FocusEvent) => { this.onBlur() e.stopImmediatePropagation() }} ${ref(this.inputRef)} > ${this._options.length > 0 ? this._options.map( (option) => html`<option value=${option.value} ?selected=${this.value == option.value || option.selected} ?disabled=${option.disabled} ?hidden=${option.hidden} > ${option.label} </option>`, ) : ''} </select> <div class="pkt-contents" slot="helptext">${slotContent(this, 'helptext')}</div> </pkt-input-wrapper> ` } private returnNumberOrNull(value: number | null | undefined): number | null { if (value === undefined || value === null || isNaN(value)) { return null } return value } } try { customElement('pkt-select')(PktSelect) } catch (e) { console.warn('Forsøker å definere <pkt-select>, men den er allerede definert') }