Skip to content
On this page

Rich Select

A full-fledged select component with support for custom options, custom values, ajax loading, options prefetching, tags, keyboard accessibility, searching & many more.

Preview & Playground 🖼️

Here you may find a preview of the component, with error & possible variants.

🏄 Click to expand the code
vue
<script setup lang="ts">
import { FormLabel, RichSelect, RichSelectOptionImage, RichSelectOptionIndicator, VanillaInputGroup } from '@flavorly/vanilla-components'
import { ref } from 'vue'
const value = ref('Option 1')
const valueErrors = ref('Option 1')
const value2 = ref(['Option 1', 'Option 2'])
const value3 = ref(null)
const value4 = ref([])
const value5 = ref(null)
const value6 = ref(null)
const value7 = ref(null)
const valueDisabled = ref(null)

const options = [
    { value: 'Option 1', text: 'One Option' },
    { value: 'Option 2', text: 'Two Options' },
    { value: [{ anotherObject: true, nested: 'deep' }], text: 'Complex Object' },
    {
      value: 'seprator',
      text: 'A sperator can be usefull',
      children: [
        { value: 'Option 4', text: '4th Option' },
        { value: 'Option 5', text: '5th Option' },
      ],
    },
]

const optionsPersons = [
  {
    value: 'jon-doe',
    text: 'Jon Doe',
    description: 'This an additional text for your select',
    image: 'https://images.unsplash.com/photo-1491528323818-fdd1faba62cc?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
  },
  {
    value: 'robert-boes',
    text: 'Robert Boes',
    description: 'This an additional text for your select',
    image: 'https://images.unsplash.com/photo-1550525811-e5869dd03032?ixlib=rb-1.2.1&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
    disabled: true,
  },
  {
    value: 'armando-sharlaton',
    text: 'Amandon Sharlaton',
    description: 'This an additional text for your select',
    image: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
  },
  {
    value: 'armando-sharlaton2',
    text: 'Another person here',
    description: 'This an additional text for your select',
    image: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80',
  },
]

const optionsOrders = [
  {
    value: '1',
    text: 'Order #1 - Pending',
    description: 'Order is waiting for the partner',
    status: 'blue',
  },
  {
    value: '2',
    text: 'Order #2 - Completed',
    description: 'This order is completed',
    status: 'green',
    disabled: true,
  },
  {
    value: '3',
    text: 'Order #3 - Refunded',
    description: 'This order was refunded with payment provider',
    status: 'red',
  },
  {
    value: '4',
    text: 'Order #4 - Waiting Approval',
    description: 'This order is waiting payment approval',
    status: 'yellow',
  },
]

const fetchOptions = (query?: string, nextPage?: number) => {
  const url = `https://www.omdbapi.com/?apikey=dac6304b&s=${query}&page=${nextPage || 1}`
  return fetch(url)
    .then(response => response.json())
    .then((data) => {
      return {
        results: data.Search as Record<string, any>[],
        hasMorePages: data.Search && data.totalResults > (data.Search.length * (nextPage || 1)) * 10,
      }
    })
}
</script>

