387 lines
12 KiB
Plaintext
387 lines
12 KiB
Plaintext
component ComboBox {
|
|
props {
|
|
size = "default"
|
|
color = "primary"
|
|
label = "ComboBox"
|
|
name = ""
|
|
value = ""
|
|
options = []
|
|
groups = []
|
|
placeholder = "Search or select an option"
|
|
searchPlaceholder = "Start typing…"
|
|
clearable = true
|
|
allowCustomValue = false
|
|
disabled = false
|
|
required = false
|
|
invalid = false
|
|
validationMessage = ""
|
|
helpText = ""
|
|
loading = false
|
|
loadingLabel = "Loading suggestions…"
|
|
emptyLabel = "No matching options"
|
|
clearLabel = "Clear value"
|
|
toggleLabel = "Toggle suggestions"
|
|
searchMode = "contains"
|
|
searchFields = "label,description"
|
|
minSearchLength = 0
|
|
searchResultLimit = 0
|
|
optionTemplate = "default"
|
|
defaultOpen = false
|
|
closeOnSelect = true
|
|
fixed = false
|
|
placement = "bottom"
|
|
autocomplete = "off"
|
|
remote = false
|
|
remoteUrl = ""
|
|
remoteQueryParam = "q"
|
|
remoteDebounce = 250
|
|
remoteAutoLoad = true
|
|
infinite = false
|
|
hasMore = false
|
|
page = 1
|
|
loadMoreLabel = "Load more"
|
|
class = ""
|
|
@event search = function
|
|
@event select = function
|
|
@event change = function
|
|
@event clear = function
|
|
@event open = function
|
|
@event close = function
|
|
@event load = function
|
|
@event error = function
|
|
}
|
|
|
|
state open = defaultOpen
|
|
state query = ""
|
|
state selectedValue = value
|
|
state activeIndex = -1
|
|
|
|
functions {
|
|
function allOptions() {
|
|
return [...groups.flatMap((group) => group.options || []), ...options]
|
|
}
|
|
|
|
function searchableText(option) {
|
|
return ((option.label || "") + " " + (option.description || "")).toLowerCase()
|
|
}
|
|
|
|
function matches(option) {
|
|
if (!query || query.length < minSearchLength) {
|
|
return true
|
|
}
|
|
if (searchMode === "startsWith") {
|
|
return searchableText(option).startsWith(query.toLowerCase())
|
|
}
|
|
if (searchMode === "exact") {
|
|
return searchableText(option) === query.toLowerCase()
|
|
}
|
|
return searchableText(option).includes(query.toLowerCase())
|
|
}
|
|
|
|
function matchingOptions(list) {
|
|
return list.filter((option) => matches(option))
|
|
}
|
|
|
|
function visibleOptions(list) {
|
|
if (searchResultLimit > 0) {
|
|
return matchingOptions(list).slice(0, searchResultLimit)
|
|
}
|
|
return matchingOptions(list)
|
|
}
|
|
|
|
function flatVisibleOptions() {
|
|
return visibleOptions(allOptions())
|
|
}
|
|
|
|
function isSelected(option) {
|
|
return selectedValue === option.value
|
|
}
|
|
|
|
function selectedOptions() {
|
|
return allOptions().filter((option) => isSelected(option))
|
|
}
|
|
|
|
function selectedText() {
|
|
return selectedOptions().length ? selectedOptions()[0].label : selectedValue
|
|
}
|
|
|
|
function inputText() {
|
|
return query || selectedText()
|
|
}
|
|
|
|
function shouldShowDropdown() {
|
|
if (!open) {
|
|
return false
|
|
}
|
|
if (loading) {
|
|
return true
|
|
}
|
|
if (remote && query.length !== 0 && query.length < minSearchLength) {
|
|
return true
|
|
}
|
|
if (flatVisibleOptions().length) {
|
|
return true
|
|
}
|
|
return infinite && hasMore
|
|
}
|
|
|
|
function canSelect(option) {
|
|
return !option.disabled
|
|
}
|
|
|
|
function chooseOption(option) {
|
|
if (!canSelect(option)) {
|
|
return
|
|
}
|
|
selectedValue = option.value
|
|
query = option.label
|
|
activeIndex = optionIndex(option)
|
|
if (closeOnSelect) {
|
|
open = false
|
|
}
|
|
}
|
|
|
|
function updateQuery(event) {
|
|
query = event.target.value
|
|
selectedValue = allowCustomValue ? event.target.value : ""
|
|
open = true
|
|
activeIndex = flatVisibleOptions().length ? 0 : -1
|
|
}
|
|
|
|
function clearValue(event) {
|
|
if (event) {
|
|
event.stopPropagation()
|
|
}
|
|
query = ""
|
|
selectedValue = ""
|
|
activeIndex = -1
|
|
open = false
|
|
}
|
|
|
|
function openDropdown() {
|
|
if (!disabled) {
|
|
open = true
|
|
activeIndex = flatVisibleOptions().length ? 0 : -1
|
|
}
|
|
}
|
|
|
|
function closeDropdown() {
|
|
open = false
|
|
}
|
|
|
|
function toggle() {
|
|
if (open) {
|
|
closeDropdown()
|
|
} else {
|
|
openDropdown()
|
|
}
|
|
}
|
|
|
|
function setValue(nextValue) {
|
|
selectedValue = nextValue
|
|
query = ""
|
|
}
|
|
|
|
function moveActive(direction) {
|
|
if (!flatVisibleOptions().length) {
|
|
return
|
|
}
|
|
activeIndex = (activeIndex + direction + flatVisibleOptions().length) % flatVisibleOptions().length
|
|
}
|
|
|
|
function handleKeydown(event) {
|
|
if (disabled) {
|
|
return
|
|
}
|
|
if (event.key === "ArrowDown") {
|
|
event.preventDefault()
|
|
openDropdown()
|
|
moveActive(1)
|
|
} else if (event.key === "ArrowUp") {
|
|
event.preventDefault()
|
|
openDropdown()
|
|
moveActive(-1)
|
|
} else if (event.key === "Enter" && open && activeIndex >= 0) {
|
|
event.preventDefault()
|
|
chooseOption(flatVisibleOptions()[activeIndex])
|
|
} else if (event.key === "Escape") {
|
|
closeDropdown()
|
|
} else if (event.key === "Home" && open) {
|
|
event.preventDefault()
|
|
activeIndex = 0
|
|
} else if (event.key === "End" && open) {
|
|
event.preventDefault()
|
|
activeIndex = flatVisibleOptions().length - 1
|
|
}
|
|
}
|
|
|
|
function optionIndex(option) {
|
|
return flatVisibleOptions().findIndex((item) => item.value === option.value)
|
|
}
|
|
}
|
|
|
|
view {
|
|
<div
|
|
class="wire-next wire-next--color-{color} wire-next--size-{size} wire-next--advanced-select wire-next--combobox {open ? 'wire-next--open' : ''} {fixed ? 'wire-next--advanced-select-fixed' : ''} {invalid ? 'wire-next--invalid' : ''} {disabled ? 'wire-next--disabled' : ''} {class}"
|
|
data-placement="{placement}"
|
|
data-search-mode="{searchMode}"
|
|
data-remote="{remote ? 'true' : 'false'}"
|
|
data-remote-url="{remoteUrl}"
|
|
data-remote-query-param="{remoteQueryParam}"
|
|
data-remote-debounce="{remoteDebounce}"
|
|
data-remote-auto-load="{remoteAutoLoad ? 'true' : 'false'}"
|
|
data-infinite="{infinite ? 'true' : 'false'}"
|
|
data-page="{page}"
|
|
data-wrn-select
|
|
data-wrn-combobox
|
|
@focusout="if (!event.currentTarget.contains(event.relatedTarget)) { closeDropdown() }"
|
|
>
|
|
<label id="{name}-label" for="{name}-input">{label}</label>
|
|
|
|
<div class="wire-next__select-control wire-next__combobox-control">
|
|
<span class="wire-next__search-icon icon-[lucide--search]" aria-hidden="true"></span>
|
|
<input
|
|
{...attrs}
|
|
id="{name}-input"
|
|
class="wire-next__select-trigger wire-next__combobox-input"
|
|
type="text"
|
|
role="combobox"
|
|
name="{allowCustomValue ? name : ''}"
|
|
value="{inputText()}"
|
|
placeholder="{placeholder}"
|
|
autocomplete="{autocomplete}"
|
|
aria-autocomplete="list"
|
|
aria-haspopup="listbox"
|
|
aria-expanded="{open}"
|
|
aria-controls="{name}-listbox"
|
|
aria-labelledby="{name}-label"
|
|
aria-invalid="{invalid}"
|
|
disabled="{disabled}"
|
|
required="{required && allowCustomValue}"
|
|
@focus="openDropdown()"
|
|
@click="openDropdown()"
|
|
@input="updateQuery(event)"
|
|
@keydown="handleKeydown(event)"
|
|
/>
|
|
|
|
<button
|
|
type="button"
|
|
class="wire-next__clear-select"
|
|
data-show="clearable && inputText() && !disabled"
|
|
aria-label="{clearLabel}"
|
|
title="{clearLabel}"
|
|
@click="clearValue(event)"
|
|
>
|
|
<span class="icon-[lucide--x]" aria-hidden="true"></span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="wire-next__combobox-toggle"
|
|
aria-label="{toggleLabel}"
|
|
tabindex="-1"
|
|
disabled="{disabled}"
|
|
@click="toggle()"
|
|
>
|
|
<span class="wire-next__select-chevron icon-[lucide--chevrons-up-down]" aria-hidden="true"></span>
|
|
</button>
|
|
</div>
|
|
|
|
<div
|
|
class="wire-next__select-dropdown {fixed ? 'wire-next__select-dropdown--fixed' : ''}"
|
|
data-show="shouldShowDropdown()"
|
|
style="{shouldShowDropdown() ? '' : 'display: none'}"
|
|
>
|
|
<div
|
|
class="wire-next__select-message"
|
|
data-show="remote && query.length !== 0 && query.length < minSearchLength"
|
|
>Enter at least {minSearchLength} characters.</div>
|
|
<div class="wire-next__select-message" data-show="loading" role="status">
|
|
<i class="wire-spinner wire-spinner--inline" aria-hidden="true"></i>
|
|
{loadingLabel}
|
|
</div>
|
|
|
|
<div
|
|
id="{name}-listbox"
|
|
class="wire-next__select-list"
|
|
data-show="(!remote || (query.length === 0 && remoteAutoLoad) || query.length >= minSearchLength) && !loading"
|
|
role="listbox"
|
|
>
|
|
{#each groups as group}
|
|
{#if visibleOptions(group.options || []).length}
|
|
<div class="wire-next__option-group" role="group" aria-label="{group.label}">
|
|
<div class="wire-next__group-label">{group.label}</div>
|
|
{#each group.options || [] as option}
|
|
<button
|
|
type="button"
|
|
class="wire-next__select-option {isSelected(option) ? 'wire-next__select-option--selected' : ''} {optionIndex(option) === activeIndex ? 'wire-next__select-option--active' : ''}"
|
|
data-option-value="{option.value}"
|
|
data-show="matches(option)"
|
|
role="option"
|
|
aria-selected="{isSelected(option)}"
|
|
disabled="{!canSelect(option)}"
|
|
@mouseenter="activeIndex = optionIndex(option)"
|
|
@mousedown="event.preventDefault()"
|
|
@click="chooseOption(option)"
|
|
>
|
|
{#if optionTemplate === "icon" && option.icon}<i class="{option.icon}" aria-hidden="true"></i>{/if}
|
|
{#if optionTemplate === "avatar" && option.avatar}<img src="{option.avatar}" alt="" />{/if}
|
|
{#if optionTemplate === "color" && option.color}<i class="wire-next__color-dot" style="--option-color: {option.color}"></i>{/if}
|
|
<span><strong>{option.label}</strong>{#if option.description}<small>{option.description}</small>{/if}</span>
|
|
<i class="wire-next__check icon-[lucide--check]" data-show="isSelected(option)" aria-hidden="true"></i>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
{/each}
|
|
|
|
{#each options as option}
|
|
<button
|
|
type="button"
|
|
class="wire-next__select-option {isSelected(option) ? 'wire-next__select-option--selected' : ''} {optionIndex(option) === activeIndex ? 'wire-next__select-option--active' : ''}"
|
|
data-option-value="{option.value}"
|
|
data-show="matches(option)"
|
|
role="option"
|
|
aria-selected="{isSelected(option)}"
|
|
disabled="{!canSelect(option)}"
|
|
@mouseenter="activeIndex = optionIndex(option)"
|
|
@mousedown="event.preventDefault()"
|
|
@click="chooseOption(option)"
|
|
>
|
|
{#if optionTemplate === "icon" && option.icon}<i class="{option.icon}" aria-hidden="true"></i>{/if}
|
|
{#if optionTemplate === "avatar" && option.avatar}<img src="{option.avatar}" alt="" />{/if}
|
|
{#if optionTemplate === "color" && option.color}<i class="wire-next__color-dot" style="--option-color: {option.color}"></i>{/if}
|
|
<span><strong>{option.label}</strong>{#if option.description}<small>{option.description}</small>{/if}</span>
|
|
<i class="wire-next__check icon-[lucide--check]" data-show="isSelected(option)" aria-hidden="true"></i>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
class="wire-next__load-more"
|
|
data-wrn-select-load-more
|
|
data-show="infinite && hasMore"
|
|
>{loadMoreLabel}</button>
|
|
</div>
|
|
|
|
{#if !allowCustomValue}
|
|
<input
|
|
type="hidden"
|
|
name="{name}"
|
|
value="{selectedValue}"
|
|
required="{required}"
|
|
data-error="{name}"
|
|
aria-describedby="{validationMessage ? `${name}-validation` : helpText ? `${name}-help` : ''}"
|
|
/>
|
|
{/if}
|
|
|
|
{#if helpText && !validationMessage}<small id="{name}-help">{helpText}</small>{/if}
|
|
<small
|
|
id="{name}-validation"
|
|
class="wire-next__validation"
|
|
data-error="{name}"
|
|
>{validationMessage}</small>
|
|
</div>
|
|
}
|
|
}
|