@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
475 lines (428 loc) • 14.6 kB
text/typescript
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
}
export class PktListbox extends PktElement implements IPktListbox {
id: string = uuidish()
label: string | null = null
options: IPktComboboxOption[] = []
isOpen: boolean = false
disabled: boolean = false
includeSearch: boolean = false
isMultiSelect: boolean = false
allowUserInput: boolean = false
maxIsReached: boolean = false
customUserInput: string | null = null
searchPlaceholder: string | null = null
searchValue: string | null = null
maxLength: number = 0
userMessage: string | null = null
private _selectedOptions: number = 0
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
=${() => {
this.toggleOption(option)
}}
aria-selected=${option.selected ? 'true' : 'false'}
=${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"
=${() =>
this.toggleOption({
value: this.customUserInput || '',
})}
=${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}
=${this.handleSearchInput}
=${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[]
}
}