Files
UI/apps/demo/main.ts
T
2026-10-23 17:01:46 +08:00

435 lines
17 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 { h, mount, renderList, signal } from '@ui-arch/frame';
import {
accordion,
alert,
badge,
breadcrumb,
button,
card,
checkbox,
dataTable,
drawer,
form,
formItem,
menu,
modal,
pagination,
popover,
progress,
radioGroup,
select,
showToast,
skeleton,
slider,
spinner,
switchButton,
tabs,
tag,
textarea,
textField,
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),
);
toc.append(...links);
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 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);
// —— 受控选中:dataTable 内部勾选实时写回,外部可清空 ——
const picked = signal<RowKey[]>([]);
// —— 表格加载态:切换后数据行变骨架行,筛选与翻页临时禁用 ——
const tableLoading = signal(false);
// —— 菜单选中结果:演示 onSelect 回调 ——
const menuPick = signal('');
// —— 禁用态演示:开关信号同时绑定 switch 的 checked 与 checkbox 的 disabled ——
const locked = signal(true);
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 与信号双向同步:输入即回显。',
textField({ placeholder: '输入名字…', value: name }),
h('span', { class: 'out' }, () => (name() ? `你好,${name()}!` : '等待输入…')),
),
section(
'sec-form',
'表单控件',
'checkbox / switch / select / radioGroup / textarea:状态一律由调用方信号持有。',
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 }),
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-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-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 }),
),
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 / 遮罩 / × 关闭。',
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' })),
),
),
section(
'sec-menu',
'下拉菜单',
'menu 触发器 + 自绘弹层:条目支持禁用 / 危险 / 分隔线,↑↓ 环形导航,点外部或 Esc 收起。',
h(
'div',
{ class: 'row' },
menu({
label: '文件操作',
items: [
{ value: 'rename', label: '重命名' },
{ value: 'copy', label: '创建副本' },
{ 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, 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);