类型照抄内核解析器的 switch(rules/parser.go:17-98),37 种全给—— 候选列表是过滤式的,全集不构成负担,缺项才是。目标反着来:只建议 DIRECT 和 REJECT,策略组名手填——全列出来的话,每路入站自动生成的 三个专属组会把列表灌成一面墙。 按原文编辑的输入框给一个完整的逻辑规则做占位例子(原文模式存在的 理由就是这类规则)。弹层滚动条改细杆透明轨道。
307 lines
8.0 KiB
Vue
307 lines
8.0 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 订阅多选。已选的是输入框里的一排标签,输入文字过滤下拉列表,点选项在
|
||
* 选与不选之间切换,列表保持打开,方便一口气挑好几个。
|
||
*
|
||
* 交互是多选组合框的通行形态(Ant Design、Element Plus 的多选 Select 都是这一套):
|
||
* 标签收在框内、下拉列出全部选项并给已选的打勾、点击不收起。订阅只有一两个时
|
||
* 平铺标签更直接,但数量一多平铺就是一面墙,这个形态两头都撑得住。
|
||
*/
|
||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||
import IconCheck from '~icons/ri/check-line'
|
||
import IconRemove from '~icons/ri/close-line'
|
||
|
||
const props = defineProps<{ options: string[] }>()
|
||
const chosen = defineModel<string[]>({ required: true })
|
||
|
||
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 query = ref('')
|
||
/** 键盘上下键在过滤结果里走到第几项 */
|
||
const active = ref(0)
|
||
const menuStyle = ref<Record<string, string>>({})
|
||
|
||
const filtered = computed(() =>
|
||
props.options.filter((name) => name.toLowerCase().includes(query.value.trim().toLowerCase())),
|
||
)
|
||
|
||
watch(query, () => {
|
||
active.value = 0
|
||
open.value = true
|
||
})
|
||
|
||
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) {
|
||
place()
|
||
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 toggle(name: string) {
|
||
chosen.value = chosen.value.includes(name)
|
||
? chosen.value.filter((item) => item !== name)
|
||
: [...chosen.value, name]
|
||
// 点完一项接着挑下一项,不用再点回输入框
|
||
input.value?.focus()
|
||
// 标签增减会改变输入框高度,锚点变了要重新摆
|
||
void nextTick(place)
|
||
}
|
||
|
||
function remove(name: string) {
|
||
chosen.value = chosen.value.filter((item) => item !== name)
|
||
if (open.value) void nextTick(place)
|
||
}
|
||
|
||
function onKeydown(event: KeyboardEvent) {
|
||
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||
event.preventDefault()
|
||
open.value = true
|
||
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') {
|
||
event.preventDefault()
|
||
const name = filtered.value[active.value]
|
||
if (open.value && name) toggle(name)
|
||
} else if (event.key === 'Escape') {
|
||
open.value = false
|
||
} else if (event.key === 'Backspace' && query.value === '') {
|
||
const last = chosen.value.at(-1)
|
||
if (last) remove(last)
|
||
}
|
||
}
|
||
|
||
/** 焦点离开整块时收起。判断新焦点还在不在里面,否则点列表里的选项会先把自己关掉。 */
|
||
function onFocusOut(event: FocusEvent) {
|
||
const next = event.relatedTarget
|
||
if (next instanceof Node && root.value?.contains(next)) return
|
||
open.value = false
|
||
query.value = ''
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div ref="root" class="picker" @focusout="onFocusOut">
|
||
<div class="box" @click="input?.focus()">
|
||
<span v-for="name in chosen" :key="name" class="tag">
|
||
{{ name }}
|
||
<button type="button" :title="`移除 ${name}`" @click.stop="remove(name)">
|
||
<IconRemove />
|
||
</button>
|
||
</span>
|
||
<input
|
||
ref="input"
|
||
v-model="query"
|
||
:placeholder="chosen.length === 0 ? '选择订阅' : ''"
|
||
:aria-expanded="open"
|
||
role="combobox"
|
||
autocomplete="off"
|
||
@focus="open = true"
|
||
@keydown="onKeydown"
|
||
/>
|
||
</div>
|
||
|
||
<Transition name="menu">
|
||
<ul
|
||
v-if="open"
|
||
ref="menuEl"
|
||
:class="['menu', openUp && 'is-up']"
|
||
:style="menuStyle"
|
||
role="listbox"
|
||
aria-multiselectable="true"
|
||
>
|
||
<li v-if="options.length === 0" class="none">没有可选的订阅</li>
|
||
<li v-else-if="filtered.length === 0" class="none">没有匹配的订阅</li>
|
||
<li v-for="(name, i) in filtered" :key="name">
|
||
<button
|
||
type="button"
|
||
role="option"
|
||
:aria-selected="chosen.includes(name)"
|
||
:class="['opt', chosen.includes(name) && 'is-on', i === active && 'is-active']"
|
||
@mousemove="active = i"
|
||
@click="toggle(name)"
|
||
>
|
||
<span class="mark"><IconCheck v-if="chosen.includes(name)" /></span>
|
||
{{ name }}
|
||
</button>
|
||
</li>
|
||
</ul>
|
||
</Transition>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.picker {
|
||
position: relative;
|
||
}
|
||
|
||
.box {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
align-items: center;
|
||
gap: var(--s-1);
|
||
min-height: 34px;
|
||
padding: 3px var(--s-2);
|
||
border: 1px solid var(--c-border-strong);
|
||
border-radius: var(--r-sm);
|
||
background: var(--c-surface);
|
||
cursor: text;
|
||
transition: border-color 0.15s ease;
|
||
}
|
||
|
||
.box:focus-within {
|
||
border-color: var(--c-accent);
|
||
}
|
||
|
||
.tag {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: var(--s-1);
|
||
height: 26px;
|
||
padding: 0 var(--s-1) 0 var(--s-2);
|
||
border-radius: var(--r-sm);
|
||
background: var(--c-surface-sunken);
|
||
font-size: var(--t-sm);
|
||
}
|
||
|
||
.tag button {
|
||
display: grid;
|
||
place-items: center;
|
||
width: 20px;
|
||
height: 20px;
|
||
border: none;
|
||
border-radius: var(--r-sm);
|
||
background: none;
|
||
color: var(--c-text-faint);
|
||
cursor: pointer;
|
||
}
|
||
|
||
.tag button:hover {
|
||
background: var(--c-surface);
|
||
color: var(--c-danger);
|
||
}
|
||
|
||
.box input {
|
||
flex: 1;
|
||
min-width: 6em;
|
||
height: 26px;
|
||
padding: 0 var(--s-1);
|
||
border: none;
|
||
background: none;
|
||
font-size: var(--t-sm);
|
||
}
|
||
|
||
/* 浮在上面,不能把下面几行顶下去。位置由 place() 以内联样式给出 */
|
||
.menu {
|
||
position: fixed;
|
||
z-index: 1;
|
||
max-width: min(360px, calc(100vw - 2 * var(--s-4)));
|
||
max-height: 240px;
|
||
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: flex;
|
||
align-items: center;
|
||
gap: var(--s-2);
|
||
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;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.opt.is-active {
|
||
background: var(--c-surface-sunken);
|
||
}
|
||
|
||
.opt.is-on {
|
||
color: var(--c-accent-text);
|
||
}
|
||
|
||
/* 勾的位置固定占着,选没选名字都对齐 */
|
||
.mark {
|
||
display: flex;
|
||
flex: none;
|
||
width: 1em;
|
||
}
|
||
|
||
.none {
|
||
padding: var(--s-2) var(--s-3);
|
||
color: var(--c-text-faint);
|
||
font-size: var(--t-sm);
|
||
}
|
||
|
||
.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>
|