<div class="js-dropdown" data-options='[{"title":"Option 1","slug":"option-1"},{"title":"Option 2","slug":"option-2"},{"title":"Option 3","slug":"option-3"},{"title":"Option 4","slug":"option-4"},{"title":"Option 5","slug":"option-5"},{"title":"Option 6","slug":"option-6"},{"title":"Option 7","slug":"option-7"},{"title":"Option 8","slug":"option-8"},{"title":"Option 9","slug":"option-9"},{"title":"Option 10","slug":"option-10"},{"title":"Option 11","slug":"option-11"},{"title":"Option 12","slug":"option-12"},{"title":"Option 13","slug":"option-13"},{"title":"Option 14","slug":"option-14"},{"title":"Option 15","slug":"option-15"},{"title":"Option 16","slug":"option-16"},{"title":"Option 17","slug":"option-17"},{"title":"Option 18","slug":"option-18"},{"title":"Option 19","slug":"option-19"},{"title":"Option 20","slug":"option-20"},{"title":"Option 21","slug":"option-21"},{"title":"Option 22","slug":"option-22"},{"title":"Option 23","slug":"option-23"},{"title":"Option 24","slug":"option-24"},{"title":"Option 25","slug":"option-25"},{"title":"Option 26","slug":"option-26"},{"title":"Option 27","slug":"option-27"},{"title":"Option 28","slug":"option-28"},{"title":"Option 29","slug":"option-29"},{"title":"Option 30","slug":"option-30"}]' data-selected="option-4"></div>

                                                            <link media="all" rel="stylesheet" href="/dropdown/dropdown.css" />
                                                            <script src="/dropdown/dropdown.js"></script>
{% set options = [] %}
{% for option in 1..30 %}
  {% set options = options|merge([{
    title: "Option " ~ option,
    slug: "option-" ~ option
  }]) %}
{% endfor %}
<div class="js-dropdown" data-options='{{ options|json_encode() }}' data-selected="option-4"></div>

