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
+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;