From a123140fab291b5302a3d04cb56699ea054b41c0 Mon Sep 17 00:00:00 2001 From: HIsheR Date: Mon, 26 Oct 2026 13:05:34 +0800 Subject: [PATCH] =?UTF-8?q?iter(g86):=20=E5=BC=B9=E5=B1=82=E5=A4=96?= =?UTF-8?q?=E9=83=A8=E7=82=B9=E5=87=BB=E7=9B=91=E5=90=AC=E6=94=B9=E6=8C=89?= =?UTF-8?q?=E9=9C=80=E6=8C=82=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .ua/backlog.json | 8 ++++++++ packages/skin/ui/src/autoComplete.ts | 7 ++----- packages/skin/ui/src/cascader.ts | 7 ++----- packages/skin/ui/src/colorPicker.ts | 7 ++----- packages/skin/ui/src/datePicker.ts | 7 ++----- packages/skin/ui/src/menu.ts | 7 ++----- packages/skin/ui/src/multiSelect.ts | 7 ++----- packages/skin/ui/src/overlay.ts | 22 ++++++++++++++++++++++ packages/skin/ui/src/popover.ts | 6 ++---- packages/skin/ui/src/select.ts | 7 ++----- 10 files changed, 46 insertions(+), 39 deletions(-) diff --git a/.ua/backlog.json b/.ua/backlog.json index 2633c70..8462c77 100644 --- a/.ua/backlog.json +++ b/.ua/backlog.json @@ -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" } ] } diff --git a/packages/skin/ui/src/autoComplete.ts b/packages/skin/ui/src/autoComplete.ts index 48b8de9..c3b17d2 100644 --- a/packages/skin/ui/src/autoComplete.ts +++ b/packages/skin/ui/src/autoComplete.ts @@ -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(); }); // 入栈逐层关闭 diff --git a/packages/skin/ui/src/cascader.ts b/packages/skin/ui/src/cascader.ts index 4b8dd7c..37f4bcf 100644 --- a/packages/skin/ui/src/cascader.ts +++ b/packages/skin/ui/src/cascader.ts @@ -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(); }); // 入栈:与弹窗/抽屉共用逐层关闭 diff --git a/packages/skin/ui/src/colorPicker.ts b/packages/skin/ui/src/colorPicker.ts index 2c6bc2b..4a20821 100644 --- a/packages/skin/ui/src/colorPicker.ts +++ b/packages/skin/ui/src/colorPicker.ts @@ -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()); diff --git a/packages/skin/ui/src/datePicker.ts b/packages/skin/ui/src/datePicker.ts index c97fb67..7ccc190 100644 --- a/packages/skin/ui/src/datePicker.ts +++ b/packages/skin/ui/src/datePicker.ts @@ -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(); }); // 入栈逐层关闭 diff --git a/packages/skin/ui/src/menu.ts b/packages/skin/ui/src/menu.ts index 6d72a15..fb45f59 100644 --- a/packages/skin/ui/src/menu.ts +++ b/packages/skin/ui/src/menu.ts @@ -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()) { diff --git a/packages/skin/ui/src/multiSelect.ts b/packages/skin/ui/src/multiSelect.ts index 52aa9af..80815d3 100644 --- a/packages/skin/ui/src/multiSelect.ts +++ b/packages/skin/ui/src/multiSelect.ts @@ -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(); }); // 入栈逐层关闭 diff --git a/packages/skin/ui/src/overlay.ts b/packages/skin/ui/src/overlay.ts index bcde8ed..3975024 100644 --- a/packages/skin/ui/src/overlay.ts +++ b/packages/skin/ui/src/overlay.ts @@ -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, 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) { diff --git a/packages/skin/ui/src/popover.ts b/packages/skin/ui/src/popover.ts index 8554ec2..e96dbab 100644 --- a/packages/skin/ui/src/popover.ts +++ b/packages/skin/ui/src/popover.ts @@ -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 栈:与弹窗/抽屉共用同一套逐层关闭 diff --git a/packages/skin/ui/src/select.ts b/packages/skin/ui/src/select.ts index 941b9fd..1ada0db 100644 --- a/packages/skin/ui/src/select.ts +++ b/packages/skin/ui/src/select.ts @@ -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;