Files
mihomo-patchbay/web/components/ComboBox.vue
T
im0o 7d08f37248 规则表单:类型候选补全为内核全集,目标候选精简
类型照抄内核解析器的 switch(rules/parser.go:17-98),37 种全给——
候选列表是过滤式的,全集不构成负担,缺项才是。目标反着来:只建议
DIRECT 和 REJECT,策略组名手填——全列出来的话,每路入站自动生成的
三个专属组会把列表灌成一面墙。

按原文编辑的输入框给一个完整的逻辑规则做占位例子(原文模式存在的
理由就是这类规则)。弹层滚动条改细杆透明轨道。
2026-08-12 00:13:02 +08:00

282 lines
7.0 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;
/* 细杆加透明轨道,别让弹层里出现一根系统灰滚动条 */
scrollbar-width: thin;
scrollbar-color: var(--c-border-strong) transparent;
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>