下拉弹层改视口定位,规则表单换 ComboBox
absolute 的弹层会计入祖先滚动容器的可滚动区域——在对话框里打开下拉, modal 会被撑出滚动条。弹层改 position: fixed 按视口摆放:逃出所有 overflow 裁剪,又因为还在 dialog 的 DOM 子树里,照样画在 top layer 之上。 代价是位置不跟随滚动,滚动和缩放时直接收起(弹层内部滚动不算)。 三个弹层组件(SelectBox、ComboBox、SourcePicker)同一套做法。 组件根元素不再写死 width: 100%:它和调用方的宽度规则同权重,谁赢 看样式表加载顺序——请求页的级别下拉被撑满整行就是这么来的。 弹层加 user-select: none 和最大宽度,选项超长省略号截断。 规则表单的类型、载荷、目标原来是 input + datalist,原生建议列表的样式 和开合状态都管不到,换成同族的 ComboBox:选项只是建议、手填合法, Enter 在弹层开着时选中高亮项、收起时照旧提交表单。
This commit is contained in:
@@ -0,0 +1,278 @@
|
|||||||
|
<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;
|
||||||
|
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>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
* 背景色用 --select-bg 变量留了口子:对话框里的控件要比对话框底色沉一档
|
* 背景色用 --select-bg 变量留了口子:对话框里的控件要比对话框底色沉一档
|
||||||
* (--c-bg),页面上的控件要比页面底色浮一档(--c-surface),一个值满足不了。
|
* (--c-bg),页面上的控件要比页面底色浮一档(--c-surface),一个值满足不了。
|
||||||
*/
|
*/
|
||||||
import { computed, ref } from 'vue'
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import IconCheck from '~icons/ri/check-line'
|
import IconCheck from '~icons/ri/check-line'
|
||||||
import IconChevron from '~icons/ri/arrow-down-s-line'
|
import IconChevron from '~icons/ri/arrow-down-s-line'
|
||||||
|
|
||||||
@@ -31,15 +31,37 @@ const props = defineProps<{
|
|||||||
const chosen = defineModel<string>({ default: '' })
|
const chosen = defineModel<string>({ default: '' })
|
||||||
|
|
||||||
const root = ref<HTMLElement | null>(null)
|
const root = ref<HTMLElement | null>(null)
|
||||||
|
const menuEl = ref<HTMLElement | null>(null)
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
/** 往上弹。下方视口不够放弹层、上方够时翻转,不把弹层怼出屏幕 */
|
/** 往上弹。下方视口不够放弹层、上方够时翻转,不把弹层怼出屏幕 */
|
||||||
const openUp = ref(false)
|
const openUp = ref(false)
|
||||||
const active = ref(0)
|
const active = ref(0)
|
||||||
|
const menuStyle = ref<Record<string, string>>({})
|
||||||
|
|
||||||
const current = computed(() => props.options.find((o) => o.value === chosen.value) ?? null)
|
const current = computed(() => props.options.find((o) => o.value === chosen.value) ?? null)
|
||||||
|
|
||||||
const MENU_ROOM = 220
|
const MENU_ROOM = 220
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 弹层是 position: fixed,按视口摆放。absolute 的弹层会计入祖先滚动容器的
|
||||||
|
* 可滚动区域——在对话框里打开就把对话框撑出滚动条。fixed 逃出所有 overflow
|
||||||
|
* 裁剪,又因为还在 dialog 的 DOM 子树里,照样画在 top layer 之上。
|
||||||
|
* 代价是位置不跟随滚动,所以下面在滚动和缩放时直接收起。
|
||||||
|
*/
|
||||||
|
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 toggleOpen() {
|
function toggleOpen() {
|
||||||
if (open.value) {
|
if (open.value) {
|
||||||
open.value = false
|
open.value = false
|
||||||
@@ -49,11 +71,31 @@ function toggleOpen() {
|
|||||||
0,
|
0,
|
||||||
props.options.findIndex((o) => o.value === chosen.value),
|
props.options.findIndex((o) => o.value === chosen.value),
|
||||||
)
|
)
|
||||||
const rect = root.value?.getBoundingClientRect()
|
place()
|
||||||
openUp.value = !!rect && window.innerHeight - rect.bottom < MENU_ROOM && rect.top > MENU_ROOM
|
|
||||||
open.value = true
|
open.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 页面或对话框一滚动,锚点就不准了,直接收起。弹层内部的滚动不算 */
|
||||||
|
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 pick(value: string) {
|
function pick(value: string) {
|
||||||
chosen.value = value
|
chosen.value = value
|
||||||
open.value = false
|
open.value = false
|
||||||
@@ -119,7 +161,7 @@ function groupAt(index: number): string | null {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Transition name="menu">
|
<Transition name="menu">
|
||||||
<ul v-if="open" :class="['menu', openUp && 'is-up']" role="listbox">
|
<ul v-if="open" ref="menuEl" :class="['menu', openUp && 'is-up']" :style="menuStyle" role="listbox">
|
||||||
<li v-if="options.length === 0" class="none">没有可选项</li>
|
<li v-if="options.length === 0" class="none">没有可选项</li>
|
||||||
<template v-for="(item, i) in options" :key="item.value">
|
<template v-for="(item, i) in options" :key="item.value">
|
||||||
<li v-if="groupAt(i)" class="group" role="presentation">{{ groupAt(i) }}</li>
|
<li v-if="groupAt(i)" class="group" role="presentation">{{ groupAt(i) }}</li>
|
||||||
@@ -143,9 +185,10 @@ function groupAt(index: number): string | null {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
/* 宽度不在这里定:块级元素在表单列里自然撑满,行内布局由调用方给定宽度。
|
||||||
|
写死 width: 100% 会和调用方的宽度规则打架,两条同权重规则谁赢看加载顺序 */
|
||||||
.select {
|
.select {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.face {
|
.face {
|
||||||
@@ -159,6 +202,7 @@ function groupAt(index: number): string | null {
|
|||||||
background: var(--select-bg, var(--c-bg));
|
background: var(--select-bg, var(--c-bg));
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 和文本输入一致:焦点态是描边变色,不叠外圈 */
|
/* 和文本输入一致:焦点态是描边变色,不叠外圈 */
|
||||||
@@ -190,12 +234,12 @@ function groupAt(index: number): string | null {
|
|||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 位置(left/top/bottom/minWidth)由 place() 以内联样式给出 */
|
||||||
.menu {
|
.menu {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
top: calc(100% + var(--s-1));
|
/* 弹层可以比控件宽(按最长选项撑开),但不能宽出屏幕 */
|
||||||
left: 0;
|
max-width: min(360px, calc(100vw - 2 * var(--s-4)));
|
||||||
min-width: 100%;
|
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -204,11 +248,7 @@ function groupAt(index: number): string | null {
|
|||||||
border: 1px solid var(--c-border-strong);
|
border: 1px solid var(--c-border-strong);
|
||||||
border-radius: var(--r-md);
|
border-radius: var(--r-md);
|
||||||
background: var(--c-surface);
|
background: var(--c-surface);
|
||||||
}
|
user-select: none;
|
||||||
|
|
||||||
.menu.is-up {
|
|
||||||
top: auto;
|
|
||||||
bottom: calc(100% + var(--s-1));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.group {
|
.group {
|
||||||
@@ -229,6 +269,8 @@ function groupAt(index: number): string | null {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: var(--t-sm);
|
font-size: var(--t-sm);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
* 标签收在框内、下拉列出全部选项并给已选的打勾、点击不收起。订阅只有一两个时
|
* 标签收在框内、下拉列出全部选项并给已选的打勾、点击不收起。订阅只有一两个时
|
||||||
* 平铺标签更直接,但数量一多平铺就是一面墙,这个形态两头都撑得住。
|
* 平铺标签更直接,但数量一多平铺就是一面墙,这个形态两头都撑得住。
|
||||||
*/
|
*/
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import IconCheck from '~icons/ri/check-line'
|
import IconCheck from '~icons/ri/check-line'
|
||||||
import IconRemove from '~icons/ri/close-line'
|
import IconRemove from '~icons/ri/close-line'
|
||||||
|
|
||||||
@@ -16,10 +16,13 @@ const chosen = defineModel<string[]>({ required: true })
|
|||||||
|
|
||||||
const root = ref<HTMLElement | null>(null)
|
const root = ref<HTMLElement | null>(null)
|
||||||
const input = ref<HTMLInputElement | null>(null)
|
const input = ref<HTMLInputElement | null>(null)
|
||||||
|
const menuEl = ref<HTMLElement | null>(null)
|
||||||
const open = ref(false)
|
const open = ref(false)
|
||||||
|
const openUp = ref(false)
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
/** 键盘上下键在过滤结果里走到第几项 */
|
/** 键盘上下键在过滤结果里走到第几项 */
|
||||||
const active = ref(0)
|
const active = ref(0)
|
||||||
|
const menuStyle = ref<Record<string, string>>({})
|
||||||
|
|
||||||
const filtered = computed(() =>
|
const filtered = computed(() =>
|
||||||
props.options.filter((name) => name.toLowerCase().includes(query.value.trim().toLowerCase())),
|
props.options.filter((name) => name.toLowerCase().includes(query.value.trim().toLowerCase())),
|
||||||
@@ -30,16 +33,62 @@ watch(query, () => {
|
|||||||
open.value = true
|
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) {
|
function toggle(name: string) {
|
||||||
chosen.value = chosen.value.includes(name)
|
chosen.value = chosen.value.includes(name)
|
||||||
? chosen.value.filter((item) => item !== name)
|
? chosen.value.filter((item) => item !== name)
|
||||||
: [...chosen.value, name]
|
: [...chosen.value, name]
|
||||||
// 点完一项接着挑下一项,不用再点回输入框
|
// 点完一项接着挑下一项,不用再点回输入框
|
||||||
input.value?.focus()
|
input.value?.focus()
|
||||||
|
// 标签增减会改变输入框高度,锚点变了要重新摆
|
||||||
|
void nextTick(place)
|
||||||
}
|
}
|
||||||
|
|
||||||
function remove(name: string) {
|
function remove(name: string) {
|
||||||
chosen.value = chosen.value.filter((item) => item !== name)
|
chosen.value = chosen.value.filter((item) => item !== name)
|
||||||
|
if (open.value) void nextTick(place)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeydown(event: KeyboardEvent) {
|
function onKeydown(event: KeyboardEvent) {
|
||||||
@@ -93,7 +142,14 @@ function onFocusOut(event: FocusEvent) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Transition name="menu">
|
<Transition name="menu">
|
||||||
<ul v-if="open" class="menu" role="listbox" aria-multiselectable="true">
|
<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-if="options.length === 0" class="none">没有可选的订阅</li>
|
||||||
<li v-else-if="filtered.length === 0" class="none">没有匹配的订阅</li>
|
<li v-else-if="filtered.length === 0" class="none">没有匹配的订阅</li>
|
||||||
<li v-for="(name, i) in filtered" :key="name">
|
<li v-for="(name, i) in filtered" :key="name">
|
||||||
@@ -175,13 +231,11 @@ function onFocusOut(event: FocusEvent) {
|
|||||||
font-size: var(--t-sm);
|
font-size: var(--t-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 浮在上面,不能把下面几行顶下去 */
|
/* 浮在上面,不能把下面几行顶下去。位置由 place() 以内联样式给出 */
|
||||||
.menu {
|
.menu {
|
||||||
position: absolute;
|
position: fixed;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
top: calc(100% + var(--s-1));
|
max-width: min(360px, calc(100vw - 2 * var(--s-4)));
|
||||||
left: 0;
|
|
||||||
min-width: 100%;
|
|
||||||
max-height: 240px;
|
max-height: 240px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -190,6 +244,7 @@ function onFocusOut(event: FocusEvent) {
|
|||||||
border: 1px solid var(--c-border-strong);
|
border: 1px solid var(--c-border-strong);
|
||||||
border-radius: var(--r-md);
|
border-radius: var(--r-md);
|
||||||
background: var(--c-surface);
|
background: var(--c-surface);
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.opt {
|
.opt {
|
||||||
@@ -240,4 +295,9 @@ function onFocusOut(event: FocusEvent) {
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu.is-up.menu-enter-from,
|
||||||
|
.menu.is-up.menu-leave-to {
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+12
-20
@@ -9,6 +9,7 @@
|
|||||||
*/
|
*/
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { buildRule, isStructuredEditable, parseRule } from '@shared/rules.ts'
|
import { buildRule, isStructuredEditable, parseRule } from '@shared/rules.ts'
|
||||||
|
import ComboBox from '../components/ComboBox.vue'
|
||||||
import IconClose from '~icons/ri/close-line'
|
import IconClose from '~icons/ri/close-line'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -143,45 +144,36 @@ function submit() {
|
|||||||
<label class="row">
|
<label class="row">
|
||||||
<span class="k">类型</span>
|
<span class="k">类型</span>
|
||||||
<span class="v">
|
<span class="v">
|
||||||
<input v-model="type" class="mono" type="text" list="rule-types" autocomplete="off" />
|
<ComboBox v-model="type" mono :options="COMMON_TYPES" />
|
||||||
<datalist id="rule-types">
|
|
||||||
<option v-for="t in COMMON_TYPES" :key="t" :value="t" />
|
|
||||||
</datalist>
|
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label v-if="!isMatch" class="row">
|
<label v-if="!isMatch" class="row">
|
||||||
<span class="k">载荷</span>
|
<span class="k">载荷</span>
|
||||||
<span class="v">
|
<span class="v">
|
||||||
|
<ComboBox
|
||||||
|
v-if="isRuleSet"
|
||||||
|
v-model="payload"
|
||||||
|
mono
|
||||||
|
:options="providerNames"
|
||||||
|
placeholder="规则组名"
|
||||||
|
/>
|
||||||
<input
|
<input
|
||||||
|
v-else
|
||||||
v-model="payload"
|
v-model="payload"
|
||||||
class="mono"
|
class="mono"
|
||||||
type="text"
|
type="text"
|
||||||
:list="isRuleSet ? 'rule-set-names' : undefined"
|
placeholder="youtube.com"
|
||||||
:placeholder="isRuleSet ? '规则组名' : 'youtube.com'"
|
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
/>
|
/>
|
||||||
<datalist v-if="isRuleSet" id="rule-set-names">
|
|
||||||
<option v-for="name in providerNames" :key="name" :value="name" />
|
|
||||||
</datalist>
|
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="row">
|
<label class="row">
|
||||||
<span class="k">目标</span>
|
<span class="k">目标</span>
|
||||||
<span class="v">
|
<span class="v">
|
||||||
<input
|
<ComboBox v-model="target" mono :options="outboundNames" placeholder="DIRECT" />
|
||||||
v-model="target"
|
|
||||||
class="mono"
|
|
||||||
type="text"
|
|
||||||
list="rule-targets"
|
|
||||||
placeholder="DIRECT"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
<datalist id="rule-targets">
|
|
||||||
<option v-for="name in outboundNames" :key="name" :value="name" />
|
|
||||||
</datalist>
|
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user