<div class="js-menuButton" data-items='[{"title":"Chesapeake Bay","url":"https://www.chesapeakebay.net/"},{"title":"Chesapeake Progress","url":"https://www.chesapeakeprogress.com/"},{"title":"Bay Backpack","url":"https://www.baybackpack.com/"},{"title":"Wetlands Work","url":"https://www.wetlandswork.org/"},{"title":"Chesapeake Behavior Change","url":"https://www.chesapeakebehaviorchange.org/"},{"title":"Protect Local Waterways","url":"https://www.protectlocalwaterways.org/"}]'></div>

<link media="all" rel="stylesheet" href="/menu-button/menu-button.css" />
<script src="/menu-button/menu-button.js"></script>
<div class="js-menuButton" data-items='{{ items|json_encode() }}'></div>

{% import "_macros.twig" as h %}
{{ h.componentAssets('menu-button') }}
{
  "items": [
    {
      "title": "Chesapeake Bay",
      "url": "https://www.chesapeakebay.net/"
    },
    {
      "title": "Chesapeake Progress",
      "url": "https://www.chesapeakeprogress.com/"
    },
    {
      "title": "Bay Backpack",
      "url": "https://www.baybackpack.com/"
    },
    {
      "title": "Wetlands Work",
      "url": "https://www.wetlandswork.org/"
    },
    {
      "title": "Chesapeake Behavior Change",
      "url": "https://www.chesapeakebehaviorchange.org/"
    },
    {
      "title": "Protect Local Waterways",
      "url": "https://www.protectlocalwaterways.org/"
    }
  ]
}
  • Content:
    <script setup>
    //modeled on https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/examples/menu-button-links/
    import {
      nextTick,
      onMounted,
      onUnmounted,
      ref,
      useId,
      useTemplateRef,
      watch,
    } from "vue";
    
    defineOptions({
      inheritAttrs: false,
    });
    const props = defineProps({
      buttonText: {
        type: String,
        default: "Go to page",
      },
      buttonClasses: {
        type: Array,
        default: () => ["button", "button--wide", "button--primary"],
      },
      items: {
        type: Array,
        required: true,
      },
    });
    
    const open = ref(false);
    const focusedIndex = ref(-1);
    const keysSoFar = ref("");
    const keyClear = ref(null);
    const componentEl = useTemplateRef("componentRef");
    const itemRefs = useTemplateRef("itemsRef");
    const menuButtonEl = useTemplateRef("menuButton");
    
    const idModifier = useId();
    
    //Lifecycle
    onMounted(() => {
      document.addEventListener("click", onDocumentClick);
    });
    onUnmounted(() => {
      document.removeEventListener("click", onDocumentClick);
    });
    
    //Methods
    function idify(id) {
      return `${idModifier}__${id}`;
    }
    
    function openAndFocus(index = 0) {
      open.value = true;
      focusedIndex.value = index;
    }
    
    function handleSearch(character) {
      const searchIndex = focusedIndex.value;
    
      keysSoFar.value += character;
      clearKeysSoFarAfterDelay();
    
      //search from the selected item down to the end
      let nextMatch = findMatchInRange(searchIndex + 1, props.items.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.items;
      for (let n = startIndex; n < endIndex; n++) {
        const label = list[n].title;
        if (label.toString().toLowerCase().indexOf(keysSoFar.value) === 0) {
          return n;
        }
      }
      return null;
    }
    function clearKeysSoFarAfterDelay() {
      if (keyClear.value) {
        clearTimeout(keyClear.value);
        keyClear.value = null;
      }
      keyClear.value = setTimeout(() => {
        keysSoFar.value = "";
        keyClear.value = null;
      }, 500);
    }
    function onDocumentClick($event) {
      // If the click is outside the component, close the dropdown
      if (!componentEl.value.contains($event.target)) {
        if (open.value) open.value = false;
      }
    }
    function onFocusOut($event) {
      // If focus is still in the form, do nothing
      if (componentEl.value.contains($event.relatedTarget)) return;
      // otherwise, close the dropdown
      open.value = false;
    }
    
    //Key Events
    function onArrowDown() {
      if (open.value) {
        if (focusedIndex.value === props.items.length - 1) {
          focusedIndex.value = 0;
        } else {
          focusedIndex.value++;
        }
      } else openAndFocus();
    }
    function onArrowUp() {
      if (open.value) {
        if (focusedIndex.value === 0) {
          focusedIndex.value = props.items.length - 1;
        } else {
          focusedIndex.value--;
        }
      } else openAndFocus(props.items.length - 1);
    }
    function onButtonClick() {
      if (open.value) {
        focusedIndex.value = -1;
        open.value = false;
      } else {
        focusedIndex.value = 0;
        open.value = true;
      }
    }
    function onButtonEnter() {
      if (!open.value) {
        openAndFocus();
      } else open.value = false;
    }
    function onMenuEnter() {
      if (keyClear.value) return; //if we're 'typing' and hit the space key, don't trigger a click
      if (props.items?.[focusedIndex.value]) {
        itemRefs.value[focusedIndex.value].click();
      }
    }
    function onHome() {
      if (open.value) {
        focusedIndex.value = 0;
      }
    }
    function onEnd() {
      if (open.value) {
        focusedIndex.value = props.items.length - 1;
      }
    }
    function onType($event) {
      const key = $event.key;
      if (
        [
          "ArrowUp",
          "ArrowDown",
          "Home",
          "End",
          "PageUp",
          "PageDown",
          "Esc",
          "Enter",
        ].includes(key)
      )
        return;
      if (key === " " && !keysSoFar.value.length) return; //if space is the first key entered after the timeout clear, don't add it to keysSoFar
    
      const indexToFocus = handleSearch(key.toLowerCase());
    
      if (props.items?.[indexToFocus]) {
        focusedIndex.value = indexToFocus;
      }
    }
    
    //watchers
    watch(focusedIndex, async (index) => {
      if (props.items?.[index]) {
        await nextTick();
        itemRefs.value[index].focus();
      }
    });
    watch(open, (value) => {
      if (value === false) {
        menuButtonEl.value.focus();
        focusedIndex.value = -1;
      }
    });
    </script>
    
    <template>
      <div class="menuButton" v-on:focusout="onFocusOut" ref="componentRef">
        <button
          :id="idify('menuButton')"
          class="menuButton__button"
          :class="[
            {
              'menuButton__button--open': open,
            },
            ...buttonClasses,
          ]"
          type="button"
          v-bind="$attrs"
          aria-haspopup="true"
          :aria-controls="idify('menu')"
          :aria-expanded="open"
          ref="menuButton"
          v-on:click="onButtonClick"
          v-on:keydown.down.prevent="onArrowDown"
          v-on:keydown.up.prevent="onArrowUp"
          v-on:keydown.esc.prevent="open = false"
          v-on:keydown.enter.space.prevent="onButtonEnter"
          v-on:keydown.home.prevent="onHome"
          v-on:keydown.end.prevent="onEnd"
        >
          {{ buttonText
          }}<span class="appendedIcon" :class="{ 'appendedIcon--open': open }"
            >&zwj;<i class="fas fa-angle-down"></i
          ></span>
        </button>
        <ul
          :id="idify('menu')"
          class="menuButton__list"
          role="menu"
          :aria-labelledby="idify('menuButton')"
          v-show="open"
          v-on:keydown.down.prevent="onArrowDown"
          v-on:keydown.up.prevent="onArrowUp"
          v-on:keydown.esc.prevent="open = false"
          v-on:keydown.enter.space.prevent="onMenuEnter"
          v-on:keydown.home.prevent="onHome"
          v-on:keydown.end.prevent="onEnd"
          v-on:keydown="onType"
        >
          <li class="menuButton__item" role="none" v-for="(item, index) in items">
            <a
              :href="item.url"
              :target="item.newTab ? '_blank' : '_self'"
              class="menuButton__link"
              role="menuitem"
              :tabindex="focusedIndex === index ? 0 : -1"
              ref="itemsRef"
              v-on:mouseover="(event) => event.currentTarget.focus()"
              v-html="item.title"
            >
            </a>
          </li>
        </ul>
      </div>
    </template>
    
    <style scoped lang="scss">
    .menuButton {
      position: relative;
    }
    .menuButton__button--open {
      --button-bg: var(--accent);
      --button-border-color: var(--button-bg);
      --button-color: var(--blue--ultraDark);
    }
    .appendedIcon--open svg {
      transform: rotate(180deg);
    }
    .menuButton__list {
      $borderWidth: 2px;
      background-color: var(--white);
      box-shadow: 0 3px 6px rgba(41, 41, 41, 0.6); //60% of --neutral--ultraDark (#292929)
      border: $borderWidth solid var(--blue--ultraDark);
      padding: 0.3em 0;
      max-height: var(--menuButtonListMaxHeight, 350px);
      max-width: var(--menuButtonListMaxWidth, 100%);
      position: absolute;
      z-index: calc(var(--z) * 30);
      overflow-y: auto;
      top: calc(100% - $borderWidth);
      left: 0;
      right: 0;
    }
    .menuButton__item {
      list-style: none;
      margin-block-start: 0;
    }
    .menuButton__link {
      --color: var(--blue--ultraDark);
      background: none;
      font-weight: var(--bold-weight);
      display: block;
      padding: 0.5em 1em;
      text-decoration: none;
    
      &:hover,
      &:active,
      &:focus {
        background: var(--blue--dark);
        outline: 0;
        --color: var(--white);
      }
    }
    </style>
    
  • URL: /components/raw/menu-button/MenuButton.vue
  • Filesystem Path: src/components/menu-button/MenuButton.vue
  • Size: 7.4 KB
  • Content:
    import { createApp } from 'vue';
    import MenuButton from "./MenuButton.vue";
    
    const els = document.querySelectorAll(".js-menuButton");
    els.forEach((el) => {
      const app = createApp(MenuButton, {
        items: JSON.parse(el.dataset.items)
      });
      app.mount(el);
    })
  • URL: /components/raw/menu-button/menu-button.js
  • Filesystem Path: src/components/menu-button/menu-button.js
  • Size: 259 Bytes

To-do

  • Add documentation
  • Make classes on button element configurable, either through props or maybe via slots