Files
mihomo-patchbay/web/components/ComboBox.vue
T
im0o 4c43756208 下拉弹层改视口定位,规则表单换 ComboBox
absolute 的弹层会计入祖先滚动容器的可滚动区域——在对话框里打开下拉,
modal 会被撑出滚动条。弹层改 position: fixed 按视口摆放:逃出所有
overflow 裁剪,又因为还在 dialog 的 DOM 子树里,照样画在 top layer 之上。
代价是位置不跟随滚动,滚动和缩放时直接收起(弹层内部滚动不算)。
三个弹层组件(SelectBox、ComboBox、SourcePicker)同一套做法。

组件根元素不再写死 width: 100%:它和调用方的宽度规则同权重,谁赢
看样式表加载顺序——请求页的级别下拉被撑满整行就是这么来的。
弹层加 user-select: none 和最大宽度,选项超长省略号截断。

规则表单的类型、载荷、目标原来是 input + datalist,原生建议列表的样式
和开合状态都管不到,换成同族的 ComboBox:选项只是建议、手填合法,
Enter 在弹层开着时选中高亮项、收起时照旧提交表单。
2026-08-11 23:58:53 +08:00

279 lines
6.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 可自由填写的组合框,替代原生 input + datalist。
*
* 和 SelectBox 的分工:SelectBox 的值只能从选项里挑;这里选项只是建议,
* 手填任何值都合法(规则的目标可以是候选之外的出站名)。换掉 datalist
* 的理由和 SelectBox 一样:原生建议列表的样式、动画、开合状态都管不到。
*
* Enter 的归属:弹层开着且有高亮项时选中它、不提交表单;弹层收起时不拦,
* 交给对话框的表单提交——填完直接回车保存是原生 input 的习惯,不能破坏。
*/
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import IconChevron from '~icons/ri/arrow-down-s-line'
const props = defineProps<{
/** 建议列表。过滤是 includes,不区分大小写 */
options: string[]
placeholder?: string
/** 等宽字体,规则原文这类内容用 */
mono?: boolean
ariaLabel?: string
}>()
const text = defineModel<string>({ default: '' })
const root = ref<HTMLElement | null>(null)
const input = ref<HTMLInputElement | null>(null)
const menuEl = ref<HTMLElement | null>(null)
const open = ref(false)
const openUp = ref(false)
const active = ref(0)
const menuStyle = ref<Record<string, string>>({})
const filtered = computed(() => {
const q = text.value.trim().toLowerCase()
if (!q) return props.options
return props.options.filter((name) => name.toLowerCase().includes(q))
})
const MENU_ROOM = 220
/**
* 弹层是 position: fixed,按视口摆放,理由同 SelectBox:absolute 会计入
* 祖先滚动容器的可滚动区域,在对话框里打开会把对话框撑出滚动条。
* 位置不跟随滚动,所以滚动和缩放时直接收起。
*/
function place() {
const rect = root.value?.getBoundingClientRect()
if (!rect) return
const up = window.innerHeight - rect.bottom < MENU_ROOM && rect.top > MENU_ROOM
openUp.value = up
menuStyle.value = {
left: `${String(rect.left)}px`,
minWidth: `${String(rect.width)}px`,
...(up
? { bottom: `${String(window.innerHeight - rect.top + 4)}px` }
: { top: `${String(rect.bottom + 4)}px` }),
}
}
/** 页面或对话框一滚动,锚点就不准了,直接收起。弹层内部的滚动不算 */
function closeOnMove(event?: Event) {
if (event?.target instanceof Node && menuEl.value?.contains(event.target)) return
open.value = false
}
watch(open, (isOpen) => {
if (isOpen) {
window.addEventListener('scroll', closeOnMove, true)
window.addEventListener('resize', closeOnMove)
} else {
window.removeEventListener('scroll', closeOnMove, true)
window.removeEventListener('resize', closeOnMove)
}
})
onBeforeUnmount(() => {
window.removeEventListener('scroll', closeOnMove, true)
window.removeEventListener('resize', closeOnMove)
})
function show() {
active.value = 0
place()
open.value = true
}
function onInput() {
show()
}
function toggleFromChevron() {
if (open.value) open.value = false
else show()
input.value?.focus()
}
function pick(name: string) {
text.value = name
open.value = false
input.value?.focus()
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
if (!open.value) {
show()
return
}
const count = filtered.value.length
if (count === 0) return
const delta = event.key === 'ArrowDown' ? 1 : -1
active.value = (active.value + delta + count) % count
} else if (event.key === 'Enter') {
if (!open.value) return // 交给表单提交
const name = filtered.value[active.value]
event.preventDefault()
if (name) pick(name)
else open.value = false
} else if (event.key === 'Escape' && open.value) {
event.stopPropagation()
event.preventDefault()
open.value = false
}
}
function onFocusOut(event: FocusEvent) {
const next = event.relatedTarget
if (next instanceof Node && root.value?.contains(next)) return
open.value = false
}
</script>
<template>
<div ref="root" class="combo" @focusout="onFocusOut">
<input
ref="input"
v-model="text"
:class="['field', mono && 'mono']"
type="text"
role="combobox"
:aria-expanded="open"
:aria-label="ariaLabel"
:placeholder="placeholder"
autocomplete="off"
spellcheck="false"
@focus="show"
@input="onInput"
@keydown="onKeydown"
/>
<button
type="button"
class="toggle"
:aria-label="open ? '收起建议' : '展开建议'"
tabindex="-1"
@click="toggleFromChevron"
>
<IconChevron :class="['chev', open && 'is-open']" aria-hidden="true" />
</button>
<Transition name="menu">
<ul
v-if="open && filtered.length > 0"
ref="menuEl"
:class="['menu', openUp && 'is-up']"
:style="menuStyle"
role="listbox"
>
<li v-for="(name, i) in filtered" :key="name">
<button
type="button"
role="option"
:aria-selected="name === text"
:class="['opt', i === active && 'is-active']"
@mousemove="active = i"
@click="pick(name)"
>
{{ name }}
</button>
</li>
</ul>
</Transition>
</div>
</template>
<style scoped>
.combo {
position: relative;
}
.field {
width: 100%;
padding: var(--s-2) var(--s-3);
/* 给右侧箭头留位置 */
padding-right: var(--s-6);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-sm);
background: var(--select-bg, var(--c-bg));
}
.toggle {
position: absolute;
top: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
padding: 0 var(--s-2);
border: none;
background: none;
color: var(--c-text-faint);
cursor: pointer;
}
.chev {
width: 16px;
height: 16px;
transition: transform 0.2s ease;
}
.chev.is-open {
transform: rotate(180deg);
}
/* 位置(left/top/bottom/minWidth)由 place() 以内联样式给出 */
.menu {
position: fixed;
z-index: 1;
max-width: min(360px, calc(100vw - 2 * var(--s-4)));
max-height: 200px;
overflow-y: auto;
list-style: none;
margin: 0;
padding: var(--s-1);
border: 1px solid var(--c-border-strong);
border-radius: var(--r-md);
background: var(--c-surface);
user-select: none;
}
.opt {
display: block;
width: 100%;
padding: var(--s-2) var(--s-3);
border: none;
border-radius: var(--r-sm);
background: none;
text-align: left;
font-size: var(--t-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}
.opt.is-active {
background: var(--c-surface-sunken);
}
.menu-enter-active,
.menu-leave-active {
transition:
opacity 0.12s ease,
transform 0.12s ease;
}
.menu-enter-from,
.menu-leave-to {
opacity: 0;
transform: translateY(-4px);
}
.menu.is-up.menu-enter-from,
.menu.is-up.menu-leave-to {
transform: translateY(4px);
}
</style>