feat: stash-ui 0.1.0(40 个组件 + 文档站 + 主题机制)

This commit is contained in:
stash
2026-10-28 20:44:18 +08:00
commit 52296b3368
153 changed files with 17129 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
import { Avatar, Grid, Icon, Space } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
import { DEMO_APPS, DEMO_COVER } from '../data/demo'
export function Avatars() {
return (
<Section
id="avatars"
title="头像"
api="Avatar"
desc="有图显示图片,没图(或图片加载失败)退化成首字色块——列表里最常见的两种状态都由它兜住。"
>
<Demo
title="尺寸与形状"
desc="sm 24px 用于紧凑列表,md 36px、lg 48px 用于卡片与详情头部;圆形用于人 / 应用,方形用于图标类封面。"
code={`<Avatar size="sm" fallback="G" />
<Avatar size="md" fallback="G" />
<Avatar size="lg" fallback="G" />`}
>
<Avatar size="sm" fallback="G" />
<Avatar size="md" fallback="G" />
<Avatar size="lg" fallback="G" />
<Avatar shape="square" size="md" fallback="N" />
<Avatar shape="square" size="lg" fallback="F" />
</Demo>
<Demo
title="图片与降级"
desc="第三张故意给一个坏地址,用来验证加载失败会退回首字;最后一颗带认证角标。"
code={`<Avatar src={cover} alt="GitHub" fallback="G" />
<Avatar src="/broken.png" fallback="?" />
<Avatar verified size="lg" fallback="S" />`}
>
<Avatar src={DEMO_COVER} alt="渐变封面" size="lg" />
<Avatar src="/not-exist.png" alt="" size="lg" fallback="?" />
<Avatar verified size="lg" fallback="S" />
<Avatar verified size="md" fallback="B" />
</Demo>
<Demo
title="列表里的组合"
desc="头像 + 两行文字是应用条目的基本骨架:这一行本身不带底色和描边,靠间距分组。"
layout="column"
code={`<Space gap="md">
<Avatar size="lg" fallback="G" />
<Space direction="column" gap="xs" align="start">
<strong>GitHub</strong>
<span>代码托管与开源社区</span>
</Space>
</Space>`}
>
<Grid columns={2} gap="lg">
{DEMO_APPS.slice(0, 4).map((app) => (
<Space key={app.id} gap="md">
<Avatar size="lg" fallback={app.icon} />
<Space direction="column" gap="xs" align="start">
<span style={{ fontWeight: 500 }}>{app.name}</span>
<span style={{ color: 'var(--su-text-muted)', fontSize: 12 }}>{app.desc}</span>
</Space>
</Space>
))}
</Grid>
</Demo>
<Demo
title="大尺寸与底色"
desc="xl 64px 用于资料卡;无图时可以用 icon 代替字符,tone='primary' 换成品牌浅渐变底。"
code={`<Avatar size="xl" tone="primary" icon={<Icon name="user_3_line" />} verified />
<Avatar size="xl" fallback="S" verified />`}
>
<Space gap="lg" align="end">
<Avatar size="xl" tone="primary" icon={<Icon name="user_3_line" />} verified />
<Avatar size="xl" fallback="S" verified />
<Avatar size="lg" tone="primary" icon={<Icon name="folder_more_line" />} />
</Space>
</Demo>
<PropsTable
rows={[
{ name: 'src', type: 'string', desc: '图片地址;加载失败自动降级' },
{ name: 'alt', type: 'string', defaultValue: "''", desc: '图片替代文本' },
{ name: 'fallback', type: 'string', defaultValue: "'•'", desc: '无图时的字符' },
{ name: 'icon', type: 'ReactNode', desc: '无图时的图标(优先于 fallback)' },
{ name: 'size', type: "'sm' | 'md' | 'lg' | 'xl'", defaultValue: "'md'", desc: '24 / 36 / 48 / 64px' },
{ name: 'tone', type: "'neutral' | 'primary'", defaultValue: "'neutral'", desc: '无图底色:浅灰 / 品牌浅渐变' },
{ name: 'shape', type: "'circle' | 'square'", defaultValue: "'circle'", desc: '圆形 / 圆角方形' },
{ name: 'verified', type: 'boolean', defaultValue: 'false', desc: '右下角认证角标' },
]}
/>
</Section>
)
}
+58
View File
@@ -0,0 +1,58 @@
import { Breadcrumb } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
export function Breadcrumbs() {
return (
<Section
id="breadcrumb"
title="面包屑"
api="Breadcrumb"
desc="详情页的路径导航:中间项可点,最后一项是当前页(aria-current=page)。"
>
<Demo
title="基础用法"
desc="items 里只有最后一项会忽略 href;分隔符默认 `/`。"
layout="column"
code={`<Breadcrumb
items={[
{ label: '首页', href: '/' },
{ label: '常用推荐', href: '/c/recommend' },
{ label: '常用站点' },
]}
/>`}
>
<Breadcrumb
items={[
{ label: '首页', href: '#top' },
{ label: '常用推荐', href: '#top' },
{ label: '常用站点' },
]}
/>
</Demo>
<Demo
title="自定义分隔符"
desc="separator 可以是字符或图标;过深的路径在窄屏会换行。"
code={`<Breadcrumb separator="›" items={items} />`}
>
<Breadcrumb
separator="›"
items={[
{ label: '工作台', href: '#top' },
{ label: '内容库', href: '#top' },
{ label: '推荐电影' },
]}
/>
</Demo>
<PropsTable
rows={[
{ name: 'items', type: 'BreadcrumbItem[]', required: true, desc: '{ label, href? }[]' },
{ name: 'separator', type: 'ReactNode', defaultValue: "'/'", desc: '分隔符' },
]}
/>
</Section>
)
}
+153
View File
@@ -0,0 +1,153 @@
import { useState } from 'react'
import { Button, Icon, IconButton, Space } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
export function Buttons() {
const [loading, setLoading] = useState(false)
const loadOnce = () => {
setLoading(true)
window.setTimeout(() => setLoading(false), 1200)
}
return (
<Section
id="buttons"
title="按钮"
api="Button / IconButton"
desc="五种语义、两种尺寸、两种形状;图标按钮是圆形的,只放一个图标,所以无障碍名称必填。交互对齐参考站:hover 用 opacity .8(主色/危险),过渡 .3s ease-in-out。"
>
<Demo
title="语义变体"
desc="一个页面里主要操作只出现一次 primary,其余用 secondary / outline / ghost。"
code={`<Button>保存</Button>
<Button variant="secondary">取消</Button>
<Button variant="outline">更多</Button>
<Button variant="ghost">详情</Button>
<Button variant="danger">删除</Button>`}
>
<Button>保存</Button>
<Button variant="secondary">取消</Button>
<Button variant="outline">更多</Button>
<Button variant="ghost">详情</Button>
<Button variant="danger">删除</Button>
</Demo>
<Demo
title="形状"
desc="rounded(默认,6px 圆角)用于工具型操作;pill 用于登录、筛选这类主操作——参考站的全部分类筛选和登录按钮都是胶囊。"
code={`<Button shape="pill">登录</Button>
<Button shape="pill" variant="secondary">全部</Button>
<Button shape="pill" variant="ghost">重置</Button>`}
>
<Button shape="pill">登录</Button>
<Button shape="pill" variant="secondary">
全部
</Button>
<Button shape="pill" variant="ghost">
重置
</Button>
</Demo>
<Demo
title="尺寸"
desc="sm 用于卡片内的次级操作,md 用于页面级操作;两档高度分别是 32 / 36。"
code={`<Button size="sm">小号</Button>
<Button size="md">中号</Button>`}
>
<Button size="sm">小号按钮</Button>
<Button size="md">中号按钮</Button>
</Demo>
<Demo
title="状态"
desc="loading 会自动禁用点击;disabled 换成浅底弱文字(参考站的做法),不做整体降透明度。"
code={`<Button loading={loading} onClick={loadOnce}>提交</Button>
<Button disabled>不可用</Button>`}
>
<Button loading={loading} onClick={loadOnce}>
点我加载
</Button>
<Button disabled>不可用</Button>
<Button variant="secondary" disabled>
不可用(次要)
</Button>
</Demo>
<Demo
title="带图标与整行"
desc="图标放在文字左侧;block 让按钮占满容器宽度(表单底部常用)。"
layout="column"
code={`<Button icon={<Icon name="refresh_3_line" />}>重新扫描</Button>
<Button shape="pill" icon={<Icon name="arrow_right_up_line" />}>打开接口</Button>`}
>
<Space gap="md" wrap>
<Button icon={<Icon name="refresh_3_line" />}>重新扫描</Button>
<Button shape="pill" variant="secondary" icon={<Icon name="arrow_right_up_line" />}>
打开接口
</Button>
</Space>
<Button block icon={<Icon name="link_line" />}>
占满一行的按钮
</Button>
</Demo>
<Demo
title="图标按钮"
desc="xs / sm / md 是 28 / 36 / 44 的圆形,图标字号 16 / 20 / 24;ghost 用于顶栏,soft 用于需要一点底色的场景。"
code={`<IconButton label="搜索" icon={<Icon name="search_3_line" />} />
<IconButton label="更多" icon={<Icon name="more_3_line" />} variant="soft" />
<IconButton label="关闭" icon={<Icon name="close_line" />} size="xs" />
<IconButton label="刷新中" icon={<Icon name="refresh_3_line" />} spinning variant="soft" />`}
>
<IconButton label="搜索" icon={<Icon name="search_3_line" />} />
<IconButton label="用户" icon={<Icon name="user_3_line" />} />
<IconButton label="更多" icon={<Icon name="more_3_line" />} variant="soft" />
<IconButton label="小号" icon={<Icon name="close_line" />} size="xs" />
<IconButton label="大号" icon={<Icon name="share_3_line" />} size="md" variant="soft" />
<IconButton label="刷新中" icon={<Icon name="refresh_3_line" />} spinning variant="soft" />
</Demo>
<Demo
title="尺寸与进行中"
desc="sm 32 / md 36 / lg 44;spinning 让图标原地旋转(刷新类动作),loading 则换成转圈。"
code={`<Button size="lg">大按钮</Button>
<Button spinning icon={<Icon name="refresh_3_line" />}>重新扫描</Button>`}
>
<Space gap="md" wrap>
<Button size="sm">小号 32</Button>
<Button size="md">默认 36</Button>
<Button size="lg">大号 44</Button>
<Button variant="secondary" spinning icon={<Icon name="refresh_3_line" />}>
重新扫描
</Button>
</Space>
</Demo>
<PropsTable
rows={[
{ name: 'variant', type: "'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'", defaultValue: "'primary'", desc: '语义变体' },
{ name: 'size', type: "'sm' | 'md' | 'lg'", defaultValue: "'md'", desc: '32 / 36 / 44px 高' },
{ name: 'spinning', type: 'boolean', defaultValue: 'false', desc: '图标原地旋转(不换转圈)' },
{ name: 'shape', type: "'rounded' | 'pill'", defaultValue: "'rounded'", desc: '6px 圆角 / 胶囊' },
{ name: 'block', type: 'boolean', defaultValue: 'false', desc: '占满一行' },
{ name: 'loading', type: 'boolean', defaultValue: 'false', desc: '加载中,自动禁用' },
{ name: 'icon', type: 'ReactNode', desc: '文字左侧图标' },
{ name: '…rest', type: 'ButtonHTMLAttributes', desc: '透传原生属性(type 默认 button)' },
]}
/>
<PropsTable
rows={[
{ name: 'label', type: 'string', required: true, desc: '无障碍名称,同时作为 title' },
{ name: 'icon', type: 'ReactNode', required: true, desc: '按钮里的图标' },
{ name: 'variant', type: "'ghost' | 'soft'", defaultValue: "'ghost'", desc: '是否有底色' },
{ name: 'size', type: "'xs' | 'sm' | 'md'", defaultValue: "'sm'", desc: '28 / 36 / 44px 圆形' },
{ name: 'spinning', type: 'boolean', defaultValue: 'false', desc: '图标旋转' },
]}
/>
</Section>
)
}
+107
View File
@@ -0,0 +1,107 @@
import { Badge, Card, Icon, IconButton, Space } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
export function Cards() {
return (
<Section
id="cards"
title="卡片"
api="Card"
desc="白色容器 + 1px 细边 + 极轻阴影,是页面里的基本承载单位。Card 只负责容器,标题栏、角标、操作区由使用方组合。"
>
<Demo
title="基础用法"
desc="默认 md 内边距(20px)。放在白底页面上时,靠 1px 描边而不是阴影区分边界,和主题一致。"
muted
layout="column"
code={`<Card>
<p>卡片内容</p>
</Card>`}
>
<Card>
<Space direction="column" gap="sm" align="start">
<strong style={{ fontWeight: 500 }}>常用推荐</strong>
<span style={{ color: 'var(--su-text-muted)' }}>
卡片是最小布局单元,内容完全由使用方决定。
</span>
</Space>
</Card>
</Demo>
<Demo
title="内边距"
desc="padding=none 用于封面贴边的卡片;sm 用于紧凑列表;lg 用于详情页大块内容。"
code={`<Card padding="none">…</Card>
<Card padding="sm">…</Card>
<Card padding="lg">…</Card>`}
>
<Card padding="sm">
<span style={{ color: 'var(--su-text-muted)' }}>sm · 12px</span>
</Card>
<Card padding="md">
<span style={{ color: 'var(--su-text-muted)' }}>md · 20px</span>
</Card>
<Card padding="lg">
<span style={{ color: 'var(--su-text-muted)' }}>lg · 24px</span>
</Card>
</Demo>
<Demo
title="可点击卡片"
desc="hoverable 只加深阴影,不改变尺寸,避免列表在悬停时抖动。"
code={`<Card hoverable>整张卡片可点击</Card>`}
>
<Card hoverable padding="sm">
<span style={{ color: 'var(--su-text-muted)' }}>把鼠标放上来</span>
</Card>
</Demo>
<Demo
title="组合出标题栏"
desc="卡片本身不带标题栏;色条 + 标题 + 角标 + 更多按钮是一组可复用的「分区头部」,这里用基础组件拼出来。"
muted
layout="column"
code={`<Card>
<Space justify="between">
<Space gap="md">
<span style={{ width: 4, height: 16, background: 'var(--su-color-primary)' }} />
<strong>常用站点</strong>
<Badge count={18} />
</Space>
<IconButton label="查看全部" icon={<Icon name="more_3_line" />} />
</Space>
…
</Card>`}
>
<Card>
<Space justify="between">
<Space gap="md">
<span
style={{
width: 4,
height: 16,
borderRadius: 2,
backgroundColor: 'var(--su-color-primary)',
}}
/>
<strong style={{ fontWeight: 500 }}>常用站点</strong>
<Badge count={18} />
</Space>
<IconButton label="查看全部" icon={<Icon name="more_3_line" />} />
</Space>
</Card>
</Demo>
<PropsTable
rows={[
{ name: 'as', type: "'div' | 'section' | 'article' | 'aside'", defaultValue: "'div'", desc: '渲染标签,按语义选' },
{ name: 'padding', type: "'none' | 'sm' | 'md' | 'lg'", defaultValue: "'md'", desc: '内边距档位' },
{ name: 'hoverable', type: 'boolean', defaultValue: 'false', desc: '悬停加深阴影' },
{ name: 'className / style', type: 'string / CSSProperties', desc: '布局微调,例如绝对定位角标' },
]}
/>
</Section>
)
}
+88
View File
@@ -0,0 +1,88 @@
import { useState } from 'react'
import { Checkbox, Space, Text } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
export function Checkboxes() {
const [remember, setRemember] = useState(true)
const [notify, setNotify] = useState(false)
const [selected, setSelected] = useState<string[]>(['design'])
const toggle = (value: string) =>
setSelected((current) =>
current.includes(value) ? current.filter((item) => item !== value) : [...current, value],
)
return (
<Section
id="checkbox"
title="复选框"
api="Checkbox"
desc="多选与「记住我 / 订阅」这类需要确认的选项。用原生 input[type=checkbox],键盘、读屏、点击文字都可勾选。"
>
<Demo
title="基础用法"
desc="label 同时作为可见文字和无障碍名称;受控写法与 Switch 一致。"
layout="column"
code={`<Checkbox checked={remember} onChange={setRemember} label="记住登录状态" />
<Checkbox checked={notify} onChange={setNotify} label="有新内容时提醒我" />`}
>
<Checkbox checked={remember} onChange={setRemember} label="记住登录状态" />
<Checkbox checked={notify} onChange={setNotify} label="有新内容时提醒我" />
</Demo>
<Demo
title="多选组"
desc="一列用 Space 排列;组本身不需要额外容器,用方向 + 间距控制即可。"
layout="column"
code={`{options.map((item) => (
<Checkbox
key={item.value}
checked={selected.includes(item.value)}
onChange={() => toggle(item.value)}
label={item.label}
/>
))}`}
>
<Space direction="column" gap="sm" align="start">
{[
{ value: 'design', label: '设计资源' },
{ value: 'dev', label: '开发工具' },
{ value: 'movie', label: '电影片单' },
].map((item) => (
<Checkbox
key={item.value}
checked={selected.includes(item.value)}
onChange={() => toggle(item.value)}
label={item.label}
/>
))}
</Space>
<Text size="sm" tone="muted">
已选 {selected.length} 项
</Text>
</Demo>
<Demo
title="禁用"
desc="禁用态下点击文字也不会改变值;只降对比度,不隐藏。"
code={`<Checkbox checked onChange={() => {}} disabled label="不可修改(已选)" />`}
>
<Space gap="lg" wrap>
<Checkbox checked onChange={() => undefined} disabled label="不可修改(已选)" />
<Checkbox checked={false} onChange={() => undefined} disabled label="不可修改(未选)" />
</Space>
</Demo>
<PropsTable
rows={[
{ name: 'checked', type: 'boolean', required: true, desc: '受控状态' },
{ name: 'onChange', type: '(checked: boolean) => void', required: true, desc: '勾选回调' },
{ name: 'label', type: 'ReactNode', required: true, desc: '可见文字,同时作为无障碍名称' },
{ name: 'disabled', type: 'boolean', defaultValue: 'false', desc: '禁用' },
]}
/>
</Section>
)
}
+102
View File
@@ -0,0 +1,102 @@
/* 组合示例用的页面级样式:只演示「怎么拼」,不属于组件库 */
.sectionHead {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--su-space-4);
}
.headMain {
display: flex;
align-items: center;
gap: var(--su-space-3);
}
.bar {
width: 4px;
height: 16px;
border-radius: 2px;
background-color: var(--su-color-primary);
}
.titleWrap {
position: relative;
display: inline-flex;
}
.title {
font-size: var(--su-font-size-lg);
font-weight: 600;
}
.badge {
position: absolute;
top: -7px;
left: 100%;
margin-left: 4px;
}
.appRow {
display: flex;
align-items: center;
gap: 15px;
min-width: 0;
padding: 6px 0;
border-radius: var(--su-radius-sm);
}
.appRow:hover .appName {
color: var(--su-color-primary);
}
.appInfo {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.appName {
font-size: var(--su-font-size-md);
font-weight: 500;
transition: color var(--su-duration-fast) var(--su-ease-out);
}
.appDesc {
overflow: hidden;
font-size: var(--su-font-size-sm);
color: var(--su-text-muted);
text-overflow: ellipsis;
white-space: nowrap;
}
.posterCard {
overflow: hidden;
}
.posterCover {
display: block;
aspect-ratio: 2 / 3;
overflow: hidden;
background-color: var(--su-gray-2);
}
.posterCover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.posterBody {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--su-space-1);
padding: var(--su-space-2) var(--su-space-3) var(--su-space-3);
}
.posterTitle {
font-size: var(--su-font-size-md);
font-weight: 500;
}
+167
View File
@@ -0,0 +1,167 @@
import {
Avatar,
Badge,
Button,
Card,
Grid,
Icon,
IconButton,
Input,
ItemBadge,
Space,
Tag,
} from '../../src'
import { Demo } from '../components/Demo'
import { Section } from '../components/Section'
import { DEMO_APPS, DEMO_COVER, DEMO_POSTERS } from '../data/demo'
import styles from './Composition.module.css'
/**
* 组合示例:把基础组件拼成真实页面里的几块结构。
*
* 这一节不放新组件,只演示「怎么拼」——
* 后期做新页面时,从这里复制结构再改数据即可。
*/
export function Composition() {
return (
<Section
id="composition"
title="组合示例"
api="页面配方"
desc="基础组件只提供外观与行为,页面结构靠组合。下面三块都是 Stash 控制台里真实存在的结构,可以直接当模板用。"
>
<Demo
title="分区卡片(首页)"
desc="Card 提供容器,标题栏由「色条 + 标题 + 角标 + 更多按钮」拼出来,内容区用 Grid 排应用行。角标压在标题右上角,靠绝对定位。"
muted
layout="column"
code={`<Card>
<header className={styles.sectionHead}>
<Space gap="md">
<span className={styles.bar} />
<ItemBadge tone="sticky">置顶</ItemBadge>
<span className={styles.titleWrap}>
<span className={styles.title}>常用站点</span>
<Badge count={18} className={styles.badge} />
</span>
</Space>
<IconButton label="查看全部" icon={<Icon name="more_3_line" />} />
</header>
<Grid columns={4} gap="md">
{apps.map((app) => (
<a className={styles.appRow} key={app.id} href="#">
<Avatar size="lg" fallback={app.icon} />
<span className={styles.appInfo}>
<span className={styles.appName}>{app.name}</span>
<span className={styles.appDesc}>{app.desc}</span>
</span>
</a>
))}
</Grid>
</Card>`}
>
<Card padding="lg">
<header className={styles.sectionHead}>
<div className={styles.headMain}>
<span className={styles.bar} />
<ItemBadge tone="sticky">置顶</ItemBadge>
<span className={styles.titleWrap}>
<span className={styles.title}>常用站点</span>
<Badge count={18} className={styles.badge} />
</span>
</div>
<IconButton label="查看全部" icon={<Icon name="more_3_line" />} />
</header>
<Grid columns={4} gap="md">
{DEMO_APPS.slice(0, 4).map((app) => (
<a
className={styles.appRow}
key={app.id}
href="#composition"
onClick={(event) => event.preventDefault()}
>
<Avatar size="lg" fallback={app.icon} />
<span className={styles.appInfo}>
<span className={styles.appName}>{app.name}</span>
<span className={styles.appDesc}>{app.desc}</span>
</span>
</a>
))}
</Grid>
</Card>
</Demo>
<Demo
title="筛选工具条"
desc="搜索框 + 条件标签 + 操作按钮:左侧输入,右侧动作,中间用 Space 的 justify=between 撑开。"
muted
layout="column"
code={`<Card padding="sm">
<Space justify="between" wrap>
<Space gap="sm">
<Input icon="search_3_line" placeholder="筛选项目…" />
<Tag tone="neutral">全部 42</Tag>
</Space>
<Space gap="sm">
<Button variant="ghost" size="sm">重置</Button>
<Button size="sm">应用</Button>
</Space>
</Space>
</Card>`}
>
<Card padding="sm">
<Space justify="between" wrap>
<Space gap="sm">
<Input icon="search_3_line" placeholder="筛选项目…" style={{ width: 220 }} />
<Tag tone="neutral">全部 42</Tag>
</Space>
<Space gap="sm">
<Button variant="ghost" size="sm">
重置
</Button>
<Button size="sm">应用</Button>
</Space>
</Space>
</Card>
</Demo>
<Demo
title="海报卡(图文列表)"
desc="Card padding=none 承载贴边封面,图片用 aspect-ratio 固定比例,避免加载前后跳动;标题与标签放卡片底部。"
muted
layout="column"
code={`<Grid columns={4} gap="md">
{posters.map((poster) => (
<Card padding="none" className={styles.posterCard} key={poster.id}>
<span className={styles.posterCover}>
<img src={cover} alt="" />
</span>
<span className={styles.posterBody}>
<span className={styles.posterTitle}>{poster.title}</span>
<Tag shape="pill" tone="neutral">{poster.tag}</Tag>
</span>
</Card>
))}
</Grid>`}
>
<Grid columns={4} gap="md">
{DEMO_POSTERS.map((poster) => (
<Card padding="none" className={styles.posterCard} key={poster.id}>
<span className={styles.posterCover}>
<img src={DEMO_COVER} alt="" />
</span>
<span className={styles.posterBody}>
<span className={styles.posterTitle}>{poster.title}</span>
<Tag shape="pill" tone="neutral">
{poster.tag}
</Tag>
</span>
</Card>
))}
</Grid>
</Demo>
</Section>
)
}
+89
View File
@@ -0,0 +1,89 @@
import { Card, Cover, Space } from '@stash/ui'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
/** 演示用的渐变色块,避免依赖外部图片 */
const swatch = (from: string, to: string) =>
`data:image/svg+xml;utf8,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="600"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${from}"/><stop offset="1" stop-color="${to}"/></linearGradient></defs><rect width="400" height="600" fill="url(#g)"/></svg>`,
)}`
export function Covers() {
return (
<Section
id="cover"
title="封面卡"
api="Cover"
desc="比例封面卡:图片 + 底部压暗层 + 标题。海报、图文、宽图共用一套,避免每个列表各写一份 aspect-ratio 和渐变遮罩。"
>
<Demo
title="三种比例"
desc="poster 2:3(海报)、article 4:3(图文)、wide 16:9(横幅);标题最多两行,超出省略。"
code={`<Cover ratio="poster" src={cover} alt="海报" caption="海报 2:3" />
<Cover ratio="article" src={cover} alt="图文" caption="图文 4:3" />
<Cover ratio="wide" src={cover} alt="横幅" caption="横幅 16:9" />`}
>
<Space gap="md" align="start">
<div style={{ width: 120 }}>
<Cover
ratio="poster"
src={swatch('#7c5cff', '#c04bff')}
alt="海报示例"
caption="海报 2:3"
/>
</div>
<div style={{ width: 180 }}>
<Cover
ratio="article"
src={swatch('#1f6feb', '#4abd86')}
alt="图文示例"
caption="图文 4:3"
/>
</div>
<div style={{ width: 220 }}>
<Cover
ratio="wide"
src={swatch('#ff7a45', '#ffba3a')}
alt="横幅示例"
caption="横幅 16:9"
/>
</div>
</Space>
</Demo>
<Demo
title="贴边 + 可点击"
desc="放在 Card padding='none' 里时传 radius='none';传 onClick 整块变成按钮,支持键盘操作。"
layout="column"
code={`<Card padding="none">
<Cover radius="none" ratio="article" onClick={open} caption="可点击的封面" />
</Card>`}
>
<Card padding="none" style={{ maxWidth: 280 }}>
<Cover
radius="none"
ratio="article"
src={swatch('#3f51b5', '#00bcd4')}
alt="可点击的封面"
caption="可点击的封面"
onClick={() => undefined}
/>
</Card>
</Demo>
<PropsTable
rows={[
{ name: 'ratio', type: "'poster' | 'article' | 'wide' | number", defaultValue: "'article'", desc: '2:3 / 4:3 / 16:9 / 自定义宽高比' },
{ name: 'src', type: 'string', desc: '图片地址,不传显示占位字符' },
{ name: 'alt', type: 'string', required: true, desc: '图片说明,同时作为按钮 title' },
{ name: 'caption', type: 'ReactNode', desc: '底部标题(最多两行)' },
{ name: 'radius', type: "'cover' | 'none'", defaultValue: "'cover'", desc: '8px / 贴边' },
{ name: 'lift', type: 'boolean', defaultValue: 'true', desc: '悬停整块上浮 2px(参考站行为)' },
{ name: 'hoverZoom', type: 'boolean', defaultValue: 'false', desc: '悬停放大图片(与 lift 二选一)' },
{ name: 'onClick', type: '() => void', desc: '传了就把整块变成按钮' },
]}
/>
</Section>
)
}
+89
View File
@@ -0,0 +1,89 @@
import { useState } from 'react'
import { Button, Checkbox, Drawer, Space, Switch, Text } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
export function Drawers() {
const [rightOpen, setRightOpen] = useState(false)
const [leftOpen, setLeftOpen] = useState(false)
const [autoSync, setAutoSync] = useState(true)
const [notify, setNotify] = useState(false)
return (
<Section
id="drawer"
title="抽屉"
api="Drawer"
desc="从侧边滑出的面板:筛选、设置、详情。基于原生 <dialog>(焦点陷阱、Esc、背景 inert),贴边全高,不打断主流程。"
>
<Demo
title="右侧设置"
desc="标题栏带关闭按钮;底部操作区可选。点遮罩或按 Esc 也会关闭。"
code={`const [open, setOpen] = useState(false)
<Button onClick={() => setOpen(true)}>打开设置</Button>
<Drawer open={open} onClose={() => setOpen(false)} title="偏好设置"
footer={<><Button variant="ghost" onClick={() => setOpen(false)}>取消</Button>
<Button onClick={() => setOpen(false)}>保存</Button></>}>
…
</Drawer>`}
>
<Space gap="md" wrap>
<Button onClick={() => setRightOpen(true)}>打开设置</Button>
<Button variant="secondary" onClick={() => setLeftOpen(true)}>
左侧抽屉
</Button>
</Space>
</Demo>
<PropsTable
rows={[
{ name: 'open', type: 'boolean', required: true, desc: '受控开关' },
{ name: 'onClose', type: '() => void', required: true, desc: 'Esc / 遮罩 / 关闭按钮都会调用' },
{ name: 'title', type: 'ReactNode', desc: '标题' },
{ name: 'placement', type: "'right' | 'left'", defaultValue: "'right'", desc: '贴哪一边' },
{ name: 'width', type: 'number', defaultValue: '360', desc: '宽度(px)' },
{ name: 'footer', type: 'ReactNode', desc: '底部操作区' },
]}
/>
<Drawer
open={rightOpen}
onClose={() => setRightOpen(false)}
title="偏好设置"
footer={
<>
<Button variant="ghost" onClick={() => setRightOpen(false)}>
取消
</Button>
<Button onClick={() => setRightOpen(false)}>保存</Button>
</>
}
>
<Space direction="column" gap="lg" align="stretch">
<Space justify="between">
<Text>自动同步</Text>
<Switch checked={autoSync} onChange={setAutoSync} label="自动同步" />
</Space>
<Checkbox checked={notify} onChange={setNotify} label="每周摘要提醒" />
<Text size="sm" tone="muted">
抽屉内容通常是表单或筛选器;需要打断流程的确认请用 Modal。
</Text>
</Space>
</Drawer>
<Drawer
open={leftOpen}
onClose={() => setLeftOpen(false)}
placement="left"
width={300}
title="导航"
>
<Text size="sm" tone="muted">
左侧抽屉常用于移动端导航;桌面端优先用常驻侧栏。
</Text>
</Drawer>
</Section>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { Button, Empty, Notice, Space } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
export function Feedback() {
return (
<Section
id="feedback"
title="提示与空态"
api="Notice / Empty"
desc="页面里最常见的两种「没有内容」:一种是正常的空,一种是出了错。别让它们长得一样。"
>
<Demo
title="空态"
desc="空态只说明情况;带操作的空态能让用户立刻回到有内容的路径上。"
layout="column"
code={`<Empty text="暂无内容" />
<Empty
text="没有匹配「设计」的内容"
action={<Button size="sm" variant="secondary">清空搜索</Button>}
/>`}
>
<Empty text="暂无内容" />
<Empty
text="没有匹配「设计」的内容,换个关键字试试。"
action={
<Space gap="sm">
<Button size="sm" variant="secondary">
清空搜索
</Button>
<Button size="sm" variant="ghost">
查看全部
</Button>
</Space>
}
/>
</Demo>
<Demo
title="说明与错误"
desc="错误态带 role=alert,屏幕阅读器会立刻播报;说明态没有这个角色,避免每次渲染都被念。"
layout="column"
code={`<Notice title="内容库读取失败">…</Notice>
<Notice tone="error" title="连不上 Stash 服务" action={{ label: '重试', onClick }}>…</Notice>`}
>
<Notice title="还没有项目">在托管目录下建一个子目录并放入 index.html,刷新后就会出现在这里。</Notice>
<Notice
tone="error"
title="连不上 Stash 服务"
action={{ label: '重试', onClick: () => window.location.reload() }}
>
Failed to fetch /api/projects
</Notice>
</Demo>
<PropsTable
rows={[
{ name: 'tone', type: "'info' | 'error'", defaultValue: "'info'", desc: '说明 / 错误' },
{ name: 'title', type: 'ReactNode', required: true, desc: '标题行' },
{ name: 'children', type: 'ReactNode', desc: '正文' },
{ name: 'action', type: '{ label: string; onClick: () => void }', desc: '底部操作按钮' },
]}
/>
<PropsTable
rows={[
{ name: 'text', type: 'ReactNode', defaultValue: "'暂无内容'", desc: '空态文字' },
{ name: 'action', type: 'ReactNode', desc: '文字下方的操作区' },
]}
/>
</Section>
)
}
+72
View File
@@ -0,0 +1,72 @@
import { useMemo, useState } from 'react'
import { ICON_NAMES, Icon, Input, Space, type IconName } from '../../src'
import { Demo } from '../components/Demo'
import { Section } from '../components/Section'
export function Icons() {
const [keyword, setKeyword] = useState('')
const [copied, setCopied] = useState<IconName | null>(null)
const visible = useMemo(() => {
const query = keyword.trim().toLowerCase()
return query === '' ? ICON_NAMES : ICON_NAMES.filter((name) => name.includes(query))
}, [keyword])
const copy = (name: IconName) => {
const task = navigator.clipboard?.writeText(name)
task?.catch(() => undefined)
setCopied(name)
window.setTimeout(() => setCopied((current) => (current === name ? null : current)), 1200)
}
return (
<Section
id="icons"
title="图标"
api="Icon"
desc={`参考站 iconfont 的全量字形(${ICON_NAMES.length} 个,字体 52KB)。线性和填充成对出现,命名规则是「名字_风格」:search_line / search_fill、arrow_left_line / arrow_left_fill。点任意一格可以复制图标名。`}
>
<Demo
title="全部字形"
desc="按名字过滤(试试 arrow、fill、user),点击复制 `<Icon name=… />` 里的名字。"
layout="column"
code={`<Icon name="search_3_line" />
<Icon name="arrow_left_line" style={{ fontSize: 20 }} />`}
>
<Space gap="md" wrap>
<Input
icon="search_3_line"
placeholder="过滤图标名,如 arrow / fill / user…"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
clearable
onClear={() => setKeyword('')}
style={{ width: 300 }}
/>
<span className="icon-count">
{visible.length} / {ICON_NAMES.length}
</span>
</Space>
<div className="icon-wall icon-wall--scroll">
{visible.map((name) => (
<button
type="button"
className="icon-cell"
key={name}
title={`复制 ${name}`}
onClick={() => copy(name)}
>
<Icon name={name} />
<code>{copied === name ? '已复制' : name}</code>
</button>
))}
</div>
{visible.length === 0 && (
<p className="icon-empty">没有匹配「{keyword.trim()}」的图标,换个关键词试试。</p>
)}
</Demo>
</Section>
)
}
+86
View File
@@ -0,0 +1,86 @@
import { Grid, Image, Space, Text } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
const svg = (from: string, to: string) =>
`data:image/svg+xml;utf8,${encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="800" height="450"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${from}"/><stop offset="1" stop-color="${to}"/></linearGradient></defs><rect width="800" height="450" fill="url(#g)"/></svg>`,
)}`
export function Images() {
return (
<Section
id="image"
title="图片"
api="Image"
desc="带比例、圆角、加载失败降级的图片。正文配图、相册、缩略图用它;带标题遮罩的封面卡用 Cover。"
>
<Demo
title="比例"
desc="square 1:1、video 16:9、wide 21:9,也可以直接传数字(宽高比),容器宽度自适应。"
layout="column"
code={`<Image ratio="square" src={src} alt="方形" />
<Image ratio="video" src={src} alt="16:9" />
<Image ratio={4 / 3} src={src} alt="4:3" />`}
>
<Grid columns={3} gap="md">
<Image ratio="square" src={svg('#3f51b5', '#00bcd4')} alt="方形示例" radius="md" />
<Image ratio="video" src={svg('#ff7a45', '#ffba3a')} alt="16:9 示例" radius="md" />
<Image ratio={4 / 3} src={svg('#7c5cff', '#c04bff')} alt="4:3 示例" radius="md" />
</Grid>
</Demo>
<Demo
title="填充方式与圆角"
desc="cover 裁切填满(默认),contain 完整显示(多用于截图 / 图表);圆角四档。"
layout="column"
code={`<Image fit="contain" radius="none" src={src} alt="完整显示" />
<Image fit="cover" radius="cover" src={src} alt="裁切" />`}
>
<Grid columns={2} gap="md">
<Image
fit="contain"
radius="none"
ratio="video"
src={svg('#1f6feb', '#4abd86')}
alt="contain 示例"
/>
<Image fit="cover" radius="cover" ratio="video" src={svg('#ff4d4f', '#ff952f')} alt="cover 示例" />
</Grid>
</Demo>
<Demo
title="加载失败"
desc="图片地址失效时退化为占位块,不会出现破图;可传 fallback 换成文案或图标。"
layout="column"
code={`<Image src="/not-exists.png" alt="加载失败" fallback="图" />`}
>
<Space gap="md" align="start">
<div style={{ width: 160 }}>
<Image src="/not-exists.png" alt="加载失败" ratio="square" radius="md" fallback="图" />
</div>
<Text size="sm" tone="muted">
失败后显示 fallback(这里传了「图」)
</Text>
</Space>
</Demo>
<PropsTable
rows={[
{ name: 'src', type: 'string', required: true, desc: '图片地址' },
{ name: 'alt', type: 'string', required: true, desc: '替代文本' },
{
name: 'ratio',
type: "'square' | 'video' | 'wide' | number",
defaultValue: "'video'",
desc: '1:1 / 16:9 / 21:9 / 自定义宽高比',
},
{ name: 'fit', type: "'cover' | 'contain'", defaultValue: "'cover'", desc: '裁切 / 完整显示' },
{ name: 'radius', type: "'none' | 'sm' | 'md' | 'cover'", defaultValue: "'sm'", desc: '0 / 6 / 12 / 8px' },
{ name: 'fallback', type: 'ReactNode', desc: '加载失败时的占位内容' },
]}
/>
</Section>
)
}
+137
View File
@@ -0,0 +1,137 @@
import { useState } from 'react'
import { Button, FormField, Icon, Input, Space, Text } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
export function Inputs() {
const [keyword, setKeyword] = useState('')
const [host, setHost] = useState('127.0.0.1:8080')
const [email, setEmail] = useState('not-an-email')
return (
<Section
id="inputs"
title="输入框"
api="Input"
desc="左侧图标 + 清除按钮 + 快捷键提示的组合式输入。事件语义与原生 input 一致,需要字符串时在使用方包一层。"
>
<Demo
title="带图标与清除"
desc="清空按钮只在有内容且传了 onClear 时出现,避免不可用的空按钮。"
layout="column"
code={`<Input
icon="search_3_line"
placeholder="搜索本地项目…"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
clearable
onClear={() => setKeyword('')}
/>`}
>
<Space gap="md" wrap>
<Input
icon="search_3_line"
placeholder="搜索本地项目…"
value={keyword}
onChange={(event) => setKeyword(event.target.value)}
clearable
onClear={() => setKeyword('')}
style={{ width: 320 }}
/>
<span style={{ alignSelf: 'center', color: 'var(--su-text-muted)', fontSize: 12 }}>
当前值:{keyword === '' ? '(空)' : keyword}
</span>
</Space>
</Demo>
<Demo
title="快捷键提示"
desc="shortcut 只是右侧展示,键盘监听由页面自己挂(例如 Ctrl/Cmd + K 聚焦)。"
layout="column"
code={`<Input icon="search_3_line" shortcut="⌘K" placeholder="搜索…" />
<Input icon="link_line" value={host} onChange={(event) => setHost(event.target.value)} />`}
>
<Input icon="search_3_line" shortcut="⌘K" placeholder="搜索…" style={{ width: 320 }} />
<Input
icon="link_line"
value={host}
onChange={(event) => setHost(event.target.value)}
style={{ width: 320 }}
/>
</Demo>
<Demo
title="禁用与表单行"
desc="输入框与按钮可以并排成一行操作区;禁用态整块降透明度,仍保留可读性。"
layout="column"
code={`<Space gap="sm" wrap>
<Input placeholder="输入项目名…" />
<Button icon={<Icon name="search_3_line" />}>搜索</Button>
</Space>
<Input disabled value="只读" />`}
>
<Space gap="sm" wrap>
<Input placeholder="输入项目名…" style={{ width: 240 }} />
<Button icon={<Icon name="search_3_line" />}>搜索</Button>
</Space>
<Input disabled value="不可编辑" style={{ width: 240 }} />
</Demo>
<Demo
title="尺寸"
desc="32 / 36 / 44 三档;搜索这类主入口用 lg,表单行用 md,表格内筛选用 sm。"
layout="column"
code={`<Input size="sm" placeholder="小号 32" />
<Input size="md" placeholder="默认 36" />
<Input size="lg" placeholder="大号 44(搜索框)" />`}
>
<Input size="sm" placeholder="小号 32" style={{ width: 320 }} />
<Input size="md" placeholder="默认 36" style={{ width: 320 }} />
<Input size="lg" placeholder="大号 44(搜索框)" style={{ width: 320 }} />
</Demo>
<Demo
title="表单字段"
desc="FormField 负责「标签 + 控件 + 提示/错误」的排版;校验逻辑留在页面,错误出现时自动 role=alert。"
layout="column"
code={`<FormField label="邮箱" htmlFor="email" hint="我们只用它发周报" error={error}>
<Input id="email" value={email} onChange={...} />
</FormField>`}
>
<div style={{ width: 320, display: 'flex', flexDirection: 'column', gap: 16 }}>
<FormField label="项目名" htmlFor="project" required hint="显示在侧栏与首页卡片标题">
<Input id="project" placeholder="例如:常用站点" />
</FormField>
<FormField
label="邮箱"
htmlFor="email"
hint="我们只用它发周报"
error={email.includes('@') ? undefined : '邮箱格式不正确'}
>
<Input id="email" value={email} onChange={(event) => setEmail(event.target.value)} />
</FormField>
<Text size="xs" tone="muted">
当前值:{email}
</Text>
</div>
</Demo>
<PropsTable
rows={[
{ name: 'icon', type: 'IconName', desc: '左侧前缀图标' },
{ name: 'size', type: "'sm' | 'md' | 'lg'", defaultValue: "'md'", desc: '32 / 36 / 44px' },
{ name: 'clearable', type: 'boolean', defaultValue: 'false', desc: '有内容时显示清除按钮' },
{ name: 'onClear', type: '() => void', desc: '点击清除;不传则不显示按钮' },
{ name: 'shortcut', type: 'string', desc: '右侧快捷键提示(仅展示)' },
{ name: 'inputRef', type: 'Ref<HTMLInputElement>', desc: '拿到真实 input,用于聚焦' },
{ name: 'className / style', type: 'string / CSSProperties', desc: '作用在外层容器上(宽度、外边距)' },
{ name: '…rest', type: 'InputHTMLAttributes', desc: '透传原生属性,onChange 是原生事件' },
]}
/>
</Section>
)
}
+138
View File
@@ -0,0 +1,138 @@
import { Divider, Grid, Space, Tag } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
import { DEMO_POSTERS } from '../data/demo'
const BLOCKS = [1, 2, 3, 4, 5, 6, 7, 8]
function Block({ index, tall = false }: { index: number; tall?: boolean }) {
return (
<div
style={{
display: 'grid',
placeItems: 'center',
height: tall ? 96 : 56,
borderRadius: 'var(--su-radius-sm)',
background: 'var(--su-gray-2)',
color: 'var(--su-text-muted)',
fontSize: 12,
}}
>
{index}
</div>
)
}
export function Layouts() {
return (
<Section
id="layouts"
title="网格与间距"
api="Grid / Space / Divider"
desc="页面布局只保留三件套:给卡片列表用的 Grid(列数与断点收在组件里),给元素间距用的 Space,以及分隔内容块的 Divider。"
>
<Demo
title="网格列数"
desc="窄屏自动退化:≤1100px 取一半(至少 2 列),≤720px 收成一列;也可以显式指定 tabletColumns / mobileColumns。"
layout="column"
code={`<Grid columns={4} gap="md">…</Grid>
<Grid columns={6} tabletColumns={3} mobileColumns={2} gap="sm">…</Grid>`}
>
<Grid columns={4} gap="md">
{BLOCKS.slice(0, 4).map((index) => (
<Block key={index} index={index} />
))}
</Grid>
<Grid columns={6} tabletColumns={3} mobileColumns={2} gap="sm">
{BLOCKS.map((index) => (
<Block key={index} index={index} />
))}
</Grid>
</Demo>
<Demo
title="列表语义"
desc="传 as='ul' 时用 li 做列表项,屏幕阅读器能读出条目数;纯展示容器用默认的 div。"
layout="column"
code={`<Grid as="ul" role="list" columns={4} gap="md">
{items.map((item) => <li key={item.id}>…</li>)}
</Grid>`}
>
<Grid as="ul" role="list" columns={4} gap="md">
{DEMO_POSTERS.map((poster) => (
<li key={poster.id} style={{ listStyle: 'none' }}>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: 'var(--su-space-2)',
padding: 'var(--su-space-3)',
border: '1px solid var(--su-border)',
borderRadius: 'var(--su-radius-md)',
}}
>
<span style={{ fontWeight: 500 }}>{poster.title}</span>
<Tag shape="pill" tone="neutral">
{poster.tag}
</Tag>
</div>
</li>
))}
</Grid>
</Demo>
<Demo
title="间距"
desc="Space 只是 flex + gap 的封装,方向、间距、对齐都用语义化的档位表达,避免每个页面各写一套像素值。"
layout="column"
code={`<Space gap="md">
<Tag>本地</Tag>
<span>docs</span>
</Space>
<Space direction="column" gap="sm" align="start">…</Space>`}
>
<Space gap="md">
<Tag>本地</Tag>
<span>docs</span>
<Tag tone="success" dot>
已连接
</Tag>
</Space>
<Space direction="column" gap="sm" align="start">
<span style={{ fontWeight: 500 }}>竖向排列</span>
<span style={{ color: 'var(--su-text-muted)' }}>适合标题 + 描述这类两行结构</span>
</Space>
</Demo>
<Demo
title="分隔线"
desc="横向分隔内容块,纵向分隔并排元素;虚线用于「弱分隔」,和实线区分层级。"
layout="column"
code={`<Divider />
<Divider variant="dashed" spacing="sm" />
<Space gap="sm"><span>左</span><Divider orientation="vertical" /><span>右</span></Space>`}
>
<Divider />
<Divider variant="dashed" spacing="sm" />
<Space gap="sm">
<span>左</span>
<Divider orientation="vertical" />
<span>右</span>
</Space>
</Demo>
<PropsTable
rows={[
{ name: 'columns', type: 'number', defaultValue: '4', desc: '宽屏列数' },
{ name: 'tabletColumns', type: 'number', defaultValue: 'columns / 2(至少 2)', desc: '≤1100px 列数' },
{ name: 'mobileColumns', type: 'number', defaultValue: '1', desc: '≤720px 列数' },
{ name: 'gap', type: "'sm' | 'md' | 'lg'", defaultValue: "'md'", desc: '间距档位' },
{ name: 'as', type: "'div' | 'ul'", defaultValue: "'div'", desc: '列表语义' },
]}
/>
</Section>
)
}
+170
View File
@@ -0,0 +1,170 @@
import { Badge, Icon, ItemBadge, Space, Tag } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
export function Marks() {
return (
<Section
id="marks"
title="标记"
api="Tag / ItemBadge / Badge"
desc="三种标记各有分工:Tag 是分类与元信息(浅底胶囊,13px),ItemBadge 是标题前的状态角标(置顶/付费/HOT,渐变,12px),Badge 是纯数字。"
>
<Demo
title="标签:浅底(默认)"
desc="对齐参考站的 .tags-list:24px 高、13px 字、胶囊形、gray-1 浅底 + gray-6 文字。neutral 是默认态。"
code={`<Tag>未分类</Tag>
<Tag tone="primary">前端</Tag>
<Tag tone="success">已连接</Tag>
<Tag tone="warning">待确认</Tag>
<Tag tone="danger">已删除</Tag>`}
>
<Tag>未分类</Tag>
<Tag tone="primary">前端</Tag>
<Tag tone="success">已连接</Tag>
<Tag tone="warning">待确认</Tag>
<Tag tone="danger">已删除</Tag>
</Demo>
<Demo
title="标签:实底与图标"
desc="文章页的分类标签是实底白字;icon 插槽用于放分类小图标(参考站的标签左侧都有图标)。"
code={`<Tag variant="solid" tone="primary">iPhone 17e</Tag>
<Tag variant="solid" tone="danger">苹果新品</Tag>
<Tag variant="solid" tone="success">设计</Tag>
<Tag icon={<Icon name="link_line" />}>链接</Tag>`}
>
<Tag variant="solid" tone="primary">
iPhone 17e
</Tag>
<Tag variant="solid" tone="danger">
苹果新品
</Tag>
<Tag variant="solid" tone="success">
设计
</Tag>
<Tag variant="solid" tone="warning">
付费
</Tag>
<Tag icon={<Icon name="link_line" />}>链接</Tag>
</Demo>
<Demo
title="状态角标"
desc="标题前的「置顶 / 付费 / HOT」:18px 高、12px 加粗白字、4px 圆角、红/橙渐变底,同一行里放在标题左边。"
code={`<ItemBadge tone="sticky">置顶</ItemBadge>
<ItemBadge tone="paid">付费</ItemBadge>
<ItemBadge tone="hot">HOT</ItemBadge>
<ItemBadge tone="neutral">草稿</ItemBadge>`}
>
<Space gap="md" wrap>
<ItemBadge tone="sticky">
置顶
</ItemBadge>
<ItemBadge tone="paid">
付费
</ItemBadge>
<ItemBadge tone="hot">
HOT
</ItemBadge>
<ItemBadge tone="neutral">
草稿
</ItemBadge>
</Space>
</Demo>
<Demo
title="数量角标"
desc="纯数字,18px 高、12px 字;超过 99 显示 99+,需要压住标题右上角时传 className 自己定位。"
code={`<Badge count={18} />
<Badge count={1280} />
<Badge count={7} tone="primary" />
<Badge count={7} tone="neutral" />`}
>
<Badge count={18} />
<Badge count={1280} />
<Badge count={7} tone="primary" />
<Badge count={7} tone="neutral" />
</Demo>
<Demo
title="组合成标题行"
desc="状态角标 + 标题 + 分类标签,是列表条目最常见的一行;间距用 Space,不要给角标加 margin。"
layout="column"
code={`<Space gap="sm">
<ItemBadge tone="paid">付费</ItemBadge>
<strong>iPhone 17e 最全汇总</strong>
</Space>
<Space gap="sm">
<Tag variant="solid" tone="primary">未分类</Tag>
<Tag variant="solid" tone="danger">苹果新品</Tag>
</Space>`}
>
<Space gap="sm">
<ItemBadge tone="paid">
付费
</ItemBadge>
<span style={{ fontSize: 16, fontWeight: 500 }}>iPhone 17e 最全汇总:最便宜的苹果 AI 手机</span>
</Space>
<Space gap="sm">
<Tag variant="solid" tone="primary">
未分类
</Tag>
<Tag variant="solid" tone="danger">
苹果新品
</Tag>
<Tag variant="solid" tone="danger">
闪电接口
</Tag>
</Space>
</Demo>
<Demo
title="组合成顶栏信息"
desc="「标签 + 文本」是顶栏信息条的固定搭配;前缀标签用 rounded 小方块,和分类胶囊区分开。"
code={`<Space gap="md">
<Tag shape="rounded">本地</Tag>
<span>docs</span>
</Space>`}
>
<Space gap="md">
<Tag shape="rounded">本地</Tag>
<span>docs</span>
</Space>
<Space gap="md">
<Tag shape="rounded" tone="success">
状态
</Tag>
<span>实时同步已连接</span>
</Space>
</Demo>
<PropsTable
rows={[
{ name: 'tone', type: "'neutral' | 'primary' | 'success' | 'warning' | 'danger'", defaultValue: "'neutral'", desc: '语义配色' },
{ name: 'variant', type: "'soft' | 'solid'", defaultValue: "'soft'", desc: '浅底 / 实底白字' },
{ name: 'shape', type: "'pill' | 'rounded'", defaultValue: "'pill'", desc: '胶囊 / 小方块' },
{ name: 'icon', type: 'ReactNode', desc: '前置图标' },
{ name: '…rest', type: 'HTMLAttributes<HTMLSpanElement>', desc: '透传原生属性' },
]}
/>
<PropsTable
rows={[
{ name: 'tone', type: "'hot' | 'sticky' | 'paid' | 'neutral'", defaultValue: "'hot'", desc: '红渐变 / 红渐变 / 橙渐变 / 灰底' },
{ name: 'children', type: 'ReactNode', required: true, desc: '角标文字' },
]}
/>
<PropsTable
rows={[
{ name: 'count', type: 'number', required: true, desc: '数字' },
{ name: 'tone', type: "'danger' | 'primary' | 'neutral'", defaultValue: "'danger'", desc: '配色' },
{ name: 'max', type: 'number', defaultValue: '99', desc: '超过显示 N+;0 表示不截断' },
]}
/>
</Section>
)
}
+93
View File
@@ -0,0 +1,93 @@
import { useState } from 'react'
import { Card, Icon, Menu, Text } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
const ITEMS = [
{
key: 'content',
label: '内容',
icon: <Icon name="folder_more_line" />,
children: [
{ key: 'recommend', label: '常用推荐' },
{ key: 'movie', label: '推荐电影' },
{ key: 'ces', label: 'CES 现场报道' },
],
},
{
key: 'system',
label: '系统',
icon: <Icon name="settings_1_line" />,
children: [
{ key: 'profile', label: '资料' },
{ key: 'data', label: '内容库' },
],
},
{ key: 'about', label: '关于', icon: <Icon name="question_line" /> },
]
export function MenuSection() {
const [active, setActive] = useState('recommend')
return (
<Section
id="menu"
title="菜单"
api="Menu"
desc="侧栏菜单:一层折叠 + 两级结构,数据无关——业务树由页面转成 items。含 activeKey 的父级会自动展开。"
>
<Demo
title="基础用法"
desc="父级是折叠按钮(aria-expanded),子项是导航项(aria-current=page)。"
code={`const [active, setActive] = useState('recommend')
<Menu items={ITEMS} activeKey={active} onSelect={setActive} />`}
>
<Card padding="sm" style={{ width: 240 }}>
<Menu items={ITEMS} activeKey={active} onSelect={setActive} />
</Card>
<Text size="sm" tone="muted">
当前:{active}
</Text>
</Demo>
<Demo
title="默认展开"
desc="defaultOpenKeys 指定初始展开的父级;用户手动折叠后以用户操作为准。"
code={`<Menu items={ITEMS} activeKey="data" onSelect={() => {}} defaultOpenKeys={['system']} />`}
>
<Card padding="sm" style={{ width: 240 }}>
<Menu items={ITEMS} activeKey="data" onSelect={() => undefined} defaultOpenKeys={['system']} />
</Card>
</Demo>
<Demo
title="行高"
desc="默认 36px;需要更松的密度(如资料较多、点击目标更大)时传 itemHeight。"
layout="column"
code={`<Menu items={ITEMS} activeKey="data" onSelect={() => {}} itemHeight={44} />`}
>
<Card padding="sm" style={{ width: 240 }}>
<Menu
items={ITEMS}
activeKey="data"
onSelect={() => undefined}
defaultOpenKeys={['system']}
itemHeight={44}
/>
</Card>
</Demo>
<PropsTable
rows={[
{ name: 'items', type: 'MenuItem[]', required: true, desc: '{ key, label, icon?, children? }' },
{ name: 'activeKey', type: 'string', desc: '当前选中项' },
{ name: 'onSelect', type: '(key: string) => void', required: true, desc: '选中回调' },
{ name: 'defaultOpenKeys', type: 'string[]', desc: '初始展开的父级 key' },
{ name: 'itemHeight', type: 'number', defaultValue: '36', desc: '一级菜单行高(px)' },
]}
/>
</Section>
)
}
+120
View File
@@ -0,0 +1,120 @@
import { useState } from 'react'
import { Button, Modal, Notice, Space } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
export function Modals() {
const [confirmOpen, setConfirmOpen] = useState(false)
const [infoOpen, setInfoOpen] = useState(false)
const [deleted, setDeleted] = useState(false)
return (
<Section
id="modal"
title="弹窗"
api="Modal"
desc="基于原生 <dialog>:焦点陷阱、Esc 关闭、背景 inert 由浏览器提供,组件补样式、滚动锁和受控开关。对应参考站的 .nice-tips(遮罩 60% 黑、内容 12px 圆角、上滑淡入)。"
>
<Demo
title="确认弹窗"
desc="危险操作要二次确认;确认后由调用方关闭并把结果反馈给页面(这里用 Notice 示意)。"
layout="column"
code={`const [open, setOpen] = useState(false)
<Modal
open={open}
onClose={() => setOpen(false)}
title="删除这篇内容?"
footer={
<>
<Button variant="ghost" onClick={() => setOpen(false)}>取消</Button>
<Button variant="danger" onClick={handleDelete}>删除</Button>
</>
}
>
删除后不可恢复,占位数据会被一起清掉。
</Modal>`}
>
<Space gap="md" wrap>
<Button variant="danger" onClick={() => setConfirmOpen(true)}>
删除内容
</Button>
<Button variant="secondary" onClick={() => setInfoOpen(true)}>
打开说明弹窗
</Button>
</Space>
{deleted && (
<Notice title="示例:已删除" action={{ label: '撤销', onClick: () => setDeleted(false) }}>
这里只是演示确认流程,没有真的删数据。
</Notice>
)}
</Demo>
<Demo
title="尺寸与长内容"
desc="sm 300 / md 460 / lg 640;内容多时弹窗内部不滚动、由页面决定(这里演示 lg 的排版)。"
layout="column"
code={`<Modal size="lg" title="关于这套组件" open={open} onClose={close}>…</Modal>`}
>
<Button variant="secondary" onClick={() => setInfoOpen(true)}>
打开 lg 弹窗
</Button>
</Demo>
<PropsTable
rows={[
{ name: 'open', type: 'boolean', required: true, desc: '受控开关' },
{ name: 'onClose', type: '() => void', required: true, desc: 'Esc / 遮罩 / 关闭按钮都会调用' },
{ name: 'title', type: 'ReactNode', required: true, desc: '标题,同时作为 aria-labelledby' },
{ name: 'children', type: 'ReactNode', desc: '正文' },
{ name: 'footer', type: 'ReactNode', desc: '底部操作区(通常放按钮)' },
{ name: 'size', type: "'sm' | 'md' | 'lg'", defaultValue: "'md'", desc: '300 / 460 / 640' },
{ name: 'closeOnOverlay', type: 'boolean', defaultValue: 'true', desc: '点遮罩是否关闭' },
]}
/>
<Modal
open={confirmOpen}
onClose={() => setConfirmOpen(false)}
title="删除这篇内容?"
footer={
<>
<Button variant="ghost" onClick={() => setConfirmOpen(false)}>
取消
</Button>
<Button
variant="danger"
onClick={() => {
setConfirmOpen(false)
setDeleted(true)
}}
>
删除
</Button>
</>
}
>
删除后不可恢复,占位数据会被一起清掉。
</Modal>
<Modal
open={infoOpen}
onClose={() => setInfoOpen(false)}
title="关于这套组件"
size="lg"
footer={
<Button onClick={() => setInfoOpen(false)}>知道了</Button>
}
>
<p>
组件只依赖令牌,不依赖任何业务数据;页面用 Card / Grid / Space 组合,弹窗只负责「打断」。
</p>
<p style={{ marginTop: 12 }}>
关闭方式有三种:右上角按钮、Esc、点击遮罩。打开时背景滚动会被锁住,关闭后恢复。
</p>
</Modal>
</Section>
)
}
+185
View File
@@ -0,0 +1,185 @@
import { Section } from '../components/Section'
/** 令牌总览:所有组件只消费这些变量,换主题即改这一处 */
const SEMANTIC_COLORS = [
{ name: '--su-color-primary', value: '#3858f6', use: '主色:链接、选中、色条' },
{ name: '--su-color-primary-soft', value: 'rgba(56,88,246,.1)', use: '主色浅底' },
{ name: '--su-color-danger', value: '#ff3e3e', use: '错误、数量角标' },
{ name: '--su-color-warning', value: '#ffba3a', use: '提醒、强调' },
{ name: '--su-color-success', value: '#4abd86', use: '在线、成功' },
{ name: '--su-bg-card', value: '#ffffff', use: '卡片、输入框底色' },
]
const GRAYS = [
{ name: '--su-gray-1', value: '#f7f8fa' },
{ name: '--su-gray-2', value: '#f6f6f6' },
{ name: '--su-gray-3', value: '#eeeeee' },
{ name: '--su-gray-4', value: '#c9cdd4' },
{ name: '--su-gray-5', value: '#86909c' },
{ name: '--su-gray-6', value: '#4e5969' },
{ name: '--su-gray-7', value: '#2c343c' },
{ name: '--su-gray-8', value: '#1d2129' },
]
const RADII = [
{ name: '--su-radius-xs', value: '4px', use: '标签、角标' },
{ name: '--su-radius-sm', value: '6px', use: '按钮、输入框' },
{ name: '--su-radius-md', value: '12px', use: '卡片、面板' },
{ name: '--su-radius-pill', value: '999px', use: '胶囊、头像' },
]
const TYPE_SCALE = [
{ token: 'xl', size: '24 / 32', cls: 'type-sample-xl', sample: '页面大标题' },
{ token: 'lg', size: '16 / 24', cls: 'type-sample-lg', sample: '分区标题、条目标题' },
{ token: 'md', size: '14 / 20', cls: 'type-sample-md', sample: '正文、按钮、导航' },
{ token: 'sm', size: '12 / 16', cls: 'type-sample-sm', sample: '描述、元信息、标签' },
]
const SPACES = [
{ name: '--su-space-1', value: 4 },
{ name: '--su-space-2', value: 8 },
{ name: '--su-space-3', value: 12 },
{ name: '--su-space-4', value: 16 },
{ name: '--su-space-5', value: 20 },
{ name: '--su-space-6', value: 24 },
{ name: '--su-space-8', value: 32 },
]
export function Overview() {
return (
<Section
id="overview"
title="设计令牌"
api="src/theme/tokens.css"
desc="组件的颜色、间距、字号、圆角、动效全部来自 CSS 变量,前缀统一是 --su-。换主题只需要覆盖同名变量,不用改组件。"
>
<div className="demo">
<h3 className="demo-title">语义色</h3>
<div className="token-grid">
{SEMANTIC_COLORS.map((color) => (
<div className="token-swatch" key={color.name}>
<div
className="token-swatch-color"
style={{ backgroundColor: `var(${color.name})` }}
/>
<div className="token-swatch-meta">
<code className="token-swatch-name">{color.name}</code>
<span className="token-swatch-value">{color.value}</span>
<span className="token-swatch-value">{color.use}</span>
</div>
</div>
))}
</div>
</div>
<div className="demo">
<h3 className="demo-title">中性色</h3>
<div className="token-grid">
{GRAYS.map((gray) => (
<div className="token-swatch" key={gray.name}>
<div className="token-swatch-color" style={{ backgroundColor: `var(${gray.name})` }} />
<div className="token-swatch-meta">
<code className="token-swatch-name">{gray.name}</code>
<span className="token-swatch-value">{gray.value}</span>
</div>
</div>
))}
</div>
</div>
<div className="demo">
<h3 className="demo-title">圆角与阴影</h3>
<div className="token-list">
{RADII.map((radius) => (
<div className="token-row" key={radius.name}>
<code className="token-row-label">{radius.name}</code>
<div className="token-row-demo">
<span className="radius-sample" style={{ borderRadius: `var(${radius.name})` }} />
<span className="token-swatch-value">{radius.use}</span>
</div>
<span className="token-row-note">{radius.value}</span>
</div>
))}
<div className="token-row">
<code className="token-row-label">--su-shadow-card</code>
<div className="token-row-demo">
<span className="shadow-sample" style={{ boxShadow: 'var(--su-shadow-card)' }}>
常态
</span>
<span className="shadow-sample" style={{ boxShadow: 'var(--su-shadow-hover)' }}>
悬停
</span>
</div>
<span className="token-row-note">两层,都很克制</span>
</div>
</div>
</div>
<div className="demo">
<h3 className="demo-title">字号阶梯</h3>
<div className="token-list">
{TYPE_SCALE.map((type) => (
<div className="token-row" key={type.token}>
<code className="token-row-label">{`--su-font-size-${type.token}`}</code>
<div className="token-row-demo">
<span className={type.cls}>{type.sample}</span>
</div>
<span className="token-row-note">{type.size} px</span>
</div>
))}
<div className="token-row">
<code className="token-row-label">--su-font-weight-bold</code>
<div className="token-row-demo">
<span className="type-sample-md" style={{ fontWeight: 500 }}>
主题的「加粗」是 500,不是 700
</span>
</div>
<span className="token-row-note">500</span>
</div>
</div>
</div>
<div className="demo">
<h3 className="demo-title">间距</h3>
<div className="token-list">
{SPACES.map((space) => (
<div className="token-row" key={space.name}>
<code className="token-row-label">{space.name}</code>
<div className="token-row-demo">
<span className="token-bar" style={{ width: `${space.value * 4}px` }} />
</div>
<span className="token-row-note">{space.value}px</span>
</div>
))}
</div>
</div>
<div className="demo">
<h3 className="demo-title">动效</h3>
<div className="token-list">
<div className="token-row">
<code className="token-row-label">--su-duration-fast</code>
<div className="token-row-demo">
<span className="token-swatch-value">0.2s:颜色、背景变化</span>
</div>
</div>
<div className="token-row">
<code className="token-row-label">--su-duration-base</code>
<div className="token-row-demo">
<span className="token-swatch-value">0.3s:展开收起、淡入淡出</span>
</div>
</div>
</div>
</div>
<pre className="demo-code">
<code>{`/* 使用方覆盖主题:只需重写变量 */
:root[data-theme='dark'] {
--su-bg-page: #17171a;
--su-bg-card: #232324;
--su-text-main: #f2f3f5;
}`}</code>
</pre>
</Section>
)
}
+244
View File
@@ -0,0 +1,244 @@
import { useState } from 'react'
import { Card, Grid, Layout, Segmented, Space, Tag, Text, Title } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
/** 文档里的迷你页面内容:只为了把 Layout 的结构摆出来 */
function DemoHeader() {
return (
<div className="layout-bar">
<span className="layout-brand">STASH</span>
<span className="layout-bar-actions">
<span className="layout-nav-item is-active">工作台</span>
<span className="layout-nav-item">常用推荐</span>
<span className="layout-nav-item">设置</span>
</span>
</div>
)
}
function DemoSider({ compact = false }: { compact?: boolean }) {
return (
<div className="layout-side">
<span className="layout-nav-item is-active">常用站点</span>
<span className="layout-nav-item">前端工具</span>
{!compact && <span className="layout-nav-item">设计资源</span>}
{!compact && <span className="layout-nav-item">推荐电影</span>}
</div>
)
}
function DemoAside() {
return (
<div className="layout-aside">
<Text size="sm" strong>
目录
</Text>
<span className="layout-aside-item is-active">一、内容库结构</span>
<span className="layout-aside-item">二、令牌与主题</span>
<span className="layout-aside-item">三、暗色模式</span>
<Text size="xs" tone="muted">
共 3 节
</Text>
</div>
)
}
function DemoArticle() {
return (
<div className="layout-content">
<Title level={3}>内容库结构</Title>
<Text size="sm" tone="sub">
分区 → 分组 → 条目三层;侧栏由内容树派生,首页卡片由 section_block 决定。
</Text>
<Space gap="sm">
<Tag>设计系统</Tag>
<Tag tone="success">已发布</Tag>
</Space>
</div>
)
}
function DemoCards() {
return (
<div className="layout-content">
<Title level={3}>常用站点</Title>
<Text size="sm" tone="sub">
内容区放任何组件:卡片、列表、表格。Layout 只负责它坐在哪里。
</Text>
<Grid columns={2} gap="sm">
<Card padding="md">
<Text strong>GitHub</Text>
</Card>
<Card padding="md">
<Text strong>Figma</Text>
</Card>
</Grid>
</div>
)
}
const RECIPES = [
{ value: 'top', label: '顶栏 + 内容' },
{ value: 'sider', label: '左栏 + 内容' },
{ value: 'aside', label: '内容 + 右栏' },
{ value: 'three', label: '三栏' },
{ value: 'narrow', label: '窄内容' },
] as const
type RecipeValue = (typeof RECIPES)[number]['value']
const CODE: Record<RecipeValue, string> = {
top: `<Layout header={<Header />} footer={<Footer />}>
<Grid columns={2}>…</Grid>
</Layout>`,
sider: `<Layout header={<Header />} sider={<Nav />} siderWidth={180}>
<Title>常用站点</Title>
</Layout>`,
aside: `<Layout header={<Header />} aside={<Toc />} maxWidth={960}>
<Article />
</Layout>`,
three: `<Layout
header={<Header />}
sider={<Nav />}
aside={<Toc />}
siderWidth={160}
asideWidth={200}
>
<Content />
</Layout>`,
narrow: `<Layout header={<Header />} maxWidth={640}>
<Settings />
</Layout>`,
}
export function PageLayout() {
const [recipe, setRecipe] = useState<RecipeValue>('three')
return (
<Section
id="layout"
title="页面骨架"
api="Layout"
desc="页头 / 侧栏 / 内容 / 右栏 / 页脚的外壳:只做结构(区域、最大宽度、吸顶页头),不写业务内容。下面五个是实际项目里最常用的配方。响应式:≤1200px 右栏堆到内容下方,≤1100px 侧栏堆到内容上方,内容都不隐藏。"
>
<Demo
title="五种常用布局"
desc="切换配方看结构差异:顶栏+内容(首页)、左栏+内容(管理页)、内容+右栏(文章)、三栏(工作台)、窄内容(设置/阅读)。"
layout="column"
code={CODE[recipe]}
>
<Segmented options={[...RECIPES]} value={recipe} onChange={(value) => setRecipe(value as RecipeValue)} />
<div className="layout-frame" style={{ height: 440 }}>
<Layout
header={<DemoHeader />}
sider={recipe === 'sider' || recipe === 'three' ? <DemoSider compact /> : undefined}
aside={recipe === 'aside' || recipe === 'three' ? <DemoAside /> : undefined}
footer={<span>© 2026 Stash</span>}
siderWidth={recipe === 'three' ? 150 : 180}
asideWidth={190}
maxWidth={recipe === 'narrow' ? 560 : recipe === 'aside' ? 900 : 1280}
style={{ minHeight: 420 }}
>
{recipe === 'aside' || recipe === 'three' ? <DemoArticle /> : null}
{recipe === 'top' || recipe === 'sider' ? <DemoCards /> : null}
{recipe === 'narrow' ? (
<div className="layout-content">
<Title level={4}>设置</Title>
<Text size="sm" tone="sub">
单栏 + 560 最大宽度,阅读型页面常用这个组合。
</Text>
<Card padding="md">
<Text size="sm">自动同步</Text>
</Card>
<Card padding="md">
<Text size="sm">每周摘要提醒</Text>
</Card>
</div>
) : null}
</Layout>
</div>
</Demo>
<Demo
title="页头可关闭吸顶"
desc="长表单页可以把 stickyHeader 关掉,避免页头一直挡住内容。"
layout="column"
code={`<Layout header={<Header />} stickyHeader={false} maxWidth={720}>
<Form />
</Layout>`}
>
<div className="layout-frame" style={{ height: 300 }}>
<Layout header={<DemoHeader />} maxWidth={720} stickyHeader={false} style={{ minHeight: 280 }}>
<div className="layout-content">
<Title level={4}>新建条目</Title>
<Text size="sm" tone="sub">
stickyHeader=false:页头随内容滚走,适合长表单 / 长文阅读。
</Text>
</div>
</Layout>
</div>
</Demo>
<Demo
title="整屏 + 内部滚动"
desc="height='screen' 锁视口、页头与侧栏固定;scrollable 让内容区自己滚,hideScrollbar 隐藏滚动条(应用型界面)。"
layout="column"
code={`<Layout height="screen" scrollable hideScrollbar contentId="demo-scroll"
header={<Header />} sider={<Nav />} style={{ height: 320 }}>
<LongContent />
</Layout>`}
>
<div className="layout-frame" style={{ height: 320, overflow: 'hidden' }}>
<Layout
header={<DemoHeader />}
sider={<DemoSider compact />}
headerHeight={56}
height="screen"
scrollable
hideScrollbar
contentId="demo-scroll"
style={{ height: 320 }}
>
<div className="layout-content">
{Array.from({ length: 12 }, (_, index) => (
<Card padding="md" key={index}>
<Text size="sm">第 {index + 1} 行:只有内容区滚动,页头和侧栏保持不动。</Text>
</Card>
))}
</div>
</Layout>
</div>
</Demo>
<PropsTable
rows={[
{ name: 'header', type: 'ReactNode', desc: '页头,默认吸顶' },
{ name: 'sider', type: 'ReactNode', desc: '左侧栏,固定宽度' },
{ name: 'aside', type: 'ReactNode', desc: '右侧栏,固定宽度(目录 / 聚合 / 广告位)' },
{ name: 'footer', type: 'ReactNode', desc: '页脚' },
{ name: 'children', type: 'ReactNode', required: true, desc: '内容区' },
{ name: 'maxWidth', type: "number | 'full'", defaultValue: '1280', desc: '内容最大宽度' },
{ name: 'siderWidth', type: 'number', defaultValue: '260', desc: '左侧栏宽度' },
{ name: 'asideWidth', type: 'number', defaultValue: '300', desc: '右侧栏宽度' },
{ name: 'stickyHeader', type: 'boolean', defaultValue: 'true', desc: '页头是否吸顶' },
{ name: 'contained', type: 'boolean', defaultValue: 'false', desc: '页身套居中容器(页头仍通栏)' },
{ name: 'gap', type: 'number', defaultValue: '0', desc: '侧栏 / 内容 / 右栏间距(px)' },
{ name: 'contentPadding', type: "'none' | 'sm' | 'md' | 'lg'", defaultValue: "'md'", desc: '内容留白 0 / 12 / 20 / 24' },
{ name: 'contentOffset', type: 'number', defaultValue: '0', desc: '内容区顶部偏移(给悬浮搜索留位)' },
{ name: 'gutter', type: 'number', defaultValue: '0', desc: 'contained 容器的左右留白' },
{ name: 'headerHeight', type: 'number', defaultValue: '60', desc: '页头最小高度' },
{ name: 'headerVariant', type: "'blur' | 'solid'", defaultValue: "'blur'", desc: '半透明模糊 / 实底 + 边框' },
{ name: 'siderBordered', type: 'boolean', defaultValue: 'true', desc: '侧栏右侧分隔线' },
{ name: 'height', type: "'auto' | 'screen'", defaultValue: "'auto'", desc: 'screen 锁视口' },
{ name: 'scrollable', type: 'boolean', defaultValue: 'false', desc: '内容区内部滚动' },
{ name: 'hideScrollbar', type: 'boolean', defaultValue: 'false', desc: '内部滚动条隐藏' },
{ name: 'contentId', type: 'string', desc: '内容区 id(ScrollTop 用)' },
]}
/>
</Section>
)
}
+73
View File
@@ -0,0 +1,73 @@
import { useState } from 'react'
import { Card, Pagination, Space } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
export function Paginations() {
const [page, setPage] = useState(1)
const [longPage, setLongPage] = useState(6)
return (
<Section
id="pagination"
title="分页"
api="Pagination"
desc="对齐参考站 .page-numbers:32×32 方块、4px 圆角、当前页主色实底白字;页数多时自动用省略号收起。"
>
<Demo
title="基础用法"
desc="总数 + 每页条数算出页数;上一页/下一页在边界自动禁用。"
layout="column"
code={`const [page, setPage] = useState(1)
<Pagination page={page} total={42} pageSize={10} onChange={setPage} />`}
>
<Space justify="between" wrap>
<span style={{ color: 'var(--su-text-muted)', fontSize: 13 }}>
第 {page} 页 / 共 5 页
</span>
<Pagination page={page} total={42} pageSize={10} onChange={setPage} />
</Space>
</Demo>
<Demo
title="页数很多"
desc="当前页左右各留 1 个页码,首尾固定,其余折叠成省略号。"
layout="column"
code={`<Pagination page={page} total={200} pageSize={10} onChange={setPage} />`}
>
<Pagination page={longPage} total={200} pageSize={10} onChange={setLongPage} />
</Demo>
<Demo
title="放进卡片底部"
desc="分页通常和「共 N 条」一起出现,用 Space justify=between 撑开即可。"
layout="column"
code={`<Card padding="sm">
<Space justify="between">
<span>共 16 条内容</span>
<Pagination page={page} total={16} pageSize={6} onChange={setPage} />
</Space>
</Card>`}
>
<Card padding="sm">
<Space justify="between" wrap>
<span style={{ color: 'var(--su-text-muted)', fontSize: 13 }}>共 16 条内容</span>
<Pagination page={Math.min(page, 3)} total={16} pageSize={6} onChange={setPage} />
</Space>
</Card>
</Demo>
<PropsTable
rows={[
{ name: 'page', type: 'number', required: true, desc: '当前页,从 1 开始' },
{ name: 'total', type: 'number', required: true, desc: '总条数' },
{ name: 'pageSize', type: 'number', defaultValue: '10', desc: '每页条数' },
{ name: 'onChange', type: '(page: number) => void', required: true, desc: '翻页回调' },
{ name: 'siblings', type: 'number', defaultValue: '1', desc: '当前页左右各显示几个页码' },
]}
/>
</Section>
)
}
+57
View File
@@ -0,0 +1,57 @@
import { Progress, Space, Text } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
export function Progresses() {
return (
<Section
id="progress"
title="进度"
api="Progress"
desc="纯展示的进度条:任务进度、存储占用、导入比例。百分比由调用方给,组件只负责夹在 0–100 之间。"
>
<Demo
title="颜色与尺寸"
desc="tone 表达语义:主色(进行中)、成功(完成)、警示、危险(超限)。sm 4px 用于行内,md 8px 用于卡片。"
layout="column"
code={`<Progress percent={32} />
<Progress percent={64} tone="success" />
<Progress percent={88} tone="warning" size="sm" />`}
>
<Progress percent={32} />
<Progress percent={64} tone="success" />
<Progress percent={88} tone="warning" size="sm" />
<Progress percent={100} tone="danger" />
</Demo>
<Demo
title="带百分比文案"
desc="showLabel 在右侧补百分比;行内场景可以和说明文字用 Space 排成一行。"
layout="column"
code={`<Progress percent={72} showLabel />
<Space gap="sm" style={{ width: '100%' }}>
<Text size="sm" tone="sub">存储占用</Text>
<Progress percent={45} size="sm" />
</Space>`}
>
<Progress percent={72} showLabel />
<Space gap="sm">
<Text size="sm" tone="sub">
存储占用
</Text>
<Progress percent={45} size="sm" />
</Space>
</Demo>
<PropsTable
rows={[
{ name: 'percent', type: 'number', required: true, desc: '0–100,超出会被夹住' },
{ name: 'size', type: "'sm' | 'md'", defaultValue: "'md'", desc: '4 / 8px' },
{ name: 'tone', type: "'primary' | 'success' | 'warning' | 'danger'", defaultValue: "'primary'", desc: '语义配色' },
{ name: 'showLabel', type: 'boolean', defaultValue: 'false', desc: '右侧显示百分比' },
]}
/>
</Section>
)
}
+58
View File
@@ -0,0 +1,58 @@
import { useState } from 'react'
import { RadioGroup, Text } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
const OPTIONS = [
{ value: 'grid', label: '网格视图' },
{ value: 'list', label: '列表视图' },
{ value: 'compact', label: '紧凑视图(暂不可用)', disabled: true },
]
export function RadioGroups() {
const [view, setView] = useState('grid')
return (
<Section
id="radio"
title="单选"
api="RadioGroup"
desc="互斥选项组:原生 radio,键盘方向键在组内切换。单个「开/关」用 Switch,多选用 Checkbox。"
>
<Demo
title="基础用法"
desc="纵向排列适合设置项;横向排列适合 2–3 个短选项。"
layout="column"
code={`const [view, setView] = useState('grid')
<RadioGroup value={view} onChange={setView} options={OPTIONS} />
<RadioGroup value={view} onChange={setView} options={OPTIONS} direction="row" />`}
>
<RadioGroup value={view} onChange={setView} options={OPTIONS} />
<RadioGroup value={view} onChange={setView} options={OPTIONS} direction="row" />
</Demo>
<Demo
title="禁用"
desc="整组禁用时,所有选项都不可选;单个选项禁用只影响它自己。"
code={`<RadioGroup value="grid" onChange={() => {}} options={OPTIONS} disabled />`}
>
<RadioGroup value="grid" onChange={() => undefined} options={OPTIONS} disabled />
<Text size="sm" tone="muted">
整组禁用后仍能读出当前选中项
</Text>
</Demo>
<PropsTable
rows={[
{ name: 'options', type: 'RadioOption[]', required: true, desc: '{ value, label, disabled? }' },
{ name: 'value', type: 'string', required: true, desc: '当前选中值' },
{ name: 'onChange', type: '(value: string) => void', required: true, desc: '选择回调' },
{ name: 'direction', type: "'row' | 'column'", defaultValue: "'column'", desc: '排列方向' },
{ name: 'disabled', type: 'boolean', defaultValue: 'false', desc: '禁用整组' },
]}
/>
</Section>
)
}
+60
View File
@@ -0,0 +1,60 @@
import { Button, Result, Space } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
export function Results() {
return (
<Section
id="result"
title="结果"
api="Result"
desc="整块的完成 / 失败反馈:提交完成、权限不足、出错。行内提示用 Notice,轻量反馈用 toast,整块状态才用 Result。"
>
<Demo
title="四种状态"
desc="success / error / warning / info 只影响图标与颜色,正文结构一致。"
layout="column"
code={`<Result status="success" title="已发布" description="内容已同步到内容库。" />`}
>
<Space direction="column" gap="lg" align="stretch">
<Result status="success" title="已发布" description="内容已同步到内容库,5 秒内会推送到首页。" />
<Result status="error" title="保存失败" description="内容库结构版本不匹配,请删除数据库后重启。" />
</Space>
</Demo>
<Demo
title="带操作"
desc="actions 放按钮组;按钮顺序与弹窗一致——次要操作在左、主操作在右。"
layout="column"
code={`<Result
status="warning"
title="还没有内容"
description="建一个分区,再往里加条目。"
actions={<><Button variant="ghost">看文档</Button><Button>新建分区</Button></>}
/>`}
>
<Result
status="warning"
title="还没有内容"
description="先在内容库里建一个分区,再往分区里加条目。"
actions={
<>
<Button variant="ghost">看文档</Button>
<Button>新建分区</Button>
</>
}
/>
</Demo>
<PropsTable
rows={[
{ name: 'status', type: "'success' | 'error' | 'warning' | 'info'", defaultValue: "'info'", desc: '状态' },
{ name: 'title', type: 'ReactNode', required: true, desc: '标题' },
{ name: 'description', type: 'ReactNode', desc: '说明文字' },
{ name: 'actions', type: 'ReactNode', desc: '底部操作区' },
]}
/>
</Section>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { useState } from 'react'
import { ScrollTop } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
export function ScrollTopSection() {
const [visible, setVisible] = useState(false)
return (
<Section
id="scrolltop"
title="回到顶部"
api="ScrollTop"
desc="固定在视口右侧中部的圆形按钮,可见性由页面根据滚动位置控制。真实位置在页面右侧,不在下面的预览区里。"
>
<Demo
title="显隐控制"
desc="桌面端滚的是内容容器(传 targetId),窄屏滚的是窗口(不传);两种都支持,避免滚错了不生效。"
code={`<ScrollTop visible={scrolled} targetId="content-scroll" />`}
>
<button
type="button"
onClick={() => setVisible((current) => !current)}
style={{
padding: '6px 14px',
borderRadius: 6,
border: '1px solid var(--su-border-strong)',
background: 'var(--su-bg-card)',
cursor: 'pointer',
}}
>
{visible ? '隐藏' : '显示'}回到顶部按钮
</button>
<span style={{ alignSelf: 'center', color: 'var(--su-text-muted)', fontSize: 12 }}>
点左侧按钮,右下角会淡入 / 淡出
</span>
</Demo>
<ScrollTop visible={visible} />
<PropsTable
rows={[
{ name: 'visible', type: 'boolean', required: true, desc: '是否显示' },
{ name: 'targetId', type: 'string', desc: '滚动容器 id;不传则滚窗口' },
{ name: 'label', type: 'string', defaultValue: "'回到顶部'", desc: '无障碍名称与 title' },
{ name: 'offset', type: 'number', defaultValue: '24', desc: '距右侧距离(px)' },
]}
/>
</Section>
)
}
+57
View File
@@ -0,0 +1,57 @@
import { useState } from 'react'
import { Icon, Segmented, Text } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
const OPTIONS = [
{ value: 'grid', label: '网格', icon: <Icon name="grid_line" /> },
{ value: 'list', label: '列表', icon: <Icon name="menu_line" /> },
]
export function Segmenteds() {
const [view, setView] = useState('grid')
return (
<Section
id="segmented"
title="分段"
api="Segmented"
desc="在几个视图之间切换(网格/列表、全部/收藏)。只切展示形态,不切内容分区——后者用 Tabs。"
>
<Demo
title="基础用法"
desc="选中项白底 + 轻阴影,容器浅灰底;选项可以带图标。"
code={`const [view, setView] = useState('grid')
<Segmented options={OPTIONS} value={view} onChange={setView} />`}
>
<Segmented options={OPTIONS} value={view} onChange={setView} />
<Text size="sm" tone="muted">
当前:{view}
</Text>
</Demo>
<Demo
title="尺寸与占满整行"
desc="sm 用于卡片内;block 让选项等分宽度(窄容器或移动端常用)。"
layout="column"
code={`<Segmented size="sm" options={OPTIONS} value={view} onChange={setView} />
<Segmented block options={OPTIONS} value={view} onChange={setView} />`}
>
<Segmented size="sm" options={OPTIONS} value={view} onChange={setView} />
<Segmented block options={OPTIONS} value={view} onChange={setView} />
</Demo>
<PropsTable
rows={[
{ name: 'options', type: 'SegmentedOption[]', required: true, desc: '{ value, label, icon? }' },
{ name: 'value', type: 'string', required: true, desc: '当前值' },
{ name: 'onChange', type: '(value: string) => void', required: true, desc: '切换回调' },
{ name: 'size', type: "'sm' | 'md'", defaultValue: "'md'", desc: '24 / 28px 高' },
{ name: 'block', type: 'boolean', defaultValue: 'false', desc: '占满一行,选项等分' },
]}
/>
</Section>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { useState } from 'react'
import { Select, Space, Text } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
const OPTIONS = [
{ value: 'new', label: '最近更新' },
{ value: 'hot', label: '最热门' },
{ value: 'name', label: '按名称' },
{ value: 'none', label: '(暂不可用)', disabled: true },
]
export function Selects() {
const [sort, setSort] = useState('new')
const [kind, setKind] = useState<string | undefined>(undefined)
return (
<Section
id="select"
title="下拉选择"
api="Select"
desc="受控的单值选择:触发器是原生 button,展开后是 listbox;支持点击外部关闭、Esc、↑↓ 移动、Enter 选择。"
>
<Demo
title="基础用法"
desc="不传 value 时显示 placeholder;禁用的选项不可点、不可选。"
code={`const [sort, setSort] = useState('new')
<Select options={OPTIONS} value={sort} onChange={setSort} />`}
>
<Space gap="md" wrap>
<Select options={OPTIONS} value={sort} onChange={setSort} />
<Text size="sm" tone="muted">
当前:{OPTIONS.find((option) => option.value === sort)?.label}
</Text>
</Space>
</Demo>
<Demo
title="占位与禁用"
desc="placeholder 只在没有选中值时出现;整个控件 disabled 时不可展开。"
code={`<Select options={OPTIONS} value={kind} onChange={setKind} placeholder="选择类型" />
<Select options={OPTIONS} value={kind} onChange={setKind} disabled />`}
>
<Space gap="md" wrap>
<Select options={OPTIONS} value={kind} onChange={setKind} placeholder="选择类型" />
<Select options={OPTIONS} value={kind} onChange={setKind} disabled />
</Space>
</Demo>
<PropsTable
rows={[
{ name: 'options', type: 'SelectOption[]', required: true, desc: '{ value, label, disabled? }' },
{ name: 'value', type: 'string', desc: '当前值' },
{ name: 'onChange', type: '(value: string) => void', required: true, desc: '选择回调' },
{ name: 'placeholder', type: 'string', defaultValue: "'请选择'", desc: '未选中时的文案' },
{ name: 'disabled', type: 'boolean', defaultValue: 'false', desc: '禁用整个控件' },
]}
/>
</Section>
)
}
+74
View File
@@ -0,0 +1,74 @@
import { Card, Skeleton, Space } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
export function Skeletons() {
return (
<Section
id="skeleton"
title="骨架屏"
api="Skeleton"
desc="内容加载前的占位。形状要和真实内容一致,否则加载完成后会跳动;整块区域用 aria-busy 标记,骨架本身对屏幕阅读器隐藏。"
>
<Demo
title="三种形状"
desc="text 是文本行(最后一行自动收短),rect 是封面/图片,circle 是头像。"
layout="column"
code={`<Skeleton variant="text" lines={3} />
<Skeleton variant="rect" height={120} />
<Skeleton variant="circle" width={48} height={48} />`}
>
<Space gap="lg" wrap>
<div style={{ width: 240 }}>
<Skeleton variant="text" lines={3} />
</div>
<Skeleton variant="rect" width={200} height={120} />
<Skeleton variant="circle" width={48} height={48} />
</Space>
</Demo>
<Demo
title="卡片骨架"
desc="按真实卡片的结构拼:头像 + 两行标题 + 封面 + 两行摘要。加载完成后把整块换成真实内容即可。"
layout="column"
code={`<Card aria-busy="true">
<Space gap="sm">
<Skeleton variant="circle" width={36} height={36} />
<div style={{ flex: 1 }}>
<Skeleton lines={2} />
</div>
</Space>
<Skeleton variant="rect" height={160} style={{ marginTop: 16 }} />
<Skeleton lines={2} style={{ marginTop: 16 }} />
</Card>`}
>
<Space direction="column" gap="md" align="stretch">
<Card padding="lg" aria-busy="true">
<Space gap="sm">
<Skeleton variant="circle" width={36} height={36} />
<div style={{ flex: 1 }}>
<Skeleton lines={2} />
</div>
</Space>
<div style={{ marginTop: 'var(--su-space-4)' }}>
<Skeleton variant="rect" height={160} />
</div>
<div style={{ marginTop: 'var(--su-space-4)' }}>
<Skeleton lines={2} />
</div>
</Card>
</Space>
</Demo>
<PropsTable
rows={[
{ name: 'variant', type: "'text' | 'rect' | 'circle'", defaultValue: "'text'", desc: '形状' },
{ name: 'lines', type: 'number', defaultValue: '1', desc: '仅 text:行数' },
{ name: 'width', type: 'number | string', desc: '宽度;rect 默认 100%,circle 默认 40' },
{ name: 'height', type: 'number | string', desc: '高度;rect 默认 120' },
]}
/>
</Section>
)
}
+81
View File
@@ -0,0 +1,81 @@
import { useState } from 'react'
import { Button, Space, Spinner, Text } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
export function Spinners() {
const [loading, setLoading] = useState(false)
return (
<Section
id="spinner"
title="加载中"
api="Spinner"
desc="整块区域或行内的加载指示。按钮内部的 loading 由 Button 自己处理,文字占位用 Skeleton——三者分工不同。"
>
<Demo
title="尺寸"
desc="sm 14px 用于行内(跟着文字基线走),md 20px 用于区块。"
code={`<Spinner size="sm" />
<Spinner size="md" />`}
>
<Space gap="lg">
<Spinner size="sm" />
<Spinner size="md" />
</Space>
</Demo>
<Demo
title="行内状态"
desc="和一句说明放在一起时,用 Space 排;Spinner 自带 role=status,读屏会读出 label。"
layout="column"
code={`<Space gap="sm">
<Spinner size="sm" label="正在同步" />
<Text size="sm" tone="sub">正在同步内容库…</Text>
</Space>`}
>
<Space gap="sm">
<Spinner size="sm" label="正在同步" />
<Text size="sm" tone="sub">
正在同步内容库…
</Text>
</Space>
</Demo>
<Demo
title="整块加载"
desc="区域级加载时给出一个居中的 Spinner + 文案;按钮里的 loading 不要用 Spinner 代替。"
layout="column"
code={`{loading && (
<Space direction="column" gap="sm" justify="center">
<Spinner />
<Text size="sm" tone="muted">正在读取…</Text>
</Space>
)}`}
>
<Space direction="column" gap="sm" align="center">
<Button size="sm" variant="secondary" loading={loading} onClick={() => {
setLoading(true)
window.setTimeout(() => setLoading(false), 1200)
}}>
模拟加载
</Button>
<Space gap="sm">
<Spinner />
<Text size="sm" tone="muted">
{loading ? '正在读取…' : '待命中'}
</Text>
</Space>
</Space>
</Demo>
<PropsTable
rows={[
{ name: 'size', type: "'sm' | 'md'", defaultValue: "'md'", desc: '14 / 20px' },
{ name: 'label', type: 'string', defaultValue: "'加载中'", desc: '无障碍名称' },
]}
/>
</Section>
)
}
+51
View File
@@ -0,0 +1,51 @@
import { Card, Steps } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
const ITEMS = [
{ title: '建立内容库', description: '首次启动自动建表并灌入种子' },
{ title: '调整分区与分组', description: '用 SQLite 工具直接改 node 表' },
{ title: '配置首页卡片', description: 'section_block 决定展示哪个节点' },
{ title: '发布', description: '条目 body 写 Markdown 即可' },
]
export function StepsSection() {
return (
<Section
id="steps"
title="步骤"
api="Steps"
desc="纵向流程:已完成打勾、当前项高亮、未开始置灰。适合发布流程、导入向导这类有顺序的任务。"
>
<Demo
title="进行中"
desc="current 从 0 开始;index < current 视为已完成。"
layout="column"
code={`<Steps items={items} current={1} />`}
>
<Card padding="lg">
<Steps items={ITEMS} current={1} />
</Card>
</Demo>
<Demo
title="全部完成"
desc="current 超出最后一项时,所有步骤都显示为已完成。"
layout="column"
code={`<Steps items={items} current={items.length} />`}
>
<Card padding="lg">
<Steps items={ITEMS} current={ITEMS.length} />
</Card>
</Demo>
<PropsTable
rows={[
{ name: 'items', type: 'StepItem[]', required: true, desc: '{ title, description? }' },
{ name: 'current', type: 'number', required: true, desc: '当前步骤索引,从 0 开始' },
]}
/>
</Section>
)
}
+72
View File
@@ -0,0 +1,72 @@
import { useState } from 'react'
import { Space, Switch, Text } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
export function Switches() {
const [autoSync, setAutoSync] = useState(true)
const [showCover, setShowCover] = useState(false)
const [compact] = useState(true)
return (
<Section
id="switch"
title="开关"
api="Switch"
desc="设置项的即时开关:改了立刻生效,不需要「保存」。需要用户确认的场景用 Checkbox + 提交按钮,不要用开关。"
>
<Demo
title="基础用法"
desc="受控组件:checked + onChange。旁边有文字说明时,文字用 Text,开关的 label 仍要传(供读屏)。"
layout="column"
code={`<Space justify="between" style={{ width: 280 }}>
<Text>自动同步</Text>
<Switch checked={autoSync} onChange={setAutoSync} label="自动同步" />
</Space>`}
>
<Space justify="between" style={{ width: 300 }}>
<Text>自动同步</Text>
<Switch checked={autoSync} onChange={setAutoSync} label="自动同步" />
</Space>
<Space justify="between" style={{ width: 300 }}>
<Text>显示封面图</Text>
<Switch checked={showCover} onChange={setShowCover} label="显示封面图" />
</Space>
</Demo>
<Demo
title="尺寸"
desc="sm 用于行内紧凑列表,md 用于设置表单。"
code={`<Switch size="sm" checked={on} onChange={setOn} label="紧凑模式" />
<Switch size="md" checked={on} onChange={setOn} label="紧凑模式" />`}
>
<Space gap="lg">
<Switch size="sm" checked={compact} onChange={() => undefined} label="紧凑模式" />
<Switch size="md" checked={compact} onChange={() => undefined} label="紧凑模式" />
</Space>
</Demo>
<Demo
title="禁用"
desc="禁用时保留当前状态的可读性,只降透明度并改光标。"
code={`<Switch checked onChange={() => {}} disabled label="不可修改" />`}
>
<Space gap="lg">
<Switch checked onChange={() => undefined} disabled label="不可修改(开)" />
<Switch checked={false} onChange={() => undefined} disabled label="不可修改(关)" />
</Space>
</Demo>
<PropsTable
rows={[
{ name: 'checked', type: 'boolean', required: true, desc: '受控状态' },
{ name: 'onChange', type: '(checked: boolean) => void', required: true, desc: '切换回调' },
{ name: 'size', type: "'sm' | 'md'", defaultValue: "'md'", desc: '32×18 / 40×22' },
{ name: 'disabled', type: 'boolean', defaultValue: 'false', desc: '禁用' },
{ name: 'label', type: 'string', desc: '无障碍名称:旁边没有文字时必填' },
]}
/>
</Section>
)
}
+78
View File
@@ -0,0 +1,78 @@
import { Table, Tag, Text } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
const COLUMNS = [
{ key: 'name', title: '分区' },
{ key: 'kind', title: '类型', width: 100 },
{ key: 'count', title: '条目数', width: 100, align: 'right' as const },
{ key: 'status', title: '状态', width: 120 },
]
const ROWS = [
{
id: 'recommend',
name: '常用推荐',
kind: <Text size="sm">apps</Text>,
count: 18,
status: <Tag tone="success">已上线</Tag>,
},
{
id: 'movie',
name: '推荐电影',
kind: <Text size="sm">posters</Text>,
count: 12,
status: <Tag tone="success">已上线</Tag>,
},
{
id: 'ces',
name: 'CES 现场报道',
kind: <Text size="sm">articles</Text>,
count: 12,
status: <Tag tone="warning">草稿</Tag>,
},
]
export function Tables() {
return (
<Section
id="table"
title="表格"
api="Table"
desc="只做展示:列定义 + 行数据 + 空态。排序、筛选、分页由页面组合,组件不内置交互。"
>
<Demo
title="基础表格"
desc="列宽、对齐方式按列配置;单元格内容可以是任意 ReactNode(这里用了 Tag 与 Text)。"
layout="column"
code={`const columns = [
{ key: 'name', title: '分区' },
{ key: 'count', title: '条目数', width: 100, align: 'right' },
]
<Table columns={columns} rows={rows} rowKey="id" />`}
>
<Table columns={COLUMNS} rows={ROWS} rowKey="id" />
</Demo>
<Demo
title="空态"
desc="空数据时渲染默认文案;也可以传 emptyText 换一句更具体的说明。"
layout="column"
code={`<Table columns={columns} rows={[]} emptyText="还没有分区" />`}
>
<Table columns={COLUMNS} rows={[]} emptyText="还没有分区,先建一个吧。" />
</Demo>
<PropsTable
rows={[
{ name: 'columns', type: 'TableColumn[]', required: true, desc: '{ key, title, width?, align? }' },
{ name: 'rows', type: 'Record<string, ReactNode>[]', required: true, desc: '行数据,键对应列的 key' },
{ name: 'rowKey', type: 'string', defaultValue: "'id'", desc: '行的唯一键字段' },
{ name: 'emptyText', type: 'ReactNode', defaultValue: "'暂无数据'", desc: '空态文案' },
]}
/>
</Section>
)
}
+67
View File
@@ -0,0 +1,67 @@
import { useState } from 'react'
import { Text, Tabs } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
const ITEMS = [
{ key: 'detail', label: '详情' },
{ key: 'comments', label: '评论 6' },
{ key: 'history', label: '历史(暂不可用)', disabled: true },
]
export function TabsSection() {
const [tab, setTab] = useState('detail')
const [pillTab, setPillTab] = useState('all')
return (
<Section
id="tabs"
title="标签页"
api="Tabs"
desc="在几块内容之间切换。只负责标签栏,面板由页面渲染;键盘 ← → 移动、Home/End 跳首尾。"
>
<Demo
title="下划线(默认)"
desc="适合详情页、设置页这类页面级分区;选中项用 2px 主色下划线标记。"
layout="column"
code={`const [tab, setTab] = useState('detail')
<Tabs items={ITEMS} activeKey={tab} onChange={setTab} />
<div role="tabpanel" style={{ paddingTop: 12 }}>{tab} 面板</div>`}
>
<Tabs items={ITEMS} activeKey={tab} onChange={setTab} />
<Text size="sm" tone="muted">
当前面板:{tab}
</Text>
</Demo>
<Demo
title="胶囊"
desc="pill 变体放在浅底容器里,适合卡片内的次级切换。"
layout="column"
code={`<Tabs variant="pill" items={items} activeKey={pillTab} onChange={setPillTab} />`}
>
<Tabs
variant="pill"
items={[
{ key: 'all', label: '全部' },
{ key: 'published', label: '已发布' },
{ key: 'draft', label: '草稿' },
]}
activeKey={pillTab}
onChange={setPillTab}
/>
</Demo>
<PropsTable
rows={[
{ name: 'items', type: 'TabItem[]', required: true, desc: '{ key, label, disabled? }' },
{ name: 'activeKey', type: 'string', required: true, desc: '当前标签' },
{ name: 'onChange', type: '(key: string) => void', required: true, desc: '切换回调' },
{ name: 'variant', type: "'line' | 'pill'", defaultValue: "'line'", desc: '下划线 / 胶囊' },
]}
/>
</Section>
)
}
+60
View File
@@ -0,0 +1,60 @@
import { Button, Space, toast } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
export function Toasts() {
return (
<Section
id="toast"
title="轻提示"
api="toast / Toaster"
desc="操作反馈,几秒后自动消失。命令式调用:应用根节点挂一次 <Toaster />,任意位置 toast('已保存')。页面内常驻提示用 Notice。"
>
<Demo
title="三种语气"
desc="success / error 用对应图标与颜色;info 是中性提示。默认 2.4 秒后消失。"
code={`toast('已复制到剪贴板')
toast.success('已保存')
toast.error('保存失败:内容库被占用')`}
>
<Space gap="md" wrap>
<Button variant="secondary" onClick={() => toast('已复制到剪贴板')}>
info
</Button>
<Button variant="secondary" onClick={() => toast.success('已保存')}>
success
</Button>
<Button variant="secondary" onClick={() => toast.error('保存失败:内容库被占用')}>
error
</Button>
</Space>
</Demo>
<Demo
title="自定义时长"
desc="duration 毫秒;返回的 id 可以配合 dismissToast 手动关闭。"
code={`const id = toast.success('稍后会消失', { duration: 1000 })
dismissToast(id)`}
>
<Space gap="md" wrap>
<Button variant="ghost" onClick={() => toast('这条只显示 1 秒', { duration: 1000 })}>
1 秒后消失
</Button>
<Button variant="ghost" onClick={() => toast.success('固定 5 秒', { duration: 5000 })}>
5 秒后消失
</Button>
</Space>
</Demo>
<PropsTable
rows={[
{ name: 'toast(message, options?)', type: '(string, { tone?, duration? }) => number', desc: '弹一条提示,返回 id' },
{ name: 'toast.success / .error', type: '(message, options?) => number', desc: '语气快捷方法' },
{ name: 'dismissToast(id)', type: '(id: number) => void', desc: '手动关闭' },
{ name: '<Toaster />', type: 'ReactNode', desc: '渲染容器,整个应用只挂一次' },
]}
/>
</Section>
)
}
+92
View File
@@ -0,0 +1,92 @@
import { Button, IconButton, Icon, Popover, Space, Text, Tooltip } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
export function Tooltips() {
return (
<Section
id="tooltip"
title="工具提示"
api="Tooltip / Popover"
desc="Tooltip 是 hover / 聚焦时出现的一行文字;Popover 是点击展开的小面板。两者都只做轻量内容,复杂交互请用 Drawer / Modal。"
>
<Demo
title="文字提示"
desc="纯 CSS 定位(top / bottom),触发元素建议是可聚焦的按钮或链接;键盘聚焦时同样出现。"
code={`<Tooltip content="重新扫描项目">
<IconButton label="刷新" icon={<Icon name="refresh_3_line" />} />
</Tooltip>
<Tooltip content="出现在下方" placement="bottom">
<Button variant="secondary">下方提示</Button>
</Tooltip>`}
>
<Space gap="lg">
<Tooltip content="重新扫描项目">
<IconButton label="刷新" icon={<Icon name="refresh_3_line" />} variant="soft" />
</Tooltip>
<Tooltip content="出现在下方" placement="bottom">
<Button variant="secondary">下方提示</Button>
</Tooltip>
</Space>
</Demo>
<Demo
title="气泡卡片"
desc="点击触发器展开,点外部或 Esc 关闭;面板里可以放说明、链接或简单表单。"
code={`<Popover
content={<Text size="sm">改动会立即写入 data/stash.db,5 秒内推送前端。</Text>}
>
<Button variant="ghost">说明</Button>
</Popover>`}
>
<Space gap="lg">
<Popover
content={
<Text size="sm" tone="sub">
改动会立即写入 data/stash.db,服务端 5 秒内通过 SSE 推送前端。
</Text>
}
>
<Button variant="ghost">说明</Button>
</Popover>
<Popover
placement="bottom-end"
content={
<Space direction="column" gap="sm" align="start">
<Text size="sm" strong>
快捷操作
</Text>
<Button size="sm" variant="ghost">
重新扫描
</Button>
<Button size="sm" variant="ghost">
打开内容库
</Button>
</Space>
}
>
<Button variant="secondary">更多操作</Button>
</Popover>
</Space>
</Demo>
<PropsTable
rows={[
{ name: 'content', type: 'ReactNode', required: true, desc: '提示内容' },
{ name: 'placement', type: "'top' | 'bottom'", defaultValue: "'top'", desc: '出现方向' },
{ name: 'children', type: 'ReactNode', required: true, desc: '触发元素(建议可聚焦)' },
]}
/>
<PropsTable
rows={[
{ name: 'content', type: 'ReactNode', required: true, desc: '面板内容' },
{ name: 'children', type: 'ReactNode', required: true, desc: '触发器' },
{ name: 'placement', type: "'bottom-start' | 'bottom-end'", defaultValue: "'bottom-start'", desc: '对齐方式' },
]}
/>
</Section>
)
}
+129
View File
@@ -0,0 +1,129 @@
import { Paragraph, Space, Text, Title } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
export function Typographies() {
return (
<Section
id="typography"
title="排版"
api="Title / Text / Paragraph"
desc="把字号、字重、文字层级收进三个组件;页面不再各写 font-size。字号阶梯来自参考站实测:13 / 14 / 16 / 18 / 24,正文(Paragraph)默认 16/24。"
>
<Demo
title="标题层级"
desc="level 决定字号并输出对应的 h1–h4;要换语义标签时传 as。组件不带外边距,间距交给 Space。"
layout="column"
code={`<Title level={1}>页面标题</Title>
<Title level={2}>区块标题</Title>
<Title level={3}>卡片标题</Title>
<Title level={4}>小节标题</Title>`}
>
<Title level={1}>页面标题 24/32</Title>
<Title level={2}>区块标题 18/26</Title>
<Title level={3}>卡片标题 16/24</Title>
<Title level={4}>小节标题 14/22</Title>
</Demo>
<Demo
title="文字层级与尺寸"
desc="tone 控制颜色层级(main → secondary → sub → muted),size 控制字号;strong 是主题的 500 字重,truncate 做单行省略。"
layout="column"
code={`<Text tone="main">主文字</Text>
<Text tone="secondary">次级文字</Text>
<Text tone="sub">说明文字</Text>
<Text tone="muted">占位文字</Text>
<Text size="xs">12</Text>
<Text size="sm">13</Text>
<Text size="md">14</Text>
<Text size="base">16</Text>
<Text size="lg">18</Text>`}
>
<Space direction="column" gap="sm" align="start">
<Text tone="main">主文字:标题、正文重点</Text>
<Text tone="secondary">次级文字:默认段落</Text>
<Text tone="sub">说明文字:摘要、描述</Text>
<Text tone="muted">占位文字:时间、元信息</Text>
</Space>
<Space gap="lg" wrap>
<Text size="xs">12 角标</Text>
<Text size="sm">13 标签</Text>
<Text size="md">14 控件</Text>
<Text size="base">16 正文</Text>
<Text size="lg">18 标题</Text>
</Space>
<Space gap="lg" wrap>
<Text strong>500 加粗</Text>
<Text truncate style={{ maxWidth: 180 }}>
单行截断:这一行文字太长时会在末尾显示省略号,不会换行
</Text>
</Space>
</Demo>
<Demo
title="段落与行数截断"
desc="文章摘要常用 2–3 行截断;lines 用 -webkit-line-clamp 实现,不破坏文本可选中。"
layout="column"
code={`<Paragraph>正文段落,默认 16/24、次级颜色。</Paragraph>
<Paragraph lines={2}>两行后省略……</Paragraph>`}
>
<Paragraph>
组件只负责排版:字号、行高、颜色都来自令牌。段落之间不要用 <code>margin</code>,
放进 <code>Space</code> 里控制间距,这样换页面时节奏是一致的。
</Paragraph>
<Paragraph lines={2}>
这一段的文字比较长,用来演示两行截断:用 -webkit-line-clamp 做视觉截断,
文本本身仍然完整保留在 DOM 里,复制、搜索、屏幕阅读器都能拿到全文,只是超出两行后不再显示。
</Paragraph>
</Demo>
<Demo
title="字重"
desc="库内置 400 / 500 / 600 / 700 四档;默认 500,参考站的标题是 600,用 weight='semibold'。"
layout="column"
code={`<Title level={3} weight="medium">默认 500</Title>
<Title level={3} weight="semibold">600(参考站标题)</Title>
<Title level={3} weight="strong">700(强调)</Title>`}
>
<Title level={3} weight="medium">
默认 500
</Title>
<Title level={3} weight="semibold">
600(参考站标题)
</Title>
<Title level={3} weight="strong">
700(强调)
</Title>
</Demo>
<PropsTable
rows={[
{ name: 'level', type: '1 | 2 | 3 | 4', defaultValue: '2', desc: '字号档位,默认输出 h1–h4' },
{ name: 'as', type: "'h1' | 'h2' | 'h3' | 'h4' | 'div'", desc: '换语义标签' },
{ name: '…rest', type: 'HTMLAttributes', desc: '透传原生属性' },
]}
/>
<PropsTable
rows={[
{ name: 'tone', type: "'main' | 'secondary' | 'sub' | 'muted'", defaultValue: "'main'", desc: '文字层级' },
{ name: 'size', type: "'xs' | 'sm' | 'md' | 'base' | 'lg'", defaultValue: "'md'", desc: '12 / 13 / 14 / 16 / 18' },
{ name: 'strong', type: 'boolean', defaultValue: 'false', desc: '500 字重' },
{ name: 'truncate', type: 'boolean', defaultValue: 'false', desc: '单行省略' },
{ name: 'as', type: "'span' | 'p' | 'div'", defaultValue: "'span'", desc: '语义标签' },
]}
/>
<PropsTable
rows={[
{ name: 'tone', type: "'main' | 'secondary' | 'sub'", defaultValue: "'sub'", desc: '文字层级' },
{ name: 'lines', type: 'number', desc: '最多显示几行,超出省略' },
]}
/>
</Section>
)
}