<template>
  <PreviewWrapper>
    <div class="space-y-2">
      <!-- Regular -->
      <div class="w-full">
        <RichSelect
          v-model="value"
          name="test"
          :options="options"
          :clearable="true"
          feedback="Im useful helper out here, choose wisely"
          placeholder="Please select an option"
        />
      </div>
      <!-- Disabled -->
      <div class="w-full">
        <RichSelect
          v-model="valueDisabled"
          name="test"
          :options="options"
          :clearable="true"
          :disabled="true"
          feedback="Im disabled, and you shouldnt need to toggle me"
          placeholder="Please select an option"
        />
      </div>
      <!-- Regular with errors -->
      <div class="w-full">
        <RichSelect
          v-model="valueErrors"
          :options="options"
          :clearable="true"
          feedback="Im useful helper out here, choose wisely"
          placeholder="Please select an option"
          errors="Something is wrong here"
        />
      </div>
      <!-- Multiple -->
      <div class="w-full">
        <RichSelect
          v-model="value2"
          :options="options"
          :clearable="true"
          feedback="Multiple select is also possible"
          placeholder="Please select an option"
          multiple
          tags
        />
      </div>

      <!-- Persons -->
      <div class="w-full">
        <RichSelect
          v-model="value3"
          feedback="A select with a list of persons"
          :options="optionsPersons"
          :clearable="false"
          placeholder="Please select a person"
        >
          <template #label="{ option: { raw: person }, className, isSelected, hasErrors }">
            <RichSelectOptionImage
              :name="person?.text"
              :image="person?.image"
              :selected="isSelected"
              :disabled="person?.disabled"
              :parent-classes="className"
              :has-errors="hasErrors"
            />
          </template>
          <template #option="{ option: { raw: person }, className, isSelected, hasErrors }">
            <RichSelectOptionImage
              class="px-3 py-2"
              :name="person?.text"
              :image="person?.image"
              :description="person?.description"
              :selected="isSelected"
              :disabled="person?.disabled"
              :parent-classes="className"
              :has-errors="hasErrors"
            />
          </template>
        </RichSelect>
      </div>

      <!-- Persons + Tags -->
      <div class="w-full">
        <RichSelect
          v-model="value4"
          feedback="Persons + Multiple + Tags"
          :options="optionsPersons"
          :tags="true"
          :multiple="true"
          :clearable="true"
          placeholder="Please select a person"
        >
          <template #tagLabel="{ option: { raw: person }, className, isSelected, hasErrors }">
            <RichSelectOptionImage
              :name="person?.text"
              :image="person?.image"
              :selected="isSelected"
              :disabled="person?.disabled"
              :parent-classes="className"
              :has-errors="hasErrors"
            />
          </template>
          <template #option="{ option: { raw: person }, className, isSelected, hasErrors }">
            <RichSelectOptionImage
              class="px-3 py-2"
              :name="person?.text"
              :image="person?.image"
              :description="person?.description"
              :selected="isSelected"
              :disabled="person?.disabled"
              :parent-classes="className"
              :has-errors="hasErrors"
            />
          </template>
        </RichSelect>
      </div>

      <!-- Indicators -->
      <div class="w-full">
        <RichSelect
          v-model="value5"
          feedback="Example with indicators"
          :options="optionsOrders"
          placeholder="Please select a order status"
          clearable
        >
          <template #label="{ option: { raw: order }, className, isSelected }">
            <RichSelectOptionIndicator
              :name="order.text"
              :status="order.status"
              :description="order.description"
              :selected="isSelected"
              :disabled="order.disabled"
              :parent-classes="className"
            />
          </template>
          <template #option="{ option: { raw: order }, className, isSelected }">
            <RichSelectOptionIndicator
              class="px-3 py-2"
              :name="order?.text"
              :status="order?.status"
              :description="order?.description"
              :selected="isSelected"
              :disabled="order?.disabled"
              :parent-classes="className"
            />
          </template>
        </RichSelect>
      </div>

      <!-- Fetching -->
      <div class="w-full">
        <RichSelect
          v-model="value6"
          feedback="Type a movie name to search"
          placeholder="Ex: Search for the Matrix or Pokemon"
          :fetch-options="fetchOptions"
          :minimum-input-length="3"
          value-attribute="imdbID"
          text-attribute="Title"
        >
          <template #option="{ option: { raw: movie }, className, isSelected }">
            <div
              class="px-3 py-2"
              :class="className"
            >
              <div class="relative">
                <div
                  :class="[isSelected ? 'font-medium' : 'font-normal']"
                  class="flex items-center space-x-2 text-sm block"
                >
                  <div
                    class="flex-shrink-0 w-10 h-10 bg-gray-500 bg-center bg-cover rounded-lg"
                    :style="{ backgroundImage: `url(${movie?.Poster})` }"
                  />
                  <span
                    class="block whitespace-nowrap truncate"
                    v-html="`${movie?.imdbID} - ${movie?.Title}`"
                  />
                </div>
              </div>
              <div
                v-if="movie?.Year"
                class="w-100 text-xs text-left mt-1"
                :class="[isSelected ? 'font-normal opacity-60' : 'opacity-60']"
                v-html="`This movie was released in the year of ${movie?.Year}`"
              />
            </div>
          </template>
        </RichSelect>
      </div>

      <!-- Fetching with Endpoint -->
      <div class="w-full">
        <RichSelect
          v-model="value7"
          feedback="Type a movie name to search"
          placeholder="Ex: Search for the Matrix or Pokemon"
          fetch-endpoint="/fetch-users"
          :minimum-input-length="3"
          value-attribute="id"
          text-attribute="gateway"
        >
          <template #option="{ option: { raw: payment }, className, isSelected }">
            <div
              class="px-3 py-2"
              :class="className"
            >
              <div class="relative">
                <div
                  :class="[isSelected ? 'font-medium' : 'font-normal']"
                  class="flex items-center space-x-2 text-sm block"
                >
                  <span
                    class="block whitespace-nowrap truncate"
                    v-html="`${payment?.gateway} - ${payment?.id}`"
                  />
                </div>
              </div>
            </div>
          </template>
        </RichSelect>
      </div>
    </div>
  </PreviewWrapper>
