@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
257 lines (233 loc) • 8.72 kB
text/typescript
import { html, PropertyValues } from 'lit'
import { customElement, property, state } from 'lit/decorators.js'
import { Ref, createRef, ref } from 'lit/directives/ref.js'
import { ifDefined } from 'lit/directives/if-defined.js'
import { PktInputElement } from '@/base-elements/input-element'
import { PktOptionsSlotController } from '@/controllers/pkt-options-controller'
import { PktSlotController } from '@/controllers/pkt-slot-controller'
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 PktInputElement implements IPktSelect {
private inputRef: Ref<HTMLSelectElement> = createRef()
private helptextSlot: Ref<HTMLElement> = createRef()
options: TSelectOption[] = []
value: string = ''
private _options: TSelectOption[] = []
public selectedIndex: number | null = -1
public selectedOptions: HTMLCollectionOf<HTMLOptionElement> | undefined = undefined
constructor() {
super()
this.optionsController = new PktOptionsSlotController(this)
this.slotController = new PktSlotController(this, this.helptextSlot)
this.slotController.skipOptions = true
}
// Used for initilization
connectedCallback(): void {
super.connectedCallback()
const optionsReceivedFromProps = this.options.length > 0
const checkIfOptionNodesInSlot =
this.optionsController.nodes.length && this.optionsController.nodes.length > 0
if (!optionsReceivedFromProps && checkIfOptionNodesInSlot) {
this.optionsController.nodes.forEach((node: Element) => {
const option: TSelectOption = {
value: node.hasAttribute('value')
? (node.getAttribute('value') ?? '')
: (node.textContent ?? ''),
label: node.textContent || node.getAttribute('value') || '',
disabled: node.hasAttribute('disabled'),
hidden: node.hasAttribute('data-hidden'),
}
if (node.getAttribute('selected') && !this.value) {
this.value = option.value
}
this._options.push(option)
})
} else {
this._options = this.options
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) {
this.inputRef.value?.add(item, before)
this._options.push({
value: item.value || item.text,
label: item.text || item.value,
selected: item.selected,
disabled: item.disabled,
})
if (item.selected) {
this.value = item.value || item.text
this.selectedIndex = this.returnNumberOrNull(this.inputRef.value?.selectedIndex)
this.selectedOptions = this.inputRef.value?.selectedOptions
}
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.inputRef.value?.remove(item)
}
}
// 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() {
this.inputRef.value?.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('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.options.length) {
this._options = this.options
}
// 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" ${ref(this.helptextSlot)} name="helptext" slot="helptext"></div>
</pkt-input-wrapper>
`
}
private returnNumberOrNull(value: number | null | undefined): number | null {
if (value === undefined || value === null || isNaN(value)) {
return null
}
return value
}
}