@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
471 lines (431 loc) • 14.9 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 type { IPktComboboxOption } from 'shared-types/combobox'
import { uuidish } from 'shared-utils/utils'
import { filterOptionsBySearch } from 'shared-utils/combobox/option-utils'
import {
isLetterOrSpace,
createTypeaheadHandler,
findTypeaheadOptionMatch,
} from 'shared-utils/combobox/typeahead'
import {
focusAndScrollIntoView,
getOptionElements,
focusNextOption as navFocusNext,
focusPreviousOption as navFocusPrev,
focusFirstOption as navFocusFirst,
focusLastOption as navFocusLast,
focusFirstOrSelectedOption as navFocusFirstOrSelected,
} from 'shared-utils/combobox/keyboard-navigation'
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 typeahead = createTypeaheadHandler()
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)
}
disconnectedCallback(): void {
super.disconnectedCallback()
this.typeahead.reset()
}
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 === 'search-value') {
this.filterOptions()
}
super.attributeChangedCallback(name, _old, value)
}
// Render methods
private get _hasOptions(): boolean {
return this._filteredOptions.length > 0 || this.options.length > 0
}
render() {
return html`
<div
class=${classMap({
'pkt-listbox': true,
'pkt-listbox__open': this.isOpen,
'pkt-txt-16-light': true,
})}
role=${ifDefined(this._hasOptions ? 'listbox' : undefined)}
aria-multiselectable=${ifDefined(
this._hasOptions && this.isMultiSelect ? 'true' : undefined,
)}
aria-label=${ifDefined(this._hasOptions ? (this.label ?? undefined) : undefined)}
>
<div class="pkt-listbox__banners">
${this.renderSearch()} ${this.renderMaximumReachedBanner()} ${this.renderUserMessage()}
${this.renderEmptyMessage()} ${this.renderNewOptionBanner()}
</div>
<ul class="pkt-listbox__options" role="presentation">
${this.renderList()}
</ul>
</div>
<div aria-live="polite" class="pkt-visually-hidden">${this.userMessage}</div>
`
}
private 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}
=${(e: Event) => {
e.stopPropagation()
}}
.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
}
private renderEmptyMessage() {
if (this.options.length > 0 || this._filteredOptions.length > 0 || this.userMessage) {
return nothing
}
return html`<div class="pkt-listbox__banner pkt-listbox__banner--empty">
<pkt-icon
class="pkt-listbox__banner-icon"
name="exclamation-mark-circle"
size="large"
></pkt-icon>
Tom liste
</div>`
}
private 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)}
aria-disabled=${this.disabled ||
option.disabled ||
(this.maxIsReached && !option.selected)
? 'true'
: 'false'}
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>
`,
)}
`
}
private 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
}
private 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
}
private 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
}
private 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
private handleSearchInput(e: InputEvent) {
this.searchValue = (e.target as HTMLInputElement).value
this.dispatchEvent(
new CustomEvent('search', {
detail: this.searchValue,
bubbles: false,
}),
)
}
private handleSearchKeydown(e: KeyboardEvent) {
switch (e.key) {
case 'Enter':
e.preventDefault()
break
case 'ArrowUp':
case 'Escape':
this.closeOptions()
e.preventDefault()
break
case 'ArrowDown':
this.focusFirstOrSelectedOption()
break
case 'Tab':
this.tabClose()
break
}
}
private 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 (
!getOptionElements(this).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':
this.closeOptions()
e.preventDefault()
break
case 'Tab':
// Don't preventDefault — let Tab move focus naturally
this.tabClose()
break
case 'ArrowDown':
if (e.altKey) {
navFocusLast(this)
} else {
if (itemType === 'searchbox' || itemType === 'new-option') {
navFocusFirst(this)
} else {
navFocusNext(target)
}
}
e.preventDefault()
break
case 'ArrowUp':
if (e.altKey) {
navFocusFirst(this)
} 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 {
navFocusPrev(target, this, this.includeSearch)
}
}
e.preventDefault()
break
case 'Home':
navFocusFirst(this)
e.preventDefault()
break
case 'End':
navFocusLast(this)
e.preventDefault()
break
default:
if ((e.metaKey || e.ctrlKey) && e.key === 'a') {
this.selectAll()
e.preventDefault()
}
if (isLetterOrSpace(e.key)) {
this.handleTypeAhead(e.key)
e.preventDefault()
}
break
}
}
// Focus management methods (delegates to shared utils)
focusFirstOrSelectedOption() {
navFocusFirstOrSelected(this, {
disabled: this.disabled,
allowUserInput: this.allowUserInput,
customUserInput: this.customUserInput,
includeSearch: this.includeSearch,
})
}
// Event dispatching methods
private 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,
}),
)
}
private selectAll() {
this.dispatchEvent(new CustomEvent('select-all', { bubbles: false }))
}
private closeOptions() {
this.dispatchEvent(new CustomEvent('close-options', { bubbles: false }))
}
private tabClose() {
this.dispatchEvent(new CustomEvent('tab-close', { bubbles: false }))
}
// Filtering and typeahead methods
filterOptions() {
this._filteredOptions = filterOptionsBySearch(this.options, this.searchValue)
}
private handleTypeAhead(char: string) {
const searchString = this.typeahead.append(char)
const options = getOptionElements(this)
const matchIndex = findTypeaheadOptionMatch(options, searchString)
if (matchIndex >= 0) {
focusAndScrollIntoView(options[matchIndex])
}
}
}
try {
customElement('pkt-listbox')(PktListbox)
} catch (e) {
console.warn('Forsøker å definere <pkt-listbox>, men den er allerede definert')
}