iter: d14 新增抽屉 drawer 组件

This commit is contained in:
ui-arch-bot
2026-10-23 16:56:36 +08:00
parent 730ee1abde
commit d3fc754b33
11 changed files with 313 additions and 11 deletions
+39
View File
@@ -0,0 +1,39 @@
import { effect, h } from '@ui-arch/frame';
import type { DrawerProps } from './types.js';
/*** 抽屉:open 信号驱动侧滑面板,点遮罩 / × / Esc 关闭;与 modal 的分工是不打断主视图、可从侧边保留上下文 ***/
export function drawer(props: DrawerProps, ...children: unknown[]): HTMLElement {
const close = (): void => {
if (!props.open()) return; // 卫语句:已关闭不重复触发回调
props.open(false);
props.onClose?.();
};
const overlay = h('div', {
class: () => `ua-drawer-overlay${props.open() ? ' ua-open' : ''}`,
onClick: (e: Event) => {
if (e.target === overlay) close(); // 卫语句:仅点遮罩本身关闭
},
});
const panel = h(
'aside',
{
class: `ua-drawer ua-drawer-${props.placement ?? 'right'}`,
style: `width:${props.width ?? 320}px`,
role: 'dialog',
'aria-hidden': () => String(!props.open()),
},
h('div', { class: 'ua-drawer-head' }, h('h3', { class: 'ua-drawer-title' }, 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);
effect(() => {
if (!props.open()) return;
(panel.querySelector('button, input, textarea') as HTMLElement | null)?.focus(); // 打开时聚焦首个可交互元素
});
document.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape' && props.open()) close();
});
return overlay;
}
+1
View File
@@ -23,5 +23,6 @@ export { spinner } from './spinner.js';
export { progress } from './progress.js';
export { tabs } from './tabs.js';
export { modal } from './modal.js';
export { drawer } from './drawer.js';
export { form, formItem } from './form.js';
export { dataTable } from './dataTable.js';
+10
View File
@@ -232,6 +232,16 @@ export interface ModalProps {
onClose?: () => void; // 关闭回调(点遮罩、× 或 Esc 触发)
}
/*** 抽屉入参 ***/
export interface DrawerProps {
title: string; // 标题
open: Signal<boolean>; // 开关信号:true 滑出 / false 收起
placement?: 'left' | 'right'; // 滑出方向;缺省 right
width?: number; // 面板宽度像素;缺省 320
footer?: unknown; // 底部操作区内容;缺省不渲染
onClose?: () => void; // 关闭回调(遮罩 / × / Esc 触发)
}
/*** 表单校验规则:按序执行,首个失败即停止 ***/
export interface FormRule {
required?: boolean; // 非空校验(trim 后判定)
+60 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@ui-arch/ui",
"type": "skin-dom",
"description": "DOM 皮肤组件库:button / textField / textarea / slider / checkbox / switch / select / menu / radioGroup / accordion / breadcrumb / card / badge / tag / alert / toast / tooltip / popover / skeleton / spinner / progress / tabs / pagination / modal / form / formItem / dataTable,构建在 @ui-arch/frame 与 @ui-arch/table-core 之上,样式只消费 --ua-* 令牌。",
"description": "DOM 皮肤组件库:button / textField / textarea / slider / checkbox / switch / select / menu / radioGroup / accordion / breadcrumb / card / badge / tag / alert / toast / tooltip / popover / skeleton / spinner / progress / tabs / pagination / modal / drawer / form / formItem / dataTable,构建在 @ui-arch/frame 与 @ui-arch/table-core 之上,样式只消费 --ua-* 令牌。",
"tags": [
"components",
"dom",
@@ -37,6 +37,7 @@
"progress(props):进度条,轨道 + 渐变流动填充,value 信号驱动 0-100,满格切 success 色",
"tabs(props):标签页,active 信号双向绑定,滑动指示条 + 方向键环形导航,支持禁用页签",
"modal(props, ...children):弹窗,open 信号驱动显隐,Esc / 遮罩 / × 关闭,size 三档宽度,footer 提供底部操作区",
"drawer(props, ...children):抽屉,open 信号驱动侧滑面板,placement 左右可配、width 可调、footer 操作区,遮罩 / × / Esc 关闭",
"form(props, items):表单容器,布局 + 聚合校验,全部通过才触发 onSubmit",
"formItem(props, control):表单字段,label + 必填红星 + 规则校验 + 错误区,返回 { el, validate } 句柄",
"dataTable(props):数据表格,粘性表头 / 行选择(全选三态)/ 排序 / 筛选 / 分页 / 空状态;loading 为真时数据行换骨架行且筛选/翻页禁用,内核为 table-core"
@@ -74,7 +75,8 @@
"DataTableProps",
"PopoverProps",
"SliderProps",
"TagProps"
"TagProps",
"DrawerProps"
]
},
"usage": "const checked = signal(false); checkbox({ label: '同意', checked }); const open = signal(false); modal({ title: '确认', open }, '内容')",
@@ -84,7 +86,7 @@
"语义色 variant 四档:default|info / success / warning / danger,一律来自令牌,禁止硬编码颜色"
],
"docs": {
"intro": "26 个 DOM 组件,统一用法:组件是普通函数,传入 props 返回 DOM 节点;需要状态时先创建 signal 再双向绑定。引入 CSS:import '@ui-arch/ui/ui.css'(内部已消费 tokens)。",
"intro": "27 个 DOM 组件,统一用法:组件是普通函数,传入 props 返回 DOM 节点;需要状态时先创建 signal 再双向绑定。引入 CSS:import '@ui-arch/ui/ui.css'(内部已消费 tokens)。",
"steps": [
"安装依赖:pnpm add @ui-arch/ui @ui-arch/frame(workspace 内自动链接)",
"引入样式:在应用入口 import '@ui-arch/ui/ui.css',暗色主题在根元素设 data-theme='dark'",
@@ -956,6 +958,61 @@
"底部操作按钮放进 footer(如取消/确定),需要更宽内容用 size: 'lg',窄确认框用 'sm'"
]
},
{
"id": "drawer",
"title": "抽屉 Drawer",
"demo": "sec-drawer",
"props": [
{
"n": "title",
"t": "string",
"r": 1,
"d": "标题"
},
{
"n": "open",
"t": "Signal<boolean>",
"r": 1,
"d": "开关信号:true 滑出 / false 收起"
},
{
"n": "placement",
"t": "'left' | 'right'",
"d": "滑出方向;缺省 right",
"df": "right"
},
{
"n": "width",
"t": "number",
"d": "面板宽度像素(窄屏最多 92vw);缺省 320",
"df": "320"
},
{
"n": "footer",
"t": "unknown",
"d": "底部操作区内容;缺省不渲染"
},
{
"n": "onClose",
"t": "() => void",
"k": 1,
"d": "关闭回调(点遮罩 / × / Esc 触发)"
},
{
"n": "...children",
"t": "unknown[]",
"d": "面板内容子节点"
}
],
"example": "const open = signal(false); drawer({ title: '详情', open, width: 360, onClose: () => open(false) }, h('p', null, '内容'))",
"steps": [
"创建布尔信号 open = signal(false),传给 drawer;其余参数为面板内容子节点",
"打开后从侧边滑出:placement: 'right'(默认)或 'left',width 控制宽度(窄屏自动收到 92vw)",
"点遮罩、× 或按 Esc 关闭并写回 false;打开时焦点落在面板首个可交互元素",
"底部操作按钮放进 footer;与 modal 的分工:modal 是强打断的确认,drawer 保留主视图上下文做详情/编辑",
"面板内容与 modal 一样接受任意 DOM 与响应式函数"
]
},
{
"id": "form",
"title": "表单 Form / 表单字段 FormItem",
+79 -1
View File
@@ -1014,6 +1014,82 @@
margin-top: 4px;
}
/* —— 抽屉(侧滑面板) —— */
.ua-drawer-overlay {
position: fixed;
inset: 0;
z-index: 90;
background: rgba(0, 0, 0, 0.36);
opacity: 0;
visibility: hidden;
transition: opacity 0.22s ease, visibility 0.22s;
}
.ua-drawer-overlay.ua-open {
opacity: 1;
visibility: visible;
}
.ua-drawer {
position: absolute;
top: 0;
bottom: 0;
display: flex;
flex-direction: column;
max-width: 92vw;
background: var(--ua-color-surface);
border-left: 1px solid var(--ua-color-border);
box-shadow: 0 0 40px rgba(0, 0, 0, 0.22);
transition: transform 0.24s ease;
}
.ua-drawer-right {
right: 0;
transform: translateX(100%);
}
.ua-drawer-left {
left: 0;
border-left: none;
border-right: 1px solid var(--ua-color-border);
transform: translateX(-100%);
}
.ua-drawer-overlay.ua-open .ua-drawer-right,
.ua-drawer-overlay.ua-open .ua-drawer-left {
transform: none;
}
.ua-drawer-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ua-space-2);
padding: 12px 16px;
border-bottom: 1px solid var(--ua-color-border);
}
.ua-drawer-title {
margin: 0;
font-size: var(--ua-font-size-lg);
font-weight: 500;
}
.ua-drawer-close {
border: none;
background: none;
color: var(--ua-color-text-muted);
font-size: 18px;
line-height: 1;
cursor: pointer;
}
.ua-drawer-body {
flex: 1;
overflow-y: auto;
padding: 16px;
color: var(--ua-color-text);
font-size: var(--ua-font-size-md);
}
.ua-drawer-foot {
display: flex;
justify-content: flex-end;
gap: var(--ua-space-2);
padding: 12px 16px;
border-top: 1px solid var(--ua-color-border);
}
/* —— 弹窗 —— */
.ua-modal-overlay {
display: none;
@@ -1182,7 +1258,9 @@
.ua-skeleton::after,
.ua-form-error,
.ua-modal,
.ua-modal-overlay {
.ua-modal-overlay,
.ua-drawer,
.ua-drawer-overlay {
animation: none !important;
transition: none !important;
}