<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. */
<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>
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);
})
No notes defined.