路由页一轮交互细化:草稿预览、预选、若干修正
上半部分收敛成一条规则:草稿和已保存一致时显示内核现状;不一致时显示 按草稿保存后的样子,将移除的画删除线、新增的虚线描边,即将被移除的当前 选中用危险色警示。点击语义只看卡片:现在就存在的成员点了立刻生效,保存 后才加入的点了是预选,保存成功后再作为运行时动作选上。预览的集合运算在 web/member-preview.ts,组件里只留接线。 其余:订阅选择器重写成可搜索多选(原平铺标签订阅一多就是一面墙);删除 入站换成页面内确认对话框;列表右列解析到最终出口并标注链式;「高级配置」 更名「配置」;表单重置改按内容比较(按引用的话任何一次运行时刷新都会冲掉 未保存的草稿,watch 传返回数组的 getter 同样触发这个问题);保存后先刷 运行时再换配置数据,避免卡片区闪空。
This commit is contained in:
+136
-60
@@ -1,80 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 挑几个订阅。已选的是一排 tag,末尾一个加号,点开是还没选的那些。
|
||||
* 订阅多选。已选的是输入框里的一排标签,输入文字过滤下拉列表,点选项在
|
||||
* 选与不选之间切换,列表保持打开,方便一口气挑好几个。
|
||||
*
|
||||
* 不用原生 select:它在已选项旁边显示的是「当前值」,而这里要表达的是「已经选了这几个」,
|
||||
* 两种语义。之前用 select 拼出来的那版,加号和 tag 的对齐、宽度、选中后的复位都得单独调,
|
||||
* 全是在跟控件本身的默认行为较劲。
|
||||
* 交互是多选组合框的通行形态(Ant Design、Element Plus 的多选 Select 都是这一套):
|
||||
* 标签收在框内、下拉列出全部选项并给已选的打勾、点击不收起。订阅只有一两个时
|
||||
* 平铺标签更直接,但数量一多平铺就是一面墙,这个形态两头都撑得住。
|
||||
*/
|
||||
import { ref } from 'vue'
|
||||
import IconAdd from '~icons/ri/add-line'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import IconCheck from '~icons/ri/check-line'
|
||||
import IconRemove from '~icons/ri/close-line'
|
||||
|
||||
defineProps<{ options: string[] }>()
|
||||
const props = defineProps<{ options: string[] }>()
|
||||
const chosen = defineModel<string[]>({ required: true })
|
||||
|
||||
const open = ref(false)
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const input = ref<HTMLInputElement | null>(null)
|
||||
const open = ref(false)
|
||||
const query = ref('')
|
||||
/** 键盘上下键在过滤结果里走到第几项 */
|
||||
const active = ref(0)
|
||||
|
||||
function add(name: string) {
|
||||
chosen.value = [...chosen.value, name]
|
||||
open.value = false
|
||||
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">
|
||||
<span v-for="name in chosen" :key="name" class="tag">
|
||||
{{ name }}
|
||||
<button type="button" :title="`移除 ${name}`" @click="remove(name)">
|
||||
<IconRemove />
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<div v-if="options.some((o) => !chosen.includes(o))" class="add">
|
||||
<button type="button" class="plus" title="添加订阅" @click="open = !open">
|
||||
<IconAdd />
|
||||
</button>
|
||||
|
||||
<ul v-if="open" class="menu">
|
||||
<template v-for="name in options" :key="name">
|
||||
<li v-if="!chosen.includes(name)">
|
||||
<button type="button" @click="add(name)">{{ name }}</button>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<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>
|
||||
|
||||
<span v-else-if="chosen.length === 0" class="empty">没有可选的订阅</span>
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.picker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.box {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
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;
|
||||
}
|
||||
|
||||
.box:focus-within {
|
||||
border-color: var(--c-accent);
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--s-1);
|
||||
height: 34px;
|
||||
padding: 0 var(--s-1) 0 var(--s-3);
|
||||
border: 1px solid var(--c-border-strong);
|
||||
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);
|
||||
@@ -83,8 +148,8 @@ function onFocusOut(event: FocusEvent) {
|
||||
.tag button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: var(--r-sm);
|
||||
background: none;
|
||||
@@ -97,35 +162,31 @@ function onFocusOut(event: FocusEvent) {
|
||||
color: var(--c-danger);
|
||||
}
|
||||
|
||||
.add {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.plus {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px dashed var(--c-border-strong);
|
||||
border-radius: var(--r-sm);
|
||||
.box input {
|
||||
flex: 1;
|
||||
min-width: 6em;
|
||||
height: 26px;
|
||||
padding: 0 var(--s-1);
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--c-text-faint);
|
||||
cursor: pointer;
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
.plus:hover {
|
||||
border-style: solid;
|
||||
border-color: var(--c-accent);
|
||||
color: var(--c-accent-text);
|
||||
/* 焦点态由整个框的描边表达,框里的输入不再自己画一圈 */
|
||||
.box input:focus,
|
||||
.box input:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 浮在上面,不能把下面几行顶下去 */
|
||||
.menu {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: calc(34px + var(--s-1));
|
||||
top: calc(100% + var(--s-1));
|
||||
left: 0;
|
||||
min-width: 10em;
|
||||
min-width: 100%;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: var(--s-1);
|
||||
@@ -134,7 +195,10 @@ function onFocusOut(event: FocusEvent) {
|
||||
background: var(--c-surface);
|
||||
}
|
||||
|
||||
.menu button {
|
||||
.opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--s-2);
|
||||
width: 100%;
|
||||
padding: var(--s-2) var(--s-3);
|
||||
border: none;
|
||||
@@ -146,11 +210,23 @@ function onFocusOut(event: FocusEvent) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu button:hover {
|
||||
.opt.is-active {
|
||||
background: var(--c-surface-sunken);
|
||||
}
|
||||
|
||||
.empty {
|
||||
.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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user