Files
UI/apps/demo/main.ts
T

907 lines
40 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, 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,
alert,
avatar,
badge,
breadcrumb,
button,
card,
cascader,
checkbox,
dataTable,
transfer,
descriptions,
divider,
drawer,
empty,
image,
form,
formItem,
inputNumber,
list,
carousel,
tree,
notification,
result,
upload,
menu,
modal,
pagination,
popover,
progress,
radioGroup,
rate,
segmented,
select,
showToast,
skeleton,
splitter,
slider,
spinner,
stepper,
statistic,
switchButton,
tabs,
tag,
textarea,
textField,
timeline,
toast,
tooltip,
} 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 count = signal(0);
// —— 双向绑定演示 ——
const name = signal('');
// —— 表单控件状态集中声明 ——
const agreed = signal(false);
const nightMode = 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');
const modalOpen = signal(false);
const modalPick = signal('a'); // 弹窗内下拉的取值,用于演示 Esc 逐层关闭
// —— 表单校验状态集中声明 ——
const username = signal('');
const email = signal('');
const formResult = signal('');
// —— 分页联动状态:dataTable 与独立 pagination 共用同一页码信号 ——
const tablePage = signal(1);
// —— 抽屉开关 ——
const drawerOpen = signal(false);
// —— 标签列表:关闭即写回信号,容器按信号重渲染 ——
const tags = signal(['设计', '前端', '组件库']);
// —— 滑块:音量与禁用态演示 ——
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 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 content = h(
'div',
{ class: 'page' },
h(
'header',
null,
h('h1', null, '@ui-arch 组件库 · 现场演示'),
h('p', { class: 'sub' }, '驱动:@ui-arch/frame(自研微框架) + @ui-arch/ui(组件库) + @ui-arch/table-core(表格内核)'),
),
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' }, select({ options: cities.map((c) => ({ value: c, label: c })), value: city, placeholder: '选择城市…' }), h('span', { class: 'out' }, () => (city() ? `城市:${city()}` : '未选择'))),
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 关闭。',
button({ label: '打开抽屉', onClick: () => drawerOpen(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' })),
),
),
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;
})(),
),
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' }),
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 }),
),
),
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' }),
alert({ message: '信息提示:这是一条 info 提示条。' }),
alert({ message: '警告提示:操作可能存在风险。', variant: 'warning' }),
h('div', { class: 'row' }, spinner(), spinner({ size: 26 })),
progress({ value: percent }),
h('div', { class: 'row' }, h('span', { class: 'out' }, '不确定态:'), progress()),
),
section(
'sec-alert',
'提示条',
'alert:四档语义色 + 可选标题与关闭按钮,role=alert 由读屏播报。',
alert({ message: '这是一条中性提示(无标题)', variant: 'info' }),
alert({ title: '保存失败', message: '网络波动,请稍后重试;本次修改已保留在本地草稿。', variant: 'danger', closable: true, onClose: () => toast.warning('提示已关闭') }),
),
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: '聚焦我…' })),
),
),
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 }) }),
),
),
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:必填 / 长度 / 正则校验,全部通过才触发提交;失焦校验、输入清错。',
form(
{ 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, 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: '请选择城市…' })),
],
),
h('span', { class: 'out' }, () => formResult() || '等待提交…'),
),
section(
'sec-tabs-modal',
'标签页与弹窗',
'tabs 滑动指示条 + 方向键切换(←→),禁用页签置灰;modal 支持 Esc / 遮罩 / × 关闭,弹窗内的下拉展开时 Esc 先收下拉、再按才关弹窗(逐层关闭)。',
tabs({ tabs: [{ value: 'a', label: '概览' }, { value: 'b', label: '明细' }, { value: 'c', label: '设置' }, { value: 'd', label: '已停用', disabled: true }], active: activeTab }),
h('span', { class: 'out' }, () => `当前页签:${activeTab()}`),
button({ label: '打开弹窗', onClick: () => modalOpen(true) }),
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: '试试放宽筛选条件,或清空关键词重新搜索。', 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-statistic',
'统计数值',
'statistic:大号等宽数字 + 前后缀 + 环比趋势;value 变化时 400ms 缓出滚动(减弱动效偏好下直落终值)。',
h(
'div',
{ class: 'row' },
statistic({ title: '本月 GMV', value: gmv, prefix: '¥', precision: 2, trend: 'up' }),
statistic({ title: '订单数', value: orders, suffix: '单', trend: 'down' }),
),
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 选中、→ 展开、← 收起,禁用节点不响应。',
h(
'div',
{ class: 'row' },
tree({
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()}` : '未选中节点')),
),
),
section(
'sec-upload',
'文件选择',
'upload:点击或拖拽入列,清单可逐个移除;只负责选文件并回传 onChange,上传请求交给调用方。',
h(
'div',
{ class: 'row' },
upload({ multiple: true, hint: '点击或拖拽多个文件到此处', 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' }) }),
),
),
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。',
breadcrumb({
items: [{ label: '首页', href: '#sec-counter' }, { label: '组件库' }, { label: '导航类' }, { label: '面包屑' }],
separator: '›',
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',
'数据表格',
'粘性表头、斑马纹、行选择(全选三态 + 已选计数,选中行低视觉比重)、点表头排序、工具栏筛选、底部分页。',
card({ title: '用户表' }, dataTable({
data: users,
selectable: 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,
}), 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 组件:标题 + 任意子内容,样式全部来自设计令牌。',
card({ title: '令牌驱动' }, h('p', { class: 'desc' }, '这里没有硬编码颜色 —— 全部消费 --ua-* CSS 变量,暗色模式自动切换。')),
),
);
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);