62 lines
3.1 KiB
TypeScript
62 lines
3.1 KiB
TypeScript
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;
|
||
}
|