</template>

Props 📥

Props available for this component extending the default variant & global props.

PropDescriptionTypeDefault
modelValueThe value for the elementAnyundefined
nameThe name for the actual underlying selectString''
optionsArray of options for the selectArrayundefined
normalizeOptionsIf we should normalize the optionsArrayundefined
multipleAllow/Deny multiple selectionsBooleanfalse
disabledIf the selection is enable/disabledBooleanfalse
tagsIf tags/pills should be used insteadBooleanfalse
placeholderPlaceholder for the select when nothing is selectedStringPleas select..
dropdownPlacementPopper placement for the dropdownStringundefined
dropdownPopperOptionsPopper raw optionsarray, objectundefined
closeOnSelectClose the dropdown on selecting an optionBooleantrue
selectOnCloseOn Close select the last active optionBooleanfalse
clearSearchOnCloseClear the search on closeBooleantrue
toggleOnFocusOpen the select on focusBooleanfalse
toggleOnClickOpen the select on clickBooleantrue
toggleOnClickOpen the select on clickBooleantrue
valueAttributeValue Attribute key to search on optionsStringundefined
textAttributeLabel Attribute key to search on optionsStringundefined
hideSearchBoxHides the search boxBooleanfalse
searchBoxPlaceholderPlaceholder for the search boxStringSearch...
noResultsTextWhen searches and no results are found textStringNo results...
searchingTextText to show while searchingStringSearching...
loadingClosedPlaceholderText to show when dropdown is closed but fetchingStringSearching...
loadingMoreResultsTextText to show while loading more resultsStringLoading...
clearableIf we should allow selected value to be unselectedBooleanfalse
maxHeightMax Height of the dropdownNumber250
fetchOptionsA function/promise that returns the resultsFunctionundefined
fetchEndpointA Url String to fetch options fromStringundefined
prefetchOptionsInitial set of options when a fetchOptions function is definedArrayundefined
delayDelay between pulling new options when bottom is reachedNumber250
minimumInputLengthNumber of characters required to trigger the searchNumber2
minimumInputLengthTextText to show when minimum is not metStringPlease enter..
minimumResultsForSearchMinimum amount of results to consider the searchNumberundefined
teleportIf we should teleport the dropdownBooleantrue
teleportToElement / Ref to teleport intoStringbody
trapFocusIf we should trap the focus inside the dropdownBooleantrue

Options - Static

When using the options prop you can pass an array of objects of your choice and use the valueAttribute and textAttribute props to define which keys to use for the value and text. This will make it easier to use the component with your own data without worry about the structure. Internally we will convert the options to what we call normalizedOptions, that contains a value and a text.

If you want instead, you can also pass the normalizeOptions prop with the array of normalized options with the following structure :

js
[
  {
    value: 'batman',
    text: 'Batman',
    children: [
        {
            value: 'robin',
            text: 'Robin',
        },
        {
            value: 'joker',
            text: 'Joker',
            disabled: true,
        },
    ],
  },
]

By default, the select component will take the value to the v-modeland the text to the display the result label. You can also nest the options by using the children key, or even disable an option by using the disabled key.

This should be more than enough to cover the most basic usage cases, but if you need more control you can also use the fetchOptions prop to pass a function that will load your results via Fetch or any other method of your choice.

Options - Dynamic Fetch / API

Here is an example of how to use the fetchOptions prop to retrieve results from an external API:

ts
const fetchOptions = (query?: string, nextPage?: number) => {
  const url = `https://www.omdbapi.com/?apikey=xxxx&s=${query}&page=${nextPage || 1}`
  return fetch(url)
    .then(response => response.json())
    .then((data) => {
      return {
        results: data.Search as Record<string, any>[],
        hasMorePages: data.Search && data.totalResults > (data.Search.length * (nextPage || 1)) * 10,
      }
    })
}

The function will receive the current query and the next page number, and should return an object with the results and a boolean to indicate if there are more pages to load.

💡 Accessing the raw option

After the option is normalized, it is than passed to the component and everything else will be ignored, but you can still access the raw option of your data by using option.raw in the option slot.