{% import "_macros.twig" as h %}
{{ h.componentAssets('dropdown') }}
/* No context defined. */
  • Content:
    <script setup>
    //modeled on https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/combobox-select-only/
    import {
      reactive,
      ref,
      useTemplateRef,
      useId,
      onMounted,
      watch,
      computed,
    } from "vue";
    
    const props = defineProps({
      label: {
        type: String,
        default: "Select Option",
      },
      options: {
        type: Array,
        required: true,
      },
      selected: {
        type: String,
        default: "",
      },
    });
    const state = reactive({
      focusedIndex: 0,
      keysSoFar: "",
      open: false,
      selected: null,
    });
    const keyClear = ref(null);
    
    const idModifier = useId();
    const emit = defineEmits(["select"]);
    
    //refs
    const comboboxEl = useTemplateRef("combobox");
    const listboxEl = useTemplateRef("listbox");
    
    //lifecycle
    onMounted(() => {
      //set active option
      setInitialSelected();
    });
    
    //methods
    function setInitialSelected() {
      if (props.selected !== "") {
        //find selected from options
        const selectedIndex = getOptionIndexBySlug(props.selected);
        if (selectedIndex > -1) {
          state.selected = props.options[selectedIndex];
          state.focusedIndex = selectedIndex;
          return;
        }
      }
      state.selected = props.options[0];
    }
    function selectFocusedIndex() {
      state.selected = props.options[state.focusedIndex];
      state.open = false;
    }
    function onComboBlur(event) {
      // do nothing if relatedTarget is contained within listbox element
      if (listboxEl.value.contains(event.relatedTarget)) {
        return;
      }
    
      // select current option and close
      if (state.open) {
        selectFocusedIndex();
      }
    }
    function onComboArrowDown($event) {
      if (!state.open) {
        state.open = true;
      } else {
        if (state.focusedIndex < props.options.length - 1) state.focusedIndex++;
      }
    }
    function onComboArrowUp($event) {
      if (!state.open) {
        state.open = true;
        state.focusedIndex = 0;
      } else {
        if ($event.altKey) {
          selectFocusedIndex();
        }
        if (state.focusedIndex > 0) state.focusedIndex--;
      }
    }
    function onComboEnter($event) {
      if (!state.open) state.open = true;
      else {
        if ($event.key === " " && state.keysSoFar.length) return;
        selectFocusedIndex();
      }
    }
    function onComboHome() {
      state.open = true;
      state.focusedIndex = 0;
    }
    function onComboEnd() {
      state.open = true;
      state.focusedIndex = props.options.length - 1;
    }
    function onComboPageUp() {
      if (state.open) {
        const target = state.focusedIndex - 10;
        state.focusedIndex = target < 0 ? 0 : target;
      }
    }
    function onComboPageDown() {
      if (state.open) {
        const target = state.focusedIndex + 10;
        state.focusedIndex =
          target >= props.options.length - 1 ? props.options.length - 1 : target;
      }
    }
    function handleSearch($event) {
      const key = $event.key;
      if (
        [
          "ArrowUp",
          "ArrowDown",
          "Home",
          "End",
          "PageUp",
          "PageDown",
          "Esc",
          "Enter",
        ].includes(key)
      )
        return;
      if (key === " " && !state.keysSoFar.length) return; //if space is the first key entered after the timeout clear, don't add it to keysSoFar
    
      const itemToFocus = findItemToFocus(key.toLowerCase());
      if (!itemToFocus) return;
    
      const indexToFocus = getOptionIndexBySlug(itemToFocus.slug);
      if (indexToFocus > -1 && indexToFocus < props.options.length - 1) {
        state.focusedIndex = indexToFocus;
      }
    }
    function findItemToFocus(character) {
      const searchIndex = state.focusedIndex;
    
      state.keysSoFar += character;
      clearKeysSoFarAfterDelay();
    
      //search from the selected item down to the end
      let nextMatch = findMatchInRange(searchIndex + 1, props.options.length);
    
      //if no match, search from the start
      if (!nextMatch) {
        nextMatch = findMatchInRange(0, searchIndex);
      }
      return nextMatch;
    }
    function findMatchInRange(startIndex, endIndex) {
      // Find the first item starting with the keysSoFar substring, searching in
      // the specified range of items
      const list = props.options;
      for (let n = startIndex; n < endIndex; n++) {
        const label = list[n].title;
        if (label.toString().toLowerCase().indexOf(state.keysSoFar) === 0) {
          return list[n];
        }
      }
      return null;
    }
    function clearKeysSoFarAfterDelay() {
      if (keyClear.value) {
        clearTimeout(keyClear.value);
        keyClear.value = null;
      }
      keyClear.value = setTimeout(() => {
        state.keysSoFar = "";
        keyClear.value = null;
      }, 500);
    }
    
    //computed
    const selectedSlug = computed(() => {
      return state.selected ? state.selected.slug : null;
    });
    const selectedIndex = computed(() => {
      return getOptionIndexBySlug(selectedSlug.value);
    });
    
    //watchers
    watch(
      () => state.selected,
      () => {
        emit("select", state.selected);
        state.open = false;
        state.focusedIndex = getOptionIndexBySlug(state.selected.slug);
      }
    );
    watch(
      () => state.focusedIndex,
      (focusedIndex) => {
        if (isScrollable(listboxEl.value)) {
          const option = document.getElementById(
            idify(props.options[focusedIndex].slug)
          );
          maintainScrollVisibility(option, listboxEl.value);
        }
      }
    );
    
    //Helpers
    function getOptionIndexBySlug(slug) {
      return props.options.findIndex((o) => o.slug === slug);
    }
    function idify(id) {
      return `${idModifier}__${id}`;
    }
    //check if element has vertical scrollbars
    function isScrollable(element) {
      return element && element.clientHeight < element.scrollHeight;
    }
    // ensure a given child element is within the parent's visible scroll area
    // if the child is not visible, scroll the parent
    function maintainScrollVisibility(activeElement, scrollParent) {
      const { offsetHeight, offsetTop } = activeElement;
      const { offsetHeight: parentOffsetHeight, scrollTop } = scrollParent;
    
      const isAbove = offsetTop < scrollTop;
      const isBelow = offsetTop + offsetHeight > scrollTop + parentOffsetHeight;
    
      if (isAbove) {
        scrollParent.scrollTo(0, offsetTop);
      } else if (isBelow) {
        scrollParent.scrollTo(0, offsetTop - parentOffsetHeight + offsetHeight);
      }
    }
    </script>
    
    <template>
      <div class="combobox">
        <div
          :id="idify('combobox-label')"
          class="combobox__label u-srOnly"
          v-on:click="comboboxEl.focus()"
        >
          {{ label }}
        </div>
        <div
          :id="idify('combobox')"
          class="combobox__input"
          :class="{
            'combobox__input--open': state.open,
          }"
          role="combobox"
          tabindex="0"
          :aria-controls="idify('listbox')"
          :aria-expanded="state.open"
          aria-haspopup="listbox"
          :aria-labelledby="idify('combobox-label')"
          :aria-activedescendant="idify(options[state.focusedIndex].slug)"
          ref="combobox"
          v-on:blur="onComboBlur"
          v-on:focusout="onComboBlur"
          v-on:click="state.open = !state.open"
          v-on:keydown.up.prevent="onComboArrowUp"
          v-on:keydown.down.prevent="onComboArrowDown"
          v-on:keydown.enter.space.prevent="onComboEnter"
          v-on:keydown.home.prevent="onComboHome"
          v-on:keydown.end.prevent="onComboEnd"
          v-on:keydown.esc.prevent="state.open = false"
          v-on:keydown.page-up.prevent="onComboPageUp"
          v-on:keydown.page-down.prevent="onComboPageDown"
          v-on:keydown="handleSearch"
        >
          {{ state.selected ? state.selected.title : "" }}
        </div>
        <div
          :id="idify('listbox')"
          class="combobox__menu"
          role="listbox"
          tabindex="-1"
          :aria-labelledby="idify('combobox-label')"
          ref="listbox"
          v-show="state.open"
        >
          <div
            v-for="(option, key) in options"
            :id="idify(option.slug)"
            class="combobox__option button"
            :class="{
              'combobox__option--selected': selectedSlug === option.slug,
              'combobox__option--focused': key === state.focusedIndex,
            }"
            role="option"
            :aria-selected="key === state.focusedIndex"
            v-on:click="state.selected = option"
          >
            {{ option.title }}
          </div>
        </div>
      </div>
    </template>
    
    <style lang="scss"></style>
    
    <style scoped lang="scss">
    @use "../panel/panel";
    .combobox {
      position: relative;
    }
    .combobox__input {
      --color: var(--blue);
      cursor: pointer;
      font-size: var(--step-4);
      font-weight: var(--bold-weight);
      color: var(--color);
      transition: color 0.2s ease-out;
    
      &:hover {
        --color: var(--blue--dark);
      }
    
      &:before {
        content: "▶";
        display: inline-block;
        margin-inline-end: var(--adjacentIconGap, 0.5em);
      }
    }
    .combobox__input--open {
      &:before {
        transform: rotate(90deg);
      }
    }
    .combobox__option {
      --button-size: var(--step-0);
      --button-padding: 0.2em var(--panelPadding);
      --button-border: none;
      --button-color: var(--type);
      --button-bg: transparent;
      font-weight: var(--normal-weight);
      width: 100%;
      box-sizing: border-box;
      transition: background-color 0.2s ease-out;
      text-align: start;
      display: block;
    
      &:hover {
        --button-bg: var(--neutral);
      }
    }
    .combobox__option--selected {
      font-weight: var(--bold-weight);
    }
    .combobox__option--focused {
      outline: 3px solid var(--blue--dark);
      outline-offset: -3px;
    }
    
    .combobox__menu {
      @include panel.panel();
      position: absolute;
      left: 0;
      top: 100%;
      z-index: calc(var(--z) * 30);
      width: max-content;
      max-width: var(--dropdownMaxWidth, 80vw);
      box-sizing: border-box;
      max-height: min(var(--dropdownMaxHeight, 500px), 80vh);
      overflow: auto;
    }
    </style>
    
  • URL: /components/raw/dropdown/Dropdown.vue
  • Filesystem Path: src/components/dropdown/Dropdown.vue
  • Size: 9.2 KB
  • Content:
    import { createApp } from 'vue';
    import Dropdown from "./Dropdown.vue";
    
    const els = document.querySelectorAll(".js-dropdown");
    els.forEach((el) => {
      const app = createApp(Dropdown, {
        options: JSON.parse(el.dataset.options),
        selected: el.dataset.selected
      });
      app.mount(el);
    })
  • URL: /components/raw/dropdown/dropdown.js
  • Filesystem Path: src/components/dropdown/dropdown.js
  • Size: 290 Bytes

No notes defined.