Files
UI/apps/demo/main.ts
T

1923 lines
95 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,
codeBlock,
colorPicker,
timePicker,
commandPalette,
copyButton,
countdown,
dataTable,
layout,
jsonViewer,
kbd,
heatmap,
highlight,
datePicker,
transfer,
descriptions,
diffView,
divider,
drawer,
empty,
floatButton,
image,
form,
formItem,
inputNumber,
inputTag,
grid,
link,
loading,
list,
carousel,
treeSelect,
tree,
notification,
result,
upload,
watermark,
menu,
modal,
mentions,
multiSelect,
pagination,
pinInput,
popconfirm,
popover,
progress,
radioGroup,
relativeTime,
rate,
segmented,
select,
showToast,
signaturePad,
skeleton,
splitter,
slider,
sparkline,
space,
spinner,
stepper,
statistic,
switchButton,
tabs,
tag,
textarea,
textField,
timeline,
toast,
tour,
tooltip,
typography,
virtualList,
} 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 saving = signal(false); // 按钮 loading 态演示(模拟异步保存)
// —— 双向绑定演示 ——
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 stackPick = 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 wideTab = signal('t0');
const modalOpen = signal(false);
const modalPick = signal('a'); // 弹窗内下拉的取值,用于演示 Esc 逐层关闭
// —— 表单校验状态集中声明 ——
const inviteCode = signal(''); // 异步校验演示:邀请码
const note = 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 priceLo = signal(200); // 区间滑块下限
const priceHi = signal(800); // 区间滑块上限
// —— 数字输入:购买数量演示,与禁用态实例共用同一信号 ——
const qty = signal(1);
const price = signal('9.9'); // 演示 suffix 附加块
const pwd = signal(''); // 演示 password 显隐切换
const amount = signal(2); // 演示 inputNumber 前后缀
// —— 步骤条:下单流程演示,按钮推进、点已完成步骤回跳 ——
const stepIdx = signal(0);
// —— 评分:满意度演示,与只读实例共用同一信号 ——
const score = signal(0);
const score2 = signal(3); // 演示 allowClear 可清除评分
// —— 空状态:搜索无结果演示 ——
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 atTime = signal('09:30'); // 时间选择值:给定初值即受控,组件选完写回
const dateVal = signal('2026-10-26'); // 日期选择值(外部受控演示)
const rangeFrom = signal(''); // 区间起点
const rangeTo = signal(''); // 区间终点
const acValue = signal(''); // 自动补全输入值(外部受控演示)
const pinCode = signal(''); // 验证码输入值(外部受控演示)
const pinMask = signal(''); // 密文验证码输入值(mask 演示)
const resendCd = countdown({ seconds: 90, running: false, format: (s) => `重发(${s}s)` }); // 重发倒计时(手动启动演示)
const highlightText = signal('数据不出端、预测低于 50ms、习惯本地学习是输入法增强系统的三条底线'); // 高亮演示原文
const highlightKw = signal('本地'); // 高亮演示关键词(与搜索框信号联动)
const demoTags = signal<string[]>(['本地推理', '隐私优先']); // 标签输入演示集合(成标签 / 删 chip 双向写回)
const tagRows = [
{ id: 'r1', name: '本地 ASR 推理', tags: ['本地推理', '隐私优先'] },
{ id: 'r2', name: '云端补全兜底', tags: ['云端'] },
{ id: 'r3', name: '习惯冷启动加速', tags: ['本地推理'] },
]; // 标签筛选演示数据:每行带 tags 供「且」关系匹配
const tagMatched = computed(() => tagRows.filter((row) => demoTags().every((tag) => row.tags.includes(tag)))); // 派生行集合:标签变化即重算,喂给 dataTable 的信号数据源
const treeChecked = signal<string[]>(['core']); // 多选树勾选集合(外部受控演示)
const treePick = signal('core'); // 树形选中节点 key
const transferKeys = signal<string[]>(['a11y']); // 穿梭框右侧集合(外部受控演示)
const cityPick = signal(''); // 可搜索下拉的选中城市
const pagerPage = signal(1); // 独立分页器当前页
const simplePage = signal(2); // 极简分页演示用页码
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 }] },
];
// —— 主题:优先读 URL 参数(文档站 iframe 联动入口),其次上次选择,最后跟随系统;写 html[data-theme] 让令牌换色 ——
const THEME_KEY = 'ua-demo-theme';
const themeParam = new URLSearchParams(location.search).get('theme'); // ?theme=dark|light,iframe 带参重载时生效
const dark = signal(((themeParam === 'dark' || themeParam === 'light' ? themeParam : null) ?? 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) }),
button({ label: '保存(模拟请求)', loading: saving, onClick: () => { saving(true); setTimeout(() => saving(false), 1500); } }),
h('span', { class: 'out' }, () => `count = ${count()}`),
),
section(
'sec-bind',
'双向绑定',
'textField 与信号双向同步:输入即回显;prefix/suffix 渲染为输入框内侧附加块;clearable 开启后有值即显一键清空;password 原生打点并内置显隐切换。',
textField({ placeholder: '输入名字…', value: name }),
textField({ placeholder: '搜索…', value: name, prefix: searchIcon(), clearable: true }),
textField({ value: price, suffix: '元', clearable: true }),
textField({ placeholder: '密码…', value: pwd, password: true, 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' }, select({ // 分组选项:相邻同组合成一段,过滤后空组标题自动隐藏
options: [
{ value: 'vue', label: 'Vue 3', group: '前端框架' },
{ value: 'react', label: 'React 19', group: '前端框架' },
{ value: 'svelte', label: 'Svelte 5', group: '前端框架' },
{ value: 'node', label: 'Node.js 22', group: '服务端运行时' },
{ value: 'deno', label: 'Deno 2', group: '服务端运行时' },
{ value: 'bun', label: 'Bun 1.2', group: '服务端运行时' },
],
value: stackPick,
placeholder: '按分类选技术栈…',
}), h('span', { class: 'out' }, () => (stackPick() ? `技术栈:${stackPick()}` : '分组下拉:试试搜索「d」'))),
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 }),
textarea({ placeholder: '自适应高度(1-4 行钳制)…', value: signal(''), autosize: { minRows: 1, maxRows: 4 } }),
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 信号双向绑定,右侧可显示当前值;range 模式叠两条原生轨做双滑块区间。',
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}`) }),
slider({ value: priceLo, end: priceHi, range: true, min: 0, max: 1000, step: 50, onRangeChange: (a, b) => toast.info(`价格 ${a} ~ ${b}`) }),
),
),
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: '件' }),
),
h('div', { class: 'out' }, '↓ 控件高度三档并排(sm / md / lg):button / textField / select / inputNumber 统一走 --ua-control-h-*'),
h(
'div',
{ class: 'row' },
button({ label: '小号', size: 'sm' }), button({ label: '默认' }), button({ label: '大号', size: 'lg' }),
textField({ placeholder: 'sm', size: 'sm' }), textField({ placeholder: 'md' }), textField({ placeholder: 'lg', size: 'lg' }),
select({ options: [{ value: 'sm', label: 'sm' }, { value: 'md', label: 'md' }, { value: 'lg', label: 'lg' }], value: signal('md'), size: 'sm' }),
select({ options: [{ value: 'a', label: '选项 A' }, { value: 'b', label: '选项 B' }], value: signal('a') }),
select({ options: [{ value: 'a', label: '选项 A' }, { value: 'b', label: '选项 B' }], value: signal('a'), size: 'lg' }),
inputNumber({ value: qty, size: 'sm' }), inputNumber({ value: qty }), inputNumber({ value: qty, size: 'lg' }),
),
),
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 走两列栅格,窄屏自动堆叠,按钮区始终整行;字段传 span 可跨列(备注整宽)。',
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 }),
),
formItem({ label: '备注', value: note, span: 2, hint: 'span: 2 —— 多列表单里整行铺开,单列时自动退回整行' }, textarea({ placeholder: '多行备注…', value: note, rows: 2, maxCount: 120 })),
],
),
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 ?? ''); // 关掉的正好是当前页:顺位激活下一个
},
})];
}),
h('div', { style: 'max-width:320px;width:100%' }, tabs({
tabs: Array.from({ length: 8 }, (_, i) => ({ value: `t${i}`, label: `选项 ${i + 1}` })), // 八个页签必然溢出,用来验证自动回拉
active: wideTab,
})),
h('span', { class: 'out' }, () => `溢出演示当前页签:${wideTab()}(点最右侧的页签,标题条会自动滚过去)`),
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',
confirmText: '确认提交',
cancelText: '再想想',
onClose: () => modalOpen(false),
onConfirm: async () => { await new Promise((r) => setTimeout(r, 900)); toast.success('已确认,窗口自动关闭'); }, // 成功后组件自己收窗
},
h('p', null, '不传 footer 时走内置按钮组:onConfirm 返回 Promise 期间按钮变「处理中…」并禁用,成功才收窗,失败留在窗口重试(提示交给调用方的 toast)。'),
h('div', { class: 'row' }, badge({ label: 'modal', variant: 'success' }), badge({ label: 'onConfirm', variant: 'default' })),
select({ options: [{ value: 'a', label: '优先处理' }, { value: 'b', label: '排队等待' }, { value: 'c', label: '转交他人' }], value: modalPick }),
),
),
section(
'sec-stepper',
'步骤条',
'stepper:done/current/upcoming 三态随 active 信号流转,clickable 时点已完成步骤回跳;direction=vertical 走竖排。',
stepper({
steps: [{ label: '确认订单', description: '收货信息与商品清单' }, { label: '支付', description: '选择支付方式' }, { label: '完成', description: '等待发货' }],
active: stepIdx,
clickable: true,
onChange: (i) => toast.info(`回到第 ${i + 1} 步`),
}),
divider({ dashed: true }),
stepper({ // 竖向:侧栏 / 移动端场景,连接线落在圆点下方
steps: [{ label: '提交申请', description: '10-20 09:12 已提交' }, { label: '资料初审', description: '10-21 14:03 初审通过' }, { label: '终审', description: '等待风控终审' }],
active: signal(2), // 独立信号:演示停在第三步
direction: 'vertical',
}),
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 只读展示,allowClear 再点当前分清零。',
h(
'div',
{ class: 'row' },
rate({ value: score, onChange: (v) => toast.info(`评分 ${v} 星`) }),
h('span', { class: 'out' }, () => `当前:${score() || '未评分'}`),
),
h('div', { class: 'row' }, rate({ value: score2, allowClear: true }), h('span', { class: 'out' }, () => `可清除(再点当前分归零):${score2() || '已清'}`)),
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: searchIcon(), 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 让长字段占整行;layout=vertical 把字段名挪到值上方,extra 给标题行加操作区。',
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 },
],
}),
divider({ dashed: true }),
descriptions({
title: '竖排 + 标题操作区(窄栏场景)',
column: 2,
layout: 'vertical',
extra: [h('span', {}, '更新于 15:20'), button({ label: '刷新', variant: 'ghost', onClick: () => toast.info('已刷新') })],
items: [
{ label: '合同编号', value: 'HT-2026-0913' },
{ label: '签署日期', value: '2026-09-13' },
{ label: '备注', value: '续签两年,价格按季度结算;此列用 span 占满整行以演示长文本换行。', 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(),
}),
divider({ dashed: true }),
timeline({ // 双侧交替:轴线居中,奇偶左右分列,窄屏退回单列
mode: 'alternate',
items: [
{ title: '立项评审通过', time: '10-01', content: '范围与里程碑确定', variant: 'success' },
{ title: '设计定稿', time: '10-09', content: '视觉与令牌同步交付', variant: 'success' },
{ title: '一期上线', time: '10-22', content: '灰度 10% → 全量', variant: 'warning' },
],
}),
),
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 传毫秒可自动消失,悬停或聚焦会暂停倒计时、离开按剩余时间续计;带 key 的第二次调用原地改同一张卡(进度刷新不再堆条)。',
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' }) }),
button({ label: '同 key 原地更新', variant: 'ghost', onClick: () => { // 三拍演示:一条卡原地改三次,不会堆成三条
const job = notification({ key: 'sync-job', title: '同步中 0%', description: '正在上传附件' });
window.setTimeout(() => job.update({ title: '同步中 60%', description: '已上传 3 / 5 个文件' }), 900);
window.setTimeout(() => job.update({ title: '同步完成', description: '5 个文件已全部就绪', variant: 'success' }), 1800);
window.setTimeout(() => job.close(), 4000);
} }),
),
),
section(
'sec-multiselect',
'多选下拉',
'multiSelect:标签式回显 + 可搜索弹层,点选不收起弹层(连续多选),空输入退格删末个标签,max 到限后未选项禁用,maxTagCount 折叠溢出标签,clearable 一键清空。',
h(
'div',
{ class: 'row' },
multiSelect({
value: tagPick,
max: 3,
clearable: true,
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-timepicker',
'时间选择',
'timePicker:时 / 分两列弹层,value 双向绑定 HH:mm;minuteStep 控制分的粒度,选完分自动收起并归还焦点。',
h(
'div',
{ class: 'row' },
timePicker({ value: atTime, minuteStep: 15, clearable: true, onChange: (t) => toast.success(t ? `定在 ${t}` : '已清空时间') }),
timePicker({ placeholder: '小号', size: 'sm' }),
timePicker({ placeholder: '禁用态', disabled: true }),
h('span', { class: 'out' }, () => `当前时间:${atTime() || '未选'}`),
),
),
section(
'sec-pininput',
'验证码输入',
'pinInput:N 格单字符数字输入,输入自动进格、退格回删、←/→ 移格、粘贴自动分发;mask 密文显示,disabled 禁用。',
h(
'div',
{ class: 'row' },
pinInput({ value: pinCode, onChange: (v) => { if (v.length === 6) toast.success(`验证码 ${v} 已就绪`); } }),
h('span', { class: 'out' }, () => `当前:${pinCode() || '未输入'}(试试直接粘贴整段)`),
),
h(
'div',
{ class: 'row' },
pinInput({ value: pinMask, length: 4, mask: true }),
h('span', { class: 'out' }, () => `密文 4 格:${pinMask().length}/4`),
),
h('div', { class: 'row' }, pinInput({ value: signal(''), length: 6, disabled: true })),
),
section(
'sec-copybutton',
'复制按钮',
'copyButton:点击写入剪贴板,成功后短暂换成「已复制」再还原;text 可传函数取实时值,剪贴板 API 不可用时自动降级 execCommand。',
h(
'div',
{ class: 'row' },
copyButton({ text: 'npm install @ui-arch/ui', onCopied: (t) => toast.success(`已复制:${t}`) }),
copyButton({ text: () => pinCode() || '尚未输入验证码', label: '复制上方验证码' }),
copyButton({ text: '禁用态示例', disabled: true }),
),
),
section(
'sec-countdown',
'倒计时',
'countdown:秒级递减到 0 自动停表并触发 onFinish,缺省 mm:ss 展示;句柄 start / stop / reset,验证码重发场景与 pinInput 搭配。',
h(
'div',
{ class: 'row' },
countdown({ seconds: 10, onFinish: () => toast.info('倒计时结束') }).el,
resendCd.el,
button({ label: '重置 90s', variant: 'ghost', onClick: () => { resendCd.reset(); resendCd.start(); } }),
h('span', { class: 'out' }, '第二个示例初始暂停,点按钮重开'),
),
),
section(
'sec-kbd',
'键盘按键',
'kbd:键帽样式展示按键提示,单键直传字符串,组合键传数组以 + 连接(+ 对读屏隐藏)。',
h(
'div',
{ class: 'row' },
kbd({ keys: 'Esc' }),
kbd({ keys: ['Ctrl', 'K'] }),
kbd({ keys: ['Shift', 'Enter'] }),
h('span', { class: 'out' }, '按 ', kbd({ keys: ['Ctrl', 'K'] }), ' 唤起演示页命令面板'),
),
),
section(
'sec-highlight',
'关键词高亮',
'highlight:命中片段包 mark(大小写不敏感全部命中),text / keyword 接受信号实时联动,空关键词原样展示。',
h(
'div',
{ class: 'row' },
highlight({ text: highlightText, keyword: highlightKw }),
h('span', { class: 'out' }, () => `关键词:${highlightKw() || '(空)'}`),
),
h(
'div',
{ class: 'row' },
textField({ placeholder: '输入关键词试试实时联动', value: highlightKw, size: 'sm' }),
),
),
section(
'sec-inputtag',
'标签输入',
'inputTag:回车 / 逗号成标签,chip 可单个删除,空框退格删末个,失焦自动入列;max 满额自动禁输。',
h(
'div',
{ class: 'row' },
inputTag({ value: demoTags, max: 5, onChange: (list) => toast.info(`当前 ${list.length} 个标签`) }),
h('span', { class: 'out' }, () => `已选:${demoTags().join(' / ') || '(空)'}`),
),
h(
'div',
{ class: 'row' },
dataTable({
data: tagMatched, // 信号数据源:删标签后表格自动换源,排序 / 每页行数不被重置
columns: [
{ key: 'name', header: '能力' },
{ key: 'tags', header: '标签', format: (value) => (value as string[]).join(' / ') },
],
pageSize: 3,
}),
),
),
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()} 页`),
),
h(
'div',
{ class: 'row' },
pagination({ page: simplePage, pageSize: 2, total: 9, simple: true }), // 极简:只留前后翻页与 3 / 5
h('span', { class: 'out' }, '极简模式:工具栏窄位只留箭头与进度'),
pagination({ page: simplePage, pageSize: 20, total: 9, hideOnSinglePage: true }), // 单页:整块不渲染
h('span', { class: 'out' }, '单页隐藏:上面那块 9 条 / 每页 20 已自动消失'),
),
),
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' })),
popconfirm({ // 异步确认:Promise 期间按钮转「处理中…」并禁用,成功才收起
title: '归档这批任务?',
description: '调用后端需要约 1 秒,期间按钮保持 loading,失败则留在气泡里可重试。',
confirmText: '归档',
onConfirm: async () => { await new Promise((r) => setTimeout(r, 1000)); 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 }), ' 错开。') }),
),
),
),
section(
'sec-vlist',
'虚拟列表',
'virtualList 固定行高虚拟化:DOM 里只保留可视窗口 + 上下缓冲条(本例 10,000 行常驻约 20 行),滚动 rAF 合帧整窗重建;句柄 scrollTo 按全量下标回跳,越界自动钳制。',
grid({ min: '320px' },
card({ title: '10,000 行订单流水' },
(() => {
const rows = Array.from({ length: 10000 }, (_, i) => ({ id: `SO-${10000 + i}`, amount: 88 + (i % 97) * 13 })); // 金额按下标推导,回看稳定
return virtualList({ items: rows, itemHeight: 44, height: 320, renderItem: (raw, i) => `#${i + 1} · ${(raw as { id: string }).id} · ¥${(raw as { amount: number }).amount}` }).el;
})(),
typography({ variant: 'paragraph', type: 'secondary', content: '滚动条是真实全长,但 DOM 只渲染可视窗口——开 DevTools 数一数 .ua-vlist-item 就知道。' }),
),
card({ title: '滚回第 500 条' },
(() => {
const rows = Array.from({ length: 10000 }, (_, i) => ({ id: `SO-${10000 + i}`, amount: 88 + (i % 97) * 13 }));
const vlist = virtualList({ items: rows, itemHeight: 44, height: 320, renderItem: (raw, i) => `#${i + 1} · ${(raw as { id: string }).id} · ¥${(raw as { amount: number }).amount}` });
return h('div', null, vlist.el, space({ size: 'sm' }, button({ label: '跳到 #500', onClick: () => vlist.scrollTo(499) })));
})(),
typography({ variant: 'paragraph', type: 'secondary', content: 'scrollTo(index) 接全量下标(从 0 计),组件同步重渲,不等下一次滚动事件。' }),
),
),
),
section(
'sec-cmdk',
'命令面板',
'commandPalette 是 ⌘K / Ctrl+K 唤起的高频入口:输入即过滤(主文案 / 说明 / 关键词三层),↑↓ 环形选择、回车执行、Esc 收起,选中或关闭都归还焦点;组标题按 items 顺序合并,组件不替你排序。',
grid({ min: '320px' },
card({ title: '⌘K 命令面板' },
(() => {
const open = signal(false); // 显隐信号:按钮与快捷键共用同一份状态
const cmdk = commandPalette({
open,
hotkey: false, // 全局 Ctrl+K 已让给页级跳转面板(多实例约定:只有一个实例注册快捷键),此卡用按钮唤起
items: [
{ id: 'save', label: '保存当前页面', group: '文件', hint: '⌘S' },
{ id: 'export', label: '导出为 Markdown', group: '文件', hint: '⌘E' },
{ id: 'invite', label: '邀请成员', group: '协作', hint: '⌘I', keywords: ['yaoqing'] },
{ id: 'role', label: '调整成员权限', group: '协作' },
{ id: 'theme', label: '切换暗色主题', group: '偏好', keywords: ['dark', 'anse'] },
{ id: 'lang', label: '切换界面语言', group: '偏好', hint: '⌘L' },
],
onSelect: (it) => toast.info(`执行:${it.label}`),
});
return h('div', null, button({ label: '打开命令面板(⌘/Ctrl + K)', onClick: () => open(true) }), cmdk.el);
})(),
typography({ variant: 'paragraph', type: 'secondary', content: '试试输入「协」或拼音 yaoqing / dark:keywords 让中文别名与拼音也能命中,不用改组件。多实例时只有一处注册 ⌘K——本页的 Ctrl+K 属于页级跳转面板,这里用按钮唤起。' }),
),
card({ title: '为什么不用原生 input + datalist' },
typography({ variant: 'paragraph', type: 'secondary', content: 'datalist 无法分组、不搜说明文字、也控制不了高亮与焦点归还;命令面板要的是「输入即缩小 + 键盘走完整个流程」,所以自绘列表并用 aria-activedescendant 把高亮交给读屏。' }),
),
),
),
section(
'sec-layout',
'布局骨架',
'layout:页头 / 侧栏 / 内容 / 页脚一次成型,页头左侧开关可折叠侧栏;内容区自己滚动,页头能吸顶。骨架上不放圆角与投影——那是卡片的事。',
h('div', { style: 'height:280px;overflow:hidden;border-radius:var(--ua-radius-md)' },
layout(
{
header: h('strong', null, '控制台'),
sider: h('div', null, h('div', { class: 'out' }, '概览'), h('div', { class: 'out' }, '订单'), h('div', { class: 'out' }, '设置')),
footer: '© 2026 @ui-arch',
collapsible: true,
sticky: true,
},
card({ title: '内容区' }, typography({ variant: 'paragraph', type: 'secondary', content: '省略 header / sider / footer 时内容区独占整行;长内容页打开 sticky,页头随滚动留在顶部。' })),
),
),
),
section(
'sec-jsonviewer',
'JSON 树查看',
'jsonViewer:接口返回的原始对象直接丢进去即可——默认展开两层、分支显示「N 项 / N 个键」,叶子按类型着色;只读,不带编辑。',
jsonViewer({
value: {
code: 0,
message: 'ok',
data: { total: 3, page: 1, items: users.slice(0, 3).map((u) => ({ id: u.id, name: u.name, city: u.city, tags: ['活跃'], vip: u.id === 1 })) },
},
rootLabel: 'response',
depth: 2,
maxHeight: 260,
}),
),
section(
'sec-diff',
'文本差异对比',
'diffView:改前 / 改后整串丢进去——先剪公共前后缀再对中间段做 LCS,所以改两行不会把整篇标红;增删行同时有底色与 + / − 符号两条通道。',
diffView({
before: [
'export function sum(a, b) {',
' return a + b;',
'}',
'',
'// 用法',
'sum(1, 2);',
].join('\n'),
after: [
'export function sum(a, b) {',
" if (typeof a !== 'number') throw new TypeError('a 必须是数字');",
' return a + b;',
'}',
'',
'// 用法',
'sum(1, 2);',
"sum(1, '2');",
].join('\n'),
}),
diffView({
before: 'if (a) {\n return 1;\n}',
after: 'if (a) {\n return 1;\n}',
ignoreWhitespace: true, // 只差缩进:开启后不再报差异,但显示仍保留原文缩进
}),
),
section(
'sec-code',
'代码块',
'codeBlock:等宽多行 + 行号列 + 头部标题,右上角一键复制原文;行号是装饰、不进剪贴板,只做展示不高亮。',
codeBlock({
title: 'sum.ts',
lang: 'ts',
maxHeight: 200,
code: [
'export function sum(a: number, b: number): number {',
' if (typeof a !== \'number\') throw new TypeError(\'a 必须是数字\');',
' return a + b;',
'}',
'',
'// 用法:sum(1, 2) === 3',
].join('\n'),
}),
),
section(
'sec-sign',
'手写签名',
'signaturePad:指针手写、位图按 DPR 放大不糊,收笔即把 PNG dataURL 写回信号;底部清空在未落笔时自动禁用。',
(() => {
const sign = signal('');
const pad = signaturePad({ value: sign, height: 140 });
return h('div', null, pad.el, h('div', { class: 'out' }, () => (sign() ? '已签字(dataURL 长度 ' + sign().length + ')' : '尚未签字')));
})(),
),
section(
'sec-loading',
'区域加载遮罩',
'loading:把要遮挡的块交进 children,active 为真时盖上遮罩;默认延迟 120ms 出场——接口快于这个时间就不闪,慢于它必有反馈。',
(() => {
const busy = signal(false);
const box = loading({
active: busy,
text: '正在刷新订单',
minHeight: 120,
}, card({ title: '订单概览' }, typography({ variant: 'paragraph', type: 'secondary', content: '这里是一块会被整体刷新的内容:遮罩盖住时它仍在原位、只是变淡。' })));
return h('div', { style: 'display:flex;flex-direction:column;gap:8px' },
button({ label: '切换刷新状态', onClick: () => busy(!busy()) }),
box,
);
})(),
),
section(
'sec-reltime',
'相对时间',
'relativeTime:把时间戳渲染成「3 分钟前」并按间隔自动刷新,超阈值改显示绝对日期;精确时间在 title 里,悬停即见。',
h('div', { class: 'row' },
h('span', null, '刚刚:'), relativeTime({ value: Date.now() - 20 * 1000 }),
h('span', null, '半小时前:'), relativeTime({ value: Date.now() - 30 * 60 * 1000 }),
h('span', null, '三天前:'), relativeTime({ value: Date.now() - 3 * 86400 * 1000 }),
h('span', null, '两个月前:'), relativeTime({ value: Date.now() - 60 * 86400 * 1000 }),
h('span', null, '脏数据:'), relativeTime({ value: 'not-a-date' }),
),
),
section(
'sec-spark',
'迷你趋势线',
'sparkline:把数值序列压成一条折线,可选面积与末点标记;不画坐标轴、不带交互,只表达走向——主数值交给 statistic。',
h('div', { class: 'row' },
card({ title: '订单量(7 天)' }, h('div', { style: 'display:flex;align-items:center;gap:12px' }, statistic({ value: signal(1284) }), sparkline({ values: [820, 932, 901, 934, 1290, 1330, 1284], width: 140, height: 36 }))),
card({ title: '转化率(30 天)' }, h('div', { style: 'display:flex;align-items:center;gap:12px' }, statistic({ value: signal(3.62), precision: 2, suffix: '%' }), sparkline({ values: [4.1, 3.9, 4.0, 3.8, 3.7, 3.9, 3.6], area: false }))),
),
),
section(
'sec-heat',
'日历热力图',
'heatmap:一周一列、七天一行,格子浓度表示当天数量(按最大值归一四档);末列对齐到本周,悬停看具体日期与次数。',
(() => {
const today = new Date();
const values: { date: string; count: number }[] = [];
for (let i = 0; i < 84; i += 1) {
const d = new Date(today.getTime() - i * 86400000);
const key = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
const count = (i * 7) % 5 === 0 ? 0 : (i * 3) % 7; // 伪随机但确定性:每次渲染同一图案
values.push({ date: key, count });
}
return heatmap({ values, weeks: 12, formatValue: (n, d) => d + ' 提交 ' + n + ' 次' });
})(),
),
);
// 悬浮按钮: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);
// 页级命令面板:Ctrl+K 跳转任意演示区块;items 与侧边目录同源(从 .sec 反推,清单改动自动跟随)
const demoPalette = commandPalette({
open: signal(false),
placeholder: '跳转到组件…',
emptyText: '没有匹配的组件',
items: Array.from(content.querySelectorAll<HTMLElement>('section.sec')).map((sec) => ({
id: sec.id,
label: sec.querySelector('h2')?.textContent ?? sec.id,
group: '跳转',
keywords: [sec.id.replace(/^sec-/, '')], // 锚点 id 去 sec- 前缀当拼音外的第二检索路
})),
onSelect: (it) => document.getElementById(it.id)?.scrollIntoView({ behavior: 'smooth' }),
});
document.body.append(demoPalette.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);
/*** 放大镜图标:demo 内联 SVG,避免 emoji 在不同系统渲染成彩色位图 ***/
function searchIcon(): HTMLElement {
const ns = 'http://www.w3.org/2000/svg';
const holder = document.createElement('span');
holder.style.display = 'inline-flex';
const svg = document.createElementNS(ns, 'svg');
svg.setAttribute('viewBox', '0 0 16 16');
svg.setAttribute('width', '14');
svg.setAttribute('height', '14');
svg.setAttribute('fill', 'none');
svg.setAttribute('stroke', 'currentColor');
svg.setAttribute('stroke-width', '1.5');
svg.setAttribute('stroke-linecap', 'round');
const lens = document.createElementNS(ns, 'circle');
lens.setAttribute('cx', '7'); lens.setAttribute('cy', '7'); lens.setAttribute('r', '4.5');
const handle = document.createElementNS(ns, 'line');
handle.setAttribute('x1', '10.5'); handle.setAttribute('y1', '10.5'); handle.setAttribute('x2', '14'); handle.setAttribute('y2', '14');
svg.append(lens, handle);
holder.append(svg);
return holder;
}