Options - Load from endpoint

If you want to load the options from an endpoint of your backend, you may use the fetchEndpoint prop, the props accepts a full qualified URL and it will perform a GETrequest to your url with a query parameter containing the current search query and a page parameter containing the current page wanted. This also supports lazy loading of options once you reach the bottom of the dropdown, incrementing the page number.

The response must contain an array with a property called data containing the options and a property called next_page_url indicating if there are more pages to load and the url of the next page.

If you are using Laravel you can use the paginate method to return the options and the next page url.

json
{
"data": [
    {"id": 1, "name": "Option 1"},
    {"id": 2, "name": "Option 2"}
],
"next_page_url": "https://your-api.com/options?page=2"
}

You may also combine this with text-attribute and value-attribute to pick which properties to use for the value and text of the options.

If you are using the Vanilla Components Laravel package you may use the helper ResolveRichSelectOptions function that does all the heavy lifting for you, including the pagination, selecting or filtering only via certain columns and use Laravel Scout for searching!

php
Route::get('/users', function(Request $request){
    return ResolveRichSelectOptions::for(User::class, ['name', 'email']);
})->middleware('web')->name('api.fetch-users');

But that's not all! Every little detail matters! Because when you close or refresh your page sometimes you may have selected & persisted a v-model of previous selected models, but because we are not preteching any options Your select might look empty! But don't worry, we got you covered! The GET performed to your server also sends a query parameter called values that contains the current selected value(s), so you can use that to preselect the options anytime and return the appropriate options to be pre-fetched and always included on the component initial state. Once again if you are using the Laravel Package this is all done for you as well! 😃

Slots 🧬

Current slots available for this component are the following:

By default all slots get all the props and configuration from the component. The slots are divided by two categories, trigger & dropdown.

Trigger Slots

Slot trigger

Override the whole trigger element.

Slot label

This is usually the most used slot, if you want to override how the default option is displayed You can take a look on the demos to check for more implementations of this slot.

Slot placeholder

Override the default placeholder for the trigger

Slot selectorIcon

Change or override the little selector icon usually at the right side of the trigger.

Slot tagCloseIcon

Change or override the tag icon used when tags are enabled, this icon is used to remove the tag

Slot tagLabel

Override the tag label, and display it differently, by default the option text is used.

Slot clearButton

Button or icon to clear the results, if the dropdown is clearable

Slot option

This slot is also widely used and it's used to override how the options are displayed. You can have different displays for each option by overriding this slot. Also takes a option prop that contains the current option normalized, and also option.raw that contains the original option.

Slot optionLabel

Override the option label, and display it differently, by default the option text is used.

Slot optionIcon

Override the option selected icon, by default a Check icon is used.

Slot dropdownTop

Section on the top of the dropdown, before the search box for additional content in case its necessary

Slot dropdownBottom

Section on the bottom of the dropdown, before the search box for additional content in case its necessary

Slot stateFeedback

When the dropdown is loading or fetching results, this section will be used, you can override it as well

Events 🚇

Here you may find the events emitted by this component.

EventDescriptionValue
update:modelValueWhen the value changesany
changeInput Value changedCustomEvent
inputInput / Value changedCustomEvent
keydownKeyboardDown PressedKeyboardEvent
focusTrigger was focusedFocusEvent
blurTrigger was blurFocusEvent
mousedownMouseClickMouseEvent
mouseoverMouseOverMouseEvent
mouseleaveMouseLeaveMouseEvent
touchstartTouch StartedMouseEvent
shownDropdown is showntrue
hiddenDropdown is hiddentrue
beforeShowBefore dropdown showntrue
beforeHideBefore hide dropdowntrue
fetchOptionsSuccessOptions API fetched successtrue
fetchOptionsErrorOptions API fetched failedtrue

Additional Components

Because the select component can be used for a wide range of daily tasks, we also provide some additional components that can be used to extend the functionality of the select component. Most of the additional components are used to override the option and/or the label slots.

Option with Image 🖼️

This component is intended to be used to display profile images or images in general plus a Title and a description, it can be used as a slot for the option and label slot.

Use cases: Display a list of members, users, registered users, profiles, etc.

Option with Indicator 🚥

This component is intended to be used to display the option with a title + description and also a 4 different indicators: red, yellow, green and blue. It can be used as a slot for the option and label slot.

Use cases: Display orders status, Tickets Status, etc

Option Tag with Image

Same as the option with image but when being used with tags.

Released under the MIT License.