|
| 1 | +<template> |
| 2 | + <div ref="root" class="ui-select" @keydown="onKey"> |
| 3 | + <button |
| 4 | + :id="id" |
| 5 | + ref="reference" |
| 6 | + type="button" |
| 7 | + class="ui-control ui-select__trigger" |
| 8 | + :class="{'is-open': open, 'has-value': selected !== undefined, 'is-invalid': invalid}" |
| 9 | + :disabled="disabled" |
| 10 | + role="combobox" |
| 11 | + aria-haspopup="listbox" |
| 12 | + :aria-expanded="open" |
| 13 | + :aria-required="required || undefined" |
| 14 | + :aria-invalid="invalid || undefined" |
| 15 | + :aria-describedby="describedby" |
| 16 | + @click="toggle" |
| 17 | + > |
| 18 | + <span v-if="selected === undefined" class="ui-select__placeholder">{{ placeholder }}</span> |
| 19 | + <span v-else class="ui-select__value">{{ labelOf(selected) }}</span> |
| 20 | + <svg class="ui-select__chevron" viewBox="0 0 20 20" aria-hidden="true"> |
| 21 | + <path d="M5 8l5 5 5-5" fill="none" stroke="currentColor" stroke-width="2" /> |
| 22 | + </svg> |
| 23 | + </button> |
| 24 | + |
| 25 | + <ul |
| 26 | + v-if="open" |
| 27 | + ref="floating" |
| 28 | + class="ui-select__menu" |
| 29 | + role="listbox" |
| 30 | + :aria-label="optionsLabel" |
| 31 | + :style="floatingStyles" |
| 32 | + > |
| 33 | + <li v-if="!options.length" class="ui-select__empty">{{ emptyText }}</li> |
| 34 | + <li |
| 35 | + v-for="(option, index) in sorted" |
| 36 | + :key="String(option.id)" |
| 37 | + class="ui-select__option" |
| 38 | + :class="{'is-active': pointer === index}" |
| 39 | + role="option" |
| 40 | + :aria-selected="pointer === index" |
| 41 | + @mouseover="pointer = index" |
| 42 | + @click="choose(option)" |
| 43 | + > |
| 44 | + {{ labelOf(option) }} |
| 45 | + </li> |
| 46 | + </ul> |
| 47 | + </div> |
| 48 | +</template> |
| 49 | + |
| 50 | +<script setup lang="ts" generic="T extends SelectItem"> |
| 51 | +import {autoUpdate, flip, hide, offset, shift, useFloating} from '@floating-ui/vue'; |
| 52 | +import {computed, onBeforeUnmount, onMounted, ref, useTemplateRef} from 'vue'; |
| 53 | +
|
| 54 | +import type {LabelKey, SelectItem} from '../types'; |
| 55 | +
|
| 56 | +const { |
| 57 | + options, |
| 58 | + label, |
| 59 | + placeholder = 'Select…', |
| 60 | + disabled = false, |
| 61 | + alphabeticalSort = true, |
| 62 | + required = false, |
| 63 | + invalid = false, |
| 64 | + describedby, |
| 65 | + emptyText = 'No options', |
| 66 | + optionsLabel = 'Options', |
| 67 | +} = defineProps<{ |
| 68 | + options: T[]; |
| 69 | + /** property name or getter for an option's display string. */ |
| 70 | + label: LabelKey<T>; |
| 71 | + /** stable id — required so the trigger can pair with a label/error. */ |
| 72 | + id: string; |
| 73 | + placeholder?: string; |
| 74 | + disabled?: boolean; |
| 75 | + alphabeticalSort?: boolean; |
| 76 | + /** conveys the required state to assistive tech via `aria-required`. */ |
| 77 | + required?: boolean; |
| 78 | + invalid?: boolean; |
| 79 | + describedby?: string; |
| 80 | + emptyText?: string; |
| 81 | + /** accessible name for the listbox popup (`aria-label`). */ |
| 82 | + optionsLabel?: string; |
| 83 | +}>(); |
| 84 | +
|
| 85 | +const model = defineModel<T['id'] | null>({required: true}); |
| 86 | +
|
| 87 | +/** Resolve an option's display string from the `label` prop (property name or getter). */ |
| 88 | +const labelOf = (option: T): string => |
| 89 | + typeof label === 'function' |
| 90 | + ? label(option) |
| 91 | + : String((option as Record<PropertyKey, unknown>)[label as PropertyKey]); |
| 92 | +
|
| 93 | +const selected = computed(() => options.find((option) => option.id === model.value)); |
| 94 | +const sorted = computed(() => |
| 95 | + alphabeticalSort ? [...options].sort((a, b) => labelOf(a).localeCompare(labelOf(b))) : options, |
| 96 | +); |
| 97 | +
|
| 98 | +const open = ref(false); |
| 99 | +const pointer = ref(-1); |
| 100 | +
|
| 101 | +const toggle = () => { |
| 102 | + open.value = !open.value; |
| 103 | +}; |
| 104 | +const close = () => { |
| 105 | + open.value = false; |
| 106 | + pointer.value = -1; |
| 107 | +}; |
| 108 | +const choose = (option: T) => { |
| 109 | + model.value = option.id; |
| 110 | + close(); |
| 111 | +}; |
| 112 | +
|
| 113 | +// Listbox keyboard navigation. The trigger's native :disabled blocks clicks; this |
| 114 | +// guards the keyboard path too. |
| 115 | +const onKey = (event: KeyboardEvent) => { |
| 116 | + if (disabled) return; |
| 117 | + if (event.key === 'Tab') { |
| 118 | + close(); |
| 119 | + return; |
| 120 | + } |
| 121 | + if (!open.value) { |
| 122 | + if (['Enter', 'ArrowDown', ' '].includes(event.key)) { |
| 123 | + event.preventDefault(); |
| 124 | + open.value = true; |
| 125 | + } |
| 126 | + return; |
| 127 | + } |
| 128 | + switch (event.key) { |
| 129 | + case 'ArrowDown': |
| 130 | + pointer.value = Math.min(pointer.value + 1, sorted.value.length - 1); |
| 131 | + event.preventDefault(); |
| 132 | + break; |
| 133 | + case 'ArrowUp': |
| 134 | + pointer.value = Math.max(pointer.value - 1, -1); |
| 135 | + event.preventDefault(); |
| 136 | + break; |
| 137 | + case 'Enter': |
| 138 | + if (pointer.value >= 0) { |
| 139 | + choose(sorted.value[pointer.value]); |
| 140 | + event.preventDefault(); |
| 141 | + } |
| 142 | + break; |
| 143 | + case 'Escape': |
| 144 | + close(); |
| 145 | + event.preventDefault(); |
| 146 | + break; |
| 147 | + } |
| 148 | +}; |
| 149 | +
|
| 150 | +// click-outside — closes the menu without a shared directive dependency. |
| 151 | +const root = useTemplateRef<HTMLElement>('root'); |
| 152 | +const onDocumentPointer = (event: MouseEvent) => { |
| 153 | + // The listener is attached only between mount and unmount, so the ref is non-null here. |
| 154 | + if (!root.value!.contains(event.target as Node)) close(); |
| 155 | +}; |
| 156 | +onMounted(() => document.addEventListener('click', onDocumentPointer)); |
| 157 | +onBeforeUnmount(() => document.removeEventListener('click', onDocumentPointer)); |
| 158 | +
|
| 159 | +const reference = useTemplateRef<HTMLElement>('reference'); |
| 160 | +const floating = useTemplateRef<HTMLElement>('floating'); |
| 161 | +const {floatingStyles} = useFloating(reference, floating, { |
| 162 | + placement: 'bottom-start', |
| 163 | + middleware: [offset(4), flip({fallbackPlacements: ['top-start']}), shift({padding: 8}), hide()], |
| 164 | + whileElementsMounted: autoUpdate, |
| 165 | +}); |
| 166 | +</script> |
0 commit comments