Files
UI/packages/skin/ui/src/drawer.ts
T
2026-10-26 14:10:17 +08:00

62 lines
3.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { effect, h } from '@ui-arch/frame';
import type { DrawerProps } from './types.js';
import { lockScroll, pushOverlay, trapFocus } from './overlay.js';
let seq = 0; // 实例序号:给标题生成唯一 id,供 aria-labelledby 关联
/*** 抽屉:open 信号驱动侧滑面板,点遮罩 / × / Esc 关闭;Tab 焦点陷阱 + 关闭归还焦点,与 modal 的分工是不打断主视图、可从侧边保留上下文 ***/
export function drawer(props: DrawerProps, ...children: unknown[]): HTMLElement {
const titleId = `ua-drawer-title-${++seq}`; // 标题 id:抽屉的可访问名
const modal = props.modal !== false; // 是否模态:false 时无遮罩、不锁滚动、不困焦点,页面仍可操作
const close = (): void => {
if (!props.open()) return; // 卫语句:已关闭不重复触发回调
props.open(false);
props.onClose?.();
};
const overlay = h('div', {
class: () => `ua-drawer-overlay${props.open() ? ' ua-open' : ''}${modal ? '' : ' is-plain'}`,
onClick: (e: Event) => {
if (!modal) return; // 卫语句:非模态没有遮罩,点外面不算关闭
if (e.target !== overlay) return; // 卫语句:只处理点在遮罩本身的
if (props.closeOnOverlay === false) return; // 显式禁止时防误关,× 与 Esc 仍可用
close();
},
});
const panel = h(
'aside',
{
class: `ua-drawer ua-drawer-${props.placement ?? 'right'}`,
style: `${props.placement === 'top' || props.placement === 'bottom' ? 'height' : 'width'}:${props.width ?? 320}px`, // 上下滑出改高度
role: 'dialog',
'aria-labelledby': titleId,
'aria-hidden': () => String(!props.open()),
'aria-modal': modal ? 'true' : 'false',
},
h('div', { class: 'ua-drawer-head' }, h('h3', { class: 'ua-drawer-title', id: titleId }, props.title), h('button', { class: 'ua-drawer-close', 'aria-label': '关闭抽屉', onClick: close }, '×')),
h('div', { class: 'ua-drawer-body' }, ...children),
props.footer ? h('div', { class: 'ua-drawer-foot' }, props.footer) : null,
);
overlay.append(panel);
let lastFocus: HTMLElement | null = null; // 打开前焦点宿主,关闭时归还
let release: (() => void) | null = null; // Esc 栈注销函数,关闭时释放
let releaseLock: (() => void) | null = null; // 背景滚动锁解锁函数,全关才还原
effect(() => {
if (props.open()) {
lastFocus = document.activeElement as HTMLElement | null; // 记录打开前焦点
release ??= pushOverlay(close); // 入 Esc 栈:多弹层时只关最上层
if (modal) releaseLock ??= lockScroll(); // 仅模态期锁背景滚动
(panel.querySelector('button, input, textarea') as HTMLElement | null)?.focus(); // 打开时聚焦首个可交互元素
} else {
release?.(); // 出 Esc 栈
release = null;
releaseLock?.(); // 解锁背景滚动
releaseLock = null;
lastFocus?.focus(); // 关闭时归还焦点
lastFocus = null;
}
});
if (modal) trapFocus(panel, () => props.open()); // 模态才困焦点:非模态要让用户能继续操作背后页面
return overlay;
}