Files
UI/apps/demo/main.ts
T
2026-10-26 16:02:24 +08:00

1461 lines
70 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 { computed, effect, h, mount, renderList, signal } from '@ui-arch/frame';
/*** 演示用内联 SVG 图片(data URI,离线可用) ***/
const IMG_DEMO = 'data:image/svg+xml;utf8,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="480" height="320">' +
'<rect width="480" height="320" fill="#185fa5"/>' +
'<circle cx="240" cy="160" r="90" fill="#ffffff" opacity="0.85"/>' +
'<text x="240" y="170" font-size="28" text-anchor="middle" fill="#185fa5">IMAGE DEMO</text></svg>',
);
import {
accordion,
affix,
alert,
anchor,
autoComplete,
avatarGroup,
avatar,
backTop,
badge,
breadcrumb,
button,
calendar,
card,
cascader,
checkbox,
checkboxGroup,
colorPicker,
dataTable,
datePicker,
transfer,
descriptions,
divider,
drawer,
empty,
floatButton,
image,
form,
formItem,
inputNumber,
grid,
link,
list,
carousel,
treeSelect,
tree,
notification,
result,
upload,
watermark,
menu,
modal,
mentions,
multiSelect,
pagination,
popconfirm,
popover,
progress,
radioGroup,
rate,
segmented,
select,
showToast,
skeleton,
splitter,
slider,
space,
spinner,
stepper,
statistic,
switchButton,
tabs,
tag,
textarea,
textField,
timeline,
toast,
tour,
tooltip,
typography,
} from '@ui-arch/ui';
import type { RowKey } from '@ui-arch/ui';
/*** 侧边目录:挂载后从 .sec 反推锚点,IntersectionObserver 高亮视口内区块(清单改动自动跟随);顶部搜索框按标题过滤 ***/
function buildToc(toc: HTMLElement, content: HTMLElement): void {
const secs = Array.from(content.querySelectorAll<HTMLElement>('section.sec'));
if (!secs.length) return; // 卫语句:无区块不建目录
const links = secs.map((sec) =>
h('a', { class: 'toc-link', href: `#${sec.id}`, 'data-for': sec.id }, sec.querySelector('h2')?.textContent ?? sec.id),
);
const search = h('input', { class: 'toc-search', type: 'search', placeholder: '过滤组件…', 'aria-label': '过滤组件目录' }) as HTMLInputElement;
const empty = h('p', { class: 'toc-empty' }, '无匹配项'); // 过滤后一条不剩时的提示
empty.hidden = true;
/*** 按标题子串过滤目录项;空关键字恢复全量 ***/
const apply = (): void => {
const q = search.value.trim().toLowerCase();
let hit = 0;
for (const link of links) {
const on = !q || (link.textContent ?? '').toLowerCase().includes(q);
link.hidden = !on; // 隐藏项同时退出 Tab 序(hidden 自带)
if (on) hit++;
}
empty.hidden = hit > 0;
};
search.addEventListener('input', apply);
search.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key !== 'Escape' || !search.value) return; // 卫语句:无内容时 Esc 交给默认行为
search.value = '';
apply();
});
toc.append(search, ...links, empty);
const io = new IntersectionObserver(
(entries) => {
for (const en of entries) {
if (!en.isIntersecting) continue; // 只处理刚进入视口的区块
const id = (en.target as HTMLElement).id;
for (const link of links) link.classList.toggle('is-active', link.getAttribute('data-for') === id);
}
},
{ rootMargin: '-10% 0px -80% 0px' }, // 顶部窄条判定,滚动时高亮更跟手
);
for (const sec of secs) io.observe(sec);
}
/*** 生成演示区块;id 供文档站 iframe 锚点定位 ***/
function section(id: string, title: string, desc: string, ...body: unknown[]): HTMLElement {
return h(
'section',
{ class: 'sec', id },
h('h2', null, title),
h('p', { class: 'desc' }, desc),
h('div', { class: 'stage' }, ...body),
);
}
// —— 演示数据 ——
const cities = ['北京', '上海', '深圳', '杭州'];
const users = [
'Alice', 'Bob', 'Charlie', 'Dave', 'Eve', 'Frank', 'Grace', 'Hank',
'Ivy', 'Jack', 'Kate', 'Liam', 'Mia', 'Noah', 'Olivia', 'Paul',
'Quinn', 'Rose', 'Sam', 'Tina', 'Uma', 'Victor', 'Wendy', 'Xavier',
].map((name, i) => ({ id: i + 1, name, age: 22 + ((i * 7) % 20), city: cities[i % cities.length] }));
// —— 月历标记:以今天为基准前后几天,避免把日期写死 ——
const dayKey = (n: number): string => {
const d = new Date();
d.setDate(d.getDate() + n);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
};
const calendarMarks: Record<string, string> = { [dayKey(0)]: '2 项', [dayKey(2)]: '1 项', [dayKey(-3)]: '5 项' };
const calDay = signal(dayKey(0));
// —— 响应式状态演示 ——
const count = signal(0);
// —— 双向绑定演示 ——
const name = signal('');
// —— 表单控件状态集中声明 ——
const agreed = signal(false);
const nightMode = signal(true);
const sheetOpen = signal(false); // 底部抽屉开合
const stickyOpen = signal(false); // 防误关弹窗开合
const sound = signal(true); // 无可见文案的开关演示
const city = signal('');
const size = signal('m');
const bio = signal('');
// —— 反馈组件状态 ——
const percent = signal(0);
setInterval(() => percent((percent() + 5) % 105), 400); // 自动推进演示进度
// —— 标签页 / 弹窗状态 ——
const activeTab = signal('a');
// —— 可关闭页签:数据源为信号数组,onClose 里摘掉元素即复现 ——
const closableTabs = signal([
{ value: 'n1', label: '订单 #1024', closable: true },
{ value: 'n2', label: '订单 #1025', closable: true },
{ value: 'n3', label: '订单 #1026', closable: true },
]);
const closableActive = signal('n1');
const modalOpen = signal(false);
const modalPick = signal('a'); // 弹窗内下拉的取值,用于演示 Esc 逐层关闭
// —— 表单校验状态集中声明 ——
const inviteCode = signal(''); // 异步校验演示:邀请码
const username = signal('');
const email = signal('');
const formResult = signal('');
// —— 分页联动状态:dataTable 与独立 pagination 共用同一页码信号 ——
const tablePage = signal(1);
// —— 抽屉开关 ——
const drawerOpen = signal(false);
const inspectorOpen = signal(false); // 非模态抽屉:无遮罩,页面仍可操作
// —— 标签列表:关闭即写回信号,容器按信号重渲染 ——
const tags = signal(['设计', '前端', '组件库']);
const chipOn = { 全部: signal(true), 进行中: signal(false), 已完成: signal(false), 已归档: signal(false) }; // 筛选片选中态:每片独立信号,供 tag 双向绑定
// —— 滑块:音量与禁用态演示 ——
const volume = signal(40);
// —— 数字输入:购买数量演示,与禁用态实例共用同一信号 ——
const qty = signal(1);
const price = signal('9.9'); // 演示 suffix 附加块
const amount = signal(2); // 演示 inputNumber 前后缀
// —— 步骤条:下单流程演示,按钮推进、点已完成步骤回跳 ——
const stepIdx = signal(0);
// —— 评分:满意度演示,与只读实例共用同一信号 ——
const score = signal(0);
// —— 空状态:搜索无结果演示 ——
const noResult = signal(true);
// —— 受控选中:dataTable 内部勾选实时写回,外部可清空 ——
const picked = signal<RowKey[]>([]);
// —— 表格加载态:切换后数据行变骨架行,筛选与翻页临时禁用 ——
const tableLoading = signal(false);
// —— 菜单选中结果:演示 onSelect 回调 ——
const menuPick = signal('');
const listPick = signal(''); // 列表点击选中的条目标题
const slideIndex = signal(0); // 轮播当前下标(外部受控演示)
const cascaderPath = signal<string[]>(['east', 'sh']); // 级联选择路径(外部受控演示)
const splitSizes = signal<number[]>([35, 65]); // 分栏比例(外部受控演示)
const tagPick = signal<string[]>(['ts']); // 多选下拉选中值(外部受控演示)
const tagFold = signal<string[]>(['ts', 'dom', 'css']); // 多选下拉折叠演示:预置 3 项看溢出
const permPick = signal<string[]>(['read']); // 多选组选中值(外部受控演示)
const colorVal = signal('var(--ua-color-accent)'); // 颜色选择值(外部受控演示)
const dateVal = signal('2026-10-26'); // 日期选择值(外部受控演示)
const rangeFrom = signal(''); // 区间起点
const rangeTo = signal(''); // 区间终点
const acValue = signal(''); // 自动补全输入值(外部受控演示)
const treeChecked = signal<string[]>(['core']); // 多选树勾选集合(外部受控演示)
const treePick = signal('core'); // 树形选中节点 key
const transferKeys = signal<string[]>(['a11y']); // 穿梭框右侧集合(外部受控演示)
const cityPick = signal(''); // 可搜索下拉的选中城市
const pagerPage = signal(1); // 独立分页器当前页
const pickedFiles = signal(''); // 文件选择结果(名称串)
// —— 禁用态演示:开关信号同时绑定 switch 的 checked 与 checkbox 的 disabled ——
const locked = signal(true);
// —— 统计数值:两个指标共用同一组按钮刷新,演示滚动过渡 ——
const gmv = signal(128000);
const orders = signal(2048);
// —— 分段控制器:两个实例共用同一信号,演示信号驱动同步与整组禁用 ——
const range = signal('week');
// —— 时间线:false 时末尾显示「进行中」虚线节点,true 表示流程已结束 ——
const flowDone = signal(false);
// —— @提及:受控文本信号,选中的人写进评论正文 ——
const comment = signal('');
const teamMembers = [
{ name: 'zhangsan', label: '张三', description: '前端 · 组件库', keywords: ['zs', '1001'] },
{ name: 'lisi', label: '李四', description: '后端 · 订单服务', keywords: ['ls', '1002'] },
{ name: 'wangwu', label: '王五', description: '设计 · 交互', keywords: ['ww', '1003'] },
{ name: 'zhaoliu', label: '赵六', description: 'QA · 回归', keywords: ['zl', '1004'] },
];
// —— 树形选择:单选存 key、多选存 key 集合,两个实例共用同一棵部门树 ——
const dept = signal('');
const deptChecked = signal<string[]>([]);
const orgTree = [
{ key: 'tech', label: '技术中心', children: [
{ key: 'fe', label: '前端组', children: [{ key: 'ui', label: '组件库' }, { key: 'web', label: '业务前端' }] },
{ key: 'be', label: '后端组', children: [{ key: 'order', label: '订单服务' }, { key: 'pay', label: '支付服务' }] },
] },
{ key: 'design', label: '设计中心', children: [{ key: 'ix', label: '交互' }, { key: 'vd', label: '视觉' }] },
{ key: 'qa', label: '质量中心', children: [{ key: 'reg', label: '回归测试', disabled: true }] },
];
// —— 主题:优先读上次选择,其次跟随系统;写 html[data-theme] 让令牌换色 ——
const THEME_KEY = 'ua-demo-theme';
const dark = signal((localStorage.getItem(THEME_KEY) ?? (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')) === 'dark');
effect(() => {
const mode = dark() ? 'dark' : 'light';
document.documentElement.dataset.theme = mode; // 令牌按 data-theme 切换
localStorage.setItem(THEME_KEY, mode); // 记住选择,刷新不闪
});
const content = h(
'div',
{ class: 'page' },
h(
'header',
null,
h('div', null,
h('h1', null, '@ui-arch 组件库 · 现场演示'),
h('p', { class: 'sub' }, '驱动:@ui-arch/frame(自研微框架) + @ui-arch/ui(组件库) + @ui-arch/table-core(表格内核)')),
switchButton({ label: '暗色主题', checked: dark }), // 切换 html[data-theme],令牌整套换色
),
section(
'sec-counter',
'响应式状态',
'signal 驱动:按钮修改信号,文本自动更新,无虚拟 DOM。',
button({ label: 'count +1', onClick: () => count(count() + 1) }),
button({ label: '重置', variant: 'ghost', onClick: () => count(0) }),
h('span', { class: 'out' }, () => `count = ${count()}`),
),
section(
'sec-bind',
'双向绑定',
'textField 与信号双向同步:输入即回显;prefix/suffix 渲染为输入框内侧附加块;clearable 开启后有值即显一键清空。',
textField({ placeholder: '输入名字…', value: name }),
textField({ placeholder: '搜索…', value: name, prefix: '🔍', clearable: true }),
textField({ value: price, suffix: '元', clearable: true }),
h('span', { class: 'out' }, () => (name() ? `你好,${name()}!` : '等待输入…')),
),
section(
'sec-form',
'表单控件',
'checkbox / switch / select / radioGroup / textarea:状态一律由调用方信号持有;select 弹层在视口下方空间不足时自动翻到上方,展开后键入字母可快跳选项;textarea 传 maxCount 显示实时字数。',
h('div', { class: 'row' }, checkbox({ label: '同意条款', checked: agreed }), h('span', { class: 'out' }, () => (agreed() ? '已同意' : '未同意'))),
h('div', { class: 'row' }, switchButton({ label: '夜间模式', checked: nightMode }), h('span', { class: 'out' }, () => (nightMode() ? '开' : '关'))),
h('div', { class: 'row' }, switchButton({ checked: sound, ariaLabel: '通知声音' }), h('span', { class: 'out' }, () => `无可见文案的开关(ariaLabel 兜底,读屏念「通知声音」):${sound() ? '开' : '关'}`)),
h('div', { class: 'row' }, select({ options: cities.map((c) => ({ value: c, label: c })), value: city, placeholder: '选择城市…', clearable: true }), h('span', { class: 'out' }, () => (city() ? `城市:${city()}(点 × 或按 Delete 清空)` : '未选择'))),
h('div', { class: 'row' }, radioGroup({ options: [{ value: 's', label: '小' }, { value: 'm', label: '中' }, { value: 'l', label: '大' }], value: size })),
textarea({ placeholder: '一句话介绍…', value: bio, rows: 2, maxCount: 30 }),
h('span', { class: 'out' }, () => `简介 ${bio().length} 字`),
h(
'div',
{ class: 'row' },
button({ label: '禁用按钮', variant: 'ghost', disabled: true }),
checkbox({ label: '信号禁用的勾选', disabled: locked }),
switchButton({ label: '锁定', checked: locked }), // 关掉开关即解锁上面的勾选框与输入框
textField({ placeholder: '锁定后不可输入…', disabled: locked }),
),
h(
'div',
{ class: 'row' },
select({ options: cities.map((c) => ({ value: c, label: c })), value: city, placeholder: '信号禁用的下拉…', disabled: locked }),
radioGroup({ options: [{ value: 's', label: '小' }, { value: 'm', label: '中' }], value: size, disabled: locked }),
radioGroup({ options: [{ value: 'x', label: '静态禁用' }], value: size, disabled: true }),
),
),
section(
'sec-drawer',
'抽屉',
'drawer:open 信号驱动侧滑面板,保留主视图上下文;四向滑出(左右按宽度、上下按高度),遮罩 / × / Esc 关闭。',
h('div', { class: 'row' },
button({ label: '从右侧打开', onClick: () => drawerOpen(true) }),
button({ label: '从底部打开', variant: 'ghost', onClick: () => sheetOpen(true) }),
button({ label: '非模态打开', variant: 'ghost', onClick: () => inspectorOpen(true) }),
),
drawer(
{
title: '订单详情',
open: drawerOpen,
width: 360,
onClose: () => drawerOpen(false),
footer: h('div', { class: 'row' }, button({ label: '关闭', variant: 'ghost', onClick: () => drawerOpen(false) })),
},
h('p', null, '这里是抽屉内容,可放任意 DOM。'),
h('div', { class: 'row' }, tag({ label: '#A1024', variant: 'info' }), badge({ label: '已发货', variant: 'success' })),
),
drawer(
{ title: '快捷操作', open: sheetOpen, placement: 'bottom', width: 220, onClose: () => sheetOpen(false) },
h('p', null, '从底部滑出的操作面板:适合移动端底部弹层,宽度自动铺满、高度由 width 指定。'),
),
drawer(
{
title: '非模态检查器',
open: inspectorOpen,
modal: false,
width: 300,
onClose: () => inspectorOpen(false),
footer: h('div', { class: 'row' }, button({ label: '关闭', variant: 'ghost', onClick: () => inspectorOpen(false) })),
},
h('p', null, 'modal: false —— 没有遮罩、不锁背景滚动、不困焦点:左边页面照常能点能滚,适合「一边看一边改」的检查器 / 详情面板。'),
),
),
section(
'sec-tag',
'标签',
'tag:可关闭标签(badge 的交互版),关闭回调里改信号即从列表移除。',
h(
'div',
{ class: 'row' },
tag({ label: '纯展示', variant: 'default' }),
tag({ label: '通过', variant: 'success' }),
tag({ label: '待处理', variant: 'warning' }),
tag({ label: '已失效', variant: 'danger' }),
),
(() => {
const box = h('div', { class: 'row' });
renderList(box, () => tags().map((t) => tag({ label: t, variant: 'info', closable: true, onClose: () => tags(tags().filter((x) => x !== t)) })));
return box;
})(),
(() => {
const chips = h('div', { class: 'row' });
renderList(chips, () => Object.entries(chipOn).map(([name, on]) => tag({ label: name, checkable: true, checked: on })));
const out = h('span', { class: 'out' }, () => `已选:${Object.entries(chipOn).filter(([, on]) => on()).map(([name]) => name).join(' / ') || '无'}`);
return h('div', {}, chips, out);
})(),
),
section(
'sec-avatar',
'头像',
'avatar:有 src 走图片(alt 用名称),无 src 用首字占位;size 与语义底色都可调。',
h(
'div',
{ class: 'row' },
avatar({ name: '张三' }),
avatar({ name: 'Alice', size: 40, variant: 'success' }),
avatar({ name: 'Bob', size: 24, variant: 'warning' }),
avatarGroup({ items: [{ name: '张三' }, { name: '李四', variant: 'success' }, { name: '王五', variant: 'warning' }, { name: '赵六' }, { name: '钱七' }, { name: '孙八' }], max: 4 }),
avatar({ name: '李四', size: 36, variant: 'danger' }),
),
),
section(
'sec-slider',
'滑块',
'slider:原生 range 承载交互(含键盘 ←→),value 信号双向绑定,右侧可显示当前值。',
h(
'div',
{ class: 'row' },
slider({ value: volume, min: 0, max: 100, step: 5, onChange: (v) => toast.info(`音量 ${v}`) }),
slider({ value: volume, disabled: locked }),
slider({ value: volume, marks: [{ value: 0, label: '静音' }, { value: 50, label: '中' }, { value: 100, label: '最大' }], onChange: (v) => toast.info(`音量 ${v}`) }),
),
),
section(
'sec-inputnumber',
'数字输入',
'inputNumber:−/+ 按钮与键盘 ↑↓ 调节,value 信号双向绑定,min/max/step 超界自动夹取;长按按钮可连发步进。',
h(
'div',
{ class: 'row' },
inputNumber({ value: qty, min: 1, max: 99, onChange: (v) => toast.info(`数量 ${v}`) }),
inputNumber({ value: qty, disabled: locked }),
inputNumber({ value: amount, min: 0, prefix: '¥', suffix: '件' }),
),
),
section(
'sec-feedback',
'反馈与提示',
'badge / alert / spinner / progress:语义色全部来自令牌,暗色自动切换。',
badge({ label: '默认' }),
badge({ label: '成功', variant: 'success' }),
badge({ label: '警告', variant: 'warning' }),
badge({ label: '危险', variant: 'danger' }),
badge({ count: 5, label: '未读' }),
badge({ count: 128, max: 99, label: '未读' }),
badge({ dot: true, label: '有未读消息' }),
alert({ message: '信息提示:这是一条 info 提示条。' }),
alert({ message: '警告提示:操作可能存在风险。', variant: 'warning' }),
h('div', { class: 'row' }, spinner(), spinner({ size: 26 }), spinner({ size: 22, label: '正在加载订单列表' })),
progress({ value: percent, label: '上传进度' }),
h('div', { class: 'row' }, progress({ value: percent, type: 'circle', label: '上传进度(环形)' }), progress({ value: percent, type: 'circle', variant: 'success', label: '同值换语义色' })),
h('div', { class: 'row' }, h('span', { class: 'out' }, '语义色:'), progress({ value: percent, variant: 'danger', label: '同步失败重试进度' })),
h('div', { class: 'row' }, h('span', { class: 'out' }, '不确定态:'), progress({ label: '正在连接' })),
),
section(
'sec-alert',
'提示条',
'alert:四档语义色 + 可选标题与关闭按钮,role=alert 由读屏播报。',
alert({ message: '这是一条中性提示(无标题)', variant: 'info' }),
alert({ title: '保存失败', message: '网络波动,请稍后重试;本次修改已保留在本地草稿。', variant: 'danger', closable: true, onClose: () => toast.warning('提示已关闭') }),
alert({ title: '发布成功', message: '构建产物已上传,5 分钟后全网生效。', variant: 'success', showIcon: true }),
alert({ message: '配额即将用尽:本月剩余 8%,超出后新请求会被限流。', variant: 'warning', showIcon: true, banner: true }),
),
section(
'sec-tooltip',
'悬浮提示',
'tooltip 包裹触发器:hover / 聚焦延迟 150ms 出场,移出、失焦或 Esc 收起,气泡不抢指针;方位支持上下左右四向,视口该侧空间不足时自动翻到对侧。',
h(
'div',
{ class: 'row' },
tooltip({ content: '保存后立即生效' }, button({ label: '保存' })),
tooltip({ content: '该操作不可撤销', placement: 'bottom' }, button({ label: '删除', variant: 'danger' })),
tooltip({ content: '聚焦输入框同样会显示' }, textField({ placeholder: '聚焦我…' })),
tooltip({ content: '左侧方位:竖排工具栏常用', placement: 'left' }, button({ label: '左侧气泡', variant: 'ghost' })),
tooltip({ content: '右侧方位,贴右边时自动翻回左侧', placement: 'right' }, button({ label: '右侧气泡', variant: 'ghost' })),
),
),
section(
'sec-toast',
'消息提示',
'toast 右上角堆叠弹出,默认 3s 自动淡出移除(悬停可暂停倒计时);语义色四档,出入场微动画。',
h(
'div',
{ class: 'row' },
button({ label: '成功', onClick: () => toast.success('保存成功') }),
button({ label: '错误', variant: 'danger', onClick: () => toast.error('网络请求失败') }),
button({ label: '警告', onClick: () => toast.warning('磁盘空间不足') }),
button({ label: '信息(1.5s)', onClick: () => toast.info('已同步云端', 1500) }),
button({ label: '可关闭', variant: 'ghost', onClick: () => showToast({ message: '这条可以手动关掉', variant: 'warning', closable: true }) }),
button({ label: '带撤销', variant: 'ghost', onClick: () => showToast({ message: '已删除 3 条记录', variant: 'success', actionLabel: '撤销', duration: 5000, onAction: () => toast.info('已撤销删除') }) }),
),
),
section(
'sec-skeleton',
'骨架屏',
'skeleton 占位:text 多行段落 / rect 块 / circle 圆,扫光微动画,reduced-motion 自动静态化。',
card(
{ title: '加载中的用户资料' },
h('div', { class: 'row' }, skeleton({ variant: 'circle' }), skeleton({ variant: 'text', rows: 2 })),
skeleton({ variant: 'rect' }),
),
),
section(
'sec-formlab',
'表单校验',
'form + formItem:必填 / 长度 / 正则同步校验,validator 返回 Promise 即异步规则(提交会等待);全部通过才触发提交。校验不通过时顶部出现「有 N 项需要修改」汇总条(role=alert 会被读屏播报)并聚焦第一处,onError 拿到不通字段数。本例 columns: 2 走两列栅格,窄屏自动堆叠,按钮区始终整行。',
form(
{ columns: 2, submitText: '注册', onSubmit: () => formResult(`提交成功:${username()} / ${email()} / ${city()}`) },
[
formItem({ label: '用户名', required: true, value: username, rules: [{ min: 2, max: 12, message: '用户名需 2-12 个字符' }] }, textField({ placeholder: '2-12 个字符', value: username })),
formItem({ label: '邮箱', required: true, value: email, hint: '用于登录与找回密码,不会公开', rules: [{ pattern: /^[\w.-]+@[\w-]+\.[\w.]+$/, message: '邮箱格式不正确' }] }, textField({ placeholder: 'you@example.com', value: email })),
formItem({ label: '城市', required: true, value: city }, select({ options: cities.map((c) => ({ value: c, label: c })), value: city, placeholder: '请选择城市…' })),
formItem(
{ label: '邀请码', required: true, value: inviteCode, rules: [{ validator: async (v) => { await new Promise((r) => setTimeout(r, 400)); return v === 'USED' ? '该邀请码已被使用' : true; }, message: '校验未通过' }] },
textField({ placeholder: '异步规则:输入 USED 会校验失败', value: inviteCode }),
),
],
),
h('span', { class: 'out' }, () => formResult() || '等待提交…'),
),
section(
'sec-tabs-modal',
'标签页与弹窗',
'tabs 滑动指示条 + 方向键切换(←→),禁用页签置灰,closable 页签带 ×(聚焦后 Delete 也能关,关闭只回调、由数据源摘除后重绘);modal 支持 Esc / 遮罩 / × 关闭(closeOnOverlay: false 时点遮罩不关、防填一半误触),弹窗内的下拉展开时 Esc 先收下拉、再按才关弹窗(逐层关闭)。',
tabs({
tabs: [{ value: 'a', label: '概览' }, { value: 'b', label: '明细' }, { value: 'c', label: '设置' }, { value: 'd', label: '已停用', disabled: true }],
active: activeTab,
extra: button({ label: '新建页签', variant: 'ghost', onClick: () => toast.info('右侧操作区与页签同一行') }), // extra 放页签栏右侧
}),
h('span', { class: 'out' }, () => `当前页签:${activeTab()}`),
renderList(h('div', { class: 'row' }), () => {
const items = closableTabs();
if (!items.length) return [h('span', { class: 'desc' }, '页签全关掉了 —— onClose 里从数据源摘掉,重绘自然只剩空态')];
return [tabs({
tabs: items,
active: closableActive,
onClose: (v) => {
const left = closableTabs().filter((t) => t.value !== v); // 数据源唯一来源:摘掉即重绘
closableTabs(left);
if (closableActive() === v) closableActive(left[0]?.value ?? ''); // 关掉的正好是当前页:顺位激活下一个
},
})];
}),
button({ label: '打开弹窗', onClick: () => modalOpen(true) }),
button({ label: '打开防误关弹窗', variant: 'ghost', onClick: () => stickyOpen(true) }),
modal(
{
title: '填写中,点遮罩不关闭',
open: stickyOpen,
closeOnOverlay: false,
onClose: () => stickyOpen(false),
footer: h('div', { class: 'row' }, button({ label: '关闭', variant: 'ghost', onClick: () => stickyOpen(false) })),
},
h('p', null, 'closeOnOverlay: false —— 点遮罩不会关闭,防止填到一半误触丢失内容;× 与 Esc 仍然有效。'),
),
modal(
{
title: '确认操作',
open: modalOpen,
size: 'lg',
onClose: () => modalOpen(false),
footer: h(
'div',
{ class: 'row' },
button({ label: '取消', variant: 'ghost', onClick: () => modalOpen(false) }),
button({ label: '确定', onClick: () => { modalOpen(false); toast.success('已确认'); } }),
),
},
h('p', null, '这是一个由信号驱动的弹窗组件(size: lg + footer 操作区)。'),
h('div', { class: 'row' }, badge({ label: 'modal', variant: 'success' }), badge({ label: 'footer', variant: 'default' })),
select({ options: [{ value: 'a', label: '优先处理' }, { value: 'b', label: '排队等待' }, { value: 'c', label: '转交他人' }], value: modalPick }),
),
),
section(
'sec-stepper',
'步骤条',
'stepper:done/current/upcoming 三态随 active 信号流转,clickable 时点已完成步骤回跳。',
stepper({
steps: [{ label: '确认订单', description: '收货信息与商品清单' }, { label: '支付', description: '选择支付方式' }, { label: '完成', description: '等待发货' }],
active: stepIdx,
clickable: true,
onChange: (i) => toast.info(`回到第 ${i + 1} 步`),
}),
h(
'div',
{ class: 'row' },
button({ label: '上一步', variant: 'ghost', disabled: computed(() => stepIdx() === 0), onClick: () => stepIdx(Math.max(0, stepIdx() - 1)) }),
button({ label: '下一步', disabled: computed(() => stepIdx() === 2), onClick: () => stepIdx(Math.min(2, stepIdx() + 1)) }),
),
),
section(
'sec-rate',
'评分',
'rate:点选星标打分,悬停预览移出还原,键盘 →/↑ 加分 ←/↓ 减分,disabled 只读展示。',
h(
'div',
{ class: 'row' },
rate({ value: score, onChange: (v) => toast.info(`评分 ${v} 星`) }),
h('span', { class: 'out' }, () => `当前:${score() || '未评分'}`),
),
h('div', { class: 'row' }, rate({ value: score, disabled: true })),
),
section(
'sec-image',
'图片',
'image:缩略图点击 / 回车打开遮罩大图,Esc 或点击遮罩关闭,焦点归还缩略图;加载失败自动落虚线占位(第三张故意给错地址)。',
h(
'div',
{ class: 'row' },
image({ src: IMG_DEMO, alt: '演示图一', width: 180 }),
image({ src: IMG_DEMO, alt: '演示图二', width: 120 }),
image({ src: './no-such-image.png', alt: '演示图三(地址无效)', width: 120, fallback: '图片走丢了' }),
),
),
section(
'sec-empty',
'空状态',
'empty:无数据 / 无结果的统一占位,action 放引导按钮,点击按钮切回有数据态可对比。',
h('div', { class: 'row' }, h('span', { class: 'out' }, () => (noResult() ? '当前:无结果' : '当前:有数据'))),
h(
'div',
{ class: 'row' },
button({ label: '切换数据态', onClick: () => noResult(!noResult()) }),
),
() => (noResult()
? empty({ title: '暂无匹配结果', description: '试试放宽筛选条件,或清空关键词重新搜索。', icon: '🔍', action: button({ label: '重置筛选', variant: 'ghost', onClick: () => noResult(false) }) })
: card({ title: '搜索结果' }, h('p', null, '这里展示命中的数据列表。'))),
),
section(
'sec-divider',
'分隔线',
'divider:水平线可带居中文案,dashed 切虚线;vertical 行内竖线用于工具栏。',
divider({ label: '更多信息' }),
h('p', null, '分割线上下的内容各自成组。'),
divider({ dashed: true }),
h(
'div',
{ class: 'row' },
button({ label: '保存', variant: 'ghost' }),
divider({ vertical: true }),
button({ label: '另存为', variant: 'ghost' }),
divider({ vertical: true, dashed: true }),
button({ label: '导出', variant: 'ghost' }),
),
),
section(
'sec-space',
'间距容器',
'space:用 flex gap 托管一排子元素的间距(不再各自写 margin),size 三档走令牌或直接给像素,direction 切竖排,justify + block 让工具栏两端撑开。',
space(
{ justify: 'between', block: true },
h('span', { class: 'out' }, '左侧标题'),
space({ size: 'sm' }, button({ label: '取消', variant: 'ghost' }), button({ label: '保存' })),
),
space(
{ size: 'lg', wrap: true },
badge({ label: '默认' }),
badge({ label: '成功', variant: 'success' }),
badge({ label: '警告', variant: 'warning' }),
tag({ label: '#A1024', variant: 'info' }),
),
space({ direction: 'vertical', align: 'start', size: 6 }, h('span', { class: 'out' }, '竖排第一行'), h('span', { class: 'out' }, '竖排第二行(6px 精确间距)')),
space(
{ size: 'sm', split: '·' }, // 分隔符由容器插入,读屏跳过
h('span', { class: 'out' }, '全部订单'),
h('span', { class: 'out' }, '待发货'),
h('span', { class: 'out' }, '已完成'),
),
),
section(
'sec-statistic',
'统计数值',
'statistic:大号等宽数字 + 前后缀 + 环比趋势;value 变化时 400ms 缓出滚动(减弱动效偏好下直落终值);传 countdown 目标时间戳即变倒计时,到点自动停表。',
h(
'div',
{ class: 'row' },
statistic({ title: '本月 GMV', value: gmv, prefix: '¥', precision: 2, trend: 'up' }),
statistic({ title: '订单数', value: orders, suffix: '单', trend: 'down' }),
statistic({ title: '活动倒计时', countdown: Date.now() + 95 * 60 * 1000 }),
),
h(
'div',
{ class: 'row' },
button({ label: '刷新数据', variant: 'ghost', onClick: () => {
gmv(gmv() + Math.round(Math.random() * 20000 - 8000)); // 随机涨跌,演示双向滚动
orders(orders() + Math.round(Math.random() * 200 - 80));
} }),
),
),
section(
'sec-descriptions',
'描述列表',
'descriptions:label : value 网格,column 定列数(窄屏自动单列);bordered 切表格形态,span 让长字段占整行。',
descriptions({
title: '订单信息(纯网格 · 3 列)',
column: 3,
items: [
{ label: '订单号', value: 'RB-2048' },
{ label: '下单人', value: '王小明' },
{ label: '金额', value: '¥1,280.00' },
{ label: '状态', value: tag({ label: '待复核', variant: 'warning' }) },
{ label: '备注', value: '需要开专用发票,抬头与税号见附件。', span: 2 },
],
}),
divider({ dashed: true }),
descriptions({
title: '物流信息(表格形态 · 2 列)',
column: 2,
bordered: true,
size: 'sm',
items: [
{ label: '承运商', value: '顺丰速运' },
{ label: '运单号', value: 'SF1234567890' },
{ label: '收件地址', value: '浙江省杭州市余杭区文一西路 969 号', span: 2 },
],
}),
),
section(
'sec-segmented',
'分段控制器',
'segmented:胶囊分段 + 滑动滑块,value 信号双向绑定;两个实例共用信号即自动同步,整组禁用走信号。',
h(
'div',
{ class: 'row' },
segmented({
options: [
{ value: 'day', label: '日' },
{ value: 'week', label: '周' },
{ value: 'month', label: '月' },
{ value: 'year', label: '年', disabled: true },
],
value: range,
}),
h('span', { class: 'out' }, () => `当前粒度:${range()}`),
),
h(
'div',
{ class: 'row' },
segmented({ options: [{ value: 'day', label: '日' }, { value: 'week', label: '周' }, { value: 'month', label: '月' }], value: range, size: 'sm' }),
select({ options: [{ value: 'bj', label: '北京' }, { value: 'sh', label: '上海' }, { value: 'gz', label: '广州' }, { value: 'sz', label: '深圳' }, { value: 'hz', label: '杭州' }, { value: 'cd', label: '成都' }], value: cityPick, searchable: true, placeholder: '可搜索城市' }),
segmented({ options: [{ value: 'day', label: '日' }, { value: 'week', label: '周' }], value: range, size: 'sm', disabled: locked }),
h('span', { class: 'out' }, () => (locked() ? '第二个分段已禁用(同上「锁定」开关)' : '第二个分段可用')),
),
),
section(
'sec-timeline',
'时间线',
'timeline:items 自左向右排事件流,variant 给节点语义色;pending 追加虚线「进行中」节点,点按钮切换。',
h(
'div',
{ class: 'row' },
button({ label: '流程继续/结束', variant: 'ghost', onClick: () => flowDone(!flowDone()) }),
),
() => timeline({
items: [
{ title: '提交审批', time: '09:12', content: '王小明提交了报销单 RB-2048', variant: 'success' },
{ title: '财务复核', time: '10:30', content: '金额与发票一致,待张工确认', variant: 'warning' },
{ title: '打款失败', time: '14:05', content: '收款账户信息有误,已退回修改', variant: 'danger' },
],
pending: !flowDone(),
}),
),
section(
'sec-list',
'列表',
'list:header/footer 外壳 + 条目 avatar/title/description/extra;传 onItemClick 后条目可点击并有 hover 反馈,items 为空自动回落空状态。',
h(
'div',
{ class: 'row' },
list({
header: '审批待办(3)',
footer: '共 3 条,点击条目查看详情',
bordered: true,
items: [
{ title: '王小明 · 报销单 RB-2048', description: '差旅费 ¥1,280,等待财务复核', avatar: avatar({ name: '王' }), extra: tag({ label: '待审', variant: 'warning' }) },
{ title: '李静 · 采购申请 CG-112', description: '显示器 2 台,预算 ¥3,600', avatar: avatar({ name: '李', variant: 'success' }), extra: tag({ label: '已通过', variant: 'success' }) },
{ title: '张伟 · 加班调休', description: '10-24 加班 3 小时,申请调休', avatar: avatar({ name: '张', variant: 'danger' }), extra: tag({ label: '已驳回', variant: 'danger' }) },
],
onItemClick: (item) => listPick(item.title), // 点击回传整条数据
}),
h(
'div',
{ class: 'stack' },
h('span', { class: 'out' }, () => (listPick() ? '已选:' + listPick() : '未点击条目')),
list({ items: [], bordered: true, emptyText: '暂无待办事项' }),
),
),
),
section(
'sec-carousel',
'轮播',
'carousel:items 横排位移,指示点 / 箭头 / ←→ 键切换,autoplay 毫秒自动前进,悬停或焦点进入即暂停;index 传信号即可外部受控。',
h(
'div',
{ class: 'row' },
carousel({
height: 150,
autoplay: 4000,
index: slideIndex,
onChange: (i) => slideIndex(i),
items: [
h('div', { style: 'flex:1;display:flex;align-items:center;justify-content:center;height:100%;font-size:15px;background:var(--ua-color-surface);color:var(--ua-color-text)' }, '① 令牌驱动的幻灯片 —— 悬停自动暂停'),
h('div', { style: 'flex:1;display:flex;align-items:center;justify-content:center;height:100%;font-size:15px;background:color-mix(in srgb, var(--ua-color-accent) 12%, transparent);color:var(--ua-color-text)' }, '② 指示点与箭头均可点击,←→ 也能切'),
h('div', { style: 'flex:1;display:flex;align-items:center;justify-content:center;height:100%;font-size:15px;background:color-mix(in srgb, var(--ua-color-success) 14%, transparent);color:var(--ua-color-text)' }, '③ 环形回绕:末张的下一张回到首张'),
],
}),
h('span', { class: 'out' }, () => `第 ${slideIndex() + 1} / 3 张`),
),
),
section(
'sec-tree',
'树形',
'tree:递归节点,箭头展开/收起、点标签选中;键盘 Enter 选中、→ 展开、← 收起、↑↓/Home/End 行间移动,整棵树只占一个 Tab 停点(roving tabindex);顶部搜索按标签过滤并保留命中父链。',
h(
'div',
{ class: 'row' },
tree({
searchable: true,
nodes: [
{ key: 'src', label: 'src', children: [
{ key: 'core', label: 'core', children: [{ key: 'frame', label: 'frame' }, { key: 'table', label: 'table-core' }] },
{ key: 'skin', label: 'skin', children: [{ key: 'ui', label: 'ui' }, { key: 'css', label: 'ui.css' }] },
] },
{ key: 'apps', label: 'apps', children: [{ key: 'demo', label: 'demo' }, { key: 'docs', label: 'docs' }] },
{ key: 'lock', label: 'package-lock.json(禁用示例)', disabled: true },
],
selected: treePick,
onSelect: (n) => treePick(n.key),
}),
h('span', { class: 'out' }, () => (treePick() ? `已选节点:${treePick()}` : '未选中节点')),
h(
'div',
{ class: 'col' },
tree({
multi: true,
checked: treeChecked,
defaultExpandAll: true,
nodes: [
{ key: 'perm', label: '权限包', children: [
{ key: 'read', label: '读取' },
{ key: 'write', label: '写入' },
{ key: 'admin', label: '管理(不可授予)', disabled: true },
] },
{ key: 'bill', label: '计费模块', children: [{ key: 'bill-view', label: '查看账单' }] },
],
onCheck: (keys) => toast.info(`已勾选 ${keys.length} 项`),
}),
h('span', { class: 'out' }, () => `已勾选:${treeChecked().join(' / ') || '无'}`),
),
),
),
section(
'sec-upload',
'文件选择',
'upload:点击或拖拽入列,清单可逐个移除;只负责选文件并回传 onChange,上传请求交给调用方。',
h(
'div',
{ class: 'row' },
upload({ multiple: true, maxCount: 3, maxSize: 2 * 1024 * 1024, hint: '多选(上限 3 个 / 单个 2 MB)', onChange: (fs2) => pickedFiles(fs2.map((f) => f.name).join('、')) }),
h('span', { class: 'out' }, () => (pickedFiles() ? `已选:${pickedFiles()}` : '未选择文件')),
),
h('div', { class: 'row' }, upload({ accept: 'image/*', hint: '仅图片(accept: image/*,拖拽同样拦)' }), upload({ disabled: true, hint: '禁用态' })),
),
section(
'sec-notification',
'通知卡片',
'notification:右下角堆叠(与右上角 toast 分区),标题 + 描述 + 操作区,缺省常驻需手动关闭;duration 传毫秒可自动消失,悬停或聚焦会暂停倒计时、离开按剩余时间续计。',
h(
'div',
{ class: 'row' },
button({ label: '常驻通知', variant: 'primary', onClick: () => notification({ title: '有 3 条待审报销', description: '王小明 / 李静 / 张伟提交的报销单等待复核', action: button({ label: '去处理', onClick: () => toast.success('已跳转待办') }) }) }),
button({ label: '成功(4s 自动关)', onClick: () => notification({ title: '导出完成', description: 'report-2026-10.csv 已开始下载', variant: 'success', duration: 4000 }) }),
button({ label: '警告(常驻)', variant: 'ghost', onClick: () => notification({ title: '连接不稳定', description: '正在重试第 2 次,请检查网络', variant: 'warning' }) }),
button({ label: '错误(可关)', variant: 'danger', onClick: () => notification({ title: '保存失败', description: '服务端返回 500,请稍后重试', variant: 'danger' }) }),
button({ label: '左上角通知', variant: 'ghost', onClick: () => notification({ title: '后台任务已排队', description: '四个角各自独立堆叠,这条去左上角', placement: 'top-left' }) }),
),
),
section(
'sec-multiselect',
'多选下拉',
'multiSelect:标签式回显 + 可搜索弹层,点选不收起弹层(连续多选),空输入退格删末个标签,max 到限后未选项禁用,maxTagCount 折叠溢出标签。',
h(
'div',
{ class: 'row' },
multiSelect({
value: tagPick,
max: 3,
placeholder: '选择技术栈(最多 3 项)',
onChange: (v) => toast.info(`已选 ${v.length} 项`),
options: [
{ value: 'ts', label: 'TypeScript' },
{ value: 'dom', label: '原生 DOM' },
{ value: 'css', label: 'CSS 令牌' },
{ value: 'test', label: '测试' },
{ value: 'wasm', label: 'WASM(暂未支持)', disabled: true },
],
}),
h('span', { class: 'out' }, () => `已选:${tagPick().join(' / ') || '无'}`),
multiSelect({
value: tagFold,
maxTagCount: 2,
placeholder: '标签折叠演示',
options: [
{ value: 'ts', label: 'TypeScript' },
{ value: 'dom', label: '原生 DOM' },
{ value: 'css', label: 'CSS 令牌' },
{ value: 'test', label: '测试' },
],
}),
h('span', { class: 'out' }, () => `折叠后仅露出 2 枚标签,其余折成 +N(共 ${tagFold().length} 项)`),
),
),
section(
'sec-checkgroup',
'多选组',
'checkboxGroup:数组信号双向绑定,min/max 用「禁用边界项」代替报错,checkAll 带半选,只统计未禁用项。',
h(
'div',
{ class: 'col' },
checkboxGroup({
value: permPick,
min: 1,
max: 3,
checkAll: true,
onChange: (v) => toast.info(`已选 ${v.length} 项`),
options: [
{ value: 'read', label: '读取' },
{ value: 'write', label: '写入' },
{ value: 'delete', label: '删除' },
{ value: 'share', label: '分享' },
{ value: 'audit', label: '审计日志(不可授予)', disabled: true },
],
}),
checkboxGroup({ direction: 'vertical', options: [{ value: 'a', label: '纵向 A' }, { value: 'b', label: '纵向 B' }] }),
h('span', { class: 'out' }, () => `已选:${permPick().join(' / ') || '无'}(下限 1、上限 3)`),
),
),
section(
'sec-watermark',
'水印',
'watermark:canvas 生成平铺单元,颜色从 --ua-* 令牌解析成实色,覆盖层 pointer-events:none 不挡交互。',
h(
'div',
{ class: 'row' },
watermark(
{ text: 'UI-ARCH · 内部资料\n仅限内网查看', gap: 160, fontSize: 13 },
card(
{ title: '受保护内容' },
h('p', { class: 'desc' }, '水印覆盖在内容之上、交互穿透到下方,适合内部系统防截图外传。颜色缺省取 --ua-color-text-muted,暗色主题下自动变浅。'),
h('div', { class: 'row' }, button({ label: '可点击(验证穿透)', variant: 'primary', onClick: () => toast.success('水印层没有挡住点击') })),
),
),
),
),
section(
'sec-tour',
'引导漫游',
'tour({ steps }) 返回句柄:start() 从第一步播放,聚光框按目标选择器定位、卡片自动选上下方位,Esc / × 跳过走 onClose。',
h(
'div',
{ class: 'row' },
button({ label: '开始引导(3 步)', variant: 'primary', onClick: () => demoTour.start() }),
h('span', { class: 'desc' }, '三步分别指向本段、分栏段与返回顶部段,滚动后位置会实时重算。'),
),
),
section(
'sec-colorpicker',
'颜色选择',
'colorPicker:预设色板 + 原生取色器 + 手输 CSS 颜色串;缺省色板直接取语义令牌,暗色主题下自动跟着变。',
h(
'div',
{ class: 'row' },
colorPicker({ value: colorVal, onChange: (c) => toast.success(`已选 ${c}`) }),
colorPicker({ placeholder: '只给两个预设', presets: ['var(--ua-color-success)', 'var(--ua-color-danger)'] }),
h('span', { class: 'out' }, () => `当前颜色:${colorVal()}`),
h('span', { class: 'ua-colorpicker-swatch', style: () => `background:${colorVal()}` }),
),
),
section(
'sec-affix',
'固钉',
'affix:内容滚过阈值后转 fixed 贴顶,外层保留占位高度不塌陷;固定后宽度随容器自适应。',
h(
'div',
{ class: 'row' },
h('div', { style: 'flex:1;min-height:320px;display:flex;gap:16px' },
affix({ offset: 8 }, card({ title: '滚动时我会贴顶' }, h('p', { class: 'desc' }, '向下滚动本页,这块卡片会固定在视口顶部(距顶 8px),左侧占位不会塌。'))),
h('div', { class: 'desc', style: 'flex:1' }, '右侧是普通内容,用来撑高这一行,方便观察固钉效果。'.repeat(6)),
),
),
),
section(
'sec-backtop',
'返回顶部',
'backTop:滚动越过阈值浮现(右下角悬浮),外环弧长即已滚比例,点击平滑回顶并尊重减少动效偏好。',
h(
'div',
{ class: 'row' },
h('span', { class: 'desc' }, '本页已挂载一个实例:向下滚动超过 300px 后右下角出现,外环显示滚动进度。'),
backTop({ threshold: 300, onClick: () => toast.info('已回到顶部') }),
),
),
section(
'sec-datepicker',
'日期选择',
'datePicker:单月网格,翻月 / 今天 / ←→↑↓ 逐日逐周 / PageUp↓ 逐月,Esc 收起;min/max 闭区间置灰越界日;区间模式可配 presets 快捷按钮。',
h(
'div',
{ class: 'row' },
datePicker({ value: dateVal, onChange: (d) => toast.success(`已选 ${d}`) }),
datePicker({ placeholder: '只能选 10 月下旬', min: '2026-10-20', max: '2026-10-31' }),
datePicker({ range: true, value: rangeFrom, end: rangeTo, presets: [{ label: '最近 7 天', days: 7 }, { label: '最近 30 天', days: 30 }, { label: '10 月上旬', from: '2026-10-01', to: '2026-10-15' }], onRangeChange: (a, b) => toast.success(`区间 ${a} ~ ${b}`) }),
h('span', { class: 'out' }, () => (dateVal() ? `当前日期:${dateVal()}` : '未选择')),
h('span', { class: 'out' }, () => (rangeFrom() ? `区间:${rangeFrom()} ~ ${rangeTo() || '待定'}` : '区间未选择')),
),
),
section(
'sec-autocomplete',
'自动补全',
'autoComplete:输入即过滤候选,↑↓ 环形高亮 / Enter 选中 / Esc 收起;options 传函数可做远程联想,空结果给提示行。',
h(
'div',
{ class: 'row' },
autoComplete({
value: acValue,
placeholder: '输入组件名,如 table',
emptyText: '没有匹配的组件',
onSelect: (o) => toast.success(`已选 ${o.value}`),
options: [
{ value: 'dataTable', hint: '表格' },
{ value: 'transfer', hint: '穿梭框' },
{ value: 'cascader', hint: '级联' },
{ value: 'splitter', hint: '分栏' },
{ value: 'autoComplete', hint: '联想' },
{ value: 'pagination', hint: '分页' },
],
}),
autoComplete({ placeholder: '按关键字动态算候选', options: (q) => [{ value: `${q}-alpha` }, { value: `${q}-beta` }] }),
h('span', { class: 'out' }, () => (acValue() ? `输入框当前值:${acValue()}` : '未输入')),
),
),
section(
'sec-splitter',
'可拖拽分栏',
'splitter:分隔条可拖拽、可聚焦用方向键微调(Shift 加速)、双击复位均分;sizes 传信号即受控,每栏不低于 min。',
h(
'div',
{ class: 'col' },
splitter({
sizes: splitSizes,
onChange: (p) => toast.info(`比例:${p.map((n) => n + '%').join(' / ')}`),
panes: [
h('div', {}, h('div', { class: 'desc' }, '左侧栏'), list({ items: [{ title: '待办 A' }, { title: '待办 B' }], bordered: true })),
h('div', {}, h('div', { class: 'desc' }, '右侧栏:拖动中间分隔条试试'), h('p', { class: 'desc' }, '分隔条支持键盘:Tab 聚焦后 ← → 每次 2%,Shift + ← → 每次 10%,双击复位。')),
],
}),
h('span', { class: 'out' }, () => `当前比例:${splitSizes().map((n) => n + '%').join(' / ')}`),
splitter({ direction: 'vertical', min: 20, panes: [h('div', { class: 'desc' }, '上栏'), h('div', { class: 'desc' }, '下栏(纵向拖拽)')] }),
),
),
section(
'sec-cascader',
'级联选择',
'cascader:多列联动,悬停即下钻、点叶子提交;changeOnSelect 可停在中间层,Esc 收起,↑↓/→/←/Enter 全键盘可用。',
h(
'div',
{ class: 'row' },
cascader({
value: cascaderPath,
placeholder: '请选择所属区域',
changeOnSelect: true,
onChange: (p) => toast.success(`已选:${p.join(' / ')}`),
options: [
{ value: 'east', label: '华东', children: [
{ value: 'sh', label: '上海', children: [{ value: 'pd', label: '浦东新区' }, { value: 'xh', label: '徐汇区' }] },
{ value: 'hz', label: '杭州', children: [{ value: 'xh-hz', label: '西湖区' }, { value: 'yh', label: '余杭区' }] },
] },
{ value: 'south', label: '华南', children: [
{ value: 'gz', label: '广州', children: [{ value: 'th', label: '天河区' }] },
{ value: 'sz', label: '深圳', children: [{ value: 'nsh', label: '南山区' }] },
] },
{ value: 'overseas', label: '海外(暂不开放)', disabled: true },
],
}),
cascader({ separator: ' · ', placeholder: '必须选到末级', options: [
{ value: 'a', label: '研发中心', children: [{ value: 'a1', label: '前端组' }, { value: 'a2', label: '后端组' }] },
{ value: 'b', label: '设计中心', children: [{ value: 'b1', label: '交互组' }, { value: 'b2', label: '视觉组' }] },
] }),
h('span', { class: 'out' }, () => (cascaderPath().length ? `当前路径:${cascaderPath().join(' / ')}` : '未选择')),
),
),
section(
'sec-transfer',
'穿梭框',
'transfer:左右两栏由同一份 dataSource 按归属派生,勾选后点箭头整体搬运;两栏各自搜索、全选支持半选,禁用条目既不参与全选也不可搬运。',
h(
'div',
{ class: 'col' },
transfer({
targetKeys: transferKeys,
titles: ['待选能力', '已启用能力'],
height: 180,
onChange: (keys) => toast.info(`已启用 ${keys.length} 项`),
dataSource: [
{ key: 'a11y', label: '无障碍语义(aria)' },
{ key: 'theme', label: '暗色主题令牌' },
{ key: 'motion', label: '微交互动效' },
{ key: 'virtual', label: '虚拟滚动' },
{ key: 'ssr', label: 'SSR 水合(暂不可用)', disabled: true },
{ key: 'i18n', label: '多语言文案' },
],
}),
h('span', { class: 'out' }, () => `已启用 ${transferKeys().length} 项:${transferKeys().join(' / ') || '无'}`),
),
),
section(
'sec-result',
'结果页',
'result:终态页(成功 / 警告 / 失败 / 中性),状态符号 + 标题 + 说明 + 操作区,适合提交完成、权限不足等场景。',
h(
'div',
{ class: 'row' },
result({ status: 'success', title: '提交成功', description: '报销单 RB-2049 已进入财务复核,预计 2 个工作日内出结果。', extra: h('div', { class: 'row' }, button({ label: '查看单据', variant: 'primary', onClick: () => toast.success('打开单据') }), button({ label: '继续提交', variant: 'ghost', onClick: () => toast.info('已新建草稿') })) }),
result({ status: 'danger', title: '校验未通过', description: '发票金额与报销金额不一致,请核对后重新提交。' }),
),
),
section(
'sec-pagination',
'分页',
'pagination:受控页码信号,>7 页自动省略号折叠;jumpable 跳页输入、pageSizes 切每页条数(切换后回第 1 页)。',
h(
'div',
{ class: 'row' },
pagination({ page: pagerPage, pageSize: 10, total: 137, jumpable: true, pageSizes: [10, 20, 50], onChange: (p) => pagerPage(p) }),
h('span', { class: 'out' }, () => `第 ${pagerPage()} 页`),
),
),
section(
'sec-menu',
'下拉菜单',
'menu 触发器 + 自绘弹层:条目支持禁用 / 危险 / 分隔线,↑↓ 环形导航,点外部或 Esc 收起;视口下方空间不足时弹层自动翻到上方,展开后键入字母可快跳菜单项。',
h(
'div',
{ class: 'row' },
menu({
label: '文件操作',
items: [
{ value: 'rename', label: '重命名', shortcut: 'F2' },
{ value: 'copy', label: '创建副本', shortcut: '⌘D' },
{ value: 'archive', label: '归档', disabled: true },
{ divider: true, value: '', label: '' },
{ value: 'delete', label: '删除', danger: true },
],
onSelect: (v) => menuPick(v),
}),
menu({
label: '右对齐',
align: 'end',
variant: 'primary',
items: [
{ value: 'profile', label: '个人资料' },
{ value: 'settings', label: '偏好设置' },
],
onSelect: (v) => menuPick(v),
}),
h('span', { class: 'out' }, () => (menuPick() ? `已选:${menuPick()}` : '未选择')),
),
),
section(
'sec-popover',
'气泡卡片',
'popover:可交互弹层(tooltip 只展示,popover 里能点),点外部 / Esc 收起;视口下方空间不足时弹层自动翻到上方。',
h(
'div',
{ class: 'row' },
popover(
{
title: '筛选条件',
content: h(
'div',
null,
h('div', { class: 'row' }, checkbox({ label: '只看我的', checked: agreed })),
h('div', { class: 'row' }, button({ label: '应用', onClick: () => toast.success('已应用筛选') })),
),
},
button({ label: '打开气泡卡片' }),
),
popover({ placement: 'top', trigger: 'hover', content: '悬停出场,移出收起(可放链接)' }, badge({ label: 'hover 触发' })),
),
),
section(
'sec-breadcrumb',
'面包屑',
'breadcrumb:nav + ol 语义结构,末项自动标为当前页,无 href 的条目走 onNavigate;条目超过 maxItems 时中段折叠成「…」,点开后完整展开。',
breadcrumb({
items: [{ label: '首页', href: '#sec-counter' }, { label: '组件库' }, { label: '导航类' }, { label: '面包屑' }],
separator: '›',
onNavigate: (item) => toast.info(`跳转到 ${item.label}`),
}),
breadcrumb({
items: [
{ label: '首页', href: '#sec-counter' }, { label: '控制台' }, { label: '订单管理' }, { label: '华东区' },
{ label: '2026 Q4' }, { label: '待发货' }, { label: '订单 #1024' },
],
separator: '›',
maxItems: 4,
onNavigate: (item) => toast.info(`跳转到 ${item.label}`),
}),
),
section(
'sec-accordion',
'折叠面板',
'accordion:标题按钮 + grid 行高过渡,默认手风琴单开,multiple 可多开,支持禁用面板。',
accordion({
multiple: true,
items: [
{ value: 'a', title: '账户设置', content: '修改昵称、头像与绑定邮箱。' },
{ value: 'b', title: '通知偏好', content: alert({ message: '通知偏好:每日摘要已开启', variant: 'info' }) },
{ value: 'c', title: '危险操作', content: '删除账号后数据不可恢复。' },
{ value: 'd', title: '企业版专属(禁用)', content: '升级后可配置 SSO。', disabled: true },
],
}),
),
section(
'sec-table',
'数据表格',
'粘性表头、斑马纹、行选择(全选三态 + 已选计数,选中行低视觉比重)、点表头或 Tab 聚焦后按 Enter/空格 排序、定宽列表头右缘可拖拽或 ←→ 调列宽(双击复位)、工具栏筛选(0 行时按「有没有筛选词」分别提示「暂无数据」或「没有符合…的行」并给一键清除)、底部分页;工具栏「导出 CSV」导的是筛选 + 排序后的全量行(不含勾选,与当前页无关)。',
card({ title: '用户表' }, dataTable({
data: users,
selectable: true,
exportable: true,
loading: tableLoading,
columns: [
{ key: 'name', header: '姓名', sortable: true, width: 160 },
{ key: 'age', header: '年龄', sortable: true, align: 'right', width: 100, format: (v) => `${v} 岁` },
{ key: 'city', header: '城市', align: 'center', width: 'flex' },
],
pageSize: 5,
selected: picked,
onRowClick: (row) => toast.info(`打开 ${row.name} 的详情`), // 整行可点:Tab 进入 + Enter 等价
}), h(
'div',
{ class: 'row' },
switchButton({ label: '加载态', checked: tableLoading }),
badge({ label: '受控选中', variant: 'default' }),
h('span', { class: 'out' }, () => `已选 ${picked().length} 行`),
button({ label: '清空选择', variant: 'ghost', onClick: () => picked([]) }),
)),
card(
{ title: '独立分页器联动(pageSize 3,共用页码信号)' },
dataTable({
data: users,
columns: [
{ key: 'name', header: '姓名' },
{ key: 'city', header: '城市' },
],
pageSize: 3,
page: tablePage,
}),
pagination({ page: tablePage, pageSize: 3, total: users.length, jumpable: true, onChange: (p) => tablePage(p) }),
),
),
section(
'sec-card',
'卡片容器',
'card 组件:标题行(可带右侧操作区)+ 任意子内容 + 可选底部操作区;bordered / hoverable 控制外观,样式全部来自设计令牌。',
card({ title: '令牌驱动' }, h('p', { class: 'desc' }, '这里没有硬编码颜色 —— 全部消费 --ua-* CSS 变量,暗色模式自动切换。')),
card(
{
title: '带操作区的卡片',
extra: h('span', { class: 'desc' }, '共 3 项'),
hoverable: true,
footer: h('div', { class: 'row' }, button({ label: '取消', variant: 'ghost', onClick: () => toast.info('已取消') }), button({ label: '保存', onClick: () => toast.success('已保存') })),
},
h('p', { class: 'desc' }, '标题行右侧放操作或统计,底部 footer 放主次按钮;hoverable 时整卡悬停浮起。'),
),
),
section(
'sec-anchor',
'锚点导航',
'竖排区块链接:滚动时按目标位置高亮当前区块并同步 aria-current,点击按 offset 偏移量平滑定位、hash 用 replaceState 回写(不堆历史);禁用项留在列表里不响应,目标缺失的项在判定时自动跳过。',
card(
{ title: '跳到本页区块' },
anchor({
items: [
{ href: '#sec-anchor', title: '锚点导航(本节)' },
{ href: '#sec-table', title: '数据表格' },
{ href: '#sec-card', title: '卡片容器' },
{ href: '#sec-missing-zone', title: '不存在的区块(自动跳过)' },
{ href: '#sec-affix', title: '吸顶容器(禁用示例)', disabled: true },
],
offset: 12,
onChange: (href) => toast.info(`已跳到 ${href}`),
}),
),
),
section(
'sec-popconfirm',
'二次确认',
'popconfirm 把确认放在触发点旁边而不是弹窗里:确定先收起再回调(回调里改数据不会看到弹层还挂着),取消 / 点外部 / Esc 一律走 onCancel;展开时焦点落在「取消」上,误按回车不会删东西。',
popconfirm({
title: '删除这条记录?',
description: '删除后不可恢复,关联的审批记录会一并清空。',
variant: 'danger',
confirmText: '删除',
cancelText: '再想想',
onConfirm: () => toast.success('已删除(弹层先收起再回调)'),
onCancel: () => toast.info('已取消'),
}, button({ label: '删除记录', variant: 'danger' })),
popconfirm({ title: '发布到生产?', description: '发布后 1 分钟内全量生效。', confirmText: '发布', onConfirm: () => toast.success('已发布') }, button({ label: '发布', variant: 'ghost' })),
),
section(
'sec-calendar',
'月历',
'calendar 把整月网格内嵌进页面(排期 / 值班 / 日程):每日可带标记文案,标记同时写进 aria-label 让读屏也听得到;←→ 逐日、↑↓ 逐周、Enter 选中,跨月自动翻页并交接焦点。',
calendar({
value: calDay,
marker: (date) => calendarMarks[date] ?? null,
onSelect: (date) => toast.info(`选中 ${date}`),
}),
h('span', { class: 'out' }, () => `当前选中:${calDay() || '无'}`),
),
section(
'sec-typography',
'排版文本',
'typography 一个入口覆盖标题 / 正文 / 行内三种形态:variant 决定语义标签(标题出 h1-h5,读屏可按层级跳读),装饰位可叠加,长描述用 ellipsis 截断并自动挂 title 看全文,copyable 给订单号这类要复制的字段。',
card({ title: '标题层级' },
typography({ variant: 'title', level: 3, content: '订单详情' }),
typography({ variant: 'title', level: 5, content: '第三节:配送与售后' }),
typography({ variant: 'paragraph', type: 'secondary', content: '标题按 level 出 h1-h5,字号由样式表按标签给,这里没写任何内联字号。' }),
),
card({ title: '行内装饰' },
typography({
variant: 'paragraph',
content: h('span', null,
'本周新增 ',
typography({ content: '2', strong: true, type: 'success' }),
' 个组件,门禁命令 ',
typography({ content: 'npm run check', code: true }),
',搜索命中 ',
typography({ content: '排版', mark: true }),
',已作废 ',
typography({ content: '旧接口', delete: true }),
'。'),
}),
),
card({ title: '截断与复制' },
typography({
variant: 'paragraph',
ellipsis: 2,
content: '这一段故意写得很长用来演示多行截断:组件把行数写进 CSS 变量交给 -webkit-line-clamp,超出的部分显示省略号,同时把全文挂到 title 上,鼠标停留即可读完,调用方不需要自己算宽度或加「展开」按钮。',
}),
space({ size: 'sm' },
typography({ content: 'RB-2048-0917-3355', code: true, copyable: true }),
typography({ content: '点右侧复制可写进剪贴板', type: 'secondary' }),
),
),
),
section(
'sec-grid',
'栅格容器',
'grid 铺页面骨架:columns 给固定列数,min 走 auto-fit 让列数随可用宽度自然增减(比手写断点稳),列间距统一走 gutter 不产生双倍 margin,窄屏一律降为单列。',
grid({ columns: 3, gutter: 'md' },
card({ title: '实例数' }, statistic({ value: signal(128), suffix: '台' })),
card({ title: '错误率' }, statistic({ value: signal(0.42), suffix: '%', precision: 2 })),
card({ title: '平均延迟' }, statistic({ value: signal(38), suffix: 'ms' })),
),
h('div', { class: 'out' }, '↓ min: 220px 的 auto-fit:拉窄窗口可见列数自动减少'),
grid({ min: '220px', align: 'start' },
...['ap-east-1', 'ap-south-1', 'eu-west-1', 'us-east-1'].map((zone) =>
card({ title: zone }, typography({ content: '健康检查通过', type: 'success', variant: 'paragraph' }))),
),
),
section(
'sec-mentions',
'@提及输入',
'mentions 在 textarea 里键入 @ 就弹候选:↑↓ 环形移动、Enter/Tab 选中、Esc 只收这一层弹层;选中后按光标位置替换 @片段并写回 value 信号。输入拼音或工号也能命中(keywords 只参与过滤不显示)。',
grid({ min: '320px' },
card({ title: '评论框' },
mentions({
items: teamMembers,
value: comment,
rows: 3,
placeholder: '输入 @ 叫人来评审……',
onSelect: (item) => toast.info(`已提及 ${item.label}`),
}),
),
card({ title: '当前正文' },
h('div', { class: 'out' }, () => comment() || '(还没写)'),
typography({ variant: 'paragraph', type: 'secondary', content: '值由 value 信号驱动,外部改信号输入框同步;这里就是同一个信号。' }),
),
),
),
section(
'sec-treeselect',
'树形选择',
'treeSelect = 触发器 + 弹层里嵌的 tree:搜索、多选、父子级联全复用 tree,Esc 与其他弹层共享同一个栈(只关最上层),点外部自动收起;单选选中即收起,多选显示已选数量。',
grid({ min: '280px' },
card({ title: '单选(可搜索)' },
treeSelect({ nodes: orgTree, value: dept, searchable: true, placeholder: '选择归属部门', onSelect: (n) => toast.info(`已选 ${n.label}`) }),
),
card({ title: '多选(保持展开)' },
treeSelect({ nodes: orgTree, multi: true, checked: deptChecked, onChange: (keys) => toast.info(`已勾选 ${keys.length} 项`) }),
typography({ variant: 'paragraph', type: 'secondary', content: '勾选父节点会级联到子节点,「回归测试」是禁用叶子,不可选。' }),
),
),
),
section(
'sec-link',
'链接',
'link 统一站内 / 站外跳转:external 自动补 ↗、target=_blank 与 rel=noopener noreferrer,并给读屏播报「在新窗口打开」;muted 用于页脚次要链接,disabled 去掉 href 但保留可聚焦与 aria-disabled。',
space({ wrap: true, size: 'md' },
link({ href: '#sec-link', label: '站内锚点' }),
link({ href: 'https://gitea.yinyuanjingling.icu/temp/UI', label: '仓库地址', external: true }),
link({ href: '#sec-link', label: '次要说明', muted: true }),
link({ href: '#sec-link', label: '已停用', disabled: true }),
link({ href: '#sec-link', label: '带埋点的链接', onClick: () => toast.info('点击已上报') }),
),
),
section(
'sec-floatbutton',
'悬浮操作按钮',
'floatButton 停靠左下 / 右下角:只给 icon 是圆形,配 label 变胶囊,badge 走角标,tooltip 兼作 aria-label。它返回的是 fixed 包裹层,挂到 body 才不会被容器 overflow 裁掉。',
grid({ min: '240px' },
card({ title: '本页已挂两个' },
typography({ variant: 'paragraph', type: 'secondary', content: '左下角「+ 新建工单」是图标 + 文字胶囊,点击弹提示;右下角「?」是纯图标圆形并带角标 3,悬停看 title。' }),
typography({ variant: 'paragraph', content: h('span', null, '右下角同时有 backTop,所以这里改用 ', typography({ content: "position: 'left-bottom'", code: true }), ' 错开。') }),
),
),
),
);
// 悬浮按钮:fixed 定位,挂 body 不参与页面布局
document.body.append(
floatButton({ icon: '+', label: '新建工单', tooltip: '新建工单', position: 'left-bottom', onClick: () => toast.success('已新建工单') }),
floatButton({ icon: '?', tooltip: '帮助中心(3 条待读)', badge: 3, onClick: () => toast.info('打开帮助中心') }),
);
// 引导漫游实例:根节点需挂到 body(fixed 定位,不参与布局)
const demoTour = tour({
steps: [
{ target: '#sec-tour', title: '引导漫游', description: '聚光框按 target 选择器定位,卡片会自动挑空间更大的一侧。' },
{ target: '#sec-splitter', title: '可拖拽分栏', description: '分隔条支持指针拖拽与方向键微调,双击复位。' },
{ target: '#sec-backtop', title: '返回顶部', description: '滚过 300px 后右下角浮现,外环是已滚比例。' },
],
onFinish: () => toast.success('引导完成'),
onClose: () => toast.info('已跳过引导'),
});
document.body.append(demoTour.el);
const toc = h('aside', { class: 'toc' }, h('div', { class: 'toc-title' }, '目录')); // 目录项由 buildToc 填充
const app = h('div', { class: 'shell' }, toc, content);
mount('#app', () => app);
buildToc(toc, content);