page AdvancedSelectcomponent { seo { title = "AdvancedSelect component" description = "Theme-aware, responsive advanced select component." } view {
W WRNexusJS
advanced-forms component

AdvancedSelect

Theme-aware, responsive advanced select component.

@wrnexus/ui 0.3.651 props0 slots8 events

Usage

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

<AdvancedSelect
  size="default"
  color="primary"
  label="Advanced Select"
  name="name"
  value="value"
/>

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

Props and attributes

PropTypeRequirementDefault
sizestringOptional"default"
colorstringOptional"primary"
labelstringOptional"Advanced Select"
namestringOptional""
valuestringOptional""
valuesstringOptional[]
optionsstringOptional[]
groupsstringOptional[]
placeholderstringOptional"Select an option"
placeholderIconstringOptional""
searchPlaceholderstringOptional"Search options…"
multiplebooleanOptionalfalse
searchablebooleanOptionaltrue
defaultOpenbooleanOptionalfalse
clearablebooleanOptionaltrue
allowEmptybooleanOptionaltrue
tagsbooleanOptionalfalse
disabledbooleanOptionalfalse
requiredbooleanOptionalfalse
invalidbooleanOptionalfalse
validationMessagestringOptional""
helpTextstringOptional""
loadingbooleanOptionalfalse
loadingLabelstringOptional"Loading options…"
emptyLabelstringOptional"No options found"
selectedOptionsLabelstringOptional"Selected options"
clearLabelstringOptional"Clear selection"
createLabelstringOptional"Create"
loadMoreLabelstringOptional"Load more"
searchModestringOptional"contains"
searchFieldsstringOptional"label,description"
minSearchLengthnumberOptional0
searchResultLimitnumberOptional0
maxSelectionsnumberOptional0
showCounterbooleanOptionalfalse
counterTemplatestringOptional"{selected} selected"
optionTemplatestringOptional"default"
selectedTemplatestringOptional"default"
closeOnSelectbooleanOptionaltrue
scrollToSelectedbooleanOptionaltrue
fixedbooleanOptionalfalse
placementstringOptional"bottom"
remotebooleanOptionalfalse
remoteUrlstringOptional""
remoteQueryParamstringOptional"q"
remoteDebouncenumberOptional250
remoteAutoLoadbooleanOptionaltrue
infinitebooleanOptionalfalse
hasMorebooleanOptionalfalse
pagenumberOptional1
classstringOptional""

Slots

This component does not declare a slot.

Events

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

Source contract

Installed source: components/AdvancedSelect.wrn

component AdvancedSelect {
  props {
    size = "default"
    color = "primary"
    label = "Advanced Select"
    name = ""
    value = ""
    values = []
    options = []
    groups = []
    placeholder = "Select an option"
    placeholderIcon = ""
    searchPlaceholder = "Search options…"
    multiple = false
    searchable = true
    defaultOpen = false
    clearable = true
    allowEmpty = true
    tags = false
    disabled = false
    required = false
    invalid = false
    validationMessage = ""
    helpText = ""
    loading = false
    loadingLabel = "Loading options…"
    emptyLabel = "No options found"
    selectedOptionsLabel = "Selected options"
    clearLabel = "Clear selection"
    createLabel = "Create"
    loadMoreLabel = "Load more"
    searchMode = "contains"
    searchFields = "label,description"
    minSearchLength = 0
    searchResultLimit = 0
    maxSelections = 0
    showCounter = false
    counterTemplate = "{selected} selected"
    optionTemplate = "default"
    selectedTemplate = "default"
    closeOnSelect = true
    scrollToSelected = true
    fixed = false
    placement = "bottom"
    remote = false
    remoteUrl = ""
    remoteQueryParam = "q"
    remoteDebounce = 250
    remoteAutoLoad = true
    infinite = false
    hasMore = false
    page = 1
    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 activeIndex = -1
  state selectedValue = value
  state selectedValues = values

  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 < Number(minSearchLength || 0)) {
        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, Number(searchResultLimit))
      }
      return matchingOptions(list)
    }

    function flatVisibleOptions() {
      return visibleOptions(allOptions())
    }

    function isSelected(option) {
      return (
        multiple
        ? selectedValues.includes(option.value)
        : selectedValue === option.value
      )
    }

    function selectedOptions() {
      return allOptions().filter((option) => isSelected(option))
    }

    function selectedCount() {
      return multiple ? selectedValues.length : selectedValue ? 1 : 0
    }

    function counterText() {
      return (
        maxSelections
        ? selectedCount() + " / " + maxSelections + " selected"
        : selectedCount() + " selected"
      )
    }

    function selectedText() {
      return (
        multiple
        ? selectedValues.join(", ")
        : selectedOptions().length
          ? selectedOptions()[0].label
          : ""
      )
    }

    function triggerText() {
      return selectedCount() ? selectedText() : placeholder
    }

    function canSelect(option) {
      if (option.disabled) {
        return false
      }
      if (!multiple || isSelected(option)) {
        return true
      }
      if (maxSelections <= 0) {
        return true
      }
      return selectedCount() < maxSelections
    }

    function chooseSingle(option) {
      if (!canSelect(option)) {
        return
      }
      selectedValue = option.value
      query = ""
      if (closeOnSelect) {
        open = false
      }
    }

    function chooseMultiple(option) {
      if (!canSelect(option)) {
        return
      }
      if (isSelected(option)) {
        selectedValues = selectedValues.filter((item) => item !== option.value)
      } else {
        selectedValues = selectedValues.concat([option.value])
      }
      query = ""
    }

    function chooseOption(option) {
      if (multiple) {
        chooseMultiple(option)
        return
      }
      chooseSingle(option)
    }

    function clearSelection(event) {
      event.stopPropagation()
      selectedValue = ""
      selectedValues = []
      query = ""
      open = false
    }

    function toggle() {
      if (disabled) {
        return;
      };
      open = !open;
      activeIndex = open && flatVisibleOptions().length ? 0 : -1;
    }

    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()
        if (!open) {
          open = true
        }
        moveActive(1)
      } else if (event.key === "ArrowUp") {
        event.preventDefault()
        if (!open) {
          open = true
        }
        moveActive(-1)
      } else if (event.key === "Enter" || event.key === " ") {
        event.preventDefault()
        if (!open) {
          open = true
        } else if (activeIndex >= 0) {
          chooseOption(flatVisibleOptions()[activeIndex])
        }
      } else if (event.key === "Escape") {
        open = false
      } 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 {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
      @focusout="if (!event.currentTarget.contains(event.relatedTarget)) { open = false }"
    >
        <div class="wire-next__row">
        <label id="{name}-label" for="{name}-trigger">{label}</label>
        <small data-show="showCounter" data-text="counterText()">{counterText()}</small>
        </div>
        <div class="wire-next__select-control">
        <button
          {...attrs}
          id="{name}-trigger"
          type="button"
          class="wire-next__select-trigger"
          role="combobox"
          aria-haspopup="listbox"
          aria-expanded="{open}"
          aria-controls="{name}-listbox"
          aria-labelledby="{name}-label"
          aria-invalid="{invalid}"
          disabled="{disabled}"
          @click="toggle()"
          @keydown="handleKeydown(event)"
        >
        <span class="wire-next__select-value">
        <span data-show="!multiple" data-text="triggerText()">{triggerText()}</span>
        <span
              data-show="multiple && selectedTemplate === 'count' && selectedCount() > 0"
              data-text="selectedCount() + ' selected'"
            >{selectedCount()} selected</span>
        <span
              data-show="multiple && selectedTemplate === 'text' && selectedCount() > 0"
              data-text="selectedText()"
            >{selectedText()}</span>
        <span
              class="wire-next__select-tags"
              aria-label="{selectedOptionsLabel}"
              data-show="multiple && selectedTemplate !== 'count' && selectedTemplate !== 'text' && selectedCount() > 0"
            >
              {#each allOptions() as option}
                <span data-show="isSelected(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}
                  {option.label}
                </span>
              {/each}
            </span>
        <span data-show="multiple && selectedCount() === 0">
              {#if placeholderIcon}<i class="{placeholderIcon}" aria-hidden="true">
        </i>{/if}
              <span class="wire-next__placeholder">{placeholder}</span>
        </span>
        </span>
        <span
            class="wire-next__select-chevron icon-[lucide--chevrons-up-down]"
            aria-hidden="true"
          >
        </span>
        </button>
        <button
          type="button"
          class="wire-next__clear-select"
          data-show="clearable && allowEmpty && selectedCount() > 0 && !disabled"
          aria-label="{clearLabel}"
          title="{clearLabel}"
          @click="clearSelection(event)"
        >
        <span class="icon-[lucide--x]" aria-hidden="true">
        </span>
        </button>
        </div>
        <div
        class="wire-next__select-dropdown {fixed ? 'wire-next__select-dropdown--fixed' : ''}"
        data-show="open"
        style="{open ? '' : 'display: none'}"
      >
          {#if searchable}
            <label class="wire-next__select-search">
        <span class="wire-visually-hidden">{searchPlaceholder}</span>
        <span class="wire-next__search-icon" aria-hidden="true">⌕</span>
        <input
                type="search"
                value="{query}"
                placeholder="{searchPlaceholder}"
                autocomplete="off"
                @input="query = event.target.value; activeIndex = flatVisibleOptions().length ? 0 : -1"
                @keydown="handleKeydown(event)"
              />
        </label>
          {/if}

          <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"
              aria-multiselectable="{multiple}"
            >
              {#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)"
                        @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)"
                  @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}

              {#if !loading && !flatVisibleOptions().length}
                <div class="wire-next__select-message">{emptyLabel}</div>
              {/if}
            </div>

          {#if tags && query && !allOptions().some((option) => option.label.toLowerCase() === query.toLowerCase())}
            <button type="button" class="wire-next__create-option">{createLabel} “{query}”</button>
          {/if}
          <button
            type="button"
            class="wire-next__load-more"
            data-wrn-select-load-more
            data-show="infinite && hasMore"
          >{loadMoreLabel}</button>
        </div>
        <input
        type="hidden"
        name="{name}"
        value="{multiple ? selectedValues.join(',') : selectedValue}"
        required="{required}"
        aria-describedby="{validationMessage ? `${name}-validation` : helpText ? `${name}-help` : ''}"
      />

      {#if helpText && !validationMessage}<small id="{name}-help">{helpText}</small>{/if}
      <small
        id="{name}-validation"
        class="wire-next__validation"
        data-error="{name}"
      >{validationMessage}</small>
        </div>
    }
}
} }