From 4c4375620828f713ce00ca7b0fd6119f3a71daa0 Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Tue, 11 Aug 2026 23:58:53 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8B=E6=8B=89=E5=BC=B9=E5=B1=82=E6=94=B9?= =?UTF-8?q?=E8=A7=86=E5=8F=A3=E5=AE=9A=E4=BD=8D=EF=BC=8C=E8=A7=84=E5=88=99?= =?UTF-8?q?=E8=A1=A8=E5=8D=95=E6=8D=A2=20ComboBox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit absolute 的弹层会计入祖先滚动容器的可滚动区域——在对话框里打开下拉, modal 会被撑出滚动条。弹层改 position: fixed 按视口摆放:逃出所有 overflow 裁剪,又因为还在 dialog 的 DOM 子树里,照样画在 top layer 之上。 代价是位置不跟随滚动,滚动和缩放时直接收起(弹层内部滚动不算)。 三个弹层组件(SelectBox、ComboBox、SourcePicker)同一套做法。 组件根元素不再写死 width: 100%:它和调用方的宽度规则同权重,谁赢 看样式表加载顺序——请求页的级别下拉被撑满整行就是这么来的。 弹层加 user-select: none 和最大宽度,选项超长省略号截断。 规则表单的类型、载荷、目标原来是 input + datalist,原生建议列表的样式 和开合状态都管不到,换成同族的 ComboBox:选项只是建议、手填合法, Enter 在弹层开着时选中高亮项、收起时照旧提交表单。 --- web/components/ComboBox.vue | 278 ++++++++++++++++++++++++++++++++ web/components/SelectBox.vue | 70 ++++++-- web/components/SourcePicker.vue | 74 ++++++++- web/views/RuleForm.vue | 32 ++-- 4 files changed, 413 insertions(+), 41 deletions(-) create mode 100644 web/components/ComboBox.vue diff --git a/web/components/ComboBox.vue b/web/components/ComboBox.vue new file mode 100644 index 0000000..520addc --- /dev/null +++ b/web/components/ComboBox.vue @@ -0,0 +1,278 @@ + + + + + diff --git a/web/components/SelectBox.vue b/web/components/SelectBox.vue index 1ec0fa7..0f8146c 100644 --- a/web/components/SelectBox.vue +++ b/web/components/SelectBox.vue @@ -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({ default: '' }) const root = ref(null) +const menuEl = ref(null) const open = ref(false) /** 往上弹。下方视口不够放弹层、上方够时翻转,不把弹层怼出屏幕 */ const openUp = ref(false) const active = ref(0) +const menuStyle = ref>({}) 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 { -
    +
    • 没有可选项
    • diff --git a/web/views/RuleForm.vue b/web/views/RuleForm.vue index 97c9beb..604e3cf 100644 --- a/web/views/RuleForm.vue +++ b/web/views/RuleForm.vue @@ -9,6 +9,7 @@ */ import { computed, ref, watch } from 'vue' import { buildRule, isStructuredEditable, parseRule } from '@shared/rules.ts' +import ComboBox from '../components/ComboBox.vue' import IconClose from '~icons/ri/close-line' const props = defineProps<{ @@ -143,45 +144,36 @@ function submit() {