From 76c19adf407fba88a170fd5b38b5519a0e92fc15 Mon Sep 17 00:00:00 2001 From: Konrad Mohrfeldt <konrad.mohrfeldt@farbdev.org> Date: Tue, 28 Mar 2023 20:23:20 +0200 Subject: [PATCH] feat: add ComboBox component --- src/components/ComboBox.vue | 238 ++++++++++++++++++++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 src/components/ComboBox.vue diff --git a/src/components/ComboBox.vue b/src/components/ComboBox.vue new file mode 100644 index 00000000..0ef465ee --- /dev/null +++ b/src/components/ComboBox.vue @@ -0,0 +1,238 @@ +<template> + <div ref="rootEl" class="tw-relative" @keyup.esc="close"> + <div + class="tw-relative" + :class="inputContainerClass" + aria-haspopup="listbox" + :aria-expanded="isOpen" + :aria-controls="resultsId" + tabindex="-1" + > + <slot name="pre" /> + <label + v-if="label" + :for="inputId" + class="tw-absolute tw-top-2 tw-left-8 tw-text-sm tw-font-bold tw-pointer-events-none" + > + {{ label }} + </label> + <span + v-if="!isOpen && modelValue" + class="tw-absolute tw-pointer-events-none tw-top-7 tw-left-8" + > + {{ getDisplayName(modelValue) }} + </span> + <kbd + v-if="keyboardShortcut && keyboardShortcutLabel !== undefined && !isTouch" + v-show="!isOpen" + :title="keyboardShortcutLabel" + class="tw-absolute tw-pointer-events-none tw-top-4 tw-right-8 tw-bg-black/20 tw-opacity-60 tw-p-2 tw-text-xs tw-rounded-lg tw-leading-none" + > + <template v-for="(token, index) in keyboardShortcutLabel.split(' ')" :key="index"> + <span v-if="token !== '+'">{{ token }}</span> + <span v-else> </span> + </template> + </kbd> + <input + :id="inputId" + ref="inputEl" + v-model="query" + aria-autocomplete="list" + :aria-expanded="isOpen" + :aria-controls="resultsId" + :aria-activedescendant="activeResultId" + role="combobox" + tabindex="0" + type="text" + class="tw-pt-6 tw-pb-2 tw-px-8 md:tw-w-80 tw-text-inherit" + :class="inputClass" + @keydown.up.prevent + @keydown.down.prevent + @keydown.home.prevent + @keydown.end.prevent + @keyup="ensureOpen" + @keyup.down.prevent.stop="updateIndex(1)" + @keyup.up.prevent.stop="updateIndex(-1)" + @keyup.home.prevent.stop="updateIndex(Number.NEGATIVE_INFINITY)" + @keyup.end.prevent.stop="updateIndex(Number.POSITIVE_INFINITY)" + @keyup.enter.prevent.stop="selectChoice(choices[activeIndex])" + @focus="open" + /> + </div> + + <Transition + enter-active-class="tw-transition tw-duration-200 tw-ease-out" + enter-from-class="tw-translate-y-1 tw-opacity-0" + enter-to-class="tw-translate-y-0 tw-opacity-100" + leave-active-class="tw-transition tw-duration-150 tw-ease-in" + leave-from-class="tw-translate-y-0 tw-opacity-100" + leave-to-class="tw-translate-y-1 tw-opacity-0" + > + <div + v-if="isOpen" + :id="drawerId" + :class="[ + drawerClass, + { + 'tw-fixed tw-left-2 tw-right-2 tw-z-20 tw-mt-2 tw-bg-white tw-shadow-2xl tw-rounded tw-flex tw-flex-col tw-overflow-hidden': true, + 'md:tw-absolute md:tw-top-full md:tw-left-auto md:tw-right-0 md:tw-w-fit md:tw-flex-row': true, + }, + ]" + > + <div class="tw-order-2 tw-flex-none tw-p-6 tw-bg-gray-100 md:tw-w-80"> + <ul + v-if="choices.length > 0" + ref="choicesEl" + role="listbox" + class="tw-max-h-96 tw-overflow-y-auto tw-block tw-p-0 tw-m-0" + > + <template v-for="(choice, index) in choices" :key="getKey(choice)"> + <slot + :id="`${resultsId}-option-${index}`" + role="option" + tabindex="-1" + class="tw-py-2 tw-px-4 tw-rounded tw-block tw-cursor-pointer" + :class="{ + 'tw-bg-aura-primary tw-text-white': activeIndex === index, + }" + :choice="choice" + :index="index" + :active-index="activeIndex" + @click="selectChoice(choice)" + @mouseenter="activeIndex = index" + /> + </template> + </ul> + + <slot v-else name="noData"> + <p class="tw-font-bold">{{ noDataLabel }}</p> + </slot> + </div> + + <div v-if="slots.filter" class="tw-order-1 tw-p-6 tw-flex tw-flex-col tw-gap-3"> + <slot name="filter" :results-id="resultsId" /> + </div> + </div> + </Transition> + </div> +</template> + +<script lang="ts" setup> +// TODO: implement proper typing once we upgrade to Vue 3.3. +import { computed, nextTick, Ref, ref, useSlots, watchEffect } from 'vue' +import { onClickOutside, useMediaQuery, whenever } from '@vueuse/core' +import { useId } from '@/util' + +const props = withDefaults( + defineProps<{ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + modelValue: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + choices: any[] + keyboardShortcut?: Ref<boolean> + keyboardShortcutLabel?: string + label?: string + noDataLabel?: string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getDisplayName?: (obj: any) => string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getKey?: (obj: any) => string | number + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputClass?: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputContainerClass?: any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + drawerClass?: any + }>(), + { + keyboardShortcut: undefined, + keyboardShortcutLabel: '', + label: '', + noDataLabel: '', + getDisplayName: (obj: { name: string }) => String(obj?.name), + getKey: (obj: { id: string | number }) => obj.id, + inputClass: undefined, + inputContainerClass: undefined, + drawerClass: undefined, + }, +) +const emit = defineEmits<{ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (e: 'update:modelValue', value: any): void + (e: 'search', value: string): void + (e: 'open'): void + (e: 'close'): void +}>() +const slots = useSlots() + +const query = ref('') +const rootEl = ref<HTMLElement>() +const inputEl = ref<HTMLInputElement>() +const choicesEl = ref<HTMLUListElement>() +const activeIndex = ref(0) +const isOpen = ref(false) +const isTouch = useMediaQuery('screen and (pointer: coarse)') +const inputId = useId('combobox-input') +const drawerId = useId('combobox-drawer') +const resultsId = useId('combobox-results') +const activeResultId = computed(() => `${resultsId.value}-option-${activeIndex.value}`) + +function open() { + isOpen.value = true + inputEl.value?.focus?.() + emit('open') +} + +function close(event?: Event) { + if (event instanceof KeyboardEvent && isOpen.value) { + event.stopImmediatePropagation() + event.preventDefault() + } + isOpen.value = false + query.value = '' + activeIndex.value = 0 + emit('close') +} + +function ensureOpen(event: Event) { + if (!isOpen.value) { + event.preventDefault() + event.stopImmediatePropagation() + nextTick(open) + } +} + +function updateIndex(modifier: number) { + const newIndex = activeIndex.value + modifier + activeIndex.value = Math.max(0, Math.min(newIndex, props.choices.length - 1)) +} + +function selectChoice(obj: unknown) { + emit('update:modelValue', obj) + nextTick(close) +} + +watchEffect(() => { + emit('search', query.value) +}) + +watchEffect(() => { + const listItem = choicesEl.value?.children?.[activeIndex.value] + listItem?.scrollIntoView?.({ behavior: 'smooth', block: 'nearest', inline: 'start' }) +}) + +onClickOutside(rootEl, close) +whenever( + computed(() => props.keyboardShortcut?.value), + open, +) +</script> + +<script lang="ts"> +export default { + compatConfig: { + MODE: 3, + ATTR_FALSE_VALUE: false, + }, +} +</script> -- GitLab