page ComboBoxcomponent { seo { title = "ComboBox component" description = "Editable autocomplete combobox with local and remote suggestions." } view {
W WRNexusJS
advanced-forms component

ComboBox

Editable autocomplete combobox with local and remote suggestions.

@wrnexus/ui 0.3.641 props0 slots8 events

Usage

Components are auto-discovered. Use the component directly in any .wrn view:

<ComboBox
  size="default"
  color="primary"
  label="ComboBox"
  name="name"
  value="value"
/>

Legacy mount name: data-component="ComboBox".

Props and attributes

PropTypeRequirementDefault
sizestringOptional"default"
colorstringOptional"primary"
labelstringOptional"ComboBox"
namestringOptional""
valuestringOptional""
optionsstringOptional[]
groupsstringOptional[]
placeholderstringOptional"Search or select an option"
searchPlaceholderstringOptional"Start typing…"
clearablebooleanOptionaltrue
allowCustomValuebooleanOptionalfalse
disabledbooleanOptionalfalse
requiredbooleanOptionalfalse
invalidbooleanOptionalfalse
validationMessagestringOptional""
helpTextstringOptional""
loadingbooleanOptionalfalse
loadingLabelstringOptional"Loading suggestions…"
emptyLabelstringOptional"No matching options"
clearLabelstringOptional"Clear value"
toggleLabelstringOptional"Toggle suggestions"
searchModestringOptional"contains"
searchFieldsstringOptional"label,description"
minSearchLengthnumberOptional0
searchResultLimitnumberOptional0
optionTemplatestringOptional"default"
defaultOpenbooleanOptionalfalse
closeOnSelectbooleanOptionaltrue
fixedbooleanOptionalfalse
placementstringOptional"bottom"
autocompletestringOptional"off"
remotebooleanOptionalfalse
remoteUrlstringOptional""
remoteQueryParamstringOptional"q"
remoteDebouncenumberOptional250
remoteAutoLoadbooleanOptionaltrue
infinitebooleanOptionalfalse
hasMorebooleanOptionalfalse
pagenumberOptional1
loadMoreLabelstringOptional"Load more"
classstringOptional""

Slots

This component does not declare a slot.

Events

  • search
  • select
  • change
  • clear
  • open
  • close
  • load
  • error

Source contract

Installed source: components/ComboBox.wrn

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>
    }
}
} }