iter(g86): 弹层外部点击监听改按需挂载

This commit is contained in:
HIsheR
2026-10-26 13:05:34 +08:00
parent 249fa03f01
commit a123140fab
10 changed files with 46 additions and 39 deletions
+8
View File
@@ -1044,6 +1044,14 @@
"acceptance": "新增 label(可访问名,不确定态缺省「进行中」兜底)与 variant(default/success/warning/danger);语义色规则置于 .ua-done 之后同特异性胜出,满格不回退 success;样式只消费 --ua-* 令牌;文档 props/steps 与演示同步;npm run check 全绿",
"notes": "自补:无障碍 + 视觉语义;CSS 层用特异性解决 variant 与满格 success 的冲突,不加 JS 分支",
"status": "done"
},
{
"id": "g86",
"title": "弹层外部点击监听改为按需挂载",
"motivation": "8 个弹层(select / multiSelect / cascader / datePicker / colorPicker / autoComplete / menu / popover)在构造时就在 document 上挂一个永不移除的 click 监听:组件从页面移除后监听仍常驻(SPA 里实例越多越积压),且各写一份重复逻辑;popover 里还残留一个空的 keydown 监听",
"acceptance": "overlay 新增 bindOutside(root, isOpen, close):仅展开期间挂全局 click、收起即摘,内部点击直接返回;8 个弹层全部改用它并删掉各自的重复实现;popover 删掉空 keydown,点弹层内部不再误关;npm run check 全绿",
"notes": "自补:稳健性 + 去重;展开动作本身也是 click,靠 root.contains 卫语句避免刚开就被关",
"status": "open"
}
]
}
+2 -5
View File
@@ -1,6 +1,6 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { Signal } from '@ui-arch/frame';
import { pushOverlay } from './overlay.js';
import { bindOutside, pushOverlay } from './overlay.js';
import { flip } from './position.js';
import type { AutoCompleteOption, AutoCompleteProps } from './types.js';
@@ -102,10 +102,7 @@ export function autoComplete(props: AutoCompleteProps): HTMLElement {
if (e.key === 'Tab') { open(false); return; } // 失焦前收起,避免残留浮层
});
document.addEventListener('click', (e) => {
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
open(false);
});
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
effect(render); // 依赖 query / active / options,任一变化即重绘
effect(() => {
if (open()) releaseEsc ??= pushOverlay(() => { open(false); input.focus(); }); // 入栈逐层关闭
+2 -5
View File
@@ -1,6 +1,6 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { Signal } from '@ui-arch/frame';
import { pushOverlay } from './overlay.js';
import { bindOutside, pushOverlay } from './overlay.js';
import { flip } from './position.js';
import type { CascaderOption, CascaderProps } from './types.js';
@@ -132,10 +132,7 @@ export function cascader(props: CascaderProps): HTMLElement {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); btn.click(); } // 与鼠标点击同语义
});
document.addEventListener('click', (e) => {
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
open(false); // 点外部收起
});
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
effect(render); // 依赖 open / path / value,任一变化即重绘
effect(() => {
if (open()) releaseEsc ??= pushOverlay(() => { open(false); trigger.focus(); }); // 入栈:与弹窗/抽屉共用逐层关闭
+2 -5
View File
@@ -1,6 +1,6 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { Signal } from '@ui-arch/frame';
import { pushOverlay } from './overlay.js';
import { bindOutside, pushOverlay } from './overlay.js';
import { flip } from './position.js';
import type { ColorPickerProps } from './types.js';
@@ -82,10 +82,7 @@ export function colorPicker(props: ColorPickerProps): HTMLElement {
);
const root = h('div', { class: () => `ua-colorpicker${open() ? ' ua-open' : ''}` }, trigger, pop) as HTMLElement;
document.addEventListener('click', (e) => {
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
open(false);
});
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
effect(() => {
swatch.style.background = value() || 'transparent'; // 触发器色块实时预览
swatch.classList.toggle('is-empty', !value());
+2 -5
View File
@@ -1,6 +1,6 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { Signal } from '@ui-arch/frame';
import { pushOverlay } from './overlay.js';
import { bindOutside, pushOverlay } from './overlay.js';
import { flip } from './position.js';
import type { DatePickerProps } from './types.js';
@@ -136,10 +136,7 @@ export function datePicker(props: DatePickerProps): HTMLElement {
if (e.key === 'PageUp' || e.key === 'PageDown') { e.preventDefault(); turn(e.key === 'PageUp' ? -1 : 1); return; }
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); if (!outOfRange(focus())) commit(focus()); }
});
document.addEventListener('click', (e) => {
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
open(false);
});
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
effect(render); // 依赖 view / value / focus
effect(() => {
if (open()) releaseEsc ??= pushOverlay(() => { open(false); trigger.focus(); }); // 入栈逐层关闭
+2 -5
View File
@@ -1,7 +1,7 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { MenuItem, MenuProps } from './types.js';
import { flip } from './position.js';
import { pushOverlay } from './overlay.js';
import { bindOutside, pushOverlay } from './overlay.js';
/*** 下拉菜单:触发器 + 自绘弹层,项支持禁用 / 危险 / 分隔线;点项 / 点外部 / Esc 关闭,↑↓ 环形导航 + Home/End 跳转 + 键入快跳 ***/
export function menu(props: MenuProps): HTMLElement {
@@ -91,10 +91,7 @@ export function menu(props: MenuProps): HTMLElement {
focusables[(at + step + focusables.length) % focusables.length]?.focus(); // 环形移动
});
document.addEventListener('click', (e) => {
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
open(false); // 点外部收起
});
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
effect(() => {
if (open()) {
+2 -5
View File
@@ -1,6 +1,6 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { Signal } from '@ui-arch/frame';
import { pushOverlay } from './overlay.js';
import { bindOutside, pushOverlay } from './overlay.js';
import { flip } from './position.js';
import type { MultiSelectProps } from './types.js';
@@ -101,10 +101,7 @@ export function multiSelect(props: MultiSelectProps): HTMLElement {
if (e.key === 'Escape') { open(false); return; }
});
document.addEventListener('click', (e) => {
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
open(false);
});
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
effect(render);
effect(() => {
if (open()) releaseEsc ??= pushOverlay(() => { open(false); input.focus(); }); // 入栈逐层关闭
+22
View File
@@ -1,3 +1,6 @@
import { effect } from '@ui-arch/frame';
import type { Signal } from '@ui-arch/frame';
/*** 弹层键盘栈:维护打开顺序,Esc 只关最上层,避免多个弹层(含嵌套)被一次全关 ***/
// —— 状态集中声明 ——
@@ -13,6 +16,25 @@ const onKeydown = (e: KeyboardEvent) => {
top.close();
};
/*** 点外部关闭:仅在展开期间挂全局 click,收起即摘(避免逐实例常驻监听,组件卸载后不再残留) ***/
export function bindOutside(root: HTMLElement, isOpen: Signal<boolean>, close: () => void): void {
// —— 状态集中声明 ——
let release: (() => void) | null = null; // 当前监听的注销函数,未挂载时为 null
const handler = (e: Event): void => {
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
close();
};
effect(() => {
if (isOpen() && !release) {
document.addEventListener('click', handler);
release = () => document.removeEventListener('click', handler);
} else if (!isOpen() && release) {
release(); // 收起即摘
release = null;
}
});
}
/*** 注册一个打开的弹层,返回注销函数(弹层关闭时务必调用,否则栈不清理) ***/
export function pushOverlay(close: () => void): () => void {
if (!bound) {
+2 -4
View File
@@ -1,7 +1,7 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { PopoverProps } from './types.js';
import { flip } from './position.js';
import { pushOverlay } from './overlay.js';
import { bindOutside, pushOverlay } from './overlay.js';
let seq = 0; // 全局自增序号:保证每个弹层 id 唯一,供 aria-controls 关联
@@ -53,9 +53,7 @@ export function popover(props: PopoverProps, trigger: HTMLElement): HTMLElement
toggle();
});
}
root.addEventListener('keydown', (e: KeyboardEvent) => {
});
document.addEventListener('click', () => setOpen(false)); // 点外部收起(弹层内已阻止冒泡)
bindOutside(root, open, () => setOpen(false)); // 点外部收起:仅展开期间挂监听,收起即摘
effect(() => {
if (open()) {
releaseEsc ??= pushOverlay(() => { setOpen(false); trigger.focus(); }); // 入 Esc 栈:与弹窗/抽屉共用同一套逐层关闭
+2 -5
View File
@@ -1,7 +1,7 @@
import { effect, h, signal } from '@ui-arch/frame';
import type { SelectProps } from './types.js';
import { flip } from './position.js';
import { pushOverlay } from './overlay.js';
import { bindOutside, pushOverlay } from './overlay.js';
/*** 下拉选择:自绘弹层替代原生 select,value 信号双向绑定;searchable 时弹层顶部带搜索框,支持 ↑↓/Home/End 导航与 Esc 关闭 ***/
export function select(props: SelectProps): HTMLElement {
@@ -102,10 +102,7 @@ export function select(props: SelectProps): HTMLElement {
if (e.key === 'End' && open()) { e.preventDefault(); options[list[list.length - 1]]?.focus(); return; } // End 跳末个可见项
if (open() && !props.searchable && e.key.length === 1) jumpByTyped(e.key); // 可打印字符:键入快跳(搜索模式由输入框接管)
});
document.addEventListener('click', (e) => {
if (root.contains(e.target as Node)) return; // 卫语句:组件内点击不处理
open(false); // 点外部关闭
});
bindOutside(root, open, () => open(false)); // 点外部收起:仅展开期间挂监听,收起即摘
effect(() => {
const q = query();
let shown = 0;