下拉弹层改视口定位,规则表单换 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:
2026-08-11 23:58:53 +08:00
parent f927f9fff7
commit 4c43756208
4 changed files with 413 additions and 41 deletions
+278
View File
@@ -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>