mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-18 03:57:06 +08:00
344 lines
8.9 KiB
Vue
344 lines
8.9 KiB
Vue
<script setup>
|
|
import { autoUpdate, computePosition, flip, offset, shift, size } from "@floating-ui/dom";
|
|
import { computed, nextTick, onBeforeUnmount, ref, watch, watchPostEffect } from "vue";
|
|
|
|
const props = defineProps({
|
|
modelValue: { type: String, default: "" },
|
|
options: {
|
|
type: Array,
|
|
default: () => [],
|
|
},
|
|
placeholder: { type: String, default: "请选择" },
|
|
disabled: { type: Boolean, default: false },
|
|
border: { type: Boolean, default: true },
|
|
ariaLabel: { type: String, default: "" },
|
|
buttonClass: { type: String, default: "" },
|
|
menuClass: { type: String, default: "" },
|
|
});
|
|
|
|
const emit = defineEmits(["update:modelValue", "change", "blur"]);
|
|
|
|
const rootRef = ref(null);
|
|
const buttonRef = ref(null);
|
|
const menuRef = ref(null);
|
|
const optionRefs = ref([]);
|
|
const isOpen = ref(false);
|
|
const activeIndex = ref(-1);
|
|
const menuStyle = ref({});
|
|
|
|
const normalizedOptions = computed(() => props.options.map((option) => {
|
|
if (typeof option === "string") {
|
|
return { label: option, value: option };
|
|
}
|
|
|
|
return {
|
|
label: option?.label ?? option?.value ?? "",
|
|
value: option?.value ?? "",
|
|
icon: option?.icon ?? option?.iconClass ?? "",
|
|
};
|
|
}));
|
|
|
|
const selectedOption = computed(() => normalizedOptions.value.find((option) => option.value === props.modelValue) ?? null);
|
|
const selectedLabel = computed(() => selectedOption.value?.label || props.placeholder);
|
|
|
|
function setOptionRef(el, index) {
|
|
if (el) {
|
|
optionRefs.value[index] = el;
|
|
return;
|
|
}
|
|
|
|
delete optionRefs.value[index];
|
|
}
|
|
|
|
function focusActiveOption() {
|
|
nextTick(() => {
|
|
const option = optionRefs.value[activeIndex.value];
|
|
option?.focus();
|
|
});
|
|
}
|
|
|
|
function openMenu() {
|
|
if (props.disabled || isOpen.value) {
|
|
return;
|
|
}
|
|
|
|
isOpen.value = true;
|
|
const selectedIndex = normalizedOptions.value.findIndex((option) => option.value === props.modelValue);
|
|
activeIndex.value = selectedIndex >= 0 ? selectedIndex : 0;
|
|
nextTick(() => {
|
|
updatePosition();
|
|
focusActiveOption();
|
|
});
|
|
}
|
|
|
|
function closeMenu({ restoreFocus = false } = {}) {
|
|
if (!isOpen.value) {
|
|
return;
|
|
}
|
|
|
|
isOpen.value = false;
|
|
activeIndex.value = -1;
|
|
optionRefs.value = [];
|
|
menuStyle.value = {};
|
|
|
|
if (restoreFocus) {
|
|
nextTick(() => buttonRef.value?.focus());
|
|
}
|
|
|
|
emit("blur");
|
|
}
|
|
|
|
function toggleMenu() {
|
|
if (isOpen.value) {
|
|
closeMenu();
|
|
return;
|
|
}
|
|
|
|
openMenu();
|
|
}
|
|
|
|
function selectOption(option) {
|
|
if (!option || option.value === props.modelValue) {
|
|
closeMenu({ restoreFocus: true });
|
|
return;
|
|
}
|
|
|
|
emit("update:modelValue", option.value);
|
|
emit("change", option.value);
|
|
closeMenu({ restoreFocus: true });
|
|
}
|
|
|
|
function moveActiveIndex(step) {
|
|
if (!normalizedOptions.value.length) {
|
|
return;
|
|
}
|
|
|
|
if (!isOpen.value) {
|
|
openMenu();
|
|
return;
|
|
}
|
|
|
|
const total = normalizedOptions.value.length;
|
|
const current = activeIndex.value >= 0 ? activeIndex.value : 0;
|
|
activeIndex.value = (current + step + total) % total;
|
|
focusActiveOption();
|
|
}
|
|
|
|
function handleButtonKeydown(event) {
|
|
if (props.disabled) {
|
|
return;
|
|
}
|
|
|
|
switch (event.key) {
|
|
case "ArrowDown":
|
|
event.preventDefault();
|
|
moveActiveIndex(1);
|
|
break;
|
|
case "ArrowUp":
|
|
event.preventDefault();
|
|
moveActiveIndex(-1);
|
|
break;
|
|
case "Enter":
|
|
case " ":
|
|
event.preventDefault();
|
|
toggleMenu();
|
|
break;
|
|
case "Escape":
|
|
if (isOpen.value) {
|
|
event.preventDefault();
|
|
closeMenu();
|
|
}
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
function handleOptionKeydown(event, option, index) {
|
|
switch (event.key) {
|
|
case "ArrowDown":
|
|
event.preventDefault();
|
|
activeIndex.value = index;
|
|
moveActiveIndex(1);
|
|
break;
|
|
case "ArrowUp":
|
|
event.preventDefault();
|
|
activeIndex.value = index;
|
|
moveActiveIndex(-1);
|
|
break;
|
|
case "Enter":
|
|
case " ":
|
|
event.preventDefault();
|
|
selectOption(option);
|
|
break;
|
|
case "Escape":
|
|
event.preventDefault();
|
|
closeMenu({ restoreFocus: true });
|
|
break;
|
|
case "Tab":
|
|
closeMenu();
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
function handlePointerDown(event) {
|
|
if (rootRef.value?.contains(event.target) || menuRef.value?.contains(event.target)) {
|
|
return;
|
|
}
|
|
|
|
closeMenu();
|
|
}
|
|
|
|
function updatePosition() {
|
|
if (!buttonRef.value || !menuRef.value) {
|
|
return;
|
|
}
|
|
|
|
computePosition(buttonRef.value, menuRef.value, {
|
|
placement: "bottom-start",
|
|
middleware: [
|
|
offset(6),
|
|
flip({ padding: 12 }),
|
|
shift({ padding: 12 }),
|
|
size({
|
|
apply({ rects, elements, availableHeight }) {
|
|
Object.assign(elements.floating.style, {
|
|
minWidth: `${rects.reference.width}px`,
|
|
maxHeight: `${Math.max(availableHeight, 160)}px`,
|
|
});
|
|
},
|
|
padding: 12,
|
|
}),
|
|
],
|
|
}).then(({ x, y }) => {
|
|
menuStyle.value = {
|
|
left: `${x}px`,
|
|
top: `${y}px`,
|
|
};
|
|
});
|
|
}
|
|
|
|
watchPostEffect((cleanup) => {
|
|
if (!isOpen.value || !buttonRef.value || !menuRef.value) {
|
|
return;
|
|
}
|
|
|
|
const stopAutoUpdate = autoUpdate(buttonRef.value, menuRef.value, updatePosition);
|
|
|
|
cleanup(() => {
|
|
stopAutoUpdate();
|
|
});
|
|
});
|
|
|
|
watch(() => props.modelValue, () => {
|
|
if (!isOpen.value) {
|
|
return;
|
|
}
|
|
|
|
const selectedIndex = normalizedOptions.value.findIndex((option) => option.value === props.modelValue);
|
|
activeIndex.value = selectedIndex >= 0 ? selectedIndex : 0;
|
|
});
|
|
|
|
watch(isOpen, (open) => {
|
|
if (open) {
|
|
document.addEventListener("pointerdown", handlePointerDown);
|
|
return;
|
|
}
|
|
|
|
document.removeEventListener("pointerdown", handlePointerDown);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
document.removeEventListener("pointerdown", handlePointerDown);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="rootRef" class="relative">
|
|
<button
|
|
ref="buttonRef"
|
|
type="button"
|
|
:disabled="disabled"
|
|
class="flex h-9 items-center rounded-[6px] bg-[#232323] px-3 text-left text-sm text-[#e5e5e5] outline-none transition-colors disabled:cursor-not-allowed disabled:opacity-60"
|
|
:class="[
|
|
border
|
|
? 'w-full justify-between gap-2 border border-[#3f3f3f] focus:border-[#10AD5D]'
|
|
: 'w-auto justify-start gap-2 border border-transparent focus-visible:ring-2 focus-visible:ring-[#10AD5D]/35',
|
|
buttonClass,
|
|
]"
|
|
:aria-expanded="isOpen"
|
|
:aria-label="ariaLabel || undefined"
|
|
aria-haspopup="listbox"
|
|
@click="toggleMenu"
|
|
@keydown="handleButtonKeydown"
|
|
>
|
|
<span
|
|
class="flex min-w-0 items-center gap-2"
|
|
:class="[
|
|
border ? 'flex-1' : 'shrink-0',
|
|
selectedOption
|
|
? (border ? 'text-[#e5e5e5]' : 'text-current')
|
|
: 'text-[#7b7b7b]',
|
|
]"
|
|
>
|
|
<span v-if="selectedOption?.icon" :class="[selectedOption.icon, 'text-[16px] shrink-0']" aria-hidden="true"></span>
|
|
<span class="truncate">{{ selectedLabel }}</span>
|
|
</span>
|
|
<span
|
|
class="pointer-events-none center-row transition-transform duration-200"
|
|
:class="[border ? 'text-[#8f8f8f]' : 'text-current', isOpen ? 'rotate-180' : '']"
|
|
>
|
|
<span class="icon-[mdi--chevron-down] text-[18px]"></span>
|
|
</span>
|
|
</button>
|
|
</div>
|
|
|
|
<Teleport to="body">
|
|
<Transition
|
|
enter-active-class="transition duration-150 ease-out"
|
|
enter-from-class="translate-y-1 opacity-0"
|
|
enter-to-class="translate-y-0 opacity-100"
|
|
leave-active-class="transition duration-100 ease-in"
|
|
leave-from-class="translate-y-0 opacity-100"
|
|
leave-to-class="translate-y-1 opacity-0"
|
|
>
|
|
<div
|
|
v-if="isOpen"
|
|
ref="menuRef"
|
|
class="fixed z-[999] overflow-hidden rounded-[8px] border border-[#3f3f3f] bg-[#232323] p-1 shadow-[0_16px_30px_-12px_rgba(0,0,0,0.7)]"
|
|
:class="menuClass"
|
|
:style="menuStyle"
|
|
>
|
|
<ul role="listbox" class="overflow-y-auto py-1">
|
|
<li v-for="(option, index) in normalizedOptions" :key="option.value">
|
|
<button
|
|
:ref="(el) => setOptionRef(el, index)"
|
|
type="button"
|
|
role="option"
|
|
class="flex w-full items-center rounded-[6px] px-3 py-2 text-left text-sm outline-none transition-colors"
|
|
:class="[
|
|
option.value === modelValue
|
|
? 'bg-[#10AD5D]/15 text-[#10d06f]'
|
|
: 'text-[#e5e5e5] hover:bg-[#303030]',
|
|
activeIndex === index ? 'bg-[#303030]' : '',
|
|
]"
|
|
:aria-selected="option.value === modelValue"
|
|
tabindex="0"
|
|
@click="selectOption(option)"
|
|
@mouseenter="activeIndex = index"
|
|
@keydown="handleOptionKeydown($event, option, index)"
|
|
>
|
|
<span class="flex min-w-0 items-center gap-2">
|
|
<span v-if="option.icon" :class="[option.icon, 'text-[16px] shrink-0']" aria-hidden="true"></span>
|
|
<span class="truncate">{{ option.label }}</span>
|
|
</span>
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</Transition>
|
|
</Teleport>
|
|
</template>
|