组合框展开先给全量、本次展开输入过才过滤——按当前值过滤的话,预填的 类型会把列表滤到只剩它自己,MATCH 这类排在后面的选项等于不存在。类型 因此可以默认留空,placeholder 当示例。 目标建议由页面传入:全局规则给内置两个加用户自建的组和自有线路;附加 规则多一项本路专属组——「这个载荷走本路代理」写的就是它,放在拦截规则 前面还能做豁免。生成的专属组不列,每路三个会把列表灌成一面墙。 原文/字段切换只拦真正整行编辑的类型(isRawOnlyRule):之前用 isStructuredEditable 拦,新增时的空原文永远过不了,卡死在原文模式。 删除规则和附加规则不再二次确认——删错了有撤回,确认框只是多一步; toast 带上被删规则的原文。
291 lines
7.3 KiB
Vue
291 lines
7.3 KiB
Vue
<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 typed = ref(false)
|
||
|
||
const filtered = computed(() => {
|
||
const q = text.value.trim().toLowerCase()
|
||
if (!typed.value || !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() {
|
||
typed.value = false
|
||
active.value = 0
|
||
place()
|
||
open.value = true
|
||
}
|
||
|
||
function onInput() {
|
||
typed.value = true
|
||
active.value = 0
|
||
place()
|
||
open.value = true
|
||
}
|
||
|
||
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>
|