@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
268 lines (241 loc) • 9.04 kB
text/typescript
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()
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}
=${(e: Event) => {
this.touched = true
this.value = (e.target as HTMLSelectElement).value
e.stopImmediatePropagation()
}}
=${(e: Event) => {
this.onInput()
e.stopImmediatePropagation()
}}
=${(e: FocusEvent) => {
this.onFocus()
e.stopImmediatePropagation()
}}
=${(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')
}