Files
mihomo-patchbay/web/components/SourcePicker.vue
T
im0o de3a4c9bcd 路由页动效与细节一批
基调是简单快速:hover 颜色 0.15s 过渡(全局规则,只过渡颜色不碰布局)、
对话框入场淡入加 6px 上移、tab 下划线改成共享的一条并在切换时滑动、tab
内容 out-in 淡切、配置折叠箭头居右随展开旋转 90 度、订阅下拉 0.12s 出入场。
不引 animate.css:这些效果各自十几行 CSS,引库只多不少。

配置展开和拨开链式代理时,若详情底部长出窗口则平滑滚进视野;tab 切换
不触发,来回切时页面跟着跳只会晕。二次确认对话框点遮罩可关闭,内边距
挪进内层,否则点在 dialog 自己的 padding 上也会被当成遮罩。新增入站的
表单不做遮罩关闭:误点会丢掉填了一半的内容。对话框标题改为「新增路由」
「编辑路由」。select 换自绘箭头,原生箭头贴边且位置调不了。
2026-08-11 16:23:03 +08:00

250 lines
6.2 KiB
Vue

<script setup lang="ts">
/**
* 订阅多选。已选的是输入框里的一排标签,输入文字过滤下拉列表,点选项在
* 选与不选之间切换,列表保持打开,方便一口气挑好几个。
*
* 交互是多选组合框的通行形态(Ant Design、Element Plus 的多选 Select 都是这一套):
* 标签收在框内、下拉列出全部选项并给已选的打勾、点击不收起。订阅只有一两个时
* 平铺标签更直接,但数量一多平铺就是一面墙,这个形态两头都撑得住。
*/
import { computed, 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 open = ref(false)
const query = ref('')
/** 键盘上下键在过滤结果里走到第几项 */
const active = ref(0)
const filtered = computed(() =>
props.options.filter((name) => name.toLowerCase().includes(query.value.trim().toLowerCase())),
)
watch(query, () => {
active.value = 0
open.value = true
})
function toggle(name: string) {
chosen.value = chosen.value.includes(name)
? chosen.value.filter((item) => item !== name)
: [...chosen.value, name]
// 点完一项接着挑下一项,不用再点回输入框
input.value?.focus()
}
function remove(name: string) {
chosen.value = chosen.value.filter((item) => item !== name)
}
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" class="menu" 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);
}
/* 焦点态由整个框的描边表达,框里的输入不再自己画一圈 */
.box input:focus,
.box input:focus-visible {
outline: none;
}
/* 浮在上面,不能把下面几行顶下去 */
.menu {
position: absolute;
z-index: 1;
top: calc(100% + var(--s-1));
left: 0;
min-width: 100%;
max-height: 240px;
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);
}
.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);
}
</style>