下拉弹层改视口定位,规则表单换 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
+56 -14
View File
@@ -9,7 +9,7 @@
* 背景色用 --select-bg 变量留了口子:对话框里的控件要比对话框底色沉一档
* (--c-bg),页面上的控件要比页面底色浮一档(--c-surface),一个值满足不了。
*/
import { computed, ref } from 'vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import IconCheck from '~icons/ri/check-line'
import IconChevron from '~icons/ri/arrow-down-s-line'
@@ -31,15 +31,37 @@ const props = defineProps<{
const chosen = defineModel<string>({ default: '' })
const root = ref<HTMLElement | 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 current = computed(() => props.options.find((o) => o.value === chosen.value) ?? null)
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() {
if (open.value) {
open.value = false
@@ -49,11 +71,31 @@ function toggleOpen() {
0,
props.options.findIndex((o) => o.value === chosen.value),
)
const rect = root.value?.getBoundingClientRect()
openUp.value = !!rect && window.innerHeight - rect.bottom < MENU_ROOM && rect.top > MENU_ROOM
place()
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) {
chosen.value = value
open.value = false
@@ -119,7 +161,7 @@ function groupAt(index: number): string | null {
</button>
<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>
<template v-for="(item, i) in options" :key="item.value">
<li v-if="groupAt(i)" class="group" role="presentation">{{ groupAt(i) }}</li>
@@ -143,9 +185,10 @@ function groupAt(index: number): string | null {
</template>
<style scoped>
/* 宽度不在这里定:块级元素在表单列里自然撑满,行内布局由调用方给定宽度。
写死 width: 100% 会和调用方的宽度规则打架,两条同权重规则谁赢看加载顺序 */
.select {
position: relative;
width: 100%;
}
.face {
@@ -159,6 +202,7 @@ function groupAt(index: number): string | null {
background: var(--select-bg, var(--c-bg));
text-align: left;
cursor: pointer;
user-select: none;
}
/* 和文本输入一致:焦点态是描边变色,不叠外圈 */
@@ -190,12 +234,12 @@ function groupAt(index: number): string | null {
transform: rotate(180deg);
}
/* 位置(left/top/bottom/minWidth)由 place() 以内联样式给出 */
.menu {
position: absolute;
position: fixed;
z-index: 1;
top: calc(100% + var(--s-1));
left: 0;
min-width: 100%;
/* 弹层可以比控件宽(按最长选项撑开),但不能宽出屏幕 */
max-width: min(360px, calc(100vw - 2 * var(--s-4)));
max-height: 200px;
overflow-y: auto;
list-style: none;
@@ -204,11 +248,7 @@ function groupAt(index: number): string | null {
border: 1px solid var(--c-border-strong);
border-radius: var(--r-md);
background: var(--c-surface);
}
.menu.is-up {
top: auto;
bottom: calc(100% + var(--s-1));
user-select: none;
}
.group {
@@ -229,6 +269,8 @@ function groupAt(index: number): string | null {
text-align: left;
font-size: var(--t-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
}