下拉弹层改视口定位,规则表单换 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:
@@ -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 IconRemove from '~icons/ri/close-line'
|
||||
|
||||
@@ -16,10 +16,13 @@ const chosen = defineModel<string[]>({ required: true })
|
||||
|
||||
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 query = ref('')
|
||||
/** 键盘上下键在过滤结果里走到第几项 */
|
||||
const active = ref(0)
|
||||
const menuStyle = ref<Record<string, string>>({})
|
||||
|
||||
const filtered = computed(() =>
|
||||
props.options.filter((name) => name.toLowerCase().includes(query.value.trim().toLowerCase())),
|
||||
@@ -30,16 +33,62 @@ watch(query, () => {
|
||||
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) {
|
||||
chosen.value = chosen.value.includes(name)
|
||||
? chosen.value.filter((item) => item !== name)
|
||||
: [...chosen.value, name]
|
||||
// 点完一项接着挑下一项,不用再点回输入框
|
||||
input.value?.focus()
|
||||
// 标签增减会改变输入框高度,锚点变了要重新摆
|
||||
void nextTick(place)
|
||||
}
|
||||
|
||||
function remove(name: string) {
|
||||
chosen.value = chosen.value.filter((item) => item !== name)
|
||||
if (open.value) void nextTick(place)
|
||||
}
|
||||
|
||||
function onKeydown(event: KeyboardEvent) {
|
||||
@@ -93,7 +142,14 @@ function onFocusOut(event: FocusEvent) {
|
||||
</div>
|
||||
|
||||
<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-else-if="filtered.length === 0" class="none">没有匹配的订阅</li>
|
||||
<li v-for="(name, i) in filtered" :key="name">
|
||||
@@ -175,13 +231,11 @@ function onFocusOut(event: FocusEvent) {
|
||||
font-size: var(--t-sm);
|
||||
}
|
||||
|
||||
/* 浮在上面,不能把下面几行顶下去 */
|
||||
/* 浮在上面,不能把下面几行顶下去。位置由 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: 240px;
|
||||
overflow-y: auto;
|
||||
list-style: none;
|
||||
@@ -190,6 +244,7 @@ function onFocusOut(event: FocusEvent) {
|
||||
border: 1px solid var(--c-border-strong);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--c-surface);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.opt {
|
||||
@@ -240,4 +295,9 @@ function onFocusOut(event: FocusEvent) {
|
||||
opacity: 0;
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.menu.is-up.menu-enter-from,
|
||||
.menu.is-up.menu-leave-to {
|
||||
transform: translateY(4px);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user