@oslokommune/punkt-elements
Version:
Komponentbiblioteket til Punkt, et designsystem laget av Oslo Origo
506 lines (462 loc) • 19.3 kB
text/typescript
import { html, nothing, PropertyValues } from 'lit'
import { ifDefined } from 'lit/directives/if-defined.js'
import { customElement } from 'lit/decorators.js'
import { ref } from 'lit/directives/ref.js'
import { classMap } from 'lit/directives/class-map.js'
import { repeat } from 'lit/directives/repeat.js'
import type {
IPktComboboxOption,
TPktComboboxTagPlacement,
TPktComboboxDisplayValue,
} from './combobox-types'
import { findOptionByValue, findOptionIndex } from 'shared-utils/combobox/option-utils'
import { getSingleValueForInput } from 'shared-utils/combobox/input-utils'
import { slotUtils, optionStateUtils } from './combobox-utils'
import { slotContent } from '@/directives/slot-content'
import { ComboboxHandlers } from './combobox-handlers'
import '../input-wrapper'
import '../icon'
import '../tag'
import '../listbox'
// Re-export types for backward compatibility
export type { IPktComboboxOption, TPktComboboxTagPlacement } from './combobox-types'
export interface IPktCombobox {
allowUserInput?: boolean
typeahead?: boolean
disabled?: boolean
displayValueAs?: string
errorMessage?: string
fullwidth?: boolean
hasError?: boolean
helptext?: string | null
helptextDropdown?: string | null
helptextDropdownButton?: string | null
id?: string
includeSearch?: boolean
label?: string | null
maxlength?: number | null
minlength?: number | null
multiple?: boolean
name?: string
optionalTag?: boolean
optionalText?: string
options?: IPktComboboxOption[]
defaultOptions?: IPktComboboxOption[]
placeholder?: string | null
requiredTag?: boolean
requiredText?: string
searchPlaceholder?: string
tagPlacement?: TPktComboboxTagPlacement | null
tagText?: string | null
value?: string | string[]
isOpen?: boolean
}
declare global {
interface HTMLElementTagNameMap {
'pkt-combobox': PktCombobox & HTMLSelectElement
}
}
export class PktCombobox extends ComboboxHandlers implements IPktCombobox {
// Bound handler for body click — stored for cleanup in disconnectedCallback
private handleBodyClick = (e: MouseEvent) => {
if (this._isOptionsOpen && !this.contains(e.target as Node)) {
this.closeAndProcessInput()
}
}
// Lifecycle methods
connectedCallback(): void {
super.connectedCallback()
document?.body.addEventListener('click', this.handleBodyClick)
this._options = []
// Deep clone defaultOptions into options, preserving userAdded options
if (this.defaultOptions && this.defaultOptions.length) {
const userAdded = this.options?.filter((opt) => opt.userAdded) || []
this.options = [...userAdded, ...JSON.parse(JSON.stringify(this.defaultOptions))]
this._options = Array.isArray(this.options) ? [...this.options] : []
}
// If options are provided via the options slot, we need to extract them
if (this.optionsController?.nodes && this.optionsController.nodes.length) {
const parsedOptions = slotUtils.parseOptionsFromSlot(this.optionsController.nodes)
if (parsedOptions.length) {
this.options = [...parsedOptions]
this._options = [...parsedOptions]
this._optionsFromSlot = true
this._lastSlotGeneration = this.optionsController.generation
}
}
}
protected willUpdate(changedProperties: Map<PropertyKey, unknown>): void {
// Re-parse slot options when the controller detects mutations.
// The controller increments its generation counter on each mutation, but
// doesn't set any reactive properties — so we detect the change here.
if (this._optionsFromSlot && this.optionsController) {
const currentGen = this.optionsController.generation
if (currentGen !== this._lastSlotGeneration) {
this._lastSlotGeneration = currentGen
const parsedOptions = slotUtils.parseOptionsFromSlot(this.optionsController.nodes)
const userAdded = this._options.filter((o) => o.userAdded)
this.options = [...userAdded, ...parsedOptions]
}
}
super.willUpdate(changedProperties)
}
disconnectedCallback(): void {
super.disconnectedCallback()
document?.body.removeEventListener('click', this.handleBodyClick)
}
firstUpdated(changedProperties: PropertyValues): void {
// Apply defaultValue before the base class firstUpdated, which calls
// valueChanged(defaultValue) — a no-op in combobox. Setting this.value
// here lets updated() handle the sync via the normal value-change path.
if (this.defaultValue !== null && !this.value) {
this.value = this.defaultValue
}
super.firstUpdated(changedProperties)
}
updated(changedProperties: PropertyValues): void {
if (changedProperties.has('isOpen')) {
this._isOptionsOpen = this.isOpen
}
// Handle value and _value changes.
// Three cases:
// 1. value changed from our own syncValueAndDispatch (internal sync) — skip value handler,
// but still process concurrent _value changes
// 2. value changed externally — sync _value from value, dispatch events
// 3. Only _value changed — sync value from _value, dispatch events
const valueChanged = changedProperties.has('value')
const internalChanged = changedProperties.has('_value')
const isInternalSync = valueChanged && this._internalValueSync
if (isInternalSync) {
this._internalValueSync = false
if (internalChanged) {
this.syncValueAndDispatch(changedProperties.get('_value') as string[])
}
} else if (valueChanged) {
const oldInternal = [...this._value]
const newInternal = this.parseValue()
if (newInternal.join(',') !== this._value.join(',')) {
this._value = newInternal
}
this.updateMaxReached()
this.syncValueAndDispatch(oldInternal)
} else if (internalChanged) {
this.syncValueAndDispatch(changedProperties.get('_value') as string[])
}
// If defaultOptions changed, update options (preserving userAdded)
if (changedProperties.has('defaultOptions') && this.defaultOptions.length) {
const userAdded =
(Array.isArray(this.options) ? this.options : []).filter((opt) => opt.userAdded) || []
this.options = [...userAdded, ...JSON.parse(JSON.stringify(this.defaultOptions))]
this._options = Array.isArray(this.options) ? [...this.options] : []
}
if (changedProperties.has('options')) {
const prevOptions =
(changedProperties.get('options') as IPktComboboxOption[]) || this._options || []
const mergedOptions = optionStateUtils.mergeWithUserAdded(this.options, prevOptions)
this._options = mergedOptions
if (mergedOptions.length > this.options.length) {
this.options = mergedOptions
}
const syncResult = optionStateUtils.syncOptionsWithValues(this._options, this._value)
this._options = syncResult.options
if (syncResult.newValues.length > this._value.length) {
const oldValue = [...this._value]
this._value = syncResult.newValues
this.syncValueAndDispatch(oldValue)
}
}
if (changedProperties.has('_search')) {
this.dispatchEvent(
new CustomEvent('search', {
detail: this._search,
bubbles: false,
}),
)
}
// Sync text input display value for single+typeahead when dropdown is closed
if (
!this._isOptionsOpen &&
!this.multiple &&
this._hasTextInput &&
this.inputRef.value &&
this.inputRef.value.type !== 'hidden'
) {
const displayValue = this._value[0]
? getSingleValueForInput(this._value[0], this.options, this.displayValueAs)
: ''
if (this.inputRef.value.value !== displayValue) {
this.inputRef.value.value = displayValue
}
}
super.updated(changedProperties)
}
/**
* Override form reset to properly restore combobox state.
* The base class deselects all options and sets value/defaultValue, but
* combobox needs to re-sync _options with the restored values and clean up
* user-added options and UI state.
*/
protected override formResetCallback(): void {
this.touched = false
// Restore value from defaultValue (set by base class firstUpdated from
// the initial value attribute, per MDN HTMLInputElement.defaultValue)
const resetValue = this.defaultValue || (this.multiple ? '' : '')
this.value = resetValue
this._value = this.parseValue()
// Remove user-added options, then re-sync selection state with restored _value.
// We must create new arrays because the base class mutates option objects in place.
this._options = this._options
.filter((o) => !o.userAdded)
.map((o) => ({ ...o, selected: this._value.includes(o.value) }))
this.options = this.options
.filter((o) => !o.userAdded)
.map((o) => ({ ...o, selected: this._value.includes(o.value) }))
// Reset UI state
this._search = ''
this._isOptionsOpen = false
this._userInfoMessage = ''
this._addValueText = null
this._inputFocus = false
this.updateMaxReached()
if (this.inputRef.value && this.inputRef.value.type !== 'hidden') {
this.inputRef.value.value = ''
}
this.internals.setFormValue('')
this.internals.ariaInvalid = 'false'
this.requestUpdate()
}
attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
// Don't set _value here for 'value' changes — this.value hasn't been updated yet
// (super.attributeChangedCallback does that). Let updated() handle the sync.
if (name === 'options') {
this._options = Array.isArray(this.options) ? [...this.options] : []
const syncResult = optionStateUtils.syncOptionsWithValues(this._options, this._value)
this._options = syncResult.options
if (syncResult.newValues.length > this._value.length) {
this._value = syncResult.newValues
}
this._search = ''
}
super.attributeChangedCallback(name, _old, value)
}
// Render methods
render() {
return html`
<pkt-input-wrapper
.label=${this.label}
.helptext=${this.helptext}
.helptextDropdown=${ifDefined(this.helptextDropdown)}
.helptextDropdownButton=${ifDefined(this.helptextDropdownButton)}
?fullwidth=${this.fullwidth}
?hasError=${this.hasError}
?inline=${this.inline}
?disabled=${this.disabled}
.errorMessage=${this.errorMessage}
?optionalTag=${this.optionalTag}
.optionalText=${this.optionalText}
?requiredTag=${this.requiredTag}
.requiredText=${this.requiredText}
.tagText=${this.tagText}
useWrapper=${this.useWrapper}
.forId=${this._hasTextInput ? this.id + '-input' : this.id + '-combobox'}
?hasFieldset=${!this._hasTextInput}
class="pkt-combobox__wrapper"
=${this.handleInputClick}
>
<div class="pkt-contents" slot="helptext">${slotContent(this, 'helptext')}</div>
<div class="pkt-combobox" =${this.handleFocusOut}>
<div
class=${classMap({
'pkt-combobox__input': true,
'pkt-combobox__input--fullwidth': this.fullwidth,
'pkt-combobox__input--open': this._isOptionsOpen,
'pkt-combobox__input--error': this.hasError,
'pkt-combobox__input--disabled': this.disabled,
})}
id=${ifDefined(!this._hasTextInput ? `${this.id}-combobox` : undefined)}
role=${ifDefined(!this._hasTextInput ? 'combobox' : undefined)}
aria-expanded=${ifDefined(
!this._hasTextInput ? (this._isOptionsOpen ? 'true' : 'false') : undefined,
)}
aria-controls=${ifDefined(!this._hasTextInput ? `${this.id}-listbox` : undefined)}
aria-haspopup=${ifDefined(!this._hasTextInput ? 'listbox' : undefined)}
aria-labelledby=${ifDefined(
!this._hasTextInput ? `${this.id}-combobox-label` : undefined,
)}
aria-activedescendant=${ifDefined(
!this._hasTextInput &&
this._value[0] &&
!!findOptionByValue(this.options, this._value[0])
? `${this.id}-listbox-${findOptionIndex(this._options, this._value[0])}`
: undefined,
)}
aria-description=${ifDefined(this._selectionDescription || undefined)}
tabindex=${!this._hasTextInput ? (this.disabled ? '-1' : '0') : '-1'}
=${this.handleInputClick}
=${!this._hasTextInput ? this.handleSelectOnlyKeydown : nothing}
${ref(this.triggerRef)}
>
${!this._hasTextInput &&
this.placeholder &&
(!this._value.length || (this.multiple && this.tagPlacement == 'outside')) &&
!this._inputFocus
? html`<span class="pkt-combobox__placeholder" =${this.handlePlaceholderClick}
>${this.placeholder}</span
>`
: this.tagPlacement !== 'outside'
? this.renderSingleOrMultipleValues()
: nothing}
${this.renderInputField()}
<pkt-icon
class=${classMap({
'pkt-combobox__arrow-icon': true,
'pkt-combobox__arrow-icon--open': this._isOptionsOpen,
})}
name="chevron-thin-down"
aria-hidden="true"
></pkt-icon>
</div>
<pkt-listbox
id="${this.id}-listbox"
.options=${this._options}
.isOpen=${this._isOptionsOpen}
.searchPlaceholder=${this.searchPlaceholder}
.label="Liste: ${this.label || ''}"
?include-search=${this.includeSearch}
?is-multi-select=${this.multiple}
?allow-user-input=${this.allowUserInput && !this._maxIsReached}
?max-is-reached=${this._maxIsReached}
.customUserInput=${ifDefined(this._addValueText)}
.userMessage=${this._userInfoMessage}
=${this.handleSearch}
-toggle=${this.handleOptionToggled}
-all=${this.addAllOptions}
-options=${() => (this._isOptionsOpen = false)}
-close=${() => this.closeAndProcessInput()}
.searchValue=${this._search || null}
.maxLength=${this.maxlength || 0}
${ref(this.listboxRef)}
></pkt-listbox>
</div>
${this.tagPlacement === 'outside' && this.multiple
? html`<div class="pkt-combobox__tags-outside">
${this.renderSingleOrMultipleValues()}
</div>`
: nothing}
</pkt-input-wrapper>
`
}
private renderInputField() {
return this.typeahead || this.allowUserInput
? html`
<div class="pkt-combobox__input-div combobox__input">
<input
type="text"
id="${this.id}-input"
name=${(this.name || this.id) + '-input'}
placeholder=${ifDefined(
!this._value.length || (this.multiple && this.tagPlacement === 'outside')
? this.placeholder
: undefined,
)}
=${this.handleInput}
=${(e: Event) => {
e.stopPropagation()
e.stopImmediatePropagation()
}}
=${this.handleInputKeydown}
=${this.handleFocus}
=${this.handleBlur}
autocomplete="off"
role="combobox"
aria-expanded=${this._isOptionsOpen ? 'true' : 'false'}
aria-label=${ifDefined(this.label)}
aria-autocomplete=${this.typeahead ? 'both' : this.allowUserInput ? 'list' : 'none'}
aria-controls="${this.id}-listbox"
aria-activedescendant=${ifDefined(
this._value[0] && !!findOptionByValue(this.options, this._value[0])
? `${this.id}-listbox-${findOptionIndex(this._options, this._value[0])}`
: undefined,
)}
aria-description=${ifDefined(this._selectionDescription || undefined)}
${ref(this.inputRef)}
/>
</div>
`
: html`
<input
type="hidden"
id="${this.id}-input"
name=${(this.name || this.id) + '-input'}
.value=${this._value.join(',')}
${ref(this.inputRef)}
/>
`
}
private renderSingleOrMultipleValues() {
// Single select with text input: value is shown in the input field, not as a span
if (!this.multiple && this._hasTextInput) return nothing
const isSingleValueDisplay = !this.multiple
// Single value displayed as text (select-only mode)
const singleValueContent = this.renderValueTag(findOptionByValue(this.options, this._value[0]))
// Multiple values displayed as tags, wrapped in a list for accessibility
const isOutside = this.tagPlacement === 'outside'
const multipleValuesContent = html`
<ul role="list" class="pkt-combobox__tag-list">
${repeat(
this._value,
(value: string) => value,
(value: string, index: number) => {
const option = findOptionByValue(this.options, value)
const tagSkinColor = option?.tagSkinColor
return html`
<li
role="listitem"
=${isOutside ? nothing : (e: MouseEvent) => e.stopPropagation()}
=${isOutside ? nothing : (e: MouseEvent) => e.preventDefault()}
>
<pkt-tag
skin=${tagSkinColor || 'blue-dark'}
?closeTag=${!this.disabled}
.buttonTabindex=${isOutside ? undefined : -1}
=${() => this.handleTagRemove(value)}
=${isOutside
? nothing
: (e: KeyboardEvent) => this.handleTagKeydown(e, index)}
>
${this.renderValueTag(option)}
</pkt-tag>
</li>
`
},
)}
</ul>
`
return isSingleValueDisplay ? singleValueContent : multipleValuesContent
}
private renderValueTag(option: IPktComboboxOption | null) {
if (!option) return ''
const displayAs = this.displayValueAs as TPktComboboxDisplayValue
switch (displayAs) {
case 'prefixAndValue':
return html`<span class="pkt-combobox__value" data-focusfix=${this.id}
>${option.prefix || ''} ${option.value}</span
>`
case 'value':
return html`<span class="pkt-combobox__value" data-focusfix=${this.id}
>${option.value}</span
>`
case 'label':
default:
return html`<span class="pkt-combobox__value" data-focusfix=${this.id}
>${option.label || option.value}</span
>`
}
}
}
export default PktCombobox
try {
customElement('pkt-combobox')(PktCombobox)
} catch (e) {
console.warn('Forsøker å definere <pkt-combobox>, men den er allerede definert')
}