feat: stash-ui 0.1.0(40 个组件 + 文档站 + 主题机制)
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
dist-site/
|
||||
shots/
|
||||
*.local
|
||||
.DS_Store
|
||||
|
||||
.ref/
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "error",
|
||||
"react/only-export-components": ["warn", { "allowConstantExport": true }],
|
||||
"react/jsx-key": "error"
|
||||
},
|
||||
"ignorePatterns": ["dist", "dist-site", "shots"]
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
# AGENTS.md
|
||||
|
||||
本仓库是 **Stash UI**:一套独立的设计系统 + 组件库(React + CSS Modules + 设计令牌,零运行时依赖)。
|
||||
|
||||
改任何页面或组件之前:
|
||||
|
||||
1. 读 [`skills/stash-ui/SKILL.md`](skills/stash-ui/SKILL.md) —— AI 使用入口(任务分流 + 硬约束 + 验证命令)。
|
||||
2. 需要细节时再读 [`DESIGN.md`](DESIGN.md)、[`docs/COMPONENTS.md`](docs/COMPONENTS.md)、
|
||||
[`docs/ADDING-A-COMPONENT.md`](docs/ADDING-A-COMPONENT.md)。
|
||||
|
||||
提交前必须通过:
|
||||
|
||||
```bash
|
||||
npm run typecheck && npm run lint && npm run build
|
||||
```
|
||||
|
||||
视觉改动还要截图核对(`scripts/screenshot.mjs`,深浅色各看一次)。
|
||||
@@ -0,0 +1,4 @@
|
||||
# Claude Code 入口
|
||||
|
||||
规则与流程统一写在 [`AGENTS.md`](AGENTS.md) 和 [`skills/stash-ui/SKILL.md`](skills/stash-ui/SKILL.md),
|
||||
这里只做指路,避免同一份规则维护两次。
|
||||
@@ -0,0 +1,415 @@
|
||||
# Stash UI 设计系统 · 规格总纲
|
||||
|
||||
> 版本 0.1 · 适用 `@stash/ui`
|
||||
> 本文件定义**所有组件共同遵守的规则**。新增组件、改动组件、评审组件都以下面这些条目为准。
|
||||
|
||||
四份文档的分工:
|
||||
|
||||
| 文档 | 回答什么问题 | 什么时候看 |
|
||||
| --- | --- | --- |
|
||||
| **DESIGN.md**(本文) | 整体结构与硬性规则:分层、令牌、视觉、代码约定、验收标准 | 动手前看,评审时逐条对照 |
|
||||
| [docs/COMPONENTS.md](docs/COMPONENTS.md) | 有哪些组件、各自的契约、缺什么组件、缺的时候该做什么 | 想知道「用哪个 / 有没有」时看 |
|
||||
| [docs/ADDING-A-COMPONENT.md](docs/ADDING-A-COMPONENT.md) | 从零加一个组件的完整流程、模板、示例 | 确认要新增时照着做 |
|
||||
| [docs/ALRIGHT-DECOMPOSITION.md](docs/ALRIGHT-DECOMPOSITION.md) | 参考站拆解:实测令牌、组件尺寸、交互节奏、暗色映射、保留差异 | 怀疑「这个值对不对」时查依据 |
|
||||
|
||||
展示站(`site/`)是这三份文档的运行实例:**文档里写的规则,展示页上必须能直接看到**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 三条硬约束
|
||||
|
||||
违反任意一条,组件不合并。
|
||||
|
||||
1. **只认令牌**
|
||||
组件 CSS 里的颜色、字号、行高、圆角、阴影、动效、间距,一律 `var(--su-*)`。
|
||||
允许的字面量只有四类:`0`、`1px` 边框、`100%`、以及组件内声明的局部尺寸变量 `--xxx-size`(见 §5.3)。
|
||||
2. **组件不认识业务**
|
||||
不请求接口、不读路由、不 import 业务类型(`Category` / `entry` 之类)。
|
||||
组件只接收 props,数据由页面喂给它。
|
||||
3. **每个组件都有展示页**
|
||||
展示页只能用真实组件渲染,不能写「演示专用版本」;新增组件必须同时加一节。
|
||||
|
||||
---
|
||||
|
||||
## 2. 分层结构
|
||||
|
||||
| 层 | 职责 | 现有组件 | 硬性规则 |
|
||||
| --- | --- | --- | --- |
|
||||
| **L1 原子** | 不可再分的小元素 | `Icon` `Badge` `Tag` `Divider` | 不负责布局,**不带外边距**,尺寸由内容或父级决定 |
|
||||
| **L2 控件** | 接收输入 / 触发操作 | `Button` `IconButton` `Input` | 高度只用 `--su-control-height-*` 或 `--su-icon-button-*` |
|
||||
| **L3 容器与布局** | 承载内容、排布 | `Card` `Grid` `Space` | 不假设内容形态;不写死宽度;不感知业务 |
|
||||
| **L4 反馈** | 表达状态 | `Notice` `Empty` `ScrollTop` | 不阻塞、不弹层 |
|
||||
| **页面级(不进库)** | 顶栏、侧栏、详情页、业务卡片 | — | 放在应用里,用 L1–L4 拼装 |
|
||||
|
||||
依赖方向:
|
||||
|
||||
```text
|
||||
L4 ──→ L3 ──→ L1
|
||||
│ │
|
||||
└──────┴──→ L1 L2 ──→ L1
|
||||
```
|
||||
|
||||
- 只允许「上层依赖下层」,**禁止下层 import 上层**(例如 `Card` 里不 import `Empty`)。
|
||||
- 组件之间不共享 CSS。除 `Card` 这种明确的容器外,组件不假设自己处在什么容器里。
|
||||
- 业务组件(分区卡片、应用条目、海报卡……)属于页面层:**用库里组件拼,不进库**。
|
||||
|
||||
---
|
||||
|
||||
## 3. 令牌(唯一的视觉来源)
|
||||
|
||||
完整定义见 [`src/theme/tokens.css`](src/theme/tokens.css)。前缀 `--su-` 是为了避免和宿主应用变量冲突。
|
||||
所有取值都来自参考站实测(见 [docs/ALRIGHT-DECOMPOSITION.md](docs/ALRIGHT-DECOMPOSITION.md)),不是拍脑袋定的。
|
||||
|
||||
### 3.1 颜色
|
||||
|
||||
| 令牌组 | 用途 | 不要用于 |
|
||||
| --- | --- | --- |
|
||||
| `--su-gray-1..8` | 灰阶底座(与参考站一致) | 直接当品牌色用 |
|
||||
| `--su-bg-page` / `--su-bg-card` / `--su-bg-hover` / `--su-bg-subtle` / `--su-bg-light` | 页面底、卡片底、悬浮底、浅色块(标签)、浅色块(按钮) | 做强调色 |
|
||||
| `--su-bg-notice` / `--su-bg-danger-soft` | 提示条底色 | 卡片底色 |
|
||||
| `--su-text-main` / `-secondary` / `-sub` / `-muted` / `-placeholder` | 文字层级(主文 → 次级 → 说明 → 占位) | 互相替代 |
|
||||
| `--su-border` / `--su-border-strong` / `--su-card-border` | 默认细边 / 需要看清的分隔 / 卡片描边(几乎不可见) | 画装饰线 |
|
||||
| `--su-color-primary` (+`-hover` `-soft`) | 主操作、链接、选中 | 大块背景 |
|
||||
| `--su-color-danger` (+`-hover` `-soft` `-border`) | 危险、错误 | 普通强调 |
|
||||
| `--su-color-warning` (+`-soft` `-text`) / `--su-color-orange-dark` `-light` | 提醒、付费角标 | 错误态 |
|
||||
| `--su-color-success` (+`-soft`) | 在线、成功 | 主操作 |
|
||||
| `--su-gradient-verified` / `-hot` / `-paid` / `-red` | 认证角标 / 置顶 / 付费 / 热门 | 大面积背景 |
|
||||
|
||||
### 3.2 间距
|
||||
|
||||
`--su-space-1..10` = 4 / 8 / 12 / 16 / 20 / 24 / 32 / 40。
|
||||
只用这些值;需要 2px、6px 这类视觉微调时才允许字面量(且仅限内边距/圆点尺寸)。
|
||||
|
||||
### 3.3 字号与字重
|
||||
|
||||
| 令牌 | 值 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `--su-font-size-xs` / `--su-line-height-xs` | 12 / 16 | 角标(`Badge` / `ItemBadge`) |
|
||||
| `--su-font-size-sm` / `--su-line-height-sm` | 13 / 20 | 标签、辅助文字 |
|
||||
| `--su-font-size-md` / `--su-line-height-md` | 14 / 22 | 控件文字(按钮、输入、表格) |
|
||||
| `--su-font-size-base` / `--su-line-height-base` | 16 / 24 | 内容正文、卡片标题(`Paragraph` / `Title level=3`) |
|
||||
| `--su-font-size-lg` / `--su-line-height-lg` | 18 / 26 | 区块标题、大图标 |
|
||||
| `--su-font-size-xl` / `--su-line-height-xl` | 24 / 32 | 页面标题(`Title level=1`) |
|
||||
|
||||
> 参考站的实际排版是 `13/20`、`14/22`、`16/24`、`18/26`、body `16/24`。
|
||||
> 组件库把「控件文字」定在 14、把「内容正文」定在 16:控件更紧凑,文章更好读。
|
||||
|
||||
字重只有三档:`normal 400`、`bold 500`(默认「加粗」)、`strong 700`
|
||||
(**只给两类**:字形符号如认证角标的 ✓,以及 12px 的 `ItemBadge` 状态角标)。
|
||||
组件内**禁止**写 `font-weight: 600`。
|
||||
|
||||
### 3.4 圆角、阴影、控件尺寸、动效
|
||||
|
||||
| 令牌 | 值 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `--su-radius-xs` / `sm` / `md` / `lg` / `pill` | 4 / 6 / 12 / 24 / 999 | 标签 / 控件 / 卡片 / 大容器 / 胶囊 |
|
||||
| `--su-shadow-card` | `0 6px 16px 0 rgb(27 26 49 / 1%)` | 卡片静止(参考站实测,极轻) |
|
||||
| `--su-shadow-hover` | 同位置参数,3% | 卡片悬停 |
|
||||
| `--su-shadow-pop` | `0 8px 24px rgb(27 26 49 / 12%)` | 浮层 |
|
||||
| `--su-control-height-sm` / `md` | 32 / 36 | 按钮、输入框、未来的 Select |
|
||||
| `--su-icon-button-xs` / `sm` / `md` | 28 / 36 / 44 | 圆形图标按钮(参考站 `.btn-icon`) |
|
||||
| `--su-duration-fast` / `base` | 0.2s / 0.3s | 颜色变化 / 展开淡入 |
|
||||
| `--su-ease-in-out` / `-out` / `-in` | ease-in-out / ease-out / ease-in | 默认用 `-in-out`(参考站同款) |
|
||||
| `--su-skeleton-shine` | 浅色 50% 白 / 深色 8% 白 | 骨架屏扫光 |
|
||||
| `--su-focus-ring` / `-offset` | `2px solid primary` / 2px | 键盘焦点环 |
|
||||
|
||||
### 3.5 布局与阅读
|
||||
|
||||
| 令牌 | 值 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `--su-layout-width` | 1380px | 页面容器最大宽度(Layout `contained` 与页面共用) |
|
||||
| `--su-header-height` | 60px | 应用型页头高度 |
|
||||
| `--su-radius-cover` | 8px | 封面卡圆角(比卡片小一档) |
|
||||
| `--su-reading-line-height` | 1.9 | 长文正文行高(`Paragraph leading="relaxed"`) |
|
||||
| `--su-cover-scrim` | 黑 85% → 40% → 透明 | 封面标题的压暗层 |
|
||||
| `--su-shadow-card-strong` | 2% 卡片阴影 | 需要更明显层次时(如首页分区卡) |
|
||||
| `--su-z-header` / `--su-z-float` | 100 / 90 | 顶栏 / 浮动元素 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 视觉规则(拿不准就按这里来)
|
||||
|
||||
### 4.1 尺寸与间距
|
||||
|
||||
- 控件高度只有两档;**并排出现时必须同档**(`Input` 与 `Button size="md"` 都是 36px)。
|
||||
- 组件**不带 margin**;元素间距交给 `Space` 或父容器。例外:`ScrollTop` 是浮动元素、`Divider` 自身带间距。
|
||||
- 卡片内边距:`sm 12` / `md 20` / `lg 24`,不要出现 18、22 这类值。
|
||||
|
||||
### 4.2 颜色的用法
|
||||
|
||||
- 文字三档:主文 `text-main`、次级 `text-sub`、说明 `text-muted`;不要自造灰。
|
||||
- **行式条目(列表行、作者行)不加底色、不加边框、不加阴影**——这是主题的核心观感,`Card` 不要套在行上。
|
||||
- 卡片用「1px 细边 + 极轻阴影」,不要用重阴影或彩色描边制造层级。
|
||||
|
||||
### 4.3 状态矩阵
|
||||
|
||||
每个交互组件都要覆盖下表;展示页也要逐个状态摆出来。
|
||||
|
||||
| 状态 | 实现 | 备注 |
|
||||
| --- | --- | --- |
|
||||
| 默认 | 令牌默认值 | — |
|
||||
| hover | 主色/危险按钮 `opacity: .8`;次要按钮换 `gray-3` 底;图标按钮换 `gray-2` 底 + 文字加深 | 参考站同款,不做位移、不缩放 |
|
||||
| active | 与 hover 同色,不再额外变形 | 主题本身没有按压态 |
|
||||
| disabled | 换浅底 + 弱文字 + `cursor: not-allowed`(不是整体降透明) | 不改变布局 |
|
||||
| loading | 同色 spinner 占用图标位 + 自动 disabled + `aria-busy` | 仅 `Button` / `IconButton` |
|
||||
| focus-visible | 组件自己写 `outline: var(--su-focus-ring)` + `offset` | 参考站把焦点样式删了,我们**必须**补(WCAG) |
|
||||
| 空 | `Empty` | 不复用 `Notice` |
|
||||
| 错误 | `Notice tone="error"` | 带 `role="alert"` |
|
||||
|
||||
### 4.4 动效
|
||||
|
||||
- 过渡时长统一用 `--su-duration-base`(0.3s)+ `--su-ease-in-out`,与参考站一致;
|
||||
只有 hover 的快速反馈可以用 `--su-duration-fast`(0.2s)。
|
||||
- 只做颜色 / 背景 / 边框 / 阴影 / 透明度,不做位移、缩放、旋转(加载 spinner 除外)。
|
||||
- 需要 `transition` 的:`Button`、`IconButton`、`Card hoverable`、`Input`、`ScrollTop`、`Tag`(如加交互)。
|
||||
- **每个带动效的组件都要有 `@media (prefers-reduced-motion: reduce)` 分支**(过渡置 none,spinner 放慢)。
|
||||
|
||||
### 4.5 响应式
|
||||
|
||||
断点只有三个档,**组件内部**处理,页面不要各写一套:
|
||||
|
||||
| 断点 | 谁在用 | 行为 |
|
||||
| --- | --- | --- |
|
||||
| `1200px` | `Layout` | 右栏(aside)从并排变成堆到内容下方 |
|
||||
| `1100px` | `Layout`、`Grid` | 侧栏堆到内容上方;`Grid` 降为 2 列 |
|
||||
| `820px` | 页面(如示例页) | 顶部导航收进抽屉、左侧资料栏隐藏 |
|
||||
| `720px` | `Grid` | 降为 1 列 |
|
||||
|
||||
- 组件宽度默认 `100%`,由容器决定;不写死像素宽度(演示代码除外)。
|
||||
- **不靠隐藏做适配**:`Layout` 在窄屏下把侧栏/右栏堆叠,内容一律保留;页面如果确实要隐藏(如移动端的资料栏),由页面自己决定。
|
||||
- 高度用 `100dvh` 优先(`100vh` 兜底),地址栏收起时不会跳动。
|
||||
- 弹层宽度必须夹住:Modal `calc(100vw - 32px)`、Popover `min(320px, calc(100vw - 32px))`、Drawer `max-width: 100vw`。
|
||||
- 表格/标签栏/分段控件在窄屏横向滚动,不做压缩截断。
|
||||
|
||||
### 4.6 触屏
|
||||
|
||||
- 交互只用 `click`,不依赖 hover;`Tooltip` 的 hover 样式包在 `@media (hover: hover)` 里,触屏走 `:focus-within`。
|
||||
- 可点区域:`xs` 图标按钮 28×28 是**桌面密集场景**下限;移动端布局请用 `sm(36)` / `md(44)`。
|
||||
- 按钮类加 `touch-action: manipulation`,去掉双击缩放带来的点击延迟。
|
||||
- 原生控件(checkbox/radio/switch)都包在 `<label>` 里,点文字也能操作。
|
||||
|
||||
> 现状:组件是响应式的、也能在手机上用(示例页与文档站都在 390 / 820 宽实测过),
|
||||
> 但**还没有在真机上跑过**(iOS Safari 的 `dvh`、软键盘、`backdrop-filter` 需要真机复核)。
|
||||
> 结论性的兼容承诺见 §10「已知坑」与 README 的「浏览器支持」。
|
||||
|
||||
---
|
||||
|
||||
## 5. 代码约定
|
||||
|
||||
### 5.1 文件结构
|
||||
|
||||
```text
|
||||
src/components/
|
||||
├── Xxx.tsx # 组件 + props 类型(同文件,不拆 types 目录)
|
||||
└── Xxx.module.css # 只属于这个组件的样式
|
||||
src/index.ts # 唯一出口:组件 + 类型(export type)
|
||||
```
|
||||
|
||||
### 5.2 props 命名
|
||||
|
||||
| 类型 | 约定 | 例子 |
|
||||
| --- | --- | --- |
|
||||
| 语义变体 | `variant` | `primary / secondary / ghost / danger` |
|
||||
| 尺寸 | `size` | `sm / md / lg` |
|
||||
| 颜色语义 | `tone` | `primary / success / warning / danger / neutral` |
|
||||
| 形状 | `shape` | `rounded / pill`、`circle / square` |
|
||||
| 布尔 | 直接用形容词,**不加 is/has 前缀** | `block` `loading` `clearable` `wrap` |
|
||||
| 透传 | 展开 `...rest` 到最外层原生元素 | 原生属性一律可用 |
|
||||
| 受控 | 值由使用方持有;组件不偷偷维护业务状态 | `value` + `onChange` |
|
||||
| 内部 UI 状态 | 只在确实属于组件自身时保留 | `Avatar` 的图片加载失败 |
|
||||
|
||||
- 事件保持原生语义:`onChange` 传事件,不要偷偷换成值;需要值由调用方 `event.target.value`。
|
||||
- `className` / `style` 一律作用在**最外层**元素;要作用到内部就用具名 props(如 `inputRef`)。
|
||||
- 原生 `button` 必须显式 `type`,默认 `"button"`,避免表单里误提交。
|
||||
|
||||
### 5.3 样式写法
|
||||
|
||||
- CSS Modules,类名短:`.root`、`.sm`、`.primary`、`.hoverable`;修饰类平铺,不嵌套。
|
||||
- 选择器最多两层;禁止 `!important`;禁止标签选择器(伪类、`::before` 除外)。
|
||||
- 组件专属尺寸(头像 24/36/48、角标 18、回到顶部 42)写成**组件内局部变量**,集中在 `.root` 或尺寸类里:
|
||||
|
||||
```css
|
||||
.sm {
|
||||
--avatar-size: 24px;
|
||||
width: var(--avatar-size);
|
||||
height: var(--avatar-size);
|
||||
}
|
||||
```
|
||||
|
||||
- 颜色 / 字号 / 间距永远从全局令牌取,**不要**复制一份到局部变量。
|
||||
|
||||
### 5.4 无障碍基线
|
||||
|
||||
- 装饰性图标 `aria-hidden="true"`;**只有图标**的控件必须传 `label`(进 `aria-label` 与 `title`)。
|
||||
- 错误提示 `role="alert"`;说明类提示不加 live region,避免每次渲染都被播报。
|
||||
- 不得移除 `focus-visible`;隐藏但仍可聚焦的元素要 `tabIndex={-1}`。
|
||||
- 文案用中文标点;数字与单位之间不加空格(`24px`、`0.3s`)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 验收清单(合并前逐条打勾)
|
||||
|
||||
```text
|
||||
[ ] 1. 组件 CSS 里没有 hex / rgba / 字号 / 间距字面量(只用 --su-*)
|
||||
[ ] 2. 覆盖默认 / hover / disabled / focus-visible(交互组件再加 loading)
|
||||
[ ] 3. 透传 ...rest,button 的 type 默认 "button"
|
||||
[ ] 4. className / style 作用在最外层
|
||||
[ ] 5. 无外边距(margin),间距交给 Space / 父容器
|
||||
[ ] 6. 展示页新增一节:变体 / 状态 / props 表 / 代码片段
|
||||
[ ] 7. 导航锚点、README 清单、COMPONENTS.md 契约三处同步
|
||||
[ ] 8. npm run typecheck && npm run lint && npm run build 通过
|
||||
[ ] 9. 截图核对(scripts/screenshot.mjs),无视觉回归
|
||||
[ ] 10. 没有新增运行时依赖
|
||||
[ ] 11. 深色主题下不破:不写死白底/黑字假设,颜色全部走令牌
|
||||
[ ] 12. props 名符合 §5.2;没有出现「业务字段名」(如 categoryId)
|
||||
[ ] 13. 键盘走一遍:Tab 能看到焦点环,Enter/Space 能触发,Esc 能退出浮层
|
||||
[ ] 14. 打开「减少动态效果」后没有持续动画(spinner 允许放慢)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 深色主题
|
||||
|
||||
已实现,组件零改动:所有颜色走语义令牌,深色只覆盖令牌块([tokens.css](src/theme/tokens.css) 末尾)。
|
||||
|
||||
用法:在 `<html>` 上设置 `data-scheme`:
|
||||
|
||||
| 值 | 效果 |
|
||||
| --- | --- |
|
||||
| 不设置 / `light` | 浅色 |
|
||||
| `dark` | 深色 |
|
||||
| `auto` | 跟随系统(`prefers-color-scheme`) |
|
||||
|
||||
```html
|
||||
<html lang="zh-CN" data-scheme="dark">
|
||||
```
|
||||
|
||||
取值来自参考站 `body[data-scheme=dark]` 的实测映射(见 [拆解文档 §8](docs/ALRIGHT-DECOMPOSITION.md)):
|
||||
|
||||
- 灰阶整体反相:`gray-5..8` 变深,文字改用浅色;
|
||||
- `--su-bg-card` → `#202022`、`--su-border` → `#303033`、`--su-bg-page` → `#0f0f11`;
|
||||
- 浅色底(`primary-soft` / `danger-soft` / `success-soft` / `warning-soft`)透明度提高,否则标签看不见;
|
||||
- 阴影换成更深的黑(深色下浅色阴影没有意义)。
|
||||
|
||||
**新增组件的深色要求**:
|
||||
|
||||
- 颜色只用语义令牌(`bg-card` / `bg-subtle` / `bg-light` / `bg-hover` / `text-*` / `border-*`),
|
||||
不要直接写 `--su-gray-*`——灰阶在深色下是反相的,直接用会串色;
|
||||
- 展示页用 `demo.html?scheme=dark` 或右上角开关看一眼深色;
|
||||
- 验收清单第 11 条会在深色下逐项检查。
|
||||
|
||||
---
|
||||
|
||||
## 8. 决策记录(为什么是这样)
|
||||
|
||||
| 决策 | 结论 | 理由 |
|
||||
| --- | --- | --- |
|
||||
| 视觉基准 | 以参考站实测值为准 | 之前按文档里的旧值做(阴影 2%、字阶 10/12/14/16),实测是 1% 和 13/14/16/18,见拆解文档 |
|
||||
| 自研 vs 直接用 antd | 自研 | 视觉目标是 Alright 主题,用 antd 要重写大半 token;单二进制发布也不想多 300KB+ |
|
||||
| 样式方案 | CSS Modules | Vite 内置、零依赖、类名可读;Tailwind 会把样式散进 className,emotion 要运行时 |
|
||||
| 令牌前缀 | `--su-` | 宿主应用(stash-web)自己有一批 `--primary-color` 之类变量,必须能共存 |
|
||||
| 组件边界 | 不收业务组件 | 业务卡片变化快,进库会把组件库拖成第二个页面层 |
|
||||
| 展示站 | 自建 Vite 站 | 见 README「为什么不直接用 Storybook」 |
|
||||
| 图标 | iconfont 字形子集 | 主题自带线性图标;按需加字形(流程见 COMPONENTS.md §5) |
|
||||
| 焦点样式 | 与参考站相反:保留并加粗 | 参考站把 focus 样式删了(`box-shadow:none`),标了 tab 看不见焦点;组件库必须可访问 |
|
||||
|
||||
---
|
||||
|
||||
## 9. 已知坑(都踩过)
|
||||
|
||||
1. **展示站要显式 import 主题 CSS**:只靠 `src/index.ts` 的副作用引入时,生产构建会把 `iconfont.css` 摇掉(dev 正常、build 后图标全空)。
|
||||
2. **库产物的 CSS 内联了图标字体**:lib 模式下 Vite 把 woff2 转成 data URI,`dist/stash-ui.css` 有 80KB+(gzip 56KB),换来「只引一个 CSS」。
|
||||
3. **比例封面不要放进 `flex-direction: column` 的父容器**:纵向 flex 会把子项按内容高度压缩,`aspect-ratio` 封面被裁掉一截;用 grid 或 block 容器包一层。
|
||||
4. **并排控件必须同高**:`Input` 与 `Button size="md"` 都是 `--su-control-height-md`。
|
||||
5. **展示站里不要用 `.demo-*` 之外的自造类**:文档站样式只写在 `site/app.css`,组件样式不外溢。
|
||||
6. **别照着旧文档抄数值**:参考站的 token(`--font-size-lg: 16px`)和它实际渲染的 `.text-lg: 18px` 不是一回事,
|
||||
以 [拆解文档](docs/ALRIGHT-DECOMPOSITION.md) 的实测表为准。
|
||||
|
||||
---
|
||||
|
||||
## 10. 版本与变更
|
||||
|
||||
- 当前 `0.x`:API 仍可能调整,破坏性改动在 [COMPONENTS.md](docs/COMPONENTS.md) 末尾的「变更记录」记一行。
|
||||
- 组件新增 / 删除 / 重命名,必须同步三处:`src/index.ts`、展示页、COMPONENTS.md。
|
||||
|
||||
## 11. 外部风格接入(2026-10-28 记录,讨论中,尚未执行)
|
||||
|
||||
> **场景**(之前记错了,这里更正):
|
||||
> 开新项目时,使用方会给一张**别的网站的截图**,风格与 stash / Alright **不一致**,
|
||||
> 要求「照这个做布局 / 这个组件」。这时必然出现三种情况:
|
||||
> 库里没有、参数不够、组件不灵活。
|
||||
>
|
||||
> 这不是「迁移时补 CSS」的问题,而是**库要不要吸收外来设计语言**的问题。
|
||||
> 如果不设闸门,结果只有两个:要么每个项目各写一套页面 CSS(库永远长不大),
|
||||
> 要么把品牌专属样式塞进通用组件(库变成第二个页面层)。
|
||||
|
||||
### 11.1 为什么会撞墙(现有结构的三层限制)
|
||||
|
||||
| 层 | 现状 | 外来截图会撞到的地方 |
|
||||
| --- | --- | --- |
|
||||
| **颜色 / 字体** | 语义令牌 + CSS 变量,可整体覆盖 | 基本够用:换 `--su-color-*` / `--su-font-*` 即可,但**前提是页面只用语义令牌** |
|
||||
| **形态**(圆角 / 阴影 / 高度 / 密度) | 一部分是令牌(radius、shadow、control-height),一部分写死在组件里(卡片圆角、按钮高度档位、Tab 高度) | 外来设计常用「32 / 40 / 48 三档高度」「24px 大圆角」「无阴影」——现在只能新增组件参数或覆盖内部类名 |
|
||||
| **结构 / 交互** | 组件是按 stash 的形态做的(分区卡、封面卡、侧栏菜单) | 外来截图可能是「瀑布流 + 悬浮操作条」「顶部 Tab + 全宽表格」——这是**新组件**,不是参数问题 |
|
||||
|
||||
结论先说:**令牌对「配色/字体」够用,对「形态」还不够;组件对「结构」永远需要增量。**
|
||||
所以闸门不是「能不能加」,而是「加在哪一层、以什么粒度加」。
|
||||
|
||||
### 11.2 闸门流程(收到截图后,先停再动手)
|
||||
|
||||
```text
|
||||
截图
|
||||
→ ① 拆解:把截图拆成「布局骨架 / 组件清单 / 形态值 / 交互」四张表
|
||||
→ ② 映射:每一项对到库里的组件 + 令牌,标出「完全匹配 / 参数可调 / 缺失」
|
||||
→ ③ 决策:按 11.3 的矩阵定归属(主题覆盖 / 加参数 / 加新组件 / 页面私有)
|
||||
→ ④ 评审:把「缺失项 + 最小 API 方案」给使用方确认(此时不动代码)
|
||||
→ ⑤ 实施:库先行(组件 + 契约 + 展示页 + 令牌),页面后接
|
||||
→ ⑥ 验收:深浅色、焦点、reduced-motion、移动端、无业务字段
|
||||
```
|
||||
|
||||
第 ② 步的产物就是一张 **「截图 → 组件/令牌」映射表**,模板见 [docs/STYLE-INTAKE.md](docs/STYLE-INTAKE.md)。
|
||||
|
||||
### 11.3 决策矩阵(外来样式该落到哪)
|
||||
|
||||
| 情况 | 归属 | 例子 |
|
||||
| --- | --- | --- |
|
||||
| 只是配色 / 字体不同 | **主题覆盖**:新增 `themes/<name>.css`,只改变量 | 紫色主色、衬线标题 |
|
||||
| 同结构、形态不同(圆角 / 高度 / 密度) | **加参数或加尺寸档**到现有组件 | 按钮 40 / 48 档、卡片 16 圆角 |
|
||||
| 同结构、交互不同(悬浮操作、展开方式) | **加 variant**(受控、可访问) | Tab 改成胶囊 + 下划线、卡片 hover 出操作 |
|
||||
| 结构不同(新骨架 / 新组合模式) | **新组件**,前提是「≥2 处会用」或「属于通用模式」 | 瀑布流、顶部筛选条、全宽表格 |
|
||||
| 只此一页的装饰(背景图、悬浮搜索、文章排版) | **页面私有 CSS**,必须在注释里写清「为什么不是组件」 | stash 的背景图层 |
|
||||
|
||||
三条硬边界:
|
||||
|
||||
1. **不许 fork 库**(复制组件到项目里改),要么参数化,要么进库;
|
||||
2. **不许在页面覆盖组件内部类名**(`.su-xxx` / hashed class),只能传 props 或改令牌;
|
||||
3. **品牌专属值不许进组件默认值**:外来项目的紫色不能改 `--su-color-primary` 的默认值,只能在主题文件里覆盖。
|
||||
|
||||
### 11.4 吸收的判定标准(避免库被拖成页面层)
|
||||
|
||||
一个能力同时满足下面三条才进库:
|
||||
|
||||
- **可复用**:至少两个使用方 / 两个页面会用到,或属于公认的通用模式;
|
||||
- **无业务**:不携带字段、不请求数据、不 import 业务类型;
|
||||
- **可验收**:能用 props 表达,且不破坏现有契约(破坏性改动走 COMPONENTS 变更记录)。
|
||||
|
||||
### 11.5 改共享组件必须报兼容性影响
|
||||
|
||||
组件不只一个地方用,改之前(以及交付说明里)必须显式列出:
|
||||
|
||||
1. **调用方清单**:哪些页面 / 组件用了它(至少列出仓库内已知调用点);
|
||||
2. **变更性质**:新增可选 prop(兼容)/ 改默认值(视觉回归)/ 改或删 prop(破坏性)/ 只改样式(视觉回归);
|
||||
3. **回归范围**:哪些展示页 demo、哪些应用页面需要重新截图;
|
||||
4. **迁移方式**:破坏性改动要给出替代写法(旧 → 新 的对照),必要时保留一个版本周期。
|
||||
|
||||
> 经验:这次 `Layout` 把「页头」从限宽容器里移出来(页头通栏),属于**视觉回归**,
|
||||
> 虽然 API 没变,但所有用 `contained` 的页面都会变——这种改动必须在说明里点出来,而不是静默提交。
|
||||
|
||||
### 11.5 待办(只记录,不执行)
|
||||
|
||||
- **令牌分层**:现在 `--su-*` 把「原语(灰阶、色板)」和「语义(bg-card、text-sub)」混在一起,
|
||||
外来主题想换「形态」时没有落点。候选方案:
|
||||
`--su-primitive-*`(色板/尺度)→ `--su-*`(语义)→ 组件只吃语义;另外把「形态值」抽成
|
||||
`--su-shape-*`(radius / shadow / control-height / density),让主题能整体换形态。
|
||||
- **尺寸档位**:Button / Input / Avatar 的高度档是否需要补齐 40 / 48,以及是否引入 `density="compact|comfortable"`。
|
||||
- **主题文件规范**:`themes/<name>.css` 只允许改变量,禁止写选择器;需要一个 lint 或 review 检查项。
|
||||
- **外部截图进库的示例**:拿一个真实的外来设计走一遍 11.2,验证流程是否可落地。
|
||||
@@ -0,0 +1,253 @@
|
||||
# Stash UI
|
||||
|
||||
Stash 的设计系统与组件库:**设计令牌 + 基础组件 + 组件展示站**。
|
||||
独立于 `stash` 应用仓库,后续页面(控制台、详情页、后台)都从这里取组件拼装。
|
||||
|
||||
- 视觉标准来自 Alright 主题(与 `stash/ALRIGHT-DESIGN.md` 一致);
|
||||
- 零运行时依赖(只 peer 依赖 react / react-dom);
|
||||
- 样式用 CSS Modules + 设计令牌,换主题只改变量;
|
||||
- 自带一个可点开的展示站,每个组件都有变体、状态、props 与代码片段。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # 文档站:http://127.0.0.1:5199
|
||||
# 整页示例:http://127.0.0.1:5199/demo.html(?scheme=dark 直接进深色)
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run build # 构建库:dist/stash-ui.js + dist/stash-ui.css + dist/types
|
||||
npm run build:site # 构建静态站点:dist-site/(index.html + demo.html)
|
||||
npm run lint # oxlint
|
||||
|
||||
# 截图核对(需要本机 Chrome / Edge;playwright-core 不自带浏览器)
|
||||
node scripts/screenshot.mjs # 首屏
|
||||
node scripts/screenshot.mjs http://127.0.0.1:5199/#buttons shots/buttons.png
|
||||
node scripts/screenshot.mjs http://127.0.0.1:5199/ shots/cards.png '#cards' # 只截某一节
|
||||
```
|
||||
|
||||
环境要求:Node 24(`package.json` 里用 volta 固定了版本)。
|
||||
|
||||
## 文档(先读这个)
|
||||
|
||||
四份文档构成组件库的「契约」,新增 / 修改组件全部以它们为准:
|
||||
|
||||
| 文档 | 内容 | 什么时候看 |
|
||||
| --- | --- | --- |
|
||||
| [DESIGN.md](DESIGN.md) | **规格总纲**:分层结构、设计令牌、视觉规则、代码约定、验收清单、决策与已知坑 | 动手前、评审时 |
|
||||
| [docs/COMPONENTS.md](docs/COMPONENTS.md) | **组件目录与契约**:现有 40 个组件的 API 与边界、组合模式、候选组件、图标新增流程 | 想知道「用哪个 / 有没有 / 缺什么」 |
|
||||
| [docs/ADDING-A-COMPONENT.md](docs/ADDING-A-COMPONENT.md) | **新增组件手册**:12 步流程、代码模板、`Spinner` 完整示例、常见错误 | 要补一个组件时照着做 |
|
||||
| [docs/ALRIGHT-DECOMPOSITION.md](docs/ALRIGHT-DECOMPOSITION.md) | **参考站拆解**:实测令牌、组件尺寸、交互节奏、暗色映射、保留差异 | 想核对「这个值对不对」 |
|
||||
| [docs/STYLE-INTAKE.md](docs/STYLE-INTAKE.md) | **外部风格接入**:截图 → 组件/令牌映射表、决策矩阵、验收清单 | 新项目给了外部设计稿、和现有风格不一致时 |
|
||||
|
||||
缺组件时的标准动作:先在 COMPONENTS.md §5 找到它(或登记一行)→ 按 ADDING-A-COMPONENT.md 走流程 →
|
||||
用 DESIGN.md §6 的清单验收。
|
||||
|
||||
## 给 AI 用
|
||||
|
||||
仓库自带一个 skill,AI 不需要每次把五份文档读一遍:
|
||||
|
||||
- [`skills/stash-ui/SKILL.md`](skills/stash-ui/SKILL.md) —— **入口**:任务分流(用 / 改 / 加)、硬约束、验证命令、已知坑
|
||||
- [`AGENTS.md`](AGENTS.md) / [`CLAUDE.md`](CLAUDE.md) —— 仓库根指路,Codex / Claude Code / Cursor 会自动读到
|
||||
- [docs/AI-USAGE.md](docs/AI-USAGE.md) —— 三种接入方式(直接读文件 / DSH 技能目录 / DSH 插件包)与维护规则
|
||||
|
||||
原则:**skill 只做路由和护栏,规则只写在文档里**,避免两处维护。
|
||||
|
||||
## 主题(外部风格)
|
||||
|
||||
- [`themes/example.css`](themes/example.css) —— 只改 CSS 变量的主题示例(换品牌色 / 圆角 / 阴影 / 控件高度);
|
||||
- 用法:`style.css` 之后引入主题文件,**禁止写选择器**;
|
||||
- 完整流程(截图 → 映射 → 决策 → 吸收)见 [DESIGN.md §11](DESIGN.md) 与 [docs/STYLE-INTAKE.md](docs/STYLE-INTAKE.md)。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
stash-ui/
|
||||
├── DESIGN.md # 规格总纲(分层/令牌/视觉/验收)
|
||||
├── AGENTS.md / CLAUDE.md # AI 入口(指路到 skill 与文档)
|
||||
├── skills/stash-ui/SKILL.md # AI skill:任务分流 + 约束 + 验证
|
||||
├── docs/
|
||||
│ ├── COMPONENTS.md # 组件目录与契约
|
||||
│ ├── ADDING-A-COMPONENT.md # 新增组件手册
|
||||
│ ├── ALRIGHT-DECOMPOSITION.md# 参考站拆解(数值依据)
|
||||
│ └── AI-USAGE.md # AI 接入方式与 skill 维护
|
||||
├── src/ # 组件库(发布物只有这一层)
|
||||
│ ├── index.ts # 统一出口:组件 + 类型 + 样式
|
||||
│ ├── theme/
|
||||
│ │ ├── tokens.css # 设计令牌(颜色/间距/字号/圆角/阴影/动效)
|
||||
│ │ ├── icons.ts # 446 个图标名(自动生成)
|
||||
│ │ ├── iconfont.css # 图标字体与字形(自动生成)
|
||||
│ │ └── assets/iconfont.woff2
|
||||
│ └── components/ # 37 个组件,每个 = Xxx.tsx + Xxx.module.css(见下方清单)
|
||||
├── site/ # 展示站(不参与库构建)
|
||||
│ ├── index.html / demo.html # 文档站 / 整页示例
|
||||
│ ├── App.tsx # 左侧目录 + 右侧示例
|
||||
│ ├── app.css # 文档站自己的排版样式
|
||||
│ ├── components/ # Section / Demo / PropsTable
|
||||
│ └── sections/ # 每个组件一节
|
||||
├── scripts/screenshot.mjs # 截图核对用
|
||||
├── vite.config.ts # 展示站构建
|
||||
└── vite.lib.config.ts # 库构建
|
||||
```
|
||||
|
||||
## 组件清单
|
||||
|
||||
| 组件 | 用途 | 关键 props |
|
||||
| --- | --- | --- |
|
||||
| `Button` | 操作按钮(圆角 / 胶囊,支持描边) | `variant` `size` `shape` `block` `loading` `icon` |
|
||||
| `IconButton` | 圆形图标按钮(28/36/44) | `label`(必填) `icon` `variant` `size` `spinning` |
|
||||
| `Card` | 白色容器(极浅描边) | `as` `padding` `hoverable` |
|
||||
| `Cover` | 比例封面卡(2:3 / 4:3 / 16:9) | `ratio` `src` `caption` `lift` `onClick` |
|
||||
| `Image` | 带比例 / 圆角 / 失败降级的图片 | `ratio` `fit` `radius` `fallback` |
|
||||
| `FormField` | 表单字段排版(标签 + 提示 / 错误) | `label` `hint` `error` `required` |
|
||||
| `Typography` | 标题 / 文字 / 段落 | `level` `tone` `size` `lines` |
|
||||
| `Skeleton` | 骨架屏占位 | `variant` `lines` `width` `height` |
|
||||
| `Spinner` | 加载指示 | `size` `label` |
|
||||
| `Switch` | 即时开关 | `checked` `onChange` `size` `label` |
|
||||
| `Checkbox` | 复选框 | `checked` `onChange` `label` |
|
||||
| `RadioGroup` | 互斥单选组 | `options` `value` `onChange` `direction` |
|
||||
| `Select` | 受控下拉选择 | `options` `value` `onChange` `placeholder` |
|
||||
| `Segmented` | 视图形态切换 | `options` `value` `onChange` `size` `block` |
|
||||
| `Tabs` | 页内分区切换 | `items` `activeKey` `onChange` `variant` |
|
||||
| `Menu` | 侧栏两级菜单 | `items` `activeKey` `onSelect` |
|
||||
| `Breadcrumb` | 路径导航 | `items` `separator` |
|
||||
| `Table` | 只读数据表格 | `columns` `rows` `rowKey` `emptyText` |
|
||||
| `Tag` | 分类 / 标签 / 状态(浅底 / 实底) | `tone` `variant` `shape` `icon` `dot` |
|
||||
| `ItemBadge` | 状态角标:置顶 / 付费 / HOT | `tone` |
|
||||
| `Badge` | 数字角标 | `count` `tone` `max` |
|
||||
| `Avatar` | 头像 / 封面占位 | `src` `fallback` `size` `shape` `verified` |
|
||||
| `Grid` | 卡片列表网格 | `columns` `tabletColumns` `mobileColumns` `gap` `as` |
|
||||
| `Layout` | 页面骨架:页头 / 侧栏 / 内容 / 右栏 / 页脚 | `header` `sider` `aside` `footer` `maxWidth` `gap` |
|
||||
| `Space` | 元素间距 | `direction` `gap` `wrap` `align` `justify` |
|
||||
| `Divider` | 分隔线 | `orientation` `variant` `spacing` |
|
||||
| `Input` | 输入框 | `icon` `clearable` `onClear` `shortcut` `inputRef` |
|
||||
| `Notice` | 提示条 | `tone` `title` `action` |
|
||||
| `Empty` | 空态 | `text` `action` |
|
||||
| `Result` | 整块结果反馈 | `status` `title` `description` `actions` |
|
||||
| `Toast` | 命令式轻提示 | `toast()` `toast.success()` `<Toaster />` |
|
||||
| `Progress` | 进度条 | `percent` `size` `tone` `showLabel` |
|
||||
| `Steps` | 纵向步骤条 | `items` `current` |
|
||||
| `Modal` | 居中弹窗(原生 dialog) | `open` `onClose` `title` `footer` `size` |
|
||||
| `Drawer` | 侧滑抽屉(原生 dialog) | `open` `onClose` `title` `placement` `width` |
|
||||
| `Tooltip` | hover / 聚焦文字提示 | `content` `placement` |
|
||||
| `Popover` | 点击展开的小面板 | `content` `placement` |
|
||||
| `Pagination` | 分页 | `page` `total` `pageSize` `onChange` |
|
||||
| `ScrollTop` | 回到顶部 | `visible` `targetId` |
|
||||
| `Icon` | 图标(446 个字形) | `name` |
|
||||
|
||||
## 整页示例与深色主题
|
||||
|
||||
`site/demo.html` 是用这套组件拼出来的完整页面(三栏布局:资料卡 / 内容流 / 侧栏聚合),
|
||||
只用了库里的组件 + 页面自己的布局样式:
|
||||
|
||||
- `http://127.0.0.1:5199/demo.html` —— 浅色
|
||||
- `http://127.0.0.1:5199/demo.html?scheme=dark` —— 深色
|
||||
- 右上角按钮可以实时切换
|
||||
|
||||
深色主题由 `<html data-scheme>` 驱动(`dark` / `auto` / 不设置),**组件零改动**:
|
||||
所有颜色走语义令牌,深色只覆盖 [tokens.css](src/theme/tokens.css) 末尾的变量块。
|
||||
新增组件时不要直接写 `--su-gray-*`,否则深色下会串色——规则见 [DESIGN.md §7](DESIGN.md)。
|
||||
|
||||
## 在应用里使用
|
||||
|
||||
库构建产物是标准 ESM 包,本地项目可以直接按路径引用:
|
||||
|
||||
```bash
|
||||
# 在应用目录里
|
||||
npm install ../stash-ui # 或 "dependencies": { "@stash/ui": "file:../stash-ui" }
|
||||
```
|
||||
|
||||
```tsx
|
||||
// main.tsx:样式只引一次(令牌 + 图标字体 + 组件样式都在这份文件里)
|
||||
import '@stash/ui/style.css'
|
||||
|
||||
import { Button, Card, Grid } from '@stash/ui'
|
||||
```
|
||||
|
||||
接入约定:
|
||||
|
||||
- 只引一次 `style.css`,不要在页面里重复引令牌;
|
||||
- 应用自己的页面级样式(布局、页面专属类)写在应用里,不要加进组件库;
|
||||
- 需要改主题就覆盖 `--su-*` 变量,不要覆盖组件的内部类名。
|
||||
|
||||
## 浏览器与响应式
|
||||
|
||||
**目标**:桌面 Chrome / Edge / Safari / Firefox 最新两个版本;iOS Safari 16+、Android Chrome 110+。
|
||||
库只用这些基线能力:CSS 变量、CSS Modules、`:focus-visible`、`color-mix()`(吸顶页头)、
|
||||
`<dialog>`(Modal / Drawer)、`dvh`、`-webkit-line-clamp`(段落截断)。
|
||||
|
||||
响应式在组件内部处理,页面不用各写一套:
|
||||
|
||||
| 断点 | 组件 | 行为 |
|
||||
| --- | --- | --- |
|
||||
| ≤1200px | `Layout` | 右栏堆到内容下方 |
|
||||
| ≤1100px | `Layout` / `Grid` | 侧栏堆到内容上方;网格降为 2 列 |
|
||||
| ≤720px | `Grid` | 网格降为 1 列 |
|
||||
|
||||
触屏相关的约定:
|
||||
|
||||
- 交互只用 click,不依赖 hover;`Tooltip` 的 hover 样式包在 `@media (hover: hover)` 里;
|
||||
- 弹层宽度都有夹取(Modal `100vw - 32px`、Popover `min(320px, 100vw - 32px)`、Drawer `max-width: 100vw`);
|
||||
- 表格、标签栏、分段控件在窄屏横向滚动,不做压缩;
|
||||
- 移动端建议用 `sm(36)` / `md(44)` 的图标按钮,`xs(28)` 留给桌面密集场景。
|
||||
|
||||
已实测:文档站与示例页在 **390×844(手机)**、**820×1180(平板)**、**1440×900(桌面)** 下无横向溢出。
|
||||
**尚未**在真机(iOS Safari 软键盘、`dvh`、`backdrop-filter`)上复核。
|
||||
|
||||
## 约定(摘要,完整规则见 DESIGN.md)
|
||||
|
||||
- **令牌先行**:组件里不允许出现硬编码色值 / 字号 / 间距,一律走 `--su-*`;
|
||||
- **CSS Modules**:每个组件一个 `Component.module.css`,类名短(`.root` / `.sm`)且不外泄;
|
||||
- **组件不带数据**:库里的组件只接收 props,不请求接口、不读全局状态;
|
||||
- **新增组件**:按 [ADDING-A-COMPONENT.md](docs/ADDING-A-COMPONENT.md) 的 12 步走,
|
||||
并用 [DESIGN.md §6](DESIGN.md) 的清单验收(组件文件 → 导出 → 展示页 → 导航 → README 清单 → 契约 → typecheck/lint/build → 截图)。
|
||||
|
||||
## 与 stash 应用的关系
|
||||
|
||||
`stash-web` 目前仍是自己的全局 CSS(`App.css`),本次接入是分阶段的:
|
||||
|
||||
1. **已具备**(本仓库):独立组件库 + 展示站,可单独构建与预览;
|
||||
2. **下一步**:在 `stash-web` 里以 `file:` 依赖接入,先把侧栏 / 顶栏 / 空态 / 按钮换成库组件;
|
||||
3. **再下一步**:把 `App.css` 按组件拆分,页面只保留布局样式;同步删掉库中重复的样式定义。
|
||||
|
||||
对照表(迁移时查):
|
||||
|
||||
| stash 现有类 | 组件库对应 |
|
||||
| --- | --- |
|
||||
| `.card-box` | `Card` |
|
||||
| `.section-empty` | `Empty` |
|
||||
| `.notice` / `.notice-error` | `Notice`(`tone="info" / "error"`) |
|
||||
| `.header-info-tag` / `.item-tag` / `.service-pill` | `Tag` |
|
||||
| `.count-badge` | `Badge` |
|
||||
| `.search-bar` | `Input`(`icon="search"` + `clearable`) |
|
||||
| `.app-grid` / `.poster-grid` / `.article-grid` / `.ces-grid` | `Grid` |
|
||||
| `.back-to-top` | `ScrollTop` |
|
||||
| `.profile-avatar` / `.app-card-avatar` | `Avatar` |
|
||||
|
||||
## 构建注意(踩过的坑)
|
||||
|
||||
- **展示站要显式 import 主题 CSS**:`site/main.tsx` 里直接引 `src/theme/tokens.css` 与
|
||||
`src/theme/iconfont.css`。只靠 `src/index.ts` 的副作用引入时,生产构建会把
|
||||
`iconfont.css` 摇掉(dev 正常、build 后图标全空)。
|
||||
- **库产物的 CSS 内联了图标字体**:lib 模式下 Vite 会把 woff2 转成 data URI,
|
||||
所以 `dist/stash-ui.css` 有 80KB+(gzip 56KB),换来「只引一个 CSS 文件」。
|
||||
展示站走普通构建,字体仍是独立资源。
|
||||
- **比例封面不要放进 `flex-direction: column` 的父容器**:纵向 flex 会把子项按内容高度压缩,
|
||||
带 `aspect-ratio` 的封面会被裁掉一截(`site/app.css` 里纵向示例因此改用 grid)。
|
||||
- **组件尺寸对齐**:`Input` 与 `Button size="md"` 都是 36px,并排成一行才是齐的。
|
||||
|
||||
## 为什么不直接用 Storybook
|
||||
|
||||
Storybook 能做的事我们这里只需要一小部分,而它要额外的依赖、端口和一套独立构建;
|
||||
本项目是「本地小工具 + 单二进制发布」的配套,直接做成一个 Vite 站点更轻:
|
||||
|
||||
- `site/` 就是文档站,dev 下改组件即时生效,build 后是纯静态页;
|
||||
- 展示用例直接用真实组件,不存在「story 与页面不一致」;
|
||||
- 截图脚本(`scripts/screenshot.mjs`)已经把视觉回归的基础打好了,
|
||||
将来要接 Playwright 截图对比也不用换工具。
|
||||
|
||||
## 后续可做
|
||||
|
||||
- 暗色主题:令牌已经收敛,补一份 `[data-theme='dark']` 覆盖即可;
|
||||
- Typography / Menu / Tabs / Modal 这类更上层的组件;
|
||||
- 展示站加「复制代码」与 props 表自动生成;
|
||||
- 视觉回归:在展示站上加 Playwright 截图对比(`scripts/screenshot.mjs` 已经是起点)。
|
||||
@@ -0,0 +1,365 @@
|
||||
# 新增一个组件 · 操作手册
|
||||
|
||||
> 配套:[DESIGN.md](../DESIGN.md)(规则)、[COMPONENTS.md](COMPONENTS.md)(目录与契约)
|
||||
> 目标:任何人(或 AI)只读这三份文档,就能补出一个风格、API、文档都与现有组件一致的组件。
|
||||
|
||||
---
|
||||
|
||||
## 0. 先判断:该不该进库
|
||||
|
||||
三个问题**全部为「是」**才进库:
|
||||
|
||||
1. **通用吗**:换一个页面、换一个应用还用得上?
|
||||
2. **无业务吗**:不需要请求接口、不需要认识 `Category` / `entry` 这类业务字段?
|
||||
3. **组合不出来吗**:现有组件拼不出来?只是「拼起来麻烦一点」不算。
|
||||
|
||||
对应处理:
|
||||
|
||||
| 判断结果 | 处理 |
|
||||
| --- | --- |
|
||||
| 全「是」 | 进库,走下面的流程 |
|
||||
| 业务相关 | 放应用里(页面层),用现有组件拼;必要时在 COMPONENTS.md §4 记一条组合模式 |
|
||||
| 组合能解决 | 不进库,把组合写进展示页 `#composition` |
|
||||
|
||||
---
|
||||
|
||||
## 1. 流程总览(12 步)
|
||||
|
||||
| # | 动作 | 产物 | 卡点 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 在 COMPONENTS.md 登记 | 组件地图一行 + §3 契约草案(状态 🚧) | 没登记不开工 |
|
||||
| 2 | 定层级与命名 | 决定 L1–L4 与组件名 | 参照 DESIGN §2 |
|
||||
| 3 | 设计 API | props 表(含类型、默认值) | 命名必须符合 DESIGN §5.2 |
|
||||
| 4 | 写组件 | `src/components/Xxx.tsx` | 透传 rest、`type="button"`、className 合并 |
|
||||
| 5 | 写样式 | `src/components/Xxx.module.css` | 只用令牌;覆盖状态矩阵 |
|
||||
| 6 | 导出 | `src/index.ts` | 组件 + `export type` |
|
||||
| 7 | 展示页 | `site/sections/Xxx.tsx` + App.tsx 挂载 | 变体 / 状态 / props / 代码 |
|
||||
| 8 | 导航与清单 | `site/App.tsx` 的 NAV、README 组件表 | 三处同步(还有契约) |
|
||||
| 9 | 契约定稿 | COMPONENTS.md 状态改 ✅ | 与代码逐字对一遍 |
|
||||
| 10 | 验收 | DESIGN §6 清单逐条打勾 | 有一条不过就不提交 |
|
||||
| 11 | 截图核对 | `shots/` 下的截图 | 看 hover / disabled / 边界 |
|
||||
| 12 | 提交 | `feat(ui): add Xxx` | 一个组件一个提交 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 模板
|
||||
|
||||
### 2.1 组件 TSX
|
||||
|
||||
```tsx
|
||||
import type { HTMLAttributes, ReactNode } from 'react'
|
||||
import styles from './Xxx.module.css'
|
||||
|
||||
/**
|
||||
* 一句话说清它是什么。
|
||||
*
|
||||
* 第二段写「边界」:不该用在哪、和相邻组件的区别。
|
||||
*/
|
||||
export type XxxVariant = 'primary' | 'secondary'
|
||||
|
||||
export type XxxProps = {
|
||||
/** 语义变体 */
|
||||
variant?: XxxVariant
|
||||
/** 尺寸档位 */
|
||||
size?: 'sm' | 'md'
|
||||
children?: ReactNode
|
||||
} & HTMLAttributes<HTMLDivElement> // 透传原生属性
|
||||
|
||||
export function Xxx({ variant = 'primary', size = 'md', children, className, ...rest }: XxxProps) {
|
||||
// 类名合并:固定顺序 root → 修饰 → 外部传入
|
||||
const classes = [styles.root, styles[variant], styles[size], className].filter(Boolean).join(' ')
|
||||
|
||||
return (
|
||||
<div className={classes} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- `className` / `style` 落在**最外层**;
|
||||
- 变体用 `styles[variant]` 映射,不写 if 分支拼类名;
|
||||
- 按钮类组件:`type` 默认 `"button"`,`disabled` 与 `loading` 一起处理;
|
||||
- 受控组件不维护业务状态(`Avatar` 的图片加载失败属于 UI 状态,可以留)。
|
||||
|
||||
### 2.2 样式 module
|
||||
|
||||
```css
|
||||
/* 组件专属尺寸用局部变量;颜色 / 字号 / 间距一律全局令牌 */
|
||||
.root {
|
||||
--xxx-size: 36px;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-2);
|
||||
height: var(--xxx-size);
|
||||
padding: 0 var(--su-space-3);
|
||||
border-radius: var(--su-radius-sm);
|
||||
font-size: var(--su-font-size-md);
|
||||
color: var(--su-text-main);
|
||||
transition: background-color var(--su-duration-fast) var(--su-ease-out);
|
||||
}
|
||||
|
||||
/* 变体 */
|
||||
.primary {
|
||||
background-color: var(--su-color-primary);
|
||||
color: var(--su-white);
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background-color: var(--su-gray-2);
|
||||
color: var(--su-text-main);
|
||||
}
|
||||
|
||||
/* 尺寸 */
|
||||
.sm {
|
||||
--xxx-size: var(--su-control-height-sm);
|
||||
font-size: var(--su-font-size-sm);
|
||||
}
|
||||
|
||||
/* 状态:hover / disabled / focus-visible 见 DESIGN §4.3 */
|
||||
.root:hover:not(:disabled) {
|
||||
background-color: var(--su-gray-3);
|
||||
}
|
||||
|
||||
.root:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 展示页 section
|
||||
|
||||
```tsx
|
||||
import { Xxx } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
export function XxxSection() {
|
||||
return (
|
||||
<Section id="xxx" title="组件名" api="Xxx" desc="一句话说明它的职责和边界。">
|
||||
<Demo
|
||||
title="基础用法"
|
||||
desc="说明这个变体适合什么场景。"
|
||||
code={`<Xxx variant="primary">内容</Xxx>`}
|
||||
>
|
||||
<Xxx>内容</Xxx>
|
||||
</Demo>
|
||||
|
||||
<Demo title="状态" desc="把 hover / disabled / 空态都摆出来。" layout="column">
|
||||
<Xxx>默认</Xxx>
|
||||
<Xxx disabled>禁用</Xxx>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'variant', type: "'primary' | 'secondary'", defaultValue: "'primary'", desc: '语义变体' },
|
||||
{ name: '…rest', type: 'HTMLAttributes', desc: '透传原生属性' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 契约(贴进 COMPONENTS.md §3)
|
||||
|
||||
见 [COMPONENTS.md §8](COMPONENTS.md) 的模板。
|
||||
|
||||
---
|
||||
|
||||
## 3. 完整示例:`Spinner`
|
||||
|
||||
假设要补 COMPONENTS.md §5 里的 P2 组件 `Spinner`,从头到尾就是这样。
|
||||
|
||||
### 3.1 登记(第 1 步)
|
||||
|
||||
在 COMPONENTS.md §1 加一行:
|
||||
|
||||
| 组件 | 层 | 状态 | 一句话 | 关键 props |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `Spinner` | L4 | 🚧 | 独立加载指示 | `size` `label` |
|
||||
|
||||
### 3.2 组件(第 4 步)
|
||||
|
||||
`src/components/Spinner.tsx`:
|
||||
|
||||
```tsx
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import styles from './Spinner.module.css'
|
||||
|
||||
/**
|
||||
* 加载指示:一个旋转的圆环。
|
||||
*
|
||||
* 按钮内部的 loading 由 Button 自己处理;
|
||||
* 这个组件用于「整块区域正在加载」——列表首屏、面板刷新。
|
||||
* 一行文字的占位请用 Skeleton,不要用 Spinner。
|
||||
*/
|
||||
export type SpinnerProps = {
|
||||
size?: 'sm' | 'md'
|
||||
/** 无障碍名称,屏幕阅读器会读出来 */
|
||||
label?: string
|
||||
} & HTMLAttributes<HTMLSpanElement>
|
||||
|
||||
export function Spinner({ size = 'md', label = '加载中', className, ...rest }: SpinnerProps) {
|
||||
const classes = [styles.root, styles[size], className].filter(Boolean).join(' ')
|
||||
|
||||
return <span className={classes} role="status" aria-label={label} {...rest} />
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 样式(第 5 步)
|
||||
|
||||
`src/components/Spinner.module.css`:
|
||||
|
||||
```css
|
||||
.root {
|
||||
--spinner-size: 20px;
|
||||
--spinner-border: 2px;
|
||||
|
||||
display: inline-block;
|
||||
width: var(--spinner-size);
|
||||
height: var(--spinner-size);
|
||||
border: var(--spinner-border) solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: var(--su-radius-pill);
|
||||
color: var(--su-color-primary);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.sm {
|
||||
--spinner-size: 14px;
|
||||
--spinner-border: 2px;
|
||||
}
|
||||
|
||||
.md {
|
||||
--spinner-size: 20px;
|
||||
}
|
||||
|
||||
/* 尊重系统的「减少动态效果」:放慢而不是停住(停住会看起来像坏掉了) */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.root {
|
||||
animation-duration: 2.4s;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 导出(第 6 步)
|
||||
|
||||
`src/index.ts`:
|
||||
|
||||
```ts
|
||||
export { Spinner } from './components/Spinner'
|
||||
export type { SpinnerProps } from './components/Spinner'
|
||||
```
|
||||
|
||||
### 3.5 展示页(第 7 步)
|
||||
|
||||
`site/sections/Spinners.tsx`:
|
||||
|
||||
```tsx
|
||||
import { Spinner } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
export function Spinners() {
|
||||
return (
|
||||
<Section
|
||||
id="spinner"
|
||||
title="加载中"
|
||||
api="Spinner"
|
||||
desc="整块区域的加载指示;文字占位用 Skeleton,按钮内的加载态交给 Button 的 loading。"
|
||||
>
|
||||
<Demo title="尺寸" code={`<Spinner size="sm" />\n<Spinner size="md" />`}>
|
||||
<Spinner size="sm" />
|
||||
<Spinner size="md" />
|
||||
</Demo>
|
||||
|
||||
<Demo title="带文字的加载区" layout="column" code={`<Spinner label="正在读取内容库" />`}>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--su-space-2)' }}>
|
||||
<Spinner />
|
||||
<span style={{ color: 'var(--su-text-muted)' }}>正在读取内容库…</span>
|
||||
</span>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'size', type: "'sm' | 'md'", defaultValue: "'md'", desc: '14px / 20px' },
|
||||
{ name: 'label', type: 'string', defaultValue: "'加载中'", desc: '无障碍名称' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
`site/App.tsx`:在「交互」组里加 `{ id: 'spinner', label: '加载中 Spinner' }`,并在 `<Feedback />` 后挂 `<Spinners />`。
|
||||
|
||||
### 3.6 契约(第 9 步)
|
||||
|
||||
```markdown
|
||||
### Spinner ✅
|
||||
|
||||
- **用途**:整块区域正在加载(列表首屏、面板刷新)。
|
||||
- **不该用在哪**:按钮内部(用 `Button loading`)、文字占位(用 `Skeleton`)。
|
||||
- **解剖**:单个 span,`role="status"` + `aria-label`,纯 CSS 圆环。
|
||||
- **Props**:`size: 'sm' | 'md'`(14 / 20)、`label`(默认「加载中」)。
|
||||
- **规则**:颜色继承 `currentColor`;`prefers-reduced-motion` 下放慢到 2.4s,不停住。
|
||||
- **展示页**:`#spinner`
|
||||
```
|
||||
|
||||
### 3.7 验收与截图(第 10–11 步)
|
||||
|
||||
```bash
|
||||
npm run typecheck && npm run lint && npm run build && npm run build:site
|
||||
npm run dev # 另开一个终端
|
||||
node scripts/screenshot.mjs http://127.0.0.1:5199/ shots/spinner.png '#spinner'
|
||||
```
|
||||
|
||||
对着截图确认:尺寸档位一致、颜色是主色、没有布局抖动、深色底不破(把背景改成 `--su-gray-8` 试一下)。
|
||||
|
||||
### 3.8 提交(第 12 步)
|
||||
|
||||
```text
|
||||
feat(ui): add Spinner
|
||||
|
||||
- 新增加载指示组件(sm/md,role=status + aria-label)
|
||||
- 展示页补一节;COMPONENTS.md 契约与 README 清单同步
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 常见错误(都踩过或差点踩)
|
||||
|
||||
| 现象 | 原因 | 正确做法 |
|
||||
| --- | --- | --- |
|
||||
| 换主题后组件颜色不变 | CSS 里写死了 `#fff` / `rgba(...)` | 一律 `var(--su-*)`;颜色不够就去 tokens.css 补令牌 |
|
||||
| 组件在页面里位置飘 | 组件自带 margin | 组件不带外边距,间距交给 `Space` / 父容器 |
|
||||
| 并排的按钮/输入框高低不齐 | 高度写死且不一致 | 用 `--su-control-height-sm/md` |
|
||||
| 图标按钮读不出来 | 只有图标没有名称 | `IconButton` 的 `label` 必填 |
|
||||
| 表单里点按钮页面刷新 | 没写 `type` | 默认 `type="button"` |
|
||||
| 行式条目看起来「一块一块」 | 给列表行套了 `Card` | 列表行不加底色/边框/阴影 |
|
||||
| 比例封面被裁掉一截 | 外层是 `flex-direction: column` | 换 grid / block 容器(DESIGN §9) |
|
||||
| 生产构建图标全空 | 展示站没有显式引入 `iconfont.css` | `site/main.tsx` 显式 import 两个 theme CSS |
|
||||
| 展示页 demo 和真实页面不一致 | demo 里写了一份「演示版」 | demo 必须直接用组件,只喂数据 |
|
||||
| 组件里出现 `categoryId` | 把业务塞进了库 | 业务字段留在页面层 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 评审时的三句话
|
||||
|
||||
1. **够通用吗?**(不是业务组件、不是一次性布局)
|
||||
2. **只用了令牌吗?**(颜色/字号/间距没有字面量)
|
||||
3. **文档齐了吗?**(展示页一节 + 契约 + 清单 + 导航)
|
||||
|
||||
三条都过,就可以合并。
|
||||
@@ -0,0 +1,102 @@
|
||||
# 让 AI 正确使用 / 扩展这套组件库
|
||||
|
||||
结论先说:**需要 skill,但 skill 只做「入口 + 护栏 + 验证循环」,规则仍然只写在文档里。**
|
||||
否则规则会两处维护,迟早漂移。
|
||||
|
||||
## 1. 三件东西各管什么
|
||||
|
||||
| 文件 | 给谁 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| [`skills/stash-ui/SKILL.md`](../skills/stash-ui/SKILL.md) | 支持 skill 的 agent(DSH 等) | 任务分流(用 / 改 / 加)、硬约束、验证命令、已知坑 |
|
||||
| [`AGENTS.md`](../AGENTS.md) | 任意 AI 编码工具(Codex、Claude Code、Cursor…) | 进仓库先读到「去哪里看」 |
|
||||
| [`DESIGN.md`](../DESIGN.md) / [`docs/COMPONENTS.md`](COMPONENTS.md) / [`docs/ADDING-A-COMPONENT.md`](ADDING-A-COMPONENT.md) | 人与 AI | 唯一事实来源(规则、契约、流程) |
|
||||
|
||||
skill 的内容刻意很短(约 80 行),因为它解决的是**入口问题**:
|
||||
AI 不需要每次都把 5 份文档读一遍,而是先被路由到正确的文档和验收命令。
|
||||
|
||||
## 2. 三种接入方式
|
||||
|
||||
### 2.1 直接读文件(最通用,零配置)
|
||||
|
||||
任何能读仓库的 AI 都可以从 `skills/stash-ui/SKILL.md` 开始:
|
||||
|
||||
```text
|
||||
读 skills/stash-ui/SKILL.md,按里面的流程执行。
|
||||
```
|
||||
|
||||
Claude Code / Codex / Cursor 这类工具会自动读仓库根的 `AGENTS.md`(或 `CLAUDE.md`),
|
||||
所以把仓库交给它们时,入口是自动的。
|
||||
|
||||
### 2.2 DeepSeek Harness(技能目录)
|
||||
|
||||
如果你的 harness 支持扫描技能目录,把 `skills/stash-ui/` 整个复制到技能根目录即可
|
||||
(目录名即 skill 名,`SKILL.md` 的 frontmatter 里也有 `name: stash-ui`)。
|
||||
|
||||
### 2.3 DeepSeek Harness(插件包,官方同款做法)
|
||||
|
||||
DSH 的 skill 由 `@deepseek-ai/dsh-skill-filesystem` 提供,插件通过 `cordis.patch.yml`
|
||||
注册一个 `bundledSkillDir`。最小骨架如下(放在本仓库之外,例如 `stash-ui-dsh-plugin/`):
|
||||
|
||||
```text
|
||||
stash-ui-dsh-plugin/
|
||||
├── package.json # name: @yourscope/stash-ui-dsh,dsh.bundle.patch: ./cordis.patch.yml
|
||||
├── cordis.patch.yml
|
||||
├── lib/index.js # 导出 resolveStashSkillRoot(可选,便于测试)
|
||||
└── skills/stash-ui/SKILL.md
|
||||
```
|
||||
|
||||
`cordis.patch.yml`(把 `<你的包名>` 换成实际 npm 名):
|
||||
|
||||
```yaml
|
||||
- insert:
|
||||
- id: stash-ui-skill-filesystem
|
||||
name: '@deepseek-ai/dsh-skill-filesystem'
|
||||
config:
|
||||
providerName: stash-ui
|
||||
includeDefaultRoots: false
|
||||
bundledSkillDir: !!js process.getBuiltinModule('node:path').join(
|
||||
process.getBuiltinModule('node:path').dirname(
|
||||
process.getBuiltinModule('node:module').createRequire(baseUrl).resolve('<你的包名>/package.json')
|
||||
),
|
||||
'skills'
|
||||
)
|
||||
```
|
||||
|
||||
`package.json` 关键字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "<你的包名>",
|
||||
"type": "module",
|
||||
"files": ["lib", "cordis.patch.yml", "skills"],
|
||||
"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }
|
||||
}
|
||||
```
|
||||
|
||||
装到 DSH profile 后,`skill` 工具的技能目录里就会出现 `stash-ui`;
|
||||
如果没出现,先确认 patch 里的包名能被 profile 的 `createRequire(baseUrl)` 解析到。
|
||||
|
||||
## 3. AI 用这套库时的三条铁律
|
||||
|
||||
1. **先查再用**:`COMPONENTS.md §2` 有「用哪个」决策表;能组合就不新增组件。
|
||||
2. **令牌唯一**:颜色 / 间距 / 字号 / 圆角只走 `--su-*`;直接写 `--su-gray-*` 会在深色下串色。
|
||||
3. **改 props = 改契约**:组件、展示页、`COMPONENTS.md` + README 三处必须同时更新。
|
||||
|
||||
## 4. 验证循环(AI 必须自己跑)
|
||||
|
||||
```bash
|
||||
npm run typecheck && npm run lint && npm run build && npm run build:site
|
||||
node scripts/screenshot.mjs http://127.0.0.1:5199/#<section> shots/<section>.png
|
||||
node scripts/screenshot.mjs "http://127.0.0.1:5199/demo.html?scheme=dark" shots/demo-dark.png full
|
||||
```
|
||||
|
||||
- `lint` 要求 **0 warnings**(不是「能过就行」)。
|
||||
- 视觉改动要贴图核对;深色必须单独看一遍。
|
||||
- 交付时说明改了哪些组件、契约是否同步、截图路径。
|
||||
|
||||
## 5. 维护 skill 的规则
|
||||
|
||||
- skill 里**不复制**具体数值、props 表、12 步流程;只写入口、约束、命令、坑。
|
||||
- 文档改了影响流程的部分(验收清单、命令、目录结构)时,同步改 skill 那几行。
|
||||
- 新增「已知坑」时,先写到 `DESIGN.md §9`,再决定要不要在 skill 里留一句指针。
|
||||
- 每次组件数量变化,只改 README / COMPONENTS,skill 里不写死数量(本文档除外)。
|
||||
@@ -0,0 +1,201 @@
|
||||
# Alright 参考站拆解
|
||||
|
||||
> 对标站点:<https://alright.demo.nicetheme.xyz/>
|
||||
> 拆解日期:2026-10-26 · 对照版本:`@stash/ui` 0.1.0
|
||||
> 产物:[DESIGN.md](../DESIGN.md)(规则)、[COMPONENTS.md](COMPONENTS.md)(契约)都按这份拆解更新。
|
||||
|
||||
## 1. 怎么拆的
|
||||
|
||||
不看截图猜,全部取实测值:
|
||||
|
||||
| 手段 | 用途 | 落盘位置 |
|
||||
| --- | --- | --- |
|
||||
| 抓取主题 CSS(620KB,Autoptimize 合并包) | 读原始规则:令牌、`:hover`、`@media`、暗色覆盖 | `.ref/theme.css` |
|
||||
| Playwright 跑计算样式 | 拿到真实渲染值(字号、行高、圆角、阴影、尺寸、过渡) | `.ref/probe.json` |
|
||||
| Playwright 截图 | 桌面 1440 / 移动 390、首页 / 详情页 | `.ref/shots/*.png` |
|
||||
|
||||
复现命令(都要先 `npm run dev`):
|
||||
|
||||
```bash
|
||||
node .ref/probe.mjs # 重新采集计算样式 + 截图
|
||||
node .ref/layout.mjs # 只量布局(列宽、头部高度)
|
||||
```
|
||||
|
||||
`.ref/` 只是拆解产物,不进构建、不进发布。
|
||||
|
||||
## 2. 站点底座(决定了哪些东西不该抄)
|
||||
|
||||
- **WordPress + Bootstrap 5**:按钮、表单、模态、分页的基础行为来自 Bootstrap;
|
||||
- **Alpine.js**(`x-data` / `@click`):交互状态,例如点赞、主题切换;
|
||||
- **Swiper / Fancybox / Plyr / CKEditor**:轮播、灯箱、播放器、编辑器,各有独立样式;
|
||||
- **自建 iconfont**(Project 4809463):线性图标,字号 18~24;
|
||||
- 主题自己的工具类:`.text-lg`、`.flex-avatar`、`.item-badge`、`.box-block`、`.tags-list`……
|
||||
|
||||
结论:**只对齐主题层**(令牌、尺寸、间距、圆角、阴影、交互节奏、组件外观),
|
||||
不复刻 Bootstrap 的组件实现(下拉、模态那种带 JS 状态机的东西我们自己做,见 COMPONENTS.md §5)。
|
||||
|
||||
## 3. 设计令牌对照
|
||||
|
||||
| 参考站变量 | 值 | 我们的令牌 | 对齐情况 |
|
||||
| --- | --- | --- | --- |
|
||||
| `--gray-1 … --gray-8` | `#f7f8fa` `#f6f6f6` `#eee` `#c9cdd4` `#86909c` `#4e5969` `#2c343c` `#1d2129` | `--su-gray-1 … -8` | ✅ 完全一致 |
|
||||
| `--blue` | `#3858f6` | `--su-color-primary` | ✅ |
|
||||
| `--red` | `#ff3e3e` | `--su-color-danger` | ✅ |
|
||||
| `--orange` / `--orange-dark` / `--orange-light` | `#ffba3a` / `#ff8338` / `#fffbe8` | `--su-color-warning` / `--su-color-orange-dark` / `--su-color-orange-light` | ✅ 本轮补齐后两项 |
|
||||
| `--green` | `#4abd86` | `--su-color-success` | ✅ |
|
||||
| `--gradient-red` | `linear-gradient(to right,#ff6034,#ff3e3e)` | `--su-gradient-red` | ✅ 本轮新增 |
|
||||
| `.item-badge` 置顶 / 付费 | `135deg #ff2f43→#ff6327` / `#ff952f→#ff662b` | `--su-gradient-hot` / `--su-gradient-paid` | ✅ 本轮新增 |
|
||||
| 认证角标 SVG | `#FFB74A → #FD900F` | `--su-gradient-verified` | ✅ |
|
||||
| `--border-radius-xs/sm/md/lg/max` | 4 / 6 / 12 / 24 / 999 | `--su-radius-*` | ✅ |
|
||||
| `--shadow` | `0 6px 16px 0 rgb(27 26 49 / 1%)` | `--su-shadow-card` | ⚠️ 原为 2%,**本轮改成 1%** |
|
||||
| `--shadow-2` | `rgb(27 26 49 / 3%)` | `--su-shadow-hover` | ⚠️ 原为 6%,**本轮改成 3%** |
|
||||
| `--border-color` / `--border-light` | `gray-2` / `gray-3` | `--su-border` / `--su-border-strong` | ✅ |
|
||||
| `--card-border-color` | `gray-1` | `--su-card-border` | ✅ 本轮新增(卡片描边几乎不可见) |
|
||||
| `--color-text` / `--color-secondary` | `gray-7` | `--su-text-secondary` | ✅ 本轮新增 |
|
||||
| `--color-muted` | `gray-6` | `--su-text-sub` | ✅(我们的 `text-muted` 保留 `gray-5` 给更弱的占位) |
|
||||
| `--bg-body` / `--card-bg` | `gray-1` / `#fff` | `--su-bg-page` / `--su-bg-card` | ✅ |
|
||||
| `--bg-light` | `gray-2` | `--su-bg-subtle`(本轮改为 gray-1) | ⚠️ 参考站标签用的是 `gray-1`,见 §6 |
|
||||
| `--animation-duration-fast/base` | `.2s` / `.3s` | `--su-duration-fast/base` | ✅ |
|
||||
| `--animation-timing-function-enter/leave` | `ease-out` / `ease-in` | `--su-ease-out` / `--su-ease-in` | ✅ |
|
||||
| 组件里的过渡 | 绝大多数是 `all .3s ease-in-out` | `--su-ease-in-out` | ✅ 本轮新增,组件过渡改用它 |
|
||||
|
||||
## 4. 字号与行高(最容易抄错的一处)
|
||||
|
||||
参考站 `:root` 里的 `--font-size-*`(10/12/14/16/24)**并不是页面实际字号**,
|
||||
真正生效的是 `.text-*` 工具类,且在小屏还有一组覆盖:
|
||||
|
||||
| 类 | 桌面 | 小屏(≤600px) | 典型用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| `.text-xs` | **13 / 20** | 12 / 16 | 标签、元信息 |
|
||||
| `.text-sm` | **14 / 22** | — | 正文(侧栏简介) |
|
||||
| `.text-md` | **16 / 24** | 15 / 24 | 正文、条目摘要 |
|
||||
| `.text-lg` | **18 / 26** | — | 条目标题、卡片标题 |
|
||||
| `body` | **16 / 24** | — | 全局 |
|
||||
|
||||
`.item-badge` 是 12 / 14.4(700 字重),标签 `.tags-list a` 是 13 / 20。
|
||||
|
||||
我们的处理:把 `--su-font-size-*` 改成 12 / 13 / 14 / 18 / 24,行高 16 / 20 / 22 / 26 / 32;
|
||||
组件内文字只用到 `sm 13`(标签)、`md 14`(控件)、`lg 18`(图标),
|
||||
`xs 12` 给角标。**基础字号仍是 14**(见 §10 的保留差异)。
|
||||
|
||||
## 5. 布局
|
||||
|
||||
| 项 | 参考站实测 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 容器 | 1320 宽,居中,左右 60 | 对应 `--layout-max-width` 一类变量 |
|
||||
| 三栏 | 左 330 / 中 660 / 右 330(gutter 24) | `col-xl-3 / col-xl-6 / col-xl-3` |
|
||||
| 顶栏 | 80 高,透明叠在渐变背景上 | 导航项 48 高、字号 16 |
|
||||
| 内容起始 | y=112(顶栏 80 + 32) | 卡片与顶栏留 32 的呼吸 |
|
||||
| 卡片内边距 | `40px 24px`(侧栏 widget) / `12px`(缩略图卡片) | 竖向留白明显大于横向 |
|
||||
| 缩略图 | 3 列,间距 8,圆角 8 | 图集区块 |
|
||||
|
||||
这些是**页面级**参数,不进组件库;卡片的内边距档位(12 / 20 / 24)继续由 `Card padding` 管。
|
||||
|
||||
## 6. 组件实测(对齐依据)
|
||||
|
||||
| 组件 | 参考站选择器 | 实测关键值 | 我们的对应 |
|
||||
| --- | --- | --- | --- |
|
||||
| 卡片 | `.box-block` | `#fff`、radius 12、`1px solid gray-1`、shadow `0 6px 16px 1%`、padding `40/24`、过渡 opacity/transform .3s | `Card` |
|
||||
| 缩略图卡 | `.item` | `#fff`、radius 12、同款描边与阴影、padding 0(内容 12) | `Card padding="none"` |
|
||||
| 标签 | `.tags-list li a` | 高 24、`13/20`、radius 30、`gray-1` 底、`gray-6` 字、左侧 20×15 图标、hover 字色变 `gray-8`;hot 变体是 `rgba(255,94,68,.1)` 底 + 红字 | `Tag`(soft / solid / icon / dot) |
|
||||
| 状态角标 | `.item-badge` | `12/14.4`、700、padding `2px 4px`、radius 4、白字、红/橙渐变底 | `ItemBadge`(本轮新增) |
|
||||
| 数字角标 | `.btn-icon small` | 12px、padding `2px 5px`、radius 10、红底白字 | `Badge`(18 高) |
|
||||
| 头像 | `.flex-avatar` + `.avatar-badge` | 96 / 40 / 20 三档;角标是 SVG 渐变对勾,尺寸 12 / 16 / 20 | `Avatar`(24/36/48 + 16/20 角标) |
|
||||
| 图标按钮 | `.btn-icon` | 圆形,默认 42(变体 28 / 36 / 44),图标字号 16 / 20 / 24,无阴影 | `IconButton`(本轮改为圆形 28/36/44) |
|
||||
| 按钮 | `.btn` / `.btn-rounded` | radius 6 / 32、padding `8px 20px`、字号 14、`transition all .3s`、`primary:hover` 是 **opacity .8** | `Button`(新增 `shape="pill"`,hover 用 opacity) |
|
||||
| 输入框 | `.form-control` | 高 36、radius 6、padding `8px 14px`、`1px solid gray-3`、focus 换主色边框、无阴影 | `Input`(描边改 `gray-3`,focus 主色) |
|
||||
| 图片悬浮 | `.custom-hover` / `.hover-content` | 图片 `scale(1.1) rotate(2deg)`;遮罩 `rgba(0,0,0,.65)` 淡入、图标上移 10px | 组合模式(见 COMPONENTS.md §4) |
|
||||
| 分页 | `.page-numbers` | 32×32、radius 4、`gray-2` 底、当前项主色底白字 | 规划中(P3) |
|
||||
| 弹层 | `.nice-tips` | 遮罩 `rgba(0,0,0,.6)`、内容 radius 12、padding 28、`translateY(-40px)→0 .3s` | 规划中(P3 Modal) |
|
||||
| 滚动工具条 | `.scroll-toolbar` | `position:fixed; right:30; bottom:50%`,内部是 42px 圆形按钮 | `ScrollTop` |
|
||||
|
||||
## 7. 交互语言
|
||||
|
||||
| 行为 | 参考站做法 | 我们的做法 |
|
||||
| --- | --- | --- |
|
||||
| 颜色 / 背景过渡 | `all .3s ease-in-out`(部分图标 `color .3s`) | 具体属性 + `--su-duration-base` + `--su-ease-in-out` |
|
||||
| 主按钮 hover | `opacity: .8`(不改色) | ✅ 一致(primary / danger) |
|
||||
| 次要按钮 hover | 换 `gray-3` 底 | ✅ 一致 |
|
||||
| 图标按钮 hover | 轻微底色 + 文字加深,无阴影 | ✅ 一致 |
|
||||
| 卡片 hover | 部分列表 `translateY(-5px) scale(1.01)`;`hoverable` 只加深阴影 | 组件层只做阴影加深,位移留给页面(避免列表抖动) |
|
||||
| 图片 hover | 图片放大 1.1 + 旋转 2°,遮罩淡入 | 组合模式,不做进组件 |
|
||||
| 焦点 | 把 Bootstrap 的 `box-shadow` 去掉了(`box-shadow:none`) | **反着做**:补 2px 主色 focus ring(a11y 要求) |
|
||||
| 减少动效 | 用 `@media (not (prefers-reduced-motion))` 包裹动画 | ✅ 每个带动效的组件都有 `prefers-reduced-motion` 分支 |
|
||||
| 禁用 | 换浅底 + 弱文字(不是整体透明) | ✅ 一致(`bg-subtle` + `text-muted`) |
|
||||
| 加载 | `.btn.loading` 浅底 + `cursor: wait` + `pointer-events:none` | `loading` 自动 disabled + spinner + `aria-busy` |
|
||||
|
||||
## 8. 暗色方案(参考站已支持,我们留接口)
|
||||
|
||||
参考站用 `body[data-scheme="dark|auto"]` 覆盖整套变量,灰阶整体反相:
|
||||
|
||||
```css
|
||||
body[data-scheme=dark] {
|
||||
--gray-1:#f7f8fa; --gray-2:#f2f3f5; --gray-3:#e5e6eb; --gray-4:#c9cdd4;
|
||||
--gray-5:#494949; --gray-6:#303030; --gray-7:#202022; --gray-8:#0f0f11;
|
||||
--color-text:var(--gray-1); --color-secondary:var(--gray-2);
|
||||
--color-muted:var(--gray-4); --color-light:var(--gray-3);
|
||||
--bg-body:var(--black); --bg-dark:var(--gray-8); --bg-secondary:var(--gray-7);
|
||||
}
|
||||
```
|
||||
|
||||
要点:**语义变量跟着翻转,灰阶保持不变**——所以组件只要引用语义令牌就自动适配。
|
||||
我们的 `--su-*` 结构相同,后续加一份 `[data-scheme='dark']` 覆盖即可;
|
||||
仍有少量组件直接用了 `--su-gray-*`(Button secondary、Tag neutral、IconButton hover),
|
||||
深色主题落地时要把这些改成 `--su-bg-subtle` / `--su-bg-hover` 之类的语义令牌。
|
||||
|
||||
## 9. 本轮对齐清单
|
||||
|
||||
**令牌**([tokens.css](../src/theme/tokens.css))
|
||||
|
||||
- 阴影 2%/6% → **1%/3%**,补 `0 … 0` 的位置参数;
|
||||
- 字阶 10/12/14/16 → **12/13/14/18**,行高 16/20/22/26;
|
||||
- 新增:`--su-color-orange-dark/light`、`--su-gradient-hot/paid/red`、`--su-text-secondary`、
|
||||
`--su-bg-subtle`、`--su-card-border`、`--su-ease-in-out`、`--su-focus-ring(-offset)`;
|
||||
- 控件高度:`sm 28→32`;图标按钮 `24/32 → 28/36/44`。
|
||||
|
||||
**组件**
|
||||
|
||||
| 组件 | 变更 |
|
||||
| --- | --- |
|
||||
| `Button` | 新增 `shape="pill"`;hover 改 opacity .8;禁用改浅底弱字;补 focus ring;补 reduced-motion |
|
||||
| `IconButton` | 改圆形,尺寸 `xs/sm/md = 28/36/44`,图标 16/20/24;补 focus ring |
|
||||
| `Card` | 描边改 `--su-card-border`(gray-1),阴影用新令牌 |
|
||||
| `Tag` | 重写:默认 `neutral` 浅底胶囊 24 高 13px;新增 `variant="solid"`、`icon`、`dot`;保留 `shape="rounded"` 做元信息前缀 |
|
||||
| `Badge` | 高度 16→18,字号 10→12 |
|
||||
| `Input` | 描边 `gray-3`;focus 主色边框 + 键盘 focus ring(`:has(:focus-visible)`) |
|
||||
| `Avatar` | `lg` 认证角标 18→20,对齐参考站三档 |
|
||||
| `ScrollTop` | 过渡改 `ease-in-out`,补 focus ring |
|
||||
| `ItemBadge` | **新增**:置顶 / 付费 / HOT / 草稿 状态角标 |
|
||||
|
||||
**展示页**:Buttons 补「形状」与图标按钮三档;Marks 重构为「浅底 / 实底 / 状态角标 / 数字角标 / 组合」;
|
||||
组合示例的卡片标题加入 `ItemBadge`。
|
||||
|
||||
## 10. 保留的差异(有意为之)
|
||||
|
||||
| 差异 | 参考站 | 我们 | 原因 |
|
||||
| --- | --- | --- | --- |
|
||||
| 基础字号 | body 16/24 | 14/22 | 这是本地工具的控制台,不是阅读型博客;14 更紧凑。内容页(文章正文)建议用 16 |
|
||||
| 卡片内边距 | `40px 24px` | 12 / 20 / 24 档位 | 参考站的竖向留白是为 widget 设计的,工具里太松 |
|
||||
| 标签图标 | 每个标签强制带 20×15 图标 | 可选 `icon` | 图标是主题的分类素材,通用组件不该强绑 |
|
||||
| Bootstrap 组件 | 下拉、模态、分页都用 BS | 自己实现(规划中) | 不引 CSS 框架,避免 200KB+ 和样式覆盖战 |
|
||||
| hover 位移 | 部分列表卡片抬升 5px | 组件不做位移 | 列表里位移会造成抖动;需要时页面自己加 |
|
||||
| 动画时长 | 全站 `all .3s` | 按属性、默认 .3s | `all` 会把布局属性也带上,性能差 |
|
||||
|
||||
## 11. 生产标准补充(参考站没有的)
|
||||
|
||||
参考站是展示型主题,下面这些是我们作为组件库必须补的:
|
||||
|
||||
- **focus-visible**:所有可交互组件补 2px 主色焦点环(参考站把焦点样式删了);
|
||||
- **reduced-motion**:`Button` / `IconButton` / `Card` / `Input` / `ScrollTop` 都有降级分支;
|
||||
- **disabled 语义**:`Button` 用真实 `disabled`,`loading` 同时 `aria-busy`;
|
||||
- **图标按钮 label 必填**:`IconButton.label` 是必填 prop,防呆;
|
||||
- **错误提示 `role="alert"`**、`Empty` 与 `Notice` 分工(§COMPONENTS);
|
||||
- **键盘可达**:`ScrollTop` 隐藏时 `tabIndex=-1`;Input 清除按钮是真实 `button`;
|
||||
- **令牌约束**:组件 CSS 里没有裸色值(`DESIGN.md` §1 硬约束)。
|
||||
|
||||
## 12. 后续对齐项
|
||||
|
||||
1. ~~**暗色主题**~~:已完成(`data-scheme="dark|auto"`,见 DESIGN §7);
|
||||
2. **ScrollTop → 滚动工具条**:参考站是「点赞 / 收藏 / 回到顶部」三键竖排,等有收藏功能再做;
|
||||
3. **图片悬浮遮罩**:抽成组合模式(`Card hoverable` + 遮罩层),配合未来的 `Image` 组件;
|
||||
4. **分页 / 弹层**:按 COMPONENTS.md §5 的 P3 优先级补。
|
||||
@@ -0,0 +1,668 @@
|
||||
# 组件目录与契约
|
||||
|
||||
> 配套:[DESIGN.md](../DESIGN.md)(规则)、[ADDING-A-COMPONENT.md](ADDING-A-COMPONENT.md)(新增流程)、
|
||||
> [ALRIGHT-DECOMPOSITION.md](ALRIGHT-DECOMPOSITION.md)(参考站实测依据)
|
||||
|
||||
状态图例:✅ 已实现 · 🚧 开发中 · 🔜 规划 · ⛔ 不进库(用组合替代)
|
||||
|
||||
---
|
||||
|
||||
## 1. 组件地图
|
||||
|
||||
| 组件 | 层 | 状态 | 一句话 | 关键 props |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `Icon` | L1 | ✅ | 图标字体封装(446 个字形) | `name` |
|
||||
| `Badge` | L1 | ✅ | 数字角标(18 高,12px) | `count` `tone` `max` |
|
||||
| `ItemBadge` | L1 | ✅ | 状态角标:置顶 / 付费 / HOT | `tone` |
|
||||
| `Tag` | L1 | ✅ | 分类与元信息标签(24 高,13px) | `tone` `variant` `shape` `icon` `dot` |
|
||||
| `Divider` | L1 | ✅ | 分隔线 | `orientation` `variant` `spacing` |
|
||||
| `Typography` | L1 | ✅ | 标题 / 文字 / 段落(Title / Text / Paragraph) | `level` `tone` `size` `lines` |
|
||||
| `Button` | L2 | ✅ | 操作按钮(圆角 / 胶囊) | `variant` `size` `shape` `block` `loading` `icon` |
|
||||
| `IconButton` | L2 | ✅ | 圆形图标按钮(28 / 36 / 44) | `label`(必填) `icon` `variant` `size` `spinning` |
|
||||
| `Input` | L2 | ✅ | 组合式输入框 | `icon` `clearable` `onClear` `shortcut` `inputRef` |
|
||||
| `Switch` | L2 | ✅ | 即时生效的开关 | `checked` `onChange` `size` `label` |
|
||||
| `Checkbox` | L2 | ✅ | 复选框(多选 / 确认) | `checked` `onChange` `label` |
|
||||
| `RadioGroup` | L2 | ✅ | 互斥单选组 | `options` `value` `onChange` `direction` |
|
||||
| `Select` | L2 | ✅ | 受控下拉选择 | `options` `value` `onChange` `placeholder` |
|
||||
| `Segmented` | L2 | ✅ | 视图形态切换 | `options` `value` `onChange` `size` `block` |
|
||||
| `Tabs` | L2 | ✅ | 页内分区切换 | `items` `activeKey` `onChange` `variant` |
|
||||
| `Menu` | L2 | ✅ | 侧栏两级菜单(可折叠动画) | `items` `activeKey` `onSelect` `itemHeight` |
|
||||
| `Breadcrumb` | L3 | ✅ | 路径导航 | `items` `separator` |
|
||||
| `Layout` | L3 | ✅ | 页面骨架:页头 / 侧栏 / 内容 / 页脚 | `header` `sider` `aside` `maxWidth` `gap` `height` |
|
||||
| `Card` | L3 | ✅ | 白色容器(1px 极浅描边) | `as` `padding` `hoverable` |
|
||||
| `Cover` | L3 | ✅ | 比例封面卡(2:3 / 4:3 / 16:9 + 标题遮罩) | `ratio` `src` `caption` `lift` `onClick` |
|
||||
| `Image` | L3 | ✅ | 带比例 / 圆角 / 失败降级的图片 | `ratio` `fit` `radius` `fallback` |
|
||||
| `FormField` | L3 | ✅ | 表单字段排版(标签 + 控件 + 提示/错误) | `label` `hint` `error` `required` |
|
||||
| `Grid` | L3 | ✅ | 卡片列表网格 | `columns` `tabletColumns` `mobileColumns` `gap` `as` |
|
||||
| `Space` | L3 | ✅ | 一维间距容器 | `direction` `gap` `wrap` `align` `justify` |
|
||||
| `Table` | L3 | ✅ | 只读数据表格 | `columns` `rows` `rowKey` `emptyText` |
|
||||
| `Notice` | L4 | ✅ | 说明 / 错误提示条 | `tone` `title` `action` |
|
||||
| `Empty` | L4 | ✅ | 空态 | `text` `action` |
|
||||
| `Result` | L4 | ✅ | 整块结果反馈 | `status` `title` `description` `actions` |
|
||||
| `Modal` | L4 | ✅ | 居中弹窗(原生 dialog) | `open` `onClose` `title` `footer` `size` |
|
||||
| `Drawer` | L4 | ✅ | 侧滑抽屉(原生 dialog) | `open` `onClose` `title` `placement` `width` |
|
||||
| `Tooltip` | L4 | ✅ | hover / 聚焦文字提示 | `content` `placement` |
|
||||
| `Popover` | L4 | ✅ | 点击展开的小面板 | `content` `placement` |
|
||||
| `Toast` | L4 | ✅ | 命令式轻提示 | `toast()` `toast.success()` `<Toaster />` |
|
||||
| `Progress` | L4 | ✅ | 进度条 | `percent` `size` `tone` `showLabel` |
|
||||
| `Steps` | L4 | ✅ | 纵向步骤条 | `items` `current` |
|
||||
| `ScrollTop` | L4 | ✅ | 回到顶部 | `visible` `targetId` `label` |
|
||||
| `Pagination` | L3 | ✅ | 分页 | `page` `total` `pageSize` `onChange` |
|
||||
| `Skeleton` | L4 | ✅ | 骨架屏占位 | `variant` `lines` `width` `height` |
|
||||
| `Spinner` | L4 | ✅ | 加载指示(行内 / 区块) | `size` `label` |
|
||||
| `Avatar` | L1 | ✅ | 头像 / 封面占位(24 / 36 / 48 / 64) | `src` `fallback` `size` `shape` `tone` `verified` |
|
||||
|
||||
规划中的组件见 §5;明确不做的见 §6。
|
||||
|
||||
---
|
||||
|
||||
## 2. 用哪个?(决策表)
|
||||
|
||||
| 场景 | 用它 | 不要 |
|
||||
| --- | --- | --- |
|
||||
| 显示数量 | `Badge count={n}` | 用 `Tag` 包数字 |
|
||||
| 显示「置顶 / 付费 / HOT」 | `ItemBadge tone="sticky \| paid \| hot"` | 用 `Tag` 拼渐变 |
|
||||
| 分类、标签、元信息 | `Tag`(默认 neutral 浅底) | 自己写带底色的 span |
|
||||
| 需要强调的分类标签 | `Tag variant="solid"`(实底白字) | 给浅底标签调色值 |
|
||||
| 连接状态 | `Tag tone="success" dot` | 自绘圆点 |
|
||||
| 列表为空、搜索无结果 | `Empty`(可带 `action`) | 复用 `Notice` |
|
||||
| 接口失败、服务不可用 | `Notice tone="error"` | `Empty` |
|
||||
| 一般说明、引导 | `Notice`(`tone="info"`) | 直接写 p |
|
||||
| 一块内容 / 一个面板 | `Card`(`padding` 决定内边距) | 自己写白色 div |
|
||||
| 列表行(应用行、作者行) | 直接 `Space` + 内容 | **套 `Card`**(行不需要底色/边框/阴影) |
|
||||
| 多列排布 | `Grid` | 手写 `display:grid; repeat(...)` |
|
||||
| 一维间距 | `Space` | 每个元素写 margin |
|
||||
| 图标 | `<Icon name="…" />` | 写 `iconfont icon-xxx` 类名 |
|
||||
| 主操作 | `Button`(一页一个 primary) | 多个 primary 并列 |
|
||||
| 登录 / 筛选这类主操作 | `Button shape="pill"` | 圆角按钮凑合 |
|
||||
| 只有图标的操作 | `IconButton label="…"` | `<button><Icon /></button>` |
|
||||
| 分隔内容块 | `Divider` | `border-top` 手写 |
|
||||
| 长内容回到顶部 | `ScrollTop` | 自己写 fixed 按钮 |
|
||||
|
||||
---
|
||||
|
||||
## 3. 现有组件契约
|
||||
|
||||
每个组件按固定结构写:用途 / 不该用在哪 / 解剖 / Props / 规则 / 展示页。新增组件照抄这个结构(模板见 §8)。
|
||||
|
||||
### 3.1 Icon ✅
|
||||
|
||||
- **用途**:把图标字体的字形封装成组件,颜色与字号继承当前文字。
|
||||
- **不该用在哪**:需要多色图标、需要 SVG 描边可控的场景。
|
||||
- **解剖**:单个 `<i class="su-iconfont su-icon-{name}">`,`aria-hidden="true"`。
|
||||
- **Props**:`name: IconName`(13 个字形)+ 原生 `<i>` 属性。
|
||||
- **规则**:图标一律视为装饰;字号在父级用 `font-size` 控制;新增字形走 §7 流程。
|
||||
- **展示页**:`#icons`
|
||||
|
||||
### 3.2 Badge ✅
|
||||
|
||||
- **用途**:数量角标(1、18、99+)。
|
||||
- **不该用在哪**:状态文字(用 `ItemBadge` 或 `Tag`)。
|
||||
- **解剖**:`inline-flex` 胶囊,18px 高、12px 字,等宽数字。
|
||||
- **Props**:
|
||||
|
||||
| prop | 类型 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `count` | `number` | 必填 | 数字 |
|
||||
| `tone` | `'danger' \| 'primary' \| 'neutral'` | `'danger'` | 配色 |
|
||||
| `max` | `number` | `99` | 超过显示 `N+`;`0` 表示不截断 |
|
||||
|
||||
- **规则**:不负责定位;压在标题右上角由使用方用 `className` 绝对定位。
|
||||
- **展示页**:`#marks`
|
||||
|
||||
### 3.3 ItemBadge ✅
|
||||
|
||||
- **用途**:标题前的状态角标——「置顶 / 付费 / HOT / 草稿」。
|
||||
- **不该用在哪**:数量(`Badge`)、分类(`Tag`)。
|
||||
- **解剖**:18px 高、radius 4、12px 加粗白字、红/橙渐变底(参考站 `.item-badge`)。
|
||||
- **Props**:
|
||||
|
||||
| prop | 类型 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `tone` | `'hot' \| 'sticky' \| 'paid' \| 'neutral'` | `'hot'` | hot/sticky 红渐变,paid 橙渐变,neutral 灰底 |
|
||||
| `children` | `ReactNode` | 必填 | 角标文字(建议 2–4 字) |
|
||||
|
||||
- **规则**:只做状态提示,不可点击;和标题并排时用 `Space gap="sm"`,不要加 margin。
|
||||
- **展示页**:`#marks`
|
||||
|
||||
### 3.4 Tag ✅
|
||||
|
||||
- **用途**:分类、标签、元信息。
|
||||
- **不该用在哪**:数量(`Badge`)、状态角标(`ItemBadge`)、可点击筛选(暂无交互态)。
|
||||
- **解剖**:24px 高胶囊、13px 字,左侧可带图标或状态点;实底变体用于强调。
|
||||
- **Props**:
|
||||
|
||||
| prop | 类型 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `tone` | `'neutral' \| 'primary' \| 'success' \| 'warning' \| 'danger'` | `'neutral'` | 语义配色 |
|
||||
| `variant` | `'soft' \| 'solid'` | `'soft'` | 浅底 / 实底白字 |
|
||||
| `shape` | `'pill' \| 'rounded'` | `'pill'` | 胶囊 / 小方块(元信息前缀用) |
|
||||
| `icon` | `ReactNode` | — | 前置图标(如分类图标) |
|
||||
| `dot` | `boolean` | `false` | 前置 6px 状态点 |
|
||||
|
||||
- **规则**:标签是内容宽度;放进纵向 flex 时用 `align-items: flex-start`,否则会被拉满。
|
||||
- **展示页**:`#marks`
|
||||
|
||||
### 3.5 Divider ✅
|
||||
|
||||
- **用途**:横向分隔内容块、纵向分隔并排元素。
|
||||
- **解剖**:`role="separator"` 的普通 div,靠 border 画线。
|
||||
- **Props**:`orientation: 'horizontal' | 'vertical'`、`variant: 'solid' | 'dashed'`、`spacing: 'sm' | 'md'`。
|
||||
- **规则**:横向自带上下外边距(唯一带 margin 的基础组件),纵向用于 `Space` 内部。
|
||||
- **展示页**:`#layouts`
|
||||
|
||||
### 3.6 Button ✅
|
||||
|
||||
- **用途**:页面操作。
|
||||
- **不该用在哪**:只有图标(`IconButton`)、纯跳转链接(`<a>`)。
|
||||
- **解剖**:`[icon] [spinner] [label]`,高度 32 / 36,左右内边距 20。
|
||||
- **Props**:
|
||||
|
||||
| prop | 类型 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `variant` | `'primary' \| 'secondary' \| 'outline' \| 'ghost' \| 'danger'` | `'primary'` | 语义 |
|
||||
| `size` | `'sm' \| 'md'` | `'md'` | 32 / 36px |
|
||||
| `shape` | `'rounded' \| 'pill'` | `'rounded'` | 6px 圆角 / 胶囊 |
|
||||
| `block` | `boolean` | `false` | 占满一行 |
|
||||
| `loading` | `boolean` | `false` | 自动禁用 + `aria-busy` |
|
||||
| `spinning` | `boolean` | `false` | 图标原地旋转(不换转圈),用于刷新类动作 |
|
||||
| `icon` | `ReactNode` | — | 文字左侧图标 |
|
||||
| `…rest` | `ButtonHTMLAttributes` | — | `type` 默认 `"button"` |
|
||||
|
||||
- **规则**:一屏一个 `primary`;hover 是 `opacity .8`(参考站同款),不要改背景色;禁用是浅底弱字。
|
||||
- **展示页**:`#buttons`
|
||||
|
||||
### 3.7 IconButton ✅
|
||||
|
||||
- **用途**:圆形图标按钮(顶栏入口、更多、刷新、关闭)。
|
||||
- **解剖**:正圆 + 居中图标;`spinning` 时图标旋转。
|
||||
- **Props**:`label`(**必填**,进 `aria-label` 与 `title`)、`icon`(必填)、
|
||||
`variant: 'ghost' | 'soft'`、`size: 'xs' | 'sm' | 'md'`(28 / 36 / 44,图标 16 / 20 / 24)、`spinning`、`…rest`。
|
||||
- **规则**:没有 `label` 不允许合并;`spinning` 只用于「操作进行中」,不做装饰动画。
|
||||
- **展示页**:`#buttons`
|
||||
|
||||
### 3.8 Input ✅
|
||||
|
||||
- **用途**:搜索框、筛选输入、表单单行输入。
|
||||
- **不该用在哪**:多行文本(未来 `Textarea`)、下拉选择(未来 `Select`)。
|
||||
- **解剖**:`[icon] [input] [shortcut] [clear]`,外层容器负责边框与高度(36px、`1px solid gray-3`)。
|
||||
- **Props**:
|
||||
|
||||
| prop | 类型 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `icon` | `IconName` | — | 左侧图标 |
|
||||
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | 32 / 36 / 44px |
|
||||
| `clearable` | `boolean` | `false` | 有值且传了 `onClear` 才显示清除 |
|
||||
| `onClear` | `() => void` | — | 清除回调 |
|
||||
| `shortcut` | `string` | — | 右侧提示(如 `⌘K`),仅展示 |
|
||||
| `inputRef` | `Ref<HTMLInputElement>` | — | 拿真实 input 做聚焦 |
|
||||
| `className` / `style` | — | — | **作用在外层容器** |
|
||||
| `…rest` | `InputHTMLAttributes` | — | `onChange` 是原生事件 |
|
||||
|
||||
- **规则**:与 `Button size="md"` 同高 36;focus 只把边框换成主色(**不再叠 outline**:文本框点击时也会命中 `:focus-visible`,叠环会出现「双框」,2026-10-28 修)。
|
||||
- **展示页**:`#inputs`
|
||||
|
||||
### 3.9 Card ✅
|
||||
|
||||
- **用途**:白色容器(面板、卡片、详情块)。
|
||||
- **不该用在哪**:列表行、纯排布容器。
|
||||
- **解剖**:`1px solid gray-1`(几乎不可见)+ radius 12 + `shadow-card`(1%),内边距由 `padding` 决定。
|
||||
- **Props**:`as: 'div' | 'section' | 'article' | 'aside'`、`padding: 'none' | 'sm' | 'md' | 'lg'`(0 / 12 / 20 / 24)、`hoverable`、`className`、`style`。
|
||||
- **规则**:卡片不提供标题栏;「色条 + 标题 + 角标 + 更多」由页面组合;`hoverable` 只加深阴影,不做位移。
|
||||
- **展示页**:`#cards`
|
||||
|
||||
### 3.10 Grid ✅
|
||||
|
||||
- **用途**:卡片列表的多列排布。
|
||||
- **不该用在哪**:一维间距(`Space`)、页面整体骨架(CSS layout)。
|
||||
- **解剖**:`display:grid`,列数走 CSS 变量,断点内建:`>1100`、`≤1100`、`≤720`。
|
||||
- **Props**:
|
||||
|
||||
| prop | 类型 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `columns` | `number` | `4` | 宽屏列数 |
|
||||
| `tabletColumns` | `number` | `columns / 2`(至少 2) | ≤1100px |
|
||||
| `mobileColumns` | `number` | `1` | ≤720px |
|
||||
| `gap` | `'sm' \| 'md' \| 'lg'` | `'md'` | 8 / 12 / 16 |
|
||||
| `as` | `'div' \| 'ul'` | `'div'` | 列表语义 |
|
||||
|
||||
- **规则**:不要在父级用 `flex-direction: column` 包住含比例图片的 Grid(会被压扁,见 DESIGN §9)。
|
||||
- **展示页**:`#layouts`
|
||||
|
||||
### 3.11 Space ✅
|
||||
|
||||
- **用途**:一组元素之间的一维间距。
|
||||
- **解剖**:`display:flex` + `gap`,方向/对齐/间距都用语义档位。
|
||||
- **Props**:`direction: 'row' | 'column'`、`gap: 'xs' | 'sm' | 'md' | 'lg'`(4 / 8 / 12 / 16)、`wrap`、`align: 'start' | 'center' | 'end' | 'stretch'`、`justify: 'start' | 'center' | 'end' | 'between'`。
|
||||
- **规则**:间距只用档位,不传像素;纵向排列 + 左对齐要给 `align="start"`。
|
||||
- **展示页**:`#layouts`
|
||||
|
||||
### 3.12 Notice ✅
|
||||
|
||||
- **用途**:页面级说明与错误提示。
|
||||
- **不该用在哪**:表单字段校验、浮层提示(未来 `Toast`)。
|
||||
- **解剖**:标题 + 正文 + 可选操作按钮;错误态 `role="alert"`。
|
||||
- **Props**:`tone: 'info' | 'error'`、`title`(必填)、`children`、`action: { label, onClick }`。
|
||||
- **规则**:错误态只用于「确实失败」;同一区域不要堆两条以上提示。
|
||||
- **展示页**:`#feedback`
|
||||
|
||||
### 3.13 Empty ✅
|
||||
|
||||
- **用途**:列表为空、搜索无结果、内容未就绪。
|
||||
- **解剖**:一句说明 + 可选操作区。
|
||||
- **Props**:`text`(默认「暂无内容」)、`action`。
|
||||
- **规则**:空态不要用红色/错误色;能给出下一步操作时一定给。
|
||||
- **展示页**:`#feedback`
|
||||
|
||||
### 3.14 ScrollTop ✅
|
||||
|
||||
- **用途**:长内容回到顶部。
|
||||
- **解剖**:固定在视口右侧中部(`top:50%`、`right:24`)的 42px 圆钮,`visible` 控制淡入淡出。
|
||||
- **Props**:`visible`(必填)、`targetId`(滚动容器 id;不传滚窗口)、`label`(默认「回到顶部」)、`offset`(距右侧 px,默认 24)。
|
||||
- **规则**:滚动容器在页面里滚动时必须传 `targetId`;隐藏时 `tabIndex=-1`。
|
||||
- **展示页**:`#scrolltop`
|
||||
|
||||
### 3.15 Avatar ✅
|
||||
|
||||
- **用途**:头像、应用图标、封面占位。
|
||||
- **解剖**:裁切层(图片 / 首字) + 外置认证角标,两者分层,角标不会被圆角切掉。
|
||||
- **Props**:
|
||||
|
||||
| prop | 类型 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `src` | `string` | — | 图片;加载失败自动降级 |
|
||||
| `alt` | `string` | `''` | 图片替代文本 |
|
||||
| `fallback` | `string` | `'•'` | 无图时的字符(通常取首字) |
|
||||
| `icon` | `ReactNode` | — | 无图时的图标(优先于 fallback),用于缺省头像 |
|
||||
| `size` | `'sm' \| 'md' \| 'lg' \| 'xl'` | `'md'` | 24 / 36 / 48 / 64px |
|
||||
| `shape` | `'circle' \| 'square'` | `'circle'` | 人/应用用圆,图标封面用方 |
|
||||
| `tone` | `'neutral' \| 'primary'` | `'neutral'` | 无图底色:浅灰 / 品牌浅渐变(资料卡) |
|
||||
| `verified` | `boolean` | `false` | 右下角认证角标(12 / 16 / 20px) |
|
||||
|
||||
- **规则**:`fallback` 传一个字符;资料卡用 `xl` + `tone="primary"`,列表里用 `lg`,紧凑行用 `sm`;不要给头像加边框。
|
||||
- **展示页**:`#avatars`
|
||||
|
||||
### 3.16 Modal ✅
|
||||
|
||||
- **用途**:需要打断流程的确认 / 表单 / 说明。
|
||||
- **不该用在哪**:轻量反馈(等 `Toast`)、页面内的展开收起(用条件渲染 + 动画)。
|
||||
- **解剖**:原生 `<dialog>`(焦点陷阱、Esc、背景 inert 由浏览器提供)+ 遮罩 + 标题栏 + 正文 + 底部操作区。
|
||||
- **Props**:
|
||||
|
||||
| prop | 类型 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `open` | `boolean` | 必填 | 受控开关 |
|
||||
| `onClose` | `() => void` | 必填 | Esc / 遮罩 / 关闭按钮都会调用 |
|
||||
| `title` | `ReactNode` | 必填 | 标题,同时作为 `aria-labelledby` |
|
||||
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | 300 / 460 / 640 |
|
||||
| `closeOnOverlay` | `boolean` | `true` | 点遮罩是否关闭 |
|
||||
| `footer` | `ReactNode` | — | 底部按钮区 |
|
||||
|
||||
- **规则**:打开时锁背景滚动;危险操作必须二次确认;底部按钮顺序是「取消在左、主操作在右」。
|
||||
- **展示页**:`#modal`
|
||||
|
||||
### 3.17 Pagination ✅
|
||||
|
||||
- **用途**:列表/搜索结果的翻页。
|
||||
- **解剖**:上一页 + 页码(超长时省略号折叠)+ 下一页;32×32、radius 4、当前页主色实底。
|
||||
- **Props**:`page`(从 1 开始)、`total`、`pageSize`(默认 10)、`onChange(page)`、`siblings`(当前页左右各几个,默认 1)。
|
||||
- **规则**:首尾页固定;边界上「上一页/下一页」禁用;总数文案由页面自己写(用 `Space justify="between"`)。
|
||||
- **展示页**:`#pagination`
|
||||
|
||||
### 3.18 Typography(Title / Text / Paragraph)✅
|
||||
|
||||
- **用途**:页面与内容排版——标题层级、文字层级、正文段落。
|
||||
- **不该用在哪**:控件内部的文字(按钮/输入自己管),不要为了「加个字号」而套一层 `Text`。
|
||||
- **解剖**:三个组件共用一个样式模块;`Title` 按 `level` 输出真实 `h1–h4`,`Text` 是 `span`/`p`/`div`,`Paragraph` 用 `-webkit-line-clamp` 做行数截断。
|
||||
- **Props**:
|
||||
|
||||
| 组件 | prop | 类型 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `Title` | `level` | `1 \| 2 \| 3 \| 4` | `2` | 24/32、18/26、16/24、14/22 |
|
||||
| `Title` | `weight` | `'normal' \| 'medium' \| 'semibold' \| 'strong'` | `'medium'` | 400 / 500 / 600 / 700;参考站的 600 用 `semibold` |
|
||||
| `Title` | `as` | `'h1' \| 'h2' \| 'h3' \| 'h4' \| 'div'` | 按 level | 换语义标签 |
|
||||
| `Text` | `tone` | `'main' \| 'secondary' \| 'sub' \| 'muted'` | `'main'` | 文字层级 |
|
||||
| `Text` | `size` | `'xs' \| 'sm' \| 'md' \| 'base' \| 'lg'` | `'md'` | 12 / 13 / 14 / 16 / 18 |
|
||||
| `Text` | `strong` / `truncate` | `boolean` | `false` | 500 字重 / 单行省略 |
|
||||
| `Paragraph` | `tone` | `'main' \| 'secondary' \| 'sub'` | `'sub'` | 文字层级 |
|
||||
| `Paragraph` | `size` | `'sm' \| 'md' \| 'base'` | `'base'` | 13 / 14 / 16,长文正文用 md |
|
||||
| `Paragraph` | `leading` | `'base' \| 'relaxed'` | `'base'` | relaxed = 1.9 行高(阅读型) |
|
||||
| `Paragraph` | `lines` | `number` | — | 最多几行,超出省略 |
|
||||
|
||||
- **规则**:组件不带外边距,段落间距交给 `Space`;不要在页面里另写 `font-size`。
|
||||
- **展示页**:`#typography`
|
||||
|
||||
### 3.19 Skeleton ✅
|
||||
|
||||
- **用途**:内容加载前的占位(列表首屏、刷新、分页加载)。
|
||||
- **不该用在哪**:按钮内的 loading(`Button` 自带 spinner)、整页空态(`Empty`)。
|
||||
- **解剖**:`text`(多行文本条,最后一行收短)/ `rect`(封面)/ `circle`(头像),占位块上有一层扫光。
|
||||
- **Props**:
|
||||
|
||||
| prop | 类型 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `variant` | `'text' \| 'rect' \| 'circle'` | `'text'` | 形状 |
|
||||
| `lines` | `number` | `1` | 仅 text:行数 |
|
||||
| `width` / `height` | `number \| string` | — | rect 默认 100% / 120px,circle 默认 40 |
|
||||
| `className` / `style` | — | — | 外边距等布局微调 |
|
||||
|
||||
- **规则**:形状必须和真实内容一致(否则加载完成后跳动);整块区域标 `aria-busy="true"`;骨架自身 `aria-hidden`;`prefers-reduced-motion` 下停止扫光。
|
||||
- **展示页**:`#skeleton`
|
||||
|
||||
### 3.20 Spinner ✅
|
||||
|
||||
- **用途**:行内或区块的加载指示(同步状态、面板刷新)。
|
||||
- **不该用在哪**:按钮内(`Button loading` 自带)、文字占位(`Skeleton`)。
|
||||
- **解剖**:2px 圆环,颜色取主色,`role="status"` + `aria-label`。
|
||||
- **Props**:`size: 'sm' | 'md'`(14 / 20)、`label`(默认「加载中」)、`className`。
|
||||
- **规则**:颜色继承 `currentColor`(默认主色,可被外层覆盖);`prefers-reduced-motion` 下降到 2.4s。
|
||||
- **展示页**:`#spinner`
|
||||
|
||||
### 3.21 Switch ✅
|
||||
|
||||
- **用途**:设置项的**即时**开关。
|
||||
- **不该用在哪**:需要点「保存」才生效的表单(用 `Checkbox` + 提交按钮)。
|
||||
- **解剖**:原生 `button` + `role="switch"`,轨道 + 白色圆点;空格/回车可切换。
|
||||
- **Props**:
|
||||
|
||||
| prop | 类型 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `checked` | `boolean` | 必填 | 受控状态 |
|
||||
| `onChange` | `(checked: boolean) => void` | 必填 | 切换回调 |
|
||||
| `size` | `'sm' \| 'md'` | `'md'` | 32×18 / 40×22 |
|
||||
| `disabled` | `boolean` | `false` | 禁用 |
|
||||
| `label` | `string` | — | 无障碍名称:旁边没有文字时必填 |
|
||||
|
||||
- **规则**:旁边配文字时用 `Text` + `Space justify="between"`,不要自己做一套行布局。
|
||||
- **展示页**:`#switch`
|
||||
|
||||
### 3.22 Checkbox ✅
|
||||
|
||||
- **用途**:多选、需要确认的选项。
|
||||
- **解剖**:原生 `input[type=checkbox]`(视觉隐藏)+ 自绘 16px 方块 + label 文本,点击文字也能勾选。
|
||||
- **Props**:`checked`、`onChange(checked)`、`label`(必填,同时作为无障碍名称)、`disabled`。
|
||||
- **规则**:多选组用 `Space direction="column"` 排列;禁用态只降对比,不隐藏。
|
||||
- **展示页**:`#checkbox`
|
||||
|
||||
### 3.23 RadioGroup ✅
|
||||
|
||||
- **用途**:互斥选项组。
|
||||
- **解剖**:原生 `input[type=radio]`(隐藏)+ 自绘圆点;同组共享 `name`,方向键在组内移动。
|
||||
- **Props**:`options: { value, label, disabled? }[]`、`value`、`onChange(value)`、`direction: 'row' | 'column'`、`disabled`。
|
||||
- **规则**:2–3 个短选项用 row;设置类用 column。单个开/关用 `Switch`,多选用 `Checkbox`。
|
||||
- **展示页**:`#radio`
|
||||
|
||||
### 3.24 Segmented ✅
|
||||
|
||||
- **用途**:视图形态切换(网格/列表、全部/收藏)。
|
||||
- **Props**:`options: { value, label, icon? }[]`、`value`、`onChange(value)`、`size: 'sm' | 'md'`、`block`。
|
||||
- **规则**:只切展示、不切内容分区(后者用 `Tabs`);选中项白底 + 轻阴影。
|
||||
- **展示页**:`#segmented`
|
||||
|
||||
### 3.25 Select ✅
|
||||
|
||||
- **用途**:受控的单值下拉选择。
|
||||
- **解剖**:触发器是原生 button(`aria-haspopup="listbox"`),展开为 `role="listbox"`;点外部 / Esc 关闭,↑↓ 移动、Enter 选择。
|
||||
- **Props**:`options`、`value`、`onChange(value)`、`placeholder`、`disabled`。
|
||||
- **规则**:多选与搜索不在范围内(用 `Input` + 列表组合);高度与 `Input` 一致(36)。
|
||||
- **展示页**:`#select`
|
||||
|
||||
### 3.26 Breadcrumb ✅
|
||||
|
||||
- **用途**:详情页路径导航。
|
||||
- **Props**:`items: { label, href? }[]`、`separator`(默认 `/`)。
|
||||
- **规则**:最后一项忽略 `href`,并标 `aria-current="page"`。
|
||||
- **展示页**:`#breadcrumb`
|
||||
|
||||
### 3.27 Tabs ✅
|
||||
|
||||
- **用途**:页内内容分区切换。
|
||||
- **Props**:`items: { key, label, disabled? }[]`、`activeKey`、`onChange(key)`、`variant: 'line' | 'pill'`。
|
||||
- **规则**:只渲染标签栏,面板由页面用 `role="tabpanel"` 渲染;← → 移动、Home/End 跳首尾。
|
||||
- **展示页**:`#tabs`
|
||||
|
||||
### 3.28 Menu ✅
|
||||
|
||||
- **用途**:侧栏菜单(一层折叠 + 两级)。
|
||||
- **Props**:`items: { key, label, icon?, children? }[]`、`activeKey`、`onSelect(key)`、`defaultOpenKeys`、`itemHeight`(一级行高,默认 36)。
|
||||
- **规则**:数据无关,业务树由页面转换;含 `activeKey` 的父级默认展开,父子均用 `aria-expanded` / `aria-current` 标注;折叠箭头是 CSS chevron(视觉回归:从字形改为边框绘制)。
|
||||
- **展示页**:`#menu`
|
||||
|
||||
### 3.29 Toast(toast / Toaster)✅
|
||||
|
||||
- **用途**:操作反馈,几秒后自动消失。
|
||||
- **解剖**:模块级 store + `useSyncExternalStore`;`Toaster` 挂在应用根节点,只挂一次。
|
||||
- **API**:`toast(message, { tone?, duration? })`、`toast.success / .error`、`dismissToast(id)`。
|
||||
- **规则**:命令式调用;页面内常驻提示用 `Notice`,整块状态用 `Result`;文案一句话,不放按钮。
|
||||
- **展示页**:`#toast`
|
||||
|
||||
### 3.30 Table ✅
|
||||
|
||||
- **用途**:数据表格(只读)。
|
||||
- **Props**:`columns: { key, title, width?, align? }[]`、`rows: Record<string, ReactNode>[]`、`rowKey`(默认 `id`)、`emptyText`。
|
||||
- **规则**:排序 / 筛选 / 分页由页面组合,组件不内置交互;空数据渲染 `emptyText`。
|
||||
- **展示页**:`#table`
|
||||
|
||||
### 3.31 Result ✅
|
||||
|
||||
- **用途**:整块的完成 / 失败 / 警示 / 说明反馈。
|
||||
- **Props**:`status: 'success' | 'error' | 'warning' | 'info'`、`title`、`description`、`actions`。
|
||||
- **规则**:只用于整页或整块状态;行内提示用 `Notice`,轻量反馈用 `toast`。
|
||||
- **展示页**:`#result`
|
||||
|
||||
### 3.32 Progress ✅
|
||||
|
||||
- **用途**:任务 / 存储进度。
|
||||
- **Props**:`percent`(夹在 0–100)、`size: 'sm' | 'md'`(4 / 8px)、`tone`、`showLabel`。
|
||||
- **规则**:纯展示;带 `role="progressbar"` 与 `aria-valuenow`。
|
||||
- **展示页**:`#progress`
|
||||
|
||||
### 3.33 Steps ✅
|
||||
|
||||
- **用途**:有顺序的流程(发布、导入向导)。
|
||||
- **Props**:`items: { title, description? }[]`、`current`(从 0 开始)。
|
||||
- **规则**:`index < current` 视为已完成;当前项加粗 + 主色描边。
|
||||
- **展示页**:`#steps`
|
||||
|
||||
### 3.34 Drawer ✅
|
||||
|
||||
- **用途**:侧滑面板(筛选、设置、详情),不打断主流程。
|
||||
- **解剖**:与 `Modal` 同源(原生 `<dialog>`),区别是贴边、全高、滑入动画。
|
||||
- **Props**:`open`、`onClose`、`title`、`placement: 'right' | 'left'`、`width`(默认 360)、`footer`。
|
||||
- **规则**:需要打断流程的确认用 `Modal`;打开时锁背景滚动。
|
||||
- **展示页**:`#drawer`
|
||||
|
||||
### 3.35 Tooltip ✅
|
||||
|
||||
- **用途**:hover / 聚焦时的一行文字说明。
|
||||
- **Props**:`content`、`placement: 'top' | 'bottom'`、`children`(建议可聚焦元素)。
|
||||
- **规则**:纯 CSS 定位,不做自动翻转;交互内容用 `Popover`。
|
||||
- **展示页**:`#tooltip`
|
||||
|
||||
### 3.36 Popover ✅
|
||||
|
||||
- **用途**:点击展开的小面板(说明、快捷操作)。
|
||||
- **Props**:`content`、`children`、`placement: 'bottom-start' | 'bottom-end'`。
|
||||
- **规则**:点外部 / Esc 关闭;内容复杂或需要滚动时改用 `Drawer` / `Modal`。
|
||||
- **展示页**:`#tooltip`
|
||||
|
||||
### 3.37 Layout ✅
|
||||
|
||||
- **用途**:页面外壳——页头 / 侧栏 / 内容 / 右栏 / 页脚的区域划分与最大宽度。
|
||||
- **不该用在哪**:应用内的小区块(用 `Card` + `Grid`);业务导航内容(由页面作为 slot 传入)。
|
||||
- **解剖**:`flex` 纵向三行(header / body / footer),body 内是 `sider + main + aside`;main 里再套一层限宽容器(`maxWidth`)。
|
||||
- **Props**:
|
||||
|
||||
| prop | 类型 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `header` | `ReactNode` | — | 页头,默认吸顶(半透明 + 模糊) |
|
||||
| `sider` | `ReactNode` | — | 左侧栏,固定宽度 |
|
||||
| `aside` | `ReactNode` | — | 右侧栏,固定宽度(目录 / 聚合 / 广告位) |
|
||||
| `footer` | `ReactNode` | — | 页脚 |
|
||||
| `children` | `ReactNode` | 必填 | 内容区 |
|
||||
| `maxWidth` | `number \| 'full'` | `1280` | 内容最大宽度 |
|
||||
| `siderWidth` | `number` | `260` | 左侧栏宽度 |
|
||||
| `asideWidth` | `number` | `300` | 右侧栏宽度 |
|
||||
| `stickyHeader` | `boolean` | `true` | 页头是否吸顶 |
|
||||
| `contained` | `boolean` | `false` | 页身套一层居中容器(**页头始终通栏**,页头内容自己限宽) |
|
||||
| `gap` | `number` | `0` | 侧栏 / 内容 / 右栏之间的间距(px) |
|
||||
| `contentPadding` | `'none' \| 'sm' \| 'md' \| 'lg'` | `'md'` | 内容区留白:0 / 12 / 20 / 24 |
|
||||
| `contentOffset` | `number` | `0` | 内容区顶部偏移(px),给页头下的悬浮搜索/工具条留位 |
|
||||
| `gutter` | `number` | `0` | contained 容器的左右留白(px) |
|
||||
| `headerHeight` | `number` | `60` | 页头最小高度(px) |
|
||||
| `headerVariant` | `'blur' \| 'solid'` | `'blur'` | 半透明模糊 / 实底 + 下边框 |
|
||||
| `siderBordered` | `boolean` | `true` | 侧栏右侧分隔线 |
|
||||
| `height` | `'auto' \| 'screen'` | `'auto'` | screen:锁视口,适合应用型界面 |
|
||||
| `scrollable` | `boolean` | `false` | 内容区内部滚动(配 `height="screen"`) |
|
||||
| `hideScrollbar` | `boolean` | `false` | 内部滚动时隐藏滚动条(仍可滚轮/键盘滚动) |
|
||||
| `contentId` | `string` | — | 内容区 id(供 ScrollTop 等按 id 滚动) |
|
||||
|
||||
- **规则**:Layout 不写业务内容,也不带背景装饰(需要渐变/光晕时由页面自己放绝对定位层);响应式在组件内处理——≤1200px 右栏堆到内容下方、≤1100px 侧栏堆到内容上方,**内容都不隐藏**。
|
||||
- **常用配方**:顶栏+内容(首页)、左栏+内容(管理页)、内容+右栏(文章)、三栏(工作台)、窄内容(设置/阅读),展示页用 `Segmented` 切换。
|
||||
- **展示页**:`#layout`
|
||||
|
||||
### 3.38 Cover ✅
|
||||
|
||||
- **用途**:比例封面卡——海报(2:3)、图文(4:3)、宽图(16:9)统一用「图片 + 底部压暗层 + 标题」。
|
||||
- **不该用在哪**:需要叠加角标 / 操作按钮的封面(用 `Card padding="none"` 里放 `Cover` + 角标);纯头像用 `Avatar`。
|
||||
- **解剖**:`aspect-ratio` 容器 + `object-fit: cover` 图片 + `--su-cover-scrim` 渐变遮罩;传 `onClick` 时整块变成 `<button>`,否则是 `<figure>`。
|
||||
- **Props**:
|
||||
|
||||
| prop | 类型 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `src` | `string` | — | 图片地址;不传时显示占位字符 |
|
||||
| `alt` | `string` | 必填 | 图片说明,同时作为按钮 title |
|
||||
| `caption` | `ReactNode` | — | 底部标题(最多 2 行) |
|
||||
| `ratio` | `'poster' \| 'article' \| 'wide' \| number` | `'article'` | 2/3、4/3、16/9,或数字宽高比 |
|
||||
| `radius` | `'cover' \| 'none'` | `'cover'` | 8px / 贴边(配合 `Card padding="none"`) |
|
||||
| `lift` | `boolean` | `true` | 悬停整块上浮 2px(参考站行为) |
|
||||
| `hoverZoom` | `boolean` | `false` | 悬停放大图片 1.04(与 lift 二选一) |
|
||||
| `onClick` | `() => void` | — | 传了就把整块变成按钮 |
|
||||
|
||||
- **规则**:封面比例由 `ratio` 定,不要在页面里再写 `aspect-ratio`;标题必须压暗层,否则白字会丢。
|
||||
- **展示页**:`#cover`
|
||||
|
||||
### 3.39 Image ✅
|
||||
|
||||
- **用途**:正文配图、相册、缩略图——带比例、圆角、加载失败降级。
|
||||
- **不该用在哪**:带标题遮罩的封面卡(用 `Cover`);头像(用 `Avatar`)。
|
||||
- **Props**:`src`、`alt`、`ratio: 'square' | 'video' | 'wide' | number`(默认 `video`)、
|
||||
`fit: 'cover' | 'contain'`(默认 `cover`)、`radius: 'none' | 'sm' | 'md' | 'cover'`(默认 `sm`)、`fallback`。
|
||||
- **规则**:必须给 `alt`;失败自动退化为占位块,不出现破图;懒加载默认开启。
|
||||
- **展示页**:`#image`
|
||||
|
||||
### 3.40 FormField ✅
|
||||
|
||||
- **用途**:表单字段的排版——标签 + 控件 + 提示 / 错误。
|
||||
- **不该用在哪**:**不做校验**(校验留在页面);不是表单容器(多个字段用 `Space` 排列)。
|
||||
- **Props**:
|
||||
|
||||
| prop | 类型 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `label` | `ReactNode` | 必填 | 字段名(渲染为 `<label>`) |
|
||||
| `htmlFor` | `string` | — | 关联控件 id |
|
||||
| `hint` | `ReactNode` | — | 说明文字,有错误时隐藏 |
|
||||
| `error` | `ReactNode` | — | 错误文案,出现时标红并 `role="alert"` |
|
||||
| `required` | `boolean` | `false` | 显示红色 `*` |
|
||||
| `children` | `ReactNode` | 必填 | 控件(`Input` / `Select` / `Switch`…) |
|
||||
|
||||
- **规则**:错误用文案而不是只换颜色(颜色不足以表达);`defaultValue` / `value` 等仍由控件自己管。
|
||||
- **展示页**:`#inputs`
|
||||
|
||||
---
|
||||
|
||||
## 4. 组合模式(不进库,但必须照这个拼)
|
||||
|
||||
| 模式 | 由什么拼 | 展示页 |
|
||||
| --- | --- | --- |
|
||||
| 分区卡片(首页一块) | `Card` + 色条 span + `ItemBadge` + 标题 + `Badge` + `IconButton` + `Grid` | `#composition` |
|
||||
| 应用行 | `Avatar` + 两行文字(名称 / 说明),无容器 | `#avatars`、`#composition` |
|
||||
| 筛选工具条 | `Card padding="sm"` + `Space justify="between"` + `Input` + `Tag` + `Button shape="pill"` | `#composition` |
|
||||
| 海报卡 | `Card padding="none"` + 比例封面 + 标题 + `Tag` | `#composition` |
|
||||
| 顶栏信息条 | `Space` + `Tag shape="rounded"` + 文字 | `#marks` |
|
||||
| 标题行 | `ItemBadge` + 标题 + `Tag` | `#marks` |
|
||||
| 图片悬浮(参考站 `.custom-hover`) | 图片容器 `overflow:hidden` + hover 时 `scale(1.1)` + 65% 黑遮罩淡入 | 未实现(等 `Image` 组件) |
|
||||
|
||||
这些结构的代码在 [`site/sections/Composition.tsx`](../site/sections/Composition.tsx),做页面时直接复制再换数据。
|
||||
|
||||
---
|
||||
|
||||
## 5. 规划中的组件
|
||||
|
||||
原计划的 14 个(Radio → Drawer)已全部落地,当前**没有待实现的组件**。
|
||||
|
||||
下一批(还没进计划,等应用侧提出真实需求再定):
|
||||
|
||||
| 候选 | 触发条件 | 注意点 |
|
||||
| --- | --- | --- |
|
||||
| `Image`(比例封面 + 悬浮遮罩) | 应用里有 3 处以上重复的封面实现 | 自带 `aspect-ratio`、懒加载、失败占位 |
|
||||
| `Form` / `FormItem` | 出现第一个带校验的表单 | 只做布局与错误文案,校验逻辑交给页面 |
|
||||
| `DatePicker` | 需要按日期筛选 | 依赖 Popover 定位,建议直接用原生 `input[type=date]` |
|
||||
| `Upload` | 需要拖拽上传 | 依赖接口,先做成受控壳 |
|
||||
| `Layout` 多侧栏 / 断点可配 | 同一页面需要两个左栏,或要自定义折叠断点 | 现在固定「一个 sider + 一个 aside」;扩展时用 `columns` 数组而不是再加 slot |
|
||||
|
||||
---
|
||||
|
||||
## 6. 明确不进库
|
||||
|
||||
| 东西 | 原因 | 替代 |
|
||||
| --- | --- | --- |
|
||||
| 分区卡片、应用条目、海报卡、图文卡 | 业务组件,字段随需求变 | 用 `Card` + `Grid` + `Avatar` + `Tag` 组合(§4) |
|
||||
| Header / Sidebar / 页面骨架 | 页面级布局 | 放在应用里 |
|
||||
| 24 栅格 Layout(`Row`/`Col`) | 只用得到 `Grid` | `Grid` + `Space` + CSS Grid |
|
||||
| 全量图标库 | 字体 500+ 字形,体积无意义 | 按需加字形(§7) |
|
||||
| 富文本编辑器、图表、日期面板 | 不是设计系统职责 | 引专用库 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 新增图标字形的流程
|
||||
|
||||
1. 从参考站 iconfont 复制字形码(`\eXXX`);
|
||||
2. `src/theme/iconfont.css` 加一条 `.su-icon-<name>::before { content: '\eXXX'; }`;
|
||||
3. `src/components/Icon.tsx` 的 `IconName` 联合类型加 `'<name>'`;
|
||||
4. 展示页 `site/sections/Icons.tsx` 的 `ICON_NAMES` 加一项;
|
||||
5. 字形不在现有 woff2 里时,替换 `src/theme/assets/iconfont.woff2` 并重新构建;
|
||||
6. 跑一次 `npm run build` 确认 CSS 里字体仍是内联的。
|
||||
|
||||
---
|
||||
|
||||
## 8. 契约模板(新增组件时复制到 §3)
|
||||
|
||||
```markdown
|
||||
### Xxx 🚧
|
||||
|
||||
- **用途**:
|
||||
- **不该用在哪**:
|
||||
- **解剖**:(结构描述:几层、每个部分的职责)
|
||||
- **Props**:
|
||||
|
||||
| prop | 类型 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| — | — | — | — |
|
||||
|
||||
- **变体与状态**:(variant / size / 状态矩阵中本组件需要覆盖哪些)
|
||||
- **规则**:(组合禁忌、尺寸对齐、a11y 要求)
|
||||
- **展示页**:`#anchor`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 变更记录
|
||||
|
||||
| 日期 | 变更 |
|
||||
| --- | --- |
|
||||
| 2026-10-26 | 初版:14 个基础组件(Icon / Badge / Tag / Divider / Button / IconButton / Input / Card / Grid / Space / Notice / Empty / ScrollTop / Avatar) |
|
||||
| 2026-10-26 | 补齐令牌:`danger-hover`、`warning-text`、`bg-notice`、`bg-danger-soft`、`gradient-verified`、`font-weight-strong`、`control-height-*`、`icon-button-*` |
|
||||
| 2026-10-26 | **对齐参考站**:阴影 1%/3%、字阶 12/13/14/18、`Tag` 重写(soft/solid/icon/dot)、新增 `ItemBadge`、`Button` 加 `shape`、`IconButton` 改圆形 28/36/44、`Card` 描边改 gray-1、`Input` 描边改 gray-3、补 focus ring 与 reduced-motion |
|
||||
| 2026-10-26 | 深色主题(`data-scheme`)+ `Button variant="outline"` + `bg-light` 语义令牌;新增整页拼装示例 `site/demo.html` |
|
||||
| 2026-10-27 | 图标改用参考站全量 iconfont(446 个字形,`IconName` 自动生成);新增 `Modal`、`Pagination`;示例页加入删除确认弹窗与分页 |
|
||||
| 2026-10-27 | 新增 `Typography`(Title / Text / Paragraph)与 `Skeleton`;补 `--su-font-size-base`(16/24)与 `--su-skeleton-shine`;示例页改用排版组件并在刷新时显示骨架屏 |
|
||||
| 2026-10-27 | 新增 `Spinner`、`Switch`、`Checkbox`(表单基础收口);示例页左栏加入「偏好」卡片(开关控制封面显示、复选框、同步中的 Spinner) |
|
||||
| 2026-10-27 | 一次补齐剩余 14 个组件:`RadioGroup`、`Segmented`、`Select`、`Breadcrumb`、`Tabs`、`Menu`、`Toast`、`Table`、`Result`、`Progress`、`Steps`、`Drawer`、`Tooltip`、`Popover`;示例页接入面包屑、下拉、抽屉与 toast |
|
||||
| 2026-10-27 | 新增 `Layout`(页面骨架:页头 / 侧栏 / 内容 / 页脚),组件总数 37 |
|
||||
| 2026-10-28 | `Layout` 增加 `aside` / `asideWidth` 右栏,形成三栏骨架;展示页改为 5 个配方切换;补移动端适配(`dvh`、触屏 hover 隔离、分段/弹层夹宽、演示页导航抽屉) |
|
||||
| 2026-10-28 | 为对齐 stash 原设计补充通用能力:`Layout` 增加 `gap` / `contentPadding` / `gutter` / `headerHeight` / `headerVariant` / `siderBordered` / `height="screen"` + `scrollable` + `contentId`;新增 `Cover`;`Avatar` 增加 `xl` 与 `tone`;`Paragraph` 增加 `size` / `leading`;新增布局类令牌(`--su-layout-width`、`--su-header-height`、`--su-reading-line-height`、`--su-radius-cover`、`--su-shadow-card-strong`、`--su-cover-scrim`) |
|
||||
| 2026-10-28 | **视觉回归**:`Layout` 的 `contained` 改为只包页身,页头通栏;`screen + scrollable` 修复内容区高度未约束导致无法滚动;新增 `contentOffset`。`Menu` 增加 `itemHeight`、字重 500、圆角 8、chevron 改为 CSS 绘制,列表间距 2→4。`Avatar` 增加 `icon`。影响面:所有用到 `Menu` / `Layout contained` 的页面都需要重截图 |
|
||||
| 2026-10-28 | **1:1 补全(视觉 + 交互)**:`Title` 增加 `weight`(600);`Input` 增加 `size`(44);`Button` 增加 `spinning`(图标原地旋转);`Cover` 改为 hover 上浮 2px(`lift`,默认开;`hoverZoom` 默认关);`ScrollTop` 增加 `offset`;`Menu` 的 hover 改主色、子菜单改 `grid-template-rows` 高度动画(0.28s);`Avatar xl` 徽标 20px、无图底色用新令牌 `--su-avatar-gradient`。**影响面**:`Cover`/`Menu`/`Avatar`/`Title` 的所有展示页 demo 与应用页面需重截图 |
|
||||
| 2026-10-28 | **修复**:`Input` 聚焦不再叠 outline(文本框点击会命中 `:focus-visible`,导致边框 + 外环「双框」;现在只用边框变主色)。`Layout` 增加 `hideScrollbar`(内部滚动条可隐藏)。影响面:所有 `Input` 页面视觉(去掉了多余外环)、`Layout screen` 页面可选用 |
|
||||
| 2026-10-28 | **补齐剩余能力**:新增 `Image`(比例 / 填充 / 失败降级)与 `FormField`(标签 + 提示 + 错误,不做校验);`Button` 增加 `size="lg"`(44);展示页补 `Title weight`、`Avatar xl/tone/icon`、`Cover lift`、`Menu itemHeight`、`Layout screen/hideScrollbar`、`Input size`、`FormField` 示例;新增 `themes/example.css` 主题示例(只改变量) |
|
||||
@@ -0,0 +1,88 @@
|
||||
# 外部风格接入(截图 → 组件/令牌)
|
||||
|
||||
> 用在「使用方给了一张别的网站截图,风格和 stash-ui 不一致」的场景。
|
||||
> 目的:**先对齐方案,再动代码**;把「能吸收的」吸进库,把「只能页面私有的」明确记成例外。
|
||||
> 规则来源:[DESIGN.md §11](../DESIGN.md)。
|
||||
|
||||
## 0. 一句话原则
|
||||
|
||||
截图只提供**意图**,不提供实现。先把它翻译成「骨架 / 组件 / 形态值 / 交互」,
|
||||
再决定每一项落在哪一层:主题变量、组件参数、组件变体、新组件、页面私有。
|
||||
|
||||
## 1. 接入单(收到截图后先填)
|
||||
|
||||
```md
|
||||
### 项目:<名称> 来源:<截图/URL> 日期:<日期>
|
||||
|
||||
1. 布局骨架:<几栏、页头/侧栏/底栏、最大宽度、断点>
|
||||
2. 组件清单:<截图里出现的每个控件,按出现顺序>
|
||||
3. 形态值:<主色、圆角、阴影、控件高度、字号阶梯、间距节奏>
|
||||
4. 交互:<hover / 展开 / 切换 / 空态 / 加载>
|
||||
5. 范围:<整站统一 / 只此一页 / 只此一个组件>
|
||||
```
|
||||
|
||||
## 2. 映射表(核心产物)
|
||||
|
||||
| # | 截图里的东西 | 库里的对应 | 结论 | 证据(值) |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | 顶栏 64px 白底 | `Layout headerVariant="solid"` + `headerHeight` | ✅ 参数可调 | 截图 64,库默认 60 |
|
||||
| 2 | 主色 #6C5CE7 | `--su-color-primary` 主题覆盖 | ✅ 主题 | 只改变量 |
|
||||
| 3 | 按钮 48 高、10 圆角 | `Button` 只有 32/36/44 | ⚠️ 缺尺寸档 | 需新增 `size="lg"`(40/48) |
|
||||
| 4 | 卡片 16 圆角 | `--su-radius-md` 12 | ⚠️ 缺档 | 主题覆盖不了,需 `radius` 档或新令牌 |
|
||||
| 5 | 瀑布流列表 | 无 | ❌ 新组件 | 判定:是否 ≥2 处会用 → `Masonry` |
|
||||
| 6 | 封面左上角角标 | `Cover` + 绝对定位 | ⚠️ 组合即可 | 不需要新组件,写组合模式 |
|
||||
| 7 | 背景光晕 | 无 | 🟡 页面私有 | 一次性装饰,页面 CSS |
|
||||
|
||||
图例:✅ 现有能力(调参/主题)|⚠️ 需要参数化或加变体|❌ 需要新组件|🟡 页面私有
|
||||
|
||||
## 3. 决策:先问三个问题
|
||||
|
||||
1. **换主题能不能解决?** 只是颜色/字体 → `themes/<name>.css` 改变量,不动组件。
|
||||
2. **同结构、不同形态或交互?** → 给现有组件加 `size` / `variant` / `radius` 之类的受控参数。
|
||||
3. **结构不一样?** → 评估新组件:
|
||||
- 会不会在 ≥2 个页面/使用方出现?
|
||||
- 能不能用 props 表达,不夹带业务字段?
|
||||
- 两条都满足才进库,否则先做页面私有实现,并记一笔「候选组件」。
|
||||
|
||||
## 4. 评审要点(给使用方确认的最小信息)
|
||||
|
||||
- **差异表**:每一项「截图值 / 库值 / 结论」;
|
||||
- **API 草案**:新增/修改的 props(只写名字、类型、默认值,不写实现);
|
||||
- **影响面**:会不会改现有组件默认值(破坏性)、要不要同步文档与展示页;
|
||||
- **兼容性提示**(改共享组件必填):
|
||||
- 调用方清单(哪些页面/组件在用);
|
||||
- 变更性质:新增可选 prop(兼容)/ 改默认值(视觉回归)/ 改删 prop(破坏性)/ 只改样式(视觉回归);
|
||||
- 回归范围(要重截哪些页面 / 哪些 demo);
|
||||
- 破坏性改动的旧 → 新写法对照;
|
||||
- **不做的事**:哪些按页面私有处理,理由是什么。
|
||||
|
||||
> 评审通过前**不动代码**。这一步是「先停下来统一方案」的落点。
|
||||
|
||||
## 5. 实施顺序(库先行)
|
||||
|
||||
```text
|
||||
① 令牌(如果是新形态值)
|
||||
② 组件 props / variant / 新组件
|
||||
③ 展示页示例(每个新能力至少一个 demo)
|
||||
④ 契约:COMPONENTS.md 对应小节 + 变更记录
|
||||
⑤ 主题文件(如果只是配色)
|
||||
⑥ 页面接入(只写页面私有的那部分 + 注释说明)
|
||||
```
|
||||
|
||||
## 6. 验收清单(外来风格专项)
|
||||
|
||||
- [ ] 深色主题:新能力在 `data-scheme="dark"` 下不串色(只吃语义令牌)
|
||||
- [ ] 键盘:新交互可聚焦、有焦点环、Esc/方向键行为明确
|
||||
- [ ] `prefers-reduced-motion`:动效有降级
|
||||
- [ ] 移动端:390 宽无横向溢出;触屏不依赖 hover
|
||||
- [ ] 业务隔离:组件不 import 业务类型、不请求数据
|
||||
- [ ] 默认值:外来品牌值没有写进组件默认值,只在主题/页面里
|
||||
- [ ] 文档:契约、展示页、README 组件表、变更记录四处同步
|
||||
|
||||
## 7. 反面样本(不要这么做)
|
||||
|
||||
- 把截图里的品牌色写成组件默认主色;
|
||||
- 在页面里覆盖 `_button_xxx` 这种 hash 类名;
|
||||
- 为了一个页面复制一份组件到项目里改;
|
||||
- 「先页面实现,回头再收」但没登记候选组件;
|
||||
- 截图里每个数值都硬编码,最后页面上出现 6 套圆角。
|
||||
Generated
+1300
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@stash/ui",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Stash 设计系统:设计令牌 + 基础组件 + 组件展示站",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "./dist/stash-ui.js",
|
||||
"module": "./dist/stash-ui.js",
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/types/index.d.ts",
|
||||
"import": "./dist/stash-ui.js"
|
||||
},
|
||||
"./style.css": "./dist/stash-ui.css"
|
||||
},
|
||||
"sideEffects": [
|
||||
"*.css"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"build": "vite build -c vite.lib.config.ts && tsc -p tsconfig.build.json",
|
||||
"build:site": "vite build",
|
||||
"preview": "vite preview --port 5199",
|
||||
"lint": "oxlint",
|
||||
"shot": "node scripts/screenshot.mjs"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=19",
|
||||
"react-dom": ">=19"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"oxlint": "^1.88.0",
|
||||
"playwright-core": "^1.64.1",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.3.4"
|
||||
},
|
||||
"volta": {
|
||||
"node": "24.12.0",
|
||||
"npm": "11.6.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { mkdir } from 'node:fs/promises'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { chromium } from 'playwright-core'
|
||||
|
||||
/**
|
||||
* 截图核对用。
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/screenshot.mjs # 首屏
|
||||
* node scripts/screenshot.mjs http://127.0.0.1:5199/#buttons shots/buttons.png
|
||||
* node scripts/screenshot.mjs http://127.0.0.1:5199/ shots/cards.png '#cards' # 只截某一节
|
||||
*
|
||||
* 依赖本机的 Chrome / Edge(playwright-core 不自带浏览器),
|
||||
* 优先 Chrome,找不到再退回 Edge。
|
||||
*/
|
||||
const [, , urlArg, outArg, selectorArg] = process.argv
|
||||
const url = urlArg ?? 'http://127.0.0.1:5199/'
|
||||
const out = resolve(outArg ?? 'shots/site.png')
|
||||
// 第四个参数可以是选择器,也可以是 `full`(整页截图)
|
||||
const wantsFull = selectorArg === 'full'
|
||||
const selector = selectorArg === undefined || wantsFull ? undefined : selectorArg
|
||||
|
||||
const channels = ['chrome', 'msedge']
|
||||
let browser
|
||||
let lastError
|
||||
|
||||
for (const channel of channels) {
|
||||
try {
|
||||
browser = await chromium.launch({ channel, headless: true })
|
||||
break
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
}
|
||||
}
|
||||
|
||||
if (browser === undefined) {
|
||||
console.error('找不到可用的 Chrome / Edge,无法截图:', lastError)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
try {
|
||||
// 可以用环境变量指定视口,方便做移动端 / 平板核对:
|
||||
// $env:SHOT_WIDTH=390; $env:SHOT_HEIGHT=844; node scripts/screenshot.mjs <url> <out>
|
||||
const width = Number(process.env.SHOT_WIDTH ?? 1440)
|
||||
const height = Number(process.env.SHOT_HEIGHT ?? 900)
|
||||
const page = await browser.newPage({ viewport: { width, height } })
|
||||
|
||||
await page.goto(url, { waitUntil: 'networkidle' })
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
|
||||
// 带 #锚点 时,浏览器在 React 渲染前就尝试跳转了,这里补一次
|
||||
const hash = new URL(url).hash
|
||||
if (hash !== '' && selector === undefined) {
|
||||
await page.evaluate((id) => {
|
||||
document.getElementById(id)?.scrollIntoView({ block: 'start' })
|
||||
}, hash.slice(1))
|
||||
}
|
||||
|
||||
await page.waitForTimeout(350)
|
||||
await mkdir(dirname(out), { recursive: true })
|
||||
|
||||
if (selector !== undefined) {
|
||||
await page.locator(selector).screenshot({ path: out })
|
||||
} else {
|
||||
await page.screenshot({ path: out, fullPage: wantsFull })
|
||||
}
|
||||
|
||||
console.log(`已保存:${out}`)
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Toaster } from '../src'
|
||||
import { Avatars } from './sections/Avatars'
|
||||
import { Breadcrumbs } from './sections/Breadcrumbs'
|
||||
import { Buttons } from './sections/Buttons'
|
||||
import { Cards } from './sections/Cards'
|
||||
import { Covers } from './sections/Covers'
|
||||
import { Checkboxes } from './sections/Checkboxes'
|
||||
import { Composition } from './sections/Composition'
|
||||
import { Drawers } from './sections/Drawers'
|
||||
import { Feedback } from './sections/Feedback'
|
||||
import { Icons } from './sections/Icons'
|
||||
import { Images } from './sections/Images'
|
||||
import { Inputs } from './sections/Inputs'
|
||||
import { Layouts } from './sections/Layouts'
|
||||
import { Marks } from './sections/Marks'
|
||||
import { MenuSection } from './sections/MenuSection'
|
||||
import { Modals } from './sections/Modals'
|
||||
import { Overview } from './sections/Overview'
|
||||
import { PageLayout } from './sections/PageLayout'
|
||||
import { Paginations } from './sections/Paginations'
|
||||
import { Progresses } from './sections/Progresses'
|
||||
import { RadioGroups } from './sections/RadioGroups'
|
||||
import { Results } from './sections/Results'
|
||||
import { ScrollTopSection } from './sections/ScrollTopSection'
|
||||
import { Segmenteds } from './sections/Segmenteds'
|
||||
import { Selects } from './sections/Selects'
|
||||
import { Skeletons } from './sections/Skeletons'
|
||||
import { Spinners } from './sections/Spinners'
|
||||
import { StepsSection } from './sections/StepsSection'
|
||||
import { Switches } from './sections/Switches'
|
||||
import { Tables } from './sections/Tables'
|
||||
import { TabsSection } from './sections/TabsSection'
|
||||
import { Toasts } from './sections/Toasts'
|
||||
import { Tooltips } from './sections/Tooltips'
|
||||
import { Typographies } from './sections/Typographies'
|
||||
|
||||
/**
|
||||
* 组件展示站:左侧目录 + 右侧示例。
|
||||
*
|
||||
* 页面只负责摆示例,所有样式都来自组件库本身;
|
||||
* 站点自己的排版样式收在 `app.css`,不污染组件。
|
||||
*/
|
||||
const NAV = [
|
||||
{
|
||||
title: '基础',
|
||||
items: [
|
||||
{ id: 'overview', label: '设计令牌' },
|
||||
{ id: 'typography', label: '排版 Typography' },
|
||||
{ id: 'icons', label: '图标 Icon' },
|
||||
{ id: 'buttons', label: '按钮 Button' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '展示',
|
||||
items: [
|
||||
{ id: 'cards', label: '卡片 Card' },
|
||||
{ id: 'cover', label: '封面卡 Cover' },
|
||||
{ id: 'image', label: '图片 Image' },
|
||||
{ id: 'marks', label: '标记 Tag / Badge' },
|
||||
{ id: 'avatars', label: '头像 Avatar' },
|
||||
{ id: 'table', label: '表格 Table' },
|
||||
{ id: 'result', label: '结果 Result' },
|
||||
{ id: 'progress', label: '进度 Progress' },
|
||||
{ id: 'steps', label: '步骤 Steps' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '表单',
|
||||
items: [
|
||||
{ id: 'inputs', label: '输入框 Input' },
|
||||
{ id: 'select', label: '下拉 Select' },
|
||||
{ id: 'switch', label: '开关 Switch' },
|
||||
{ id: 'checkbox', label: '复选框 Checkbox' },
|
||||
{ id: 'radio', label: '单选 Radio' },
|
||||
{ id: 'segmented', label: '分段 Segmented' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '导航',
|
||||
items: [
|
||||
{ id: 'breadcrumb', label: '面包屑 Breadcrumb' },
|
||||
{ id: 'tabs', label: '标签页 Tabs' },
|
||||
{ id: 'menu', label: '菜单 Menu' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '交互',
|
||||
items: [
|
||||
{ id: 'skeleton', label: '骨架屏 Skeleton' },
|
||||
{ id: 'spinner', label: '加载 Spinner' },
|
||||
{ id: 'feedback', label: '提示与空态' },
|
||||
{ id: 'toast', label: '轻提示 Toast' },
|
||||
{ id: 'modal', label: '弹窗 Modal' },
|
||||
{ id: 'drawer', label: '抽屉 Drawer' },
|
||||
{ id: 'pagination', label: '分页 Pagination' },
|
||||
{ id: 'scrolltop', label: '回到顶部' },
|
||||
{ id: 'tooltip', label: '工具提示 Tooltip' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '布局与组合',
|
||||
items: [
|
||||
{ id: 'layout', label: '页面骨架 Layout' },
|
||||
{ id: 'layouts', label: '网格与间距' },
|
||||
{ id: 'composition', label: '组合示例' },
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
const SECTION_IDS = NAV.flatMap((group) => group.items.map((item) => item.id))
|
||||
|
||||
/** 滚动监听:当前视口里最靠上的那一节高亮 */
|
||||
function useActiveSection(): string {
|
||||
const [active, setActive] = useState<string>(SECTION_IDS[0])
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
|
||||
|
||||
if (visible.length > 0) setActive(visible[0].target.id)
|
||||
},
|
||||
{ rootMargin: '-72px 0px -68% 0px' },
|
||||
)
|
||||
|
||||
for (const id of SECTION_IDS) {
|
||||
const element = document.getElementById(id)
|
||||
if (element !== null) observer.observe(element)
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return active
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const active = useActiveSection()
|
||||
|
||||
return (
|
||||
<div className="doc-shell">
|
||||
<aside className="doc-sidebar">
|
||||
<a className="doc-brand" href="#overview">
|
||||
<span className="doc-brand-mark">S</span>
|
||||
<span>
|
||||
<strong>Stash UI</strong>
|
||||
<em>v0.1.0</em>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<p className="doc-brand-desc">
|
||||
一套按 Alright 主题打磨的基础组件,供 Stash 控制台与后续页面组合使用。
|
||||
</p>
|
||||
|
||||
<nav className="doc-nav" aria-label="组件目录">
|
||||
{NAV.map((group) => (
|
||||
<section key={group.title}>
|
||||
<h2>{group.title}</h2>
|
||||
<ul>
|
||||
{group.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<a href={`#${item.id}`} className={active === item.id ? 'is-active' : undefined}>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<footer className="doc-sidebar-footer">
|
||||
令牌定义:<code>src/theme/tokens.css</code>
|
||||
<a className="doc-demo-link" href="/demo.html">
|
||||
整页拼装示例 →
|
||||
</a>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<main className="doc-main">
|
||||
<header className="doc-hero">
|
||||
<p className="doc-eyebrow">DESIGN SYSTEM</p>
|
||||
<h1>Stash UI</h1>
|
||||
<p className="doc-hero-desc">
|
||||
独立于应用的设计系统项目:令牌、基础组件、以及一个可以直接翻的组件展示页。
|
||||
组件只依赖令牌,不依赖任何页面数据,所以换页面、换应用都能直接拼。
|
||||
</p>
|
||||
<div className="doc-hero-meta">
|
||||
<span>40 个基础组件</span>
|
||||
<span>零运行时依赖</span>
|
||||
<span>CSS Modules + 设计令牌</span>
|
||||
<span>对齐 Alright 参考站</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Overview />
|
||||
<Typographies />
|
||||
<Icons />
|
||||
<Buttons />
|
||||
<Cards />
|
||||
<Covers />
|
||||
<Images />
|
||||
<Marks />
|
||||
<Avatars />
|
||||
<Tables />
|
||||
<Results />
|
||||
<Progresses />
|
||||
<StepsSection />
|
||||
<Inputs />
|
||||
<Selects />
|
||||
<Switches />
|
||||
<Checkboxes />
|
||||
<RadioGroups />
|
||||
<Segmenteds />
|
||||
<Breadcrumbs />
|
||||
<TabsSection />
|
||||
<MenuSection />
|
||||
<Skeletons />
|
||||
<Spinners />
|
||||
<Feedback />
|
||||
<Toasts />
|
||||
<Modals />
|
||||
<Drawers />
|
||||
<Paginations />
|
||||
<ScrollTopSection />
|
||||
<Tooltips />
|
||||
<PageLayout />
|
||||
<Layouts />
|
||||
<Composition />
|
||||
</main>
|
||||
|
||||
<Toaster />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+636
@@ -0,0 +1,636 @@
|
||||
:root {
|
||||
font-family: var(--su-font-sans);
|
||||
font-size: var(--su-font-size-md);
|
||||
line-height: var(--su-line-height-md);
|
||||
color: var(--su-text-main);
|
||||
background-color: var(--su-bg-page);
|
||||
color-scheme: light;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--su-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--su-font-mono);
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
/* ---------- 文档骨架 ---------- */
|
||||
|
||||
.doc-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 248px minmax(0, 1fr);
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.doc-sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-5);
|
||||
height: 100vh;
|
||||
padding: var(--su-space-8) var(--su-space-5);
|
||||
border-right: 1px solid var(--su-border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.doc-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-3);
|
||||
}
|
||||
|
||||
.doc-brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--su-radius-sm);
|
||||
background-image: linear-gradient(135deg, #4d6bff, var(--su-color-primary));
|
||||
color: var(--su-white);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.doc-brand strong {
|
||||
display: block;
|
||||
font-size: var(--su-font-size-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.doc-brand em {
|
||||
font-size: var(--su-font-size-sm);
|
||||
font-style: normal;
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.doc-brand-desc {
|
||||
font-size: var(--su-font-size-sm);
|
||||
line-height: 1.7;
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.doc-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-5);
|
||||
}
|
||||
|
||||
.doc-nav h2 {
|
||||
margin-bottom: var(--su-space-2);
|
||||
font-size: var(--su-font-size-sm);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--su-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.doc-nav ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.doc-nav a {
|
||||
display: block;
|
||||
padding: 6px var(--su-space-3);
|
||||
border-radius: var(--su-radius-sm);
|
||||
font-size: var(--su-font-size-md);
|
||||
color: var(--su-text-sub);
|
||||
transition:
|
||||
background-color var(--su-duration-fast) var(--su-ease-out),
|
||||
color var(--su-duration-fast) var(--su-ease-out);
|
||||
}
|
||||
|
||||
.doc-nav a:hover {
|
||||
background-color: var(--su-gray-2);
|
||||
color: var(--su-text-main);
|
||||
}
|
||||
|
||||
.doc-nav a.is-active {
|
||||
background-color: var(--su-color-primary-soft);
|
||||
color: var(--su-color-primary);
|
||||
font-weight: var(--su-font-weight-bold);
|
||||
}
|
||||
|
||||
.doc-sidebar-footer {
|
||||
margin-top: auto;
|
||||
padding-top: var(--su-space-4);
|
||||
border-top: 1px solid var(--su-border);
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.doc-demo-link {
|
||||
display: inline-block;
|
||||
margin-top: var(--su-space-2);
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--su-border-strong);
|
||||
border-radius: var(--su-radius-pill);
|
||||
color: var(--su-color-primary);
|
||||
transition:
|
||||
background-color var(--su-duration-fast) var(--su-ease-in-out),
|
||||
border-color var(--su-duration-fast) var(--su-ease-in-out);
|
||||
}
|
||||
|
||||
.doc-demo-link:hover {
|
||||
border-color: var(--su-color-primary);
|
||||
background-color: var(--su-color-primary-soft);
|
||||
}
|
||||
|
||||
/* ---------- 正文 ---------- */
|
||||
|
||||
.doc-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-6);
|
||||
padding: var(--su-space-10) var(--su-space-10) 160px;
|
||||
}
|
||||
|
||||
.doc-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-3);
|
||||
padding-bottom: var(--su-space-6);
|
||||
border-bottom: 1px solid var(--su-border);
|
||||
}
|
||||
|
||||
.doc-eyebrow {
|
||||
font-family: var(--su-font-mono);
|
||||
font-size: var(--su-font-size-sm);
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
.doc-hero h1 {
|
||||
font-size: 40px;
|
||||
line-height: 1.2;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.doc-hero-desc {
|
||||
max-width: 68ch;
|
||||
font-size: var(--su-font-size-md);
|
||||
line-height: 1.8;
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.doc-hero-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--su-space-2);
|
||||
margin-top: var(--su-space-2);
|
||||
}
|
||||
|
||||
.doc-hero-meta span {
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--su-border);
|
||||
border-radius: var(--su-radius-pill);
|
||||
background-color: var(--su-bg-card);
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.doc-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-4);
|
||||
scroll-margin-top: 24px;
|
||||
}
|
||||
|
||||
.doc-section-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--su-space-3);
|
||||
}
|
||||
|
||||
.doc-section-title h2 {
|
||||
font-size: var(--su-font-size-xl);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.doc-section-title code {
|
||||
color: var(--su-text-muted);
|
||||
font-size: var(--su-font-size-sm);
|
||||
}
|
||||
|
||||
.doc-section-desc {
|
||||
max-width: 76ch;
|
||||
color: var(--su-text-sub);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* ---------- 示例块 ---------- */
|
||||
|
||||
.demo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-3);
|
||||
padding-top: var(--su-space-5);
|
||||
border-top: 1px dashed var(--su-border);
|
||||
}
|
||||
|
||||
.demo:first-of-type {
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.demo-title {
|
||||
font-size: var(--su-font-size-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.demo-desc {
|
||||
font-size: var(--su-font-size-md);
|
||||
color: var(--su-text-muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.demo-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--su-space-3);
|
||||
padding: var(--su-space-6);
|
||||
border: 1px solid var(--su-border);
|
||||
border-radius: var(--su-radius-md);
|
||||
background-color: var(--su-bg-card);
|
||||
}
|
||||
|
||||
/*
|
||||
* 纵向示例用 grid 而不是 flex column:
|
||||
* flex 纵向容器会把子项(尤其是带 aspect-ratio 封面的 Grid)按内容高度压缩,
|
||||
* grid 的行高严格按内容算,卡片不会被裁掉一截。
|
||||
*/
|
||||
.demo-preview--column {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.demo-preview--muted {
|
||||
background-color: var(--su-gray-1);
|
||||
}
|
||||
|
||||
.demo-code {
|
||||
padding: var(--su-space-4);
|
||||
border-radius: var(--su-radius-md);
|
||||
background-color: #1d2129;
|
||||
color: #e5e6eb;
|
||||
font-family: var(--su-font-mono);
|
||||
font-size: var(--su-font-size-sm);
|
||||
line-height: 1.7;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* ---------- 令牌表 ---------- */
|
||||
|
||||
.token-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: var(--su-space-3);
|
||||
}
|
||||
|
||||
.token-swatch {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--su-border);
|
||||
border-radius: var(--su-radius-md);
|
||||
background-color: var(--su-bg-card);
|
||||
}
|
||||
|
||||
.token-swatch-color {
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.token-swatch-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--su-space-2) var(--su-space-3);
|
||||
}
|
||||
|
||||
.token-swatch-name {
|
||||
font-family: var(--su-font-mono);
|
||||
font-size: var(--su-font-size-sm);
|
||||
}
|
||||
|
||||
.token-swatch-value {
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.token-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--su-border);
|
||||
border-radius: var(--su-radius-md);
|
||||
background-color: var(--su-bg-card);
|
||||
}
|
||||
|
||||
.token-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-4);
|
||||
padding: var(--su-space-3) var(--su-space-4);
|
||||
border-top: 1px solid var(--su-border);
|
||||
}
|
||||
|
||||
.token-row:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.token-row-label {
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
font-family: var(--su-font-mono);
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.token-row-demo {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: var(--su-space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.token-row-note {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.token-bar {
|
||||
height: 8px;
|
||||
border-radius: var(--su-radius-pill);
|
||||
background-color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
.type-sample-xl {
|
||||
font-size: var(--su-font-size-xl);
|
||||
line-height: var(--su-line-height-xl);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.type-sample-lg {
|
||||
font-size: var(--su-font-size-lg);
|
||||
line-height: var(--su-line-height-lg);
|
||||
}
|
||||
|
||||
.type-sample-md {
|
||||
font-size: var(--su-font-size-md);
|
||||
line-height: var(--su-line-height-md);
|
||||
}
|
||||
|
||||
.type-sample-sm {
|
||||
font-size: var(--su-font-size-sm);
|
||||
line-height: var(--su-line-height-sm);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.radius-sample {
|
||||
width: 72px;
|
||||
height: 48px;
|
||||
border: 1px solid var(--su-border-strong);
|
||||
background-color: var(--su-gray-2);
|
||||
}
|
||||
|
||||
.shadow-sample {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 120px;
|
||||
height: 56px;
|
||||
border-radius: var(--su-radius-md);
|
||||
background-color: var(--su-bg-card);
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
/* ---------- Props 表 ---------- */
|
||||
|
||||
.props-table-wrap {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.props-table {
|
||||
width: 100%;
|
||||
min-width: 520px;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid var(--su-border);
|
||||
border-radius: var(--su-radius-md);
|
||||
background-color: var(--su-bg-card);
|
||||
font-size: var(--su-font-size-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.props-table th,
|
||||
.props-table td {
|
||||
padding: var(--su-space-2) var(--su-space-4);
|
||||
border-top: 1px solid var(--su-border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.props-table thead th {
|
||||
border-top: none;
|
||||
background-color: var(--su-gray-1);
|
||||
font-weight: 600;
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.props-table code {
|
||||
color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
.props-table .props-type {
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
/* ---------- 图标墙 ---------- */
|
||||
|
||||
.icon-count {
|
||||
align-self: center;
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.icon-wall {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
|
||||
gap: var(--su-space-2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.icon-wall--scroll {
|
||||
max-height: 460px;
|
||||
padding-right: var(--su-space-1);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.icon-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--su-space-2);
|
||||
padding: var(--su-space-3);
|
||||
border: 1px solid var(--su-border);
|
||||
border-radius: var(--su-radius-sm);
|
||||
background-color: var(--su-bg-card);
|
||||
font-size: var(--su-font-size-lg);
|
||||
color: var(--su-text-sub);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--su-duration-fast) var(--su-ease-in-out),
|
||||
border-color var(--su-duration-fast) var(--su-ease-in-out),
|
||||
background-color var(--su-duration-fast) var(--su-ease-in-out);
|
||||
}
|
||||
|
||||
.icon-cell:hover {
|
||||
border-color: var(--su-color-primary);
|
||||
background-color: var(--su-color-primary-soft);
|
||||
color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
.icon-cell code {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
font-size: var(--su-font-size-xs);
|
||||
color: var(--su-text-muted);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon-empty {
|
||||
font-size: var(--su-font-size-md);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
/* ---------- Layout 示例的迷你页面 ---------- */
|
||||
|
||||
.layout-frame {
|
||||
height: 440px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--su-border);
|
||||
border-radius: var(--su-radius-md);
|
||||
background-color: var(--su-bg-page);
|
||||
}
|
||||
|
||||
.layout-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--su-space-4);
|
||||
height: 56px;
|
||||
padding: 0 var(--su-space-5);
|
||||
border-bottom: 1px solid var(--su-border);
|
||||
}
|
||||
|
||||
.layout-brand {
|
||||
font-size: var(--su-font-size-lg);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.layout-bar-actions {
|
||||
display: flex;
|
||||
gap: var(--su-space-1);
|
||||
}
|
||||
|
||||
.layout-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--su-space-4);
|
||||
}
|
||||
|
||||
.layout-nav-item {
|
||||
padding: 6px var(--su-space-3);
|
||||
border-radius: var(--su-radius-sm);
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-sub);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 窄屏演示:页头只留当前项,模拟真实导航收起 */
|
||||
@media (max-width: 640px) {
|
||||
.layout-bar {
|
||||
padding: 0 var(--su-space-3);
|
||||
}
|
||||
|
||||
.layout-bar-actions .layout-nav-item:nth-child(n + 2) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-nav-item.is-active {
|
||||
background-color: var(--su-color-primary-soft);
|
||||
color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-4);
|
||||
}
|
||||
|
||||
.layout-aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-2);
|
||||
padding: var(--su-space-5) var(--su-space-4);
|
||||
}
|
||||
|
||||
.layout-aside-item {
|
||||
padding: 2px var(--su-space-2);
|
||||
border-radius: var(--su-radius-xs);
|
||||
font-size: var(--su-font-size-xs);
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.layout-aside-item.is-active {
|
||||
background-color: var(--su-bg-light);
|
||||
color: var(--su-text-main);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.doc-shell {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.doc-sidebar {
|
||||
position: static;
|
||||
height: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--su-border);
|
||||
}
|
||||
|
||||
.doc-main {
|
||||
padding: var(--su-space-6) var(--su-space-5) 120px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* 一个用法示例:标题 + 说明 + 预览区 + 可选代码。
|
||||
*
|
||||
* 预览区默认横向排列,需要纵向(表单、长文)时传 `layout="column"`。
|
||||
*/
|
||||
type DemoProps = {
|
||||
title: string
|
||||
desc?: ReactNode
|
||||
/** 展示给使用方的代码片段 */
|
||||
code?: string
|
||||
layout?: 'row' | 'column'
|
||||
/** 灰底预览,用于白色组件(卡片)看得清边界 */
|
||||
muted?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Demo({ title, desc, code, layout = 'row', muted = false, children }: DemoProps) {
|
||||
const previewClasses = [
|
||||
'demo-preview',
|
||||
layout === 'column' ? 'demo-preview--column' : undefined,
|
||||
muted ? 'demo-preview--muted' : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<article className="demo">
|
||||
<h3 className="demo-title">{title}</h3>
|
||||
{desc !== undefined && <p className="demo-desc">{desc}</p>}
|
||||
|
||||
<div className={previewClasses}>{children}</div>
|
||||
|
||||
{code !== undefined && (
|
||||
<pre className="demo-code">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Props 表:手写维护,保持和组件定义同步。
|
||||
*
|
||||
* 只列对外承诺的 props;内部实现用的字段不写进来。
|
||||
*/
|
||||
export type PropRow = {
|
||||
name: string
|
||||
type: string
|
||||
required?: boolean
|
||||
defaultValue?: string
|
||||
desc: string
|
||||
}
|
||||
|
||||
export function PropsTable({ rows }: { rows: PropRow[] }) {
|
||||
return (
|
||||
<div className="props-table-wrap">
|
||||
<table className="props-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>属性</th>
|
||||
<th>类型</th>
|
||||
<th>默认值</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.name}>
|
||||
<td>
|
||||
<code>{row.name}</code>
|
||||
{row.required === true && ' *'}
|
||||
</td>
|
||||
<td className="props-type">
|
||||
<code>{row.type}</code>
|
||||
</td>
|
||||
<td className="props-type">{row.defaultValue ?? '—'}</td>
|
||||
<td>{row.desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* 文档的一节:锚点 + 标题 + 说明。
|
||||
*
|
||||
* 每个组件一节,节内再用 Demo 摆具体用法,方便直接翻着找。
|
||||
*/
|
||||
type SectionProps = {
|
||||
/** 锚点 id,与左侧目录一致 */
|
||||
id: string
|
||||
title: string
|
||||
/** 右侧灰字,通常写组件名或导出路径 */
|
||||
api?: string
|
||||
desc?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Section({ id, title, api, desc, children }: SectionProps) {
|
||||
return (
|
||||
<section id={id} className="doc-section">
|
||||
<div className="doc-section-title">
|
||||
<h2>{title}</h2>
|
||||
{api !== undefined && <code>{api}</code>}
|
||||
</div>
|
||||
{desc !== undefined && <p className="doc-section-desc">{desc}</p>}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/** 展示页用的示例数据:与真实业务无关,只为示例好看、够长 */
|
||||
|
||||
export type DemoApp = {
|
||||
id: string
|
||||
name: string
|
||||
desc: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export const DEMO_APPS: DemoApp[] = [
|
||||
{ id: 'github', name: 'GitHub', desc: '代码托管与开源社区', icon: 'G' },
|
||||
{ id: 'figma', name: 'Figma', desc: '多人协作的界面设计工具', icon: 'F' },
|
||||
{ id: 'notion', name: 'Notion', desc: '文档、知识库与项目管理', icon: 'N' },
|
||||
{ id: 'vercel', name: 'Vercel', desc: '前端部署与边缘网络', icon: 'V' },
|
||||
{ id: 'unsplash', name: 'Unsplash', desc: '免费高清图片素材', icon: 'U' },
|
||||
{ id: 'dribbble', name: 'Dribbble', desc: '设计师作品与灵感集', icon: 'D' },
|
||||
{ id: 'bilibili', name: '哔哩哔哩', desc: '视频社区与学习资源', icon: '哔' },
|
||||
{ id: 'stash', name: 'Stash', desc: '本地静态页面托管工具', icon: 'S' },
|
||||
]
|
||||
|
||||
export const DEMO_POSTERS = [
|
||||
{ id: 'p1', title: '星际穿越', tag: '推荐电影' },
|
||||
{ id: 'p2', title: '银翼杀手 2049', tag: '赛博朋克' },
|
||||
{ id: 'p3', title: '疯狂动物城', tag: '动画' },
|
||||
{ id: 'p4', title: '沙丘', tag: '科幻史诗' },
|
||||
]
|
||||
|
||||
export const DEMO_ARTICLES = [
|
||||
{ id: 'a1', title: 'CES 2026:折叠屏的第二次机会', meta: '2 天前' },
|
||||
{ id: 'a2', title: '把设计令牌落进 CSS 变量的四种做法', meta: '1 周前' },
|
||||
]
|
||||
|
||||
/** 内联 SVG 渐变封面:不依赖网络,离线也能看到图片态 */
|
||||
export const DEMO_COVER =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='96'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%233858f6'/%3E%3Cstop offset='1' stop-color='%237c3aed'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='96' height='96' fill='url(%23g)'/%3E%3C/svg%3E"
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-scheme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>Stash 内容工作台 · 组件拼装示例</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.staticfile.net/lxgw-wenkai-webfont/1.7.0/style.min.css"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/demo/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,546 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Drawer,
|
||||
Grid,
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
ItemBadge,
|
||||
Modal,
|
||||
Notice,
|
||||
Pagination,
|
||||
Paragraph,
|
||||
ScrollTop,
|
||||
Select,
|
||||
Skeleton,
|
||||
Space,
|
||||
Spinner,
|
||||
Switch,
|
||||
Tag,
|
||||
Text,
|
||||
Title,
|
||||
Toaster,
|
||||
toast,
|
||||
} from '../../src'
|
||||
import styles from './demo.module.css'
|
||||
|
||||
/**
|
||||
* 拼装示例:用组件库搭一个完整的三栏内容页。
|
||||
*
|
||||
* 只用 `@stash/ui` 的组件 + 这一页自己的布局样式,
|
||||
* 没有引入任何业务组件;浅色/暗色由 `<html data-scheme>` 切换。
|
||||
*/
|
||||
|
||||
const NAV = ['工作台', '常用推荐', '推荐电影', 'CES 现场报道']
|
||||
|
||||
const HOT_POSTS = [
|
||||
'iPhone 17e 最全汇总:最便宜的苹果 AI 手机',
|
||||
'二十年磨一剑,微软量子芯片碾压全球超算',
|
||||
'做饭将被 AI 彻底改变!AI 厨电成新风口',
|
||||
'对未来讲话?从 AWE 2024 看未来趋势',
|
||||
]
|
||||
|
||||
const POST_TAGS = ['未分类', '苹果新品', '闪电接口']
|
||||
|
||||
const GALLERY = [1, 2, 3, 4, 5, 6]
|
||||
|
||||
function useTheme() {
|
||||
// 支持 ?scheme=dark 直接进深色,方便截图和分享
|
||||
const [scheme, setScheme] = useState<'light' | 'dark'>(() =>
|
||||
new URLSearchParams(window.location.search).get('scheme') === 'dark' ? 'dark' : 'light',
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.scheme = scheme
|
||||
}, [scheme])
|
||||
|
||||
return {
|
||||
scheme,
|
||||
toggle: () => setScheme((current) => (current === 'light' ? 'dark' : 'light')),
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新时的占位卡片:结构和真实卡片一致,避免加载完成后跳动 */
|
||||
function SkeletonPost() {
|
||||
return (
|
||||
<Card padding="lg" aria-busy="true">
|
||||
<Space gap="sm">
|
||||
<Skeleton variant="circle" width={36} height={36} />
|
||||
<Space direction="column" gap="xs" align="start">
|
||||
<Skeleton width={96} />
|
||||
<Skeleton width={64} />
|
||||
</Space>
|
||||
</Space>
|
||||
<div style={{ marginTop: 'var(--su-space-4)' }}>
|
||||
<Skeleton lines={2} />
|
||||
</div>
|
||||
<div style={{ marginTop: 'var(--su-space-4)' }}>
|
||||
<Skeleton variant="rect" height={220} />
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function Page() {
|
||||
const { scheme, toggle } = useTheme()
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const [removed, setRemoved] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [autoSync, setAutoSync] = useState(true)
|
||||
const [showCover, setShowCover] = useState(true)
|
||||
const [notify, setNotify] = useState(false)
|
||||
const [sort, setSort] = useState('new')
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [navOpen, setNavOpen] = useState(false)
|
||||
|
||||
const refreshFeed = () => {
|
||||
setLoading(true)
|
||||
window.setTimeout(() => setLoading(false), 1200)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => setScrolled(window.scrollY > 40)
|
||||
window.addEventListener('scroll', sync, { passive: true })
|
||||
return () => window.removeEventListener('scroll', sync)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.glow} aria-hidden="true" />
|
||||
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerInner}>
|
||||
<a className={styles.brand} href="/demo.html">
|
||||
<span className={styles.brandMark}>S</span>
|
||||
<span className={styles.brandName}>STASH</span>
|
||||
</a>
|
||||
|
||||
<nav className={styles.nav} aria-label="主导航">
|
||||
{NAV.map((item, index) => (
|
||||
<a
|
||||
key={item}
|
||||
href="#top"
|
||||
className={`${styles.navLink}${index === 0 ? ` ${styles.navLinkActive}` : ''}`}
|
||||
onClick={(event) => event.preventDefault()}
|
||||
>
|
||||
{item}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className={styles.headerActions}>
|
||||
<span className={styles.onlyMobile}>
|
||||
<IconButton
|
||||
label="打开导航"
|
||||
icon={<Icon name="menu_line" />}
|
||||
onClick={() => setNavOpen(true)}
|
||||
/>
|
||||
</span>
|
||||
<IconButton label="搜索" icon={<Icon name="search_3_line" />} />
|
||||
<IconButton
|
||||
label="刷新列表"
|
||||
icon={<Icon name="refresh_3_line" />}
|
||||
spinning={loading}
|
||||
onClick={refreshFeed}
|
||||
/>
|
||||
<IconButton
|
||||
label={scheme === 'light' ? '切换到深色' : '切换到浅色'}
|
||||
icon={<Icon name={scheme === 'light' ? 'moon_line' : 'sun_line'} />}
|
||||
onClick={toggle}
|
||||
/>
|
||||
<IconButton
|
||||
label="设置"
|
||||
icon={<Icon name="settings_1_line" />}
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
/>
|
||||
<IconButton label="账号" icon={<Icon name="user_3_line" />} variant="soft" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="top" className={styles.layout}>
|
||||
<aside className={styles.rail}>
|
||||
<Card padding="lg" className={styles.profile}>
|
||||
<Avatar size="lg" fallback="S" verified />
|
||||
<h1 className={styles.profileName}>Stash 工作台</h1>
|
||||
<Tag shape="rounded">管理员</Tag>
|
||||
<p className={styles.profileBio}>
|
||||
本地静态页面托管工具,内容库驱动分类、分组与条目;这一页全部由基础组件拼成。
|
||||
</p>
|
||||
<span className={styles.profileMeta}>IP 归属地:局域网</span>
|
||||
</Card>
|
||||
|
||||
<Grid columns={2} gap="sm">
|
||||
<Card padding="sm" hoverable>
|
||||
<span className={styles.statLabel}>文章</span>
|
||||
<strong className={styles.statValue}>16</strong>
|
||||
</Card>
|
||||
<Card padding="sm" hoverable>
|
||||
<span className={styles.statLabel}>评论</span>
|
||||
<strong className={styles.statValue}>0</strong>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Card padding="sm">
|
||||
<h2 className={styles.cardTitle}>🔗 社交媒体</h2>
|
||||
<Space gap="md">
|
||||
<IconButton label="链接" icon={<Icon name="link_line" />} size="xs" variant="soft" />
|
||||
<IconButton label="分享" icon={<Icon name="share_3_line" />} size="xs" variant="soft" />
|
||||
<IconButton label="浏览" icon={<Icon name="eye_line" />} size="xs" variant="soft" />
|
||||
<IconButton label="账号" icon={<Icon name="user_3_line" />} size="xs" variant="soft" />
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
<h2 className={styles.cardTitle}>⚙️ 偏好</h2>
|
||||
<Space direction="column" gap="md" align="stretch">
|
||||
<Space justify="between">
|
||||
<Text>自动同步</Text>
|
||||
<Switch checked={autoSync} onChange={setAutoSync} label="自动同步" />
|
||||
</Space>
|
||||
<Space justify="between">
|
||||
<Text>显示封面</Text>
|
||||
<Switch checked={showCover} onChange={setShowCover} label="显示封面" />
|
||||
</Space>
|
||||
<Checkbox checked={notify} onChange={setNotify} label="每周摘要提醒" />
|
||||
{autoSync && (
|
||||
<Space gap="sm">
|
||||
<Spinner size="sm" label="同步中" />
|
||||
<Text size="xs" tone="muted">
|
||||
正在同步内容库…
|
||||
</Text>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</aside>
|
||||
|
||||
<main className={styles.feed}>
|
||||
{removed && (
|
||||
<Notice
|
||||
title="已删除《本地内容库改版》(示例)"
|
||||
action={{ label: '撤销', onClick: () => setRemoved(false) }}
|
||||
>
|
||||
这里只是演示确认流程,没有真的删数据。
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
<Breadcrumb
|
||||
items={[{ label: '首页', href: '#top' }, { label: '工作台' }, { label: '常用推荐' }]}
|
||||
/>
|
||||
|
||||
<Card padding="sm">
|
||||
<Space justify="between" wrap>
|
||||
<Space gap="sm" wrap>
|
||||
<Tag variant="solid" tone="primary" icon={<Icon name="search_3_line" />}>
|
||||
全部
|
||||
</Tag>
|
||||
<Tag>🛸 前沿科技</Tag>
|
||||
<Tag>🏠 建筑力学</Tag>
|
||||
<Tag>玩物志趣</Tag>
|
||||
</Space>
|
||||
<Space gap="sm" wrap>
|
||||
<Select
|
||||
value={sort}
|
||||
onChange={setSort}
|
||||
options={[
|
||||
{ value: 'new', label: '最近更新' },
|
||||
{ value: 'hot', label: '最热门' },
|
||||
{ value: 'name', label: '按名称' },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
icon="search_3_line"
|
||||
placeholder="筛选内容…"
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
onClear={() => setKeyword('')}
|
||||
clearable
|
||||
style={{ width: 220, maxWidth: '100%' }}
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<>
|
||||
<SkeletonPost />
|
||||
<SkeletonPost />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Card padding="lg">
|
||||
<Space gap="sm">
|
||||
<Avatar size="md" fallback="S" verified />
|
||||
<Space direction="column" gap="xs" align="start">
|
||||
<Text strong>Stash</Text>
|
||||
<Text size="xs" tone="muted">
|
||||
2 年前 · 局域网
|
||||
</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Title level={3} className={styles.postTitle}>
|
||||
<ItemBadge tone="sticky">置顶</ItemBadge>
|
||||
本地内容库改版:分区、分组与条目的三层结构
|
||||
</Title>
|
||||
|
||||
<Paragraph lines={2}>
|
||||
侧栏由内容树派生,首页卡片由 section_block 决定展示哪个节点;
|
||||
条目只存 node_key,所属分区由它推导,改编码时自动跟随。
|
||||
</Paragraph>
|
||||
|
||||
{showCover && (
|
||||
<Grid columns={2} gap="sm" className={styles.media}>
|
||||
<span className={`${styles.tile} ${styles.tileA}`} />
|
||||
<span className={`${styles.tile} ${styles.tileB}`} />
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Divider spacing="sm" />
|
||||
|
||||
<Space justify="between" wrap>
|
||||
<Space gap="sm">
|
||||
{POST_TAGS.map((tag, index) => (
|
||||
<Tag key={tag} variant="solid" tone={index === 0 ? 'primary' : 'danger'}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
|
||||
<Space gap="md">
|
||||
<span className={styles.action}>
|
||||
<Icon name="eye_line" />
|
||||
1,599
|
||||
</span>
|
||||
<span className={styles.action}>
|
||||
<Icon name="thumb_up_2_line" />
|
||||
22
|
||||
</span>
|
||||
<span className={styles.action}>
|
||||
<Icon name="share_3_line" />
|
||||
6
|
||||
</span>
|
||||
<IconButton
|
||||
size="xs"
|
||||
label="删除这篇内容"
|
||||
icon={<Icon name="delete_2_line" />}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<Space gap="sm">
|
||||
<Avatar size="md" fallback="S" />
|
||||
<Space direction="column" gap="xs" align="start">
|
||||
<Text strong>Stash</Text>
|
||||
<Text size="xs" tone="muted">
|
||||
3 天前 · 局域网
|
||||
</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Title level={3} className={styles.postTitle}>
|
||||
<ItemBadge tone="paid">付费</ItemBadge>
|
||||
主题与令牌:为什么把颜色收进 CSS 变量
|
||||
</Title>
|
||||
|
||||
<Paragraph lines={2}>
|
||||
组件只消费语义令牌(bg-card / text-main / border),
|
||||
换主题时只覆盖变量——暗色主题就是这么来的。
|
||||
</Paragraph>
|
||||
|
||||
<Notice
|
||||
title="付费内容"
|
||||
action={{ label: '登录后查看', onClick: () => undefined }}
|
||||
>
|
||||
本内容为付费内容,登录后可阅读全文。
|
||||
</Notice>
|
||||
|
||||
<Divider spacing="sm" />
|
||||
|
||||
<Space justify="between" wrap>
|
||||
<Space gap="sm">
|
||||
<Tag>设计系统</Tag>
|
||||
<Tag tone="success">已发布</Tag>
|
||||
</Space>
|
||||
<Space gap="md">
|
||||
<span className={styles.action}>
|
||||
<Icon name="eye_line" />
|
||||
842
|
||||
</span>
|
||||
<span className={styles.action}>
|
||||
<Icon name="thumb_up_2_line" />
|
||||
31
|
||||
</span>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Card padding="sm">
|
||||
<Space justify="between" wrap>
|
||||
<Space gap="sm">
|
||||
<Text size="sm" tone="muted">
|
||||
共 16 篇内容
|
||||
</Text>
|
||||
<Tag tone="neutral">第 {page} / 3 页</Tag>
|
||||
</Space>
|
||||
<Pagination page={page} total={16} pageSize={6} onChange={setPage} />
|
||||
</Space>
|
||||
</Card>
|
||||
</main>
|
||||
|
||||
<aside className={`${styles.rail} ${styles.railRight}`}>
|
||||
<Card padding="md">
|
||||
<h2 className={styles.cardTitle}>
|
||||
🔥 热门文章
|
||||
<Badge count={HOT_POSTS.length} tone="neutral" />
|
||||
</h2>
|
||||
|
||||
<ul className={styles.hotList}>
|
||||
{HOT_POSTS.map((title, index) => (
|
||||
<li key={title}>
|
||||
<a
|
||||
className={styles.hotItem}
|
||||
href="#top"
|
||||
onClick={(event) => event.preventDefault()}
|
||||
>
|
||||
<span className={styles.hotTitle}>{title}</span>
|
||||
<ItemBadge tone="hot">HOT</ItemBadge>
|
||||
</a>
|
||||
{index < HOT_POSTS.length - 1 && <Divider spacing="sm" />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
<h2 className={styles.cardTitle}>🖼 图集</h2>
|
||||
<Grid columns={3} gap="sm">
|
||||
{GALLERY.map((item) => (
|
||||
<span key={item} className={`${styles.thumb} ${styles[`thumb${item}`]}`} />
|
||||
))}
|
||||
</Grid>
|
||||
</Card>
|
||||
|
||||
<Notice title="这一页怎么来的">
|
||||
全部由 Card / Tag / ItemBadge / Avatar / Grid / Divider / Button / Input / Modal /
|
||||
Pagination 拼装,没有任何业务组件;右上角可以切换深浅色。
|
||||
</Notice>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={confirmOpen}
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
title="删除这篇内容?"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setConfirmOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
setConfirmOpen(false)
|
||||
setRemoved(true)
|
||||
toast.success('已删除《本地内容库改版》')
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
删除后不可恢复,占位数据会被一起清掉。
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
title="设置"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setDrawerOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDrawerOpen(false)
|
||||
toast.success('设置已保存')
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Space direction="column" gap="lg" align="stretch">
|
||||
<Space justify="between">
|
||||
<Text>自动同步</Text>
|
||||
<Switch checked={autoSync} onChange={setAutoSync} label="自动同步" />
|
||||
</Space>
|
||||
<Space justify="between">
|
||||
<Text>显示封面</Text>
|
||||
<Switch checked={showCover} onChange={setShowCover} label="显示封面" />
|
||||
</Space>
|
||||
<Checkbox checked={notify} onChange={setNotify} label="每周摘要提醒" />
|
||||
<Space direction="column" gap="sm" align="stretch">
|
||||
<Text size="sm" tone="sub">
|
||||
默认排序
|
||||
</Text>
|
||||
<Select
|
||||
value={sort}
|
||||
onChange={setSort}
|
||||
options={[
|
||||
{ value: 'new', label: '最近更新' },
|
||||
{ value: 'hot', label: '最热门' },
|
||||
{ value: 'name', label: '按名称' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
</Drawer>
|
||||
|
||||
<Drawer open={navOpen} onClose={() => setNavOpen(false)} placement="left" width={280} title="导航">
|
||||
<Space direction="column" gap="sm" align="stretch">
|
||||
{NAV.map((item, index) => (
|
||||
<a
|
||||
key={item}
|
||||
href="#top"
|
||||
className={`${styles.drawerNav}${index === 0 ? ` ${styles.drawerNavActive}` : ''}`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
setNavOpen(false)
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</a>
|
||||
))}
|
||||
<Divider spacing="sm" />
|
||||
<Text size="sm" tone="muted">
|
||||
窄屏下顶部导航会收进这个抽屉里;桌面端直接显示在页头。
|
||||
</Text>
|
||||
</Space>
|
||||
</Drawer>
|
||||
|
||||
<Toaster />
|
||||
|
||||
<ScrollTop visible={scrolled} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/* 只放全局 reset 与页面底色;布局都在 demo.module.css */
|
||||
:root {
|
||||
font-family: var(--su-font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--su-text-main);
|
||||
background-color: var(--su-bg-page);
|
||||
color-scheme: light;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
:root[data-scheme='dark'] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background-color: var(--su-bg-page);
|
||||
color: var(--su-text-main);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: var(--su-focus-ring);
|
||||
outline-offset: var(--su-focus-ring-offset);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/* 拼装示例的页面级布局:只做排版,不重写组件样式 */
|
||||
|
||||
.page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
/* 顶部柔光:浅色下是蓝/橙/绿三团,深色下自动变淡 */
|
||||
.glow {
|
||||
position: fixed;
|
||||
inset: 0 0 auto;
|
||||
height: 420px;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(560px 240px at 18% 0%, rgba(56, 88, 246, 0.18), transparent 70%),
|
||||
radial-gradient(520px 220px at 72% -6%, rgba(255, 186, 58, 0.16), transparent 72%),
|
||||
radial-gradient(460px 240px at 96% 6%, rgba(74, 189, 134, 0.14), transparent 70%);
|
||||
mask-image: linear-gradient(to bottom, #000 40%, transparent);
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--su-z-header);
|
||||
border-bottom: 1px solid transparent;
|
||||
backdrop-filter: blur(12px);
|
||||
background: color-mix(in srgb, var(--su-bg-page) 72%, transparent);
|
||||
}
|
||||
|
||||
.headerInner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-6);
|
||||
max-width: 1280px;
|
||||
height: 64px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--su-space-6);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brandMark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--su-radius-sm);
|
||||
background-image: linear-gradient(135deg, #4d6bff, var(--su-color-primary));
|
||||
color: var(--su-white);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brandName {
|
||||
font-size: var(--su-font-size-lg);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: var(--su-space-1);
|
||||
}
|
||||
|
||||
.navLink {
|
||||
padding: 6px var(--su-space-3);
|
||||
border-radius: var(--su-radius-sm);
|
||||
font-size: var(--su-font-size-md);
|
||||
color: var(--su-text-sub);
|
||||
transition:
|
||||
color var(--su-duration-base) var(--su-ease-in-out),
|
||||
background-color var(--su-duration-base) var(--su-ease-in-out);
|
||||
}
|
||||
|
||||
.navLink:hover {
|
||||
background-color: var(--su-bg-subtle);
|
||||
color: var(--su-text-main);
|
||||
}
|
||||
|
||||
.navLinkActive {
|
||||
background-color: var(--su-color-primary-soft);
|
||||
color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-2);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 导航收进抽屉时,页头出现菜单按钮 */
|
||||
.onlyMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drawerNav {
|
||||
display: block;
|
||||
padding: var(--su-space-2) var(--su-space-3);
|
||||
border-radius: var(--su-radius-sm);
|
||||
font-size: var(--su-font-size-md);
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.drawerNavActive {
|
||||
background-color: var(--su-color-primary-soft);
|
||||
color: var(--su-color-primary);
|
||||
font-weight: var(--su-font-weight-bold);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr) 300px;
|
||||
gap: var(--su-space-5);
|
||||
align-items: start;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: var(--su-space-6);
|
||||
}
|
||||
|
||||
.rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-4);
|
||||
}
|
||||
|
||||
.feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-4);
|
||||
}
|
||||
|
||||
.profile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--su-space-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profileName {
|
||||
font-size: var(--su-font-size-lg);
|
||||
line-height: var(--su-line-height-lg);
|
||||
font-weight: var(--su-font-weight-bold);
|
||||
}
|
||||
|
||||
.profileBio {
|
||||
margin-top: var(--su-space-2);
|
||||
font-size: var(--su-font-size-sm);
|
||||
line-height: var(--su-line-height-sm);
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.profileMeta {
|
||||
font-size: var(--su-font-size-xs);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
display: block;
|
||||
font-size: var(--su-font-size-xs);
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.statValue {
|
||||
display: block;
|
||||
margin-top: var(--su-space-2);
|
||||
font-size: var(--su-font-size-xl);
|
||||
line-height: var(--su-line-height-xl);
|
||||
font-weight: var(--su-font-weight-bold);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-2);
|
||||
margin-bottom: var(--su-space-3);
|
||||
font-size: var(--su-font-size-lg);
|
||||
line-height: var(--su-line-height-lg);
|
||||
font-weight: var(--su-font-weight-bold);
|
||||
}
|
||||
|
||||
.authorName {
|
||||
font-size: var(--su-font-size-md);
|
||||
font-weight: var(--su-font-weight-bold);
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: var(--su-font-size-xs);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.postTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--su-space-2);
|
||||
margin: var(--su-space-3) 0 var(--su-space-2);
|
||||
font-size: var(--su-font-size-lg);
|
||||
line-height: var(--su-line-height-lg);
|
||||
font-weight: var(--su-font-weight-bold);
|
||||
}
|
||||
|
||||
.excerpt {
|
||||
margin-bottom: var(--su-space-3);
|
||||
font-size: var(--su-font-size-md);
|
||||
line-height: var(--su-line-height-md);
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.media {
|
||||
margin-bottom: var(--su-space-3);
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: block;
|
||||
aspect-ratio: 16 / 10;
|
||||
border-radius: var(--su-radius-sm);
|
||||
}
|
||||
|
||||
.tileA {
|
||||
background-image: linear-gradient(135deg, #3858f6, #7c3aed);
|
||||
}
|
||||
|
||||
.tileB {
|
||||
background-image: linear-gradient(135deg, #ff8338, #ff3e3e);
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-1);
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.pageInfo {
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.hotList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hotItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--su-space-2);
|
||||
padding: var(--su-space-2) 0;
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-sub);
|
||||
transition: color var(--su-duration-base) var(--su-ease-in-out);
|
||||
}
|
||||
|
||||
.hotItem:hover {
|
||||
color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
.hotTitle {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
display: block;
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--su-radius-sm);
|
||||
}
|
||||
|
||||
.thumb1 {
|
||||
background-image: linear-gradient(135deg, #3858f6, #22d3ee);
|
||||
}
|
||||
|
||||
.thumb2 {
|
||||
background-image: linear-gradient(135deg, #ff8338, #ffba3a);
|
||||
}
|
||||
|
||||
.thumb3 {
|
||||
background-image: linear-gradient(135deg, #4abd86, #0ea5e9);
|
||||
}
|
||||
|
||||
.thumb4 {
|
||||
background-image: linear-gradient(135deg, #7c3aed, #db2777);
|
||||
}
|
||||
|
||||
.thumb5 {
|
||||
background-image: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||
}
|
||||
|
||||
.thumb6 {
|
||||
background-image: linear-gradient(135deg, #0ea5e9, #6366f1);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.layout {
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.railRight {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.onlyMobile {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.rail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.headerInner {
|
||||
gap: var(--su-space-3);
|
||||
padding: 0 var(--su-space-4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import '../../src/theme/tokens.css'
|
||||
import '../../src/theme/iconfont.css'
|
||||
import './demo.css'
|
||||
import { Page } from './Page'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<Page />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>Stash UI · 设计系统与组件库</title>
|
||||
<!-- 与宿主应用同一份正文字体;加载失败时回退系统字体 -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.staticfile.net/lxgw-wenkai-webfont/1.7.0/style.min.css"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import '../src/theme/tokens.css'
|
||||
import '../src/theme/iconfont.css'
|
||||
import './app.css'
|
||||
import { App } from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
name: stash-ui
|
||||
description: Use, extend, or restyle the Stash UI component library (@stash/ui): a zero-dependency React design system with tokens, 37 components, an in-repo docs site and a full-page demo. Load before writing or changing any component, or before composing pages with @stash/ui.
|
||||
---
|
||||
|
||||
# Stash UI
|
||||
|
||||
这是一套独立的设计系统(React + CSS Modules + 设计令牌),零运行时依赖。
|
||||
文档是唯一事实来源,这个 skill 只做「入口 + 硬约束 + 验证流程」,**不要把它当成文档的副本**。
|
||||
|
||||
## 先读哪几份
|
||||
|
||||
按任务选读,不必全读:
|
||||
|
||||
| 文档 | 什么时候读 |
|
||||
| --- | --- |
|
||||
| `DESIGN.md` | 任何改动前:分层、令牌、状态矩阵、代码约定、验收清单 |
|
||||
| `docs/COMPONENTS.md` | 想用组件 / 判断有没有 / 看契约 / 看候选清单 |
|
||||
| `docs/ADDING-A-COMPONENT.md` | 要新增组件时,照 12 步走 |
|
||||
| `docs/ALRIGHT-DECOMPOSITION.md` | 怀疑某个数值(字号、阴影、间距)对不对时 |
|
||||
| `site/demo.html` 的源码 | 想看真实页面怎么把组件拼起来 |
|
||||
|
||||
## 三种任务
|
||||
|
||||
### A. 用组件拼页面
|
||||
|
||||
1. 先查 `COMPONENTS.md §2「用哪个」`决策表;能用现有组件组合就不要新增组件。
|
||||
2. 颜色 / 间距 / 字号 / 圆角只用 `--su-*` 令牌;页面自己的布局样式写页面级 CSS Module。
|
||||
3. 深色主题靠 `<html data-scheme="dark|auto">`,**页面和组件都不用改**。
|
||||
4. 常见组合(分区卡片、行式条目、工具条、海报卡)见 `COMPONENTS.md §4`。
|
||||
5. 页面外壳用 `Layout`(header / sider / children / footer),不要自己写一套 div 骨架。
|
||||
|
||||
### B. 改组件
|
||||
|
||||
1. 先看现有契约(`COMPONENTS.md §3`)。**改 props = 改契约**,必须同步三处:
|
||||
组件代码、展示页 section、`COMPONENTS.md` + README 组件表。
|
||||
2. 只用语义令牌(`bg-card` / `bg-subtle` / `bg-hover` / `text-*` / `border-*`);
|
||||
不要直接用 `--su-gray-*`——灰阶在深色下是反相的。
|
||||
3. 状态要对齐 `DESIGN.md §4.3`:默认 / hover / active / disabled / loading /
|
||||
focus-visible / 空 / 错误。
|
||||
4. 改完跑下面的验证命令,并截图核对(含深色)。
|
||||
|
||||
### C. 加组件
|
||||
|
||||
严格按 `docs/ADDING-A-COMPONENT.md`:
|
||||
|
||||
```text
|
||||
登记(COMPONENTS §1 + §3 草稿) → 定层级/API → Xxx.tsx → Xxx.module.css
|
||||
→ src/index.ts 导出 → site/sections 展示页 → site/App.tsx 导航
|
||||
→ README 清单 → 契约定稿 → 验收清单 → 截图 → 提交
|
||||
```
|
||||
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
npm run dev # 文档站 5199;整页示例 /demo.html(?scheme=dark)
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run lint # oxlint(要求 0 warnings)
|
||||
npm run build # 库产物 dist/stash-ui.js + .css + types
|
||||
npm run build:site # 静态站点 dist-site(index.html + demo.html)
|
||||
|
||||
# 视觉核对:需要本机 Chrome / Edge
|
||||
node scripts/screenshot.mjs http://127.0.0.1:5199/#buttons shots/buttons.png
|
||||
node scripts/screenshot.mjs http://127.0.0.1:5199/demo.html shots/demo.png full
|
||||
```
|
||||
|
||||
## 硬约束(违反就不合并)
|
||||
|
||||
- 组件 CSS 里禁止 hex / rgba / 字号 / 间距字面量;允许的只有 `0`、`1px` 边框、`100%`、
|
||||
以及组件内声明的 `--xxx-size` 尺寸变量。
|
||||
- `className` / `style` 落在外层元素;组件**不带 margin**,间距交给 `Space`。
|
||||
- 只有图标的控件必须传 `label`;错误提示 `role="alert"`;`focus-visible` 不得移除。
|
||||
- 不新增运行时依赖(只 peer 依赖 react / react-dom)。
|
||||
- 展示页的 demo 必须直接用真实组件,不写「演示专用版本」。
|
||||
- 组件不认识业务:不请求接口、不 import 业务类型、props 里不出现 `categoryId` 这类字段。
|
||||
|
||||
## 已知坑
|
||||
|
||||
- 比例封面不要放进 `flex-direction: column` 的父容器(行高会被压扁),用 grid / block。
|
||||
- 展示站 `site/main.tsx` 必须显式 import `tokens.css` 和 `iconfont.css`,否则生产构建会摇掉图标。
|
||||
- `<dialog>` 的居中被宿主 `* { margin: 0 }` 覆盖过,Modal / Drawer 里已显式写回 `margin`。
|
||||
- 图标名来自全量 iconfont(446 个,`src/theme/icons.ts`),新增字形要走 `COMPONENTS.md §7` 的流程。
|
||||
@@ -0,0 +1,110 @@
|
||||
/* 头像尺寸是组件内部的比例关系,用局部变量集中声明(不往全局令牌里塞) */
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* 裁切层:图片 / 字符块都在这里被圆角裁掉,角标画在它外面 */
|
||||
.media {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: inherit;
|
||||
background-color: var(--su-bg-light);
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.circle {
|
||||
border-radius: var(--su-radius-pill);
|
||||
}
|
||||
|
||||
.square {
|
||||
border-radius: var(--su-radius-sm);
|
||||
}
|
||||
|
||||
.sm {
|
||||
--avatar-size: 24px;
|
||||
--avatar-badge: 12px;
|
||||
|
||||
width: var(--avatar-size);
|
||||
height: var(--avatar-size);
|
||||
font-size: var(--su-font-size-sm);
|
||||
}
|
||||
|
||||
.md {
|
||||
--avatar-size: 36px;
|
||||
--avatar-badge: 16px;
|
||||
|
||||
width: var(--avatar-size);
|
||||
height: var(--avatar-size);
|
||||
font-size: var(--su-font-size-md);
|
||||
}
|
||||
|
||||
.lg {
|
||||
--avatar-size: 48px;
|
||||
--avatar-badge: 20px;
|
||||
|
||||
width: var(--avatar-size);
|
||||
height: var(--avatar-size);
|
||||
font-size: var(--su-font-size-lg);
|
||||
}
|
||||
|
||||
/* 资料卡 / 详情页的大头像 */
|
||||
.xl {
|
||||
--avatar-size: 64px;
|
||||
--avatar-badge: 20px;
|
||||
|
||||
width: var(--avatar-size);
|
||||
height: var(--avatar-size);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
/* 无图时的品牌底色:浅色渐变 + 主色字,比纯灰更有识别度 */
|
||||
.primary .media {
|
||||
background: var(--su-avatar-gradient);
|
||||
color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
/* 图标占位:尺寸跟随字体,颜色跟随 tone */
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.fallback {
|
||||
font-weight: var(--su-font-weight-bold);
|
||||
line-height: 1;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* 认证角标:橙底白勾,盖在头像右下角,带一圈与卡片同色的描边 */
|
||||
.verified {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -2px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: var(--avatar-badge);
|
||||
height: var(--avatar-badge);
|
||||
border: 2px solid var(--su-bg-card);
|
||||
border-radius: var(--su-radius-pill);
|
||||
background: var(--su-color-warning);
|
||||
color: var(--su-white);
|
||||
font-size: calc(var(--avatar-badge) * 0.55);
|
||||
font-weight: var(--su-font-weight-strong);
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import styles from './Avatar.module.css'
|
||||
|
||||
/**
|
||||
* 头像 / 封面占位。
|
||||
*
|
||||
* 有图片就渲染图片;没图片、或图片加载失败时退化成字符块
|
||||
* (也就是列表里常见的「首字母头像」)。
|
||||
*
|
||||
* 认证角标画在裁切层之外,所以不会被圆角切掉。
|
||||
*/
|
||||
export type AvatarSize = 'sm' | 'md' | 'lg' | 'xl'
|
||||
export type AvatarShape = 'circle' | 'square'
|
||||
export type AvatarTone = 'neutral' | 'primary'
|
||||
|
||||
export type AvatarProps = {
|
||||
src?: string
|
||||
alt?: string
|
||||
/** 无图或加载失败时显示的字符,通常取名称首字 */
|
||||
fallback?: string
|
||||
/** 无图时显示图标(优先于 fallback),用于「用户 / 应用」这类缺省头像 */
|
||||
icon?: ReactNode
|
||||
size?: AvatarSize
|
||||
shape?: AvatarShape
|
||||
/** 无图时的底色:neutral 浅灰(默认)/ primary 品牌浅色渐变(资料卡用) */
|
||||
tone?: AvatarTone
|
||||
/** 右下角认证角标(✓) */
|
||||
verified?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Avatar({
|
||||
src,
|
||||
alt = '',
|
||||
fallback = '•',
|
||||
icon,
|
||||
size = 'md',
|
||||
shape = 'circle',
|
||||
tone = 'neutral',
|
||||
verified = false,
|
||||
className,
|
||||
}: AvatarProps) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
const showImage = src !== undefined && !failed
|
||||
|
||||
const classes = [styles.root, styles[size], styles[shape], styles[tone], className]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<span className={classes}>
|
||||
<span className={styles.media}>
|
||||
{showImage ? (
|
||||
<img
|
||||
className={styles.image}
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
) : icon !== undefined ? (
|
||||
<span className={styles.icon} aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
) : (
|
||||
<span className={styles.fallback} aria-hidden="true">
|
||||
{fallback}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{verified && (
|
||||
<span className={styles.verified} aria-hidden="true">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
.root {
|
||||
--badge-height: 18px;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: var(--badge-height);
|
||||
height: var(--badge-height);
|
||||
padding: 0 var(--su-space-1);
|
||||
border-radius: var(--su-radius-pill);
|
||||
font-size: var(--su-font-size-xs);
|
||||
font-weight: var(--su-font-weight-bold);
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.danger {
|
||||
background-color: var(--su-color-danger);
|
||||
color: var(--su-white);
|
||||
}
|
||||
|
||||
.primary {
|
||||
background-color: var(--su-color-primary);
|
||||
color: var(--su-white);
|
||||
}
|
||||
|
||||
.neutral {
|
||||
background-color: var(--su-bg-hover);
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import styles from './Badge.module.css'
|
||||
|
||||
/**
|
||||
* 数量角标:分区标题旁的小圆标。
|
||||
*
|
||||
* 只负责「数字的样子」,贴在哪里由使用方决定(传 `className` 做绝对定位),
|
||||
* 这样同一个角标既可以是内联的,也可以压在标题右上角。
|
||||
*/
|
||||
export type BadgeTone = 'danger' | 'primary' | 'neutral'
|
||||
|
||||
export type BadgeProps = {
|
||||
count: number
|
||||
tone?: BadgeTone
|
||||
/** 超过这个数量显示为 `99+`,设为 0 表示不截断 */
|
||||
max?: number
|
||||
} & HTMLAttributes<HTMLSpanElement>
|
||||
|
||||
export function Badge({ count, tone = 'danger', max = 99, className, ...rest }: BadgeProps) {
|
||||
const text = max > 0 && count > max ? `${max}+` : String(count)
|
||||
|
||||
return (
|
||||
<span className={[styles.root, styles[tone], className].filter(Boolean).join(' ')} {...rest}>
|
||||
{text}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
.root {
|
||||
font-size: var(--su-font-size-sm);
|
||||
line-height: var(--su-line-height-sm);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--su-space-1);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-1);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--su-text-sub);
|
||||
transition: color var(--su-duration-base) var(--su-ease-in-out);
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
.current {
|
||||
color: var(--su-text-main);
|
||||
}
|
||||
|
||||
.separator {
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.link {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import styles from './Breadcrumb.module.css'
|
||||
|
||||
/** 面包屑:详情页的路径导航,最后一项是当前页(不可点)。 */
|
||||
export type BreadcrumbItem = {
|
||||
label: ReactNode
|
||||
href?: string
|
||||
}
|
||||
|
||||
export type BreadcrumbProps = {
|
||||
items: BreadcrumbItem[]
|
||||
/** 分隔符,默认 `/` */
|
||||
separator?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Breadcrumb({ items, separator = '/', className }: BreadcrumbProps) {
|
||||
return (
|
||||
<nav className={[styles.root, className].filter(Boolean).join(' ')} aria-label="面包屑">
|
||||
<ol className={styles.list}>
|
||||
{items.map((item, index) => {
|
||||
const last = index === items.length - 1
|
||||
|
||||
return (
|
||||
<li key={`${index}-${typeof item.label === 'string' ? item.label : ''}`} className={styles.item}>
|
||||
{item.href !== undefined && !last ? (
|
||||
<a className={styles.link} href={item.href}>
|
||||
{item.label}
|
||||
</a>
|
||||
) : (
|
||||
<span className={styles.current} aria-current={last ? 'page' : undefined}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
{!last && (
|
||||
<span className={styles.separator} aria-hidden="true">
|
||||
{separator}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ol>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
.root {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--su-space-2);
|
||||
height: var(--su-control-height-md);
|
||||
padding: 0 var(--su-space-5);
|
||||
touch-action: manipulation;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--su-radius-sm);
|
||||
background-color: transparent;
|
||||
font-family: var(--su-font-sans);
|
||||
font-size: var(--su-font-size-md);
|
||||
font-weight: var(--su-font-weight-bold);
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--su-duration-base) var(--su-ease-in-out),
|
||||
background-color var(--su-duration-base) var(--su-ease-in-out),
|
||||
border-color var(--su-duration-base) var(--su-ease-in-out),
|
||||
opacity var(--su-duration-base) var(--su-ease-in-out);
|
||||
}
|
||||
|
||||
.root:focus-visible {
|
||||
outline: var(--su-focus-ring);
|
||||
outline-offset: var(--su-focus-ring-offset);
|
||||
}
|
||||
|
||||
/* 禁用态:参考站的做法是换成浅底 + 弱文字,而不是整体降透明度 */
|
||||
.root:disabled {
|
||||
border-color: var(--su-border-strong);
|
||||
background-color: var(--su-bg-subtle);
|
||||
color: var(--su-text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.sm {
|
||||
height: var(--su-control-height-sm);
|
||||
padding: 0 var(--su-space-3);
|
||||
font-size: var(--su-font-size-sm);
|
||||
}
|
||||
|
||||
.md {
|
||||
height: var(--su-control-height-md);
|
||||
padding: 0 var(--su-space-5);
|
||||
font-size: var(--su-font-size-md);
|
||||
}
|
||||
|
||||
/* 大按钮:表单主操作 / 落地页 CTA;与 Input size="lg" 同高 */
|
||||
.lg {
|
||||
height: var(--su-control-height-lg);
|
||||
padding: 0 var(--su-space-6);
|
||||
font-size: var(--su-font-size-base);
|
||||
}
|
||||
|
||||
/* 胶囊按钮:参考站的 .btn-rounded,登录、筛选这类主操作常用 */
|
||||
.pill {
|
||||
border-radius: var(--su-radius-pill);
|
||||
padding: 0 var(--su-space-5);
|
||||
}
|
||||
|
||||
.primary {
|
||||
background-color: var(--su-color-primary);
|
||||
color: var(--su-white);
|
||||
}
|
||||
|
||||
/* 参考站的 .btn-primary:hover 是 opacity .8,不是改色 */
|
||||
.primary:hover:not(:disabled) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
border-color: var(--su-border-strong);
|
||||
background-color: var(--su-bg-light);
|
||||
color: var(--su-text-secondary);
|
||||
}
|
||||
|
||||
.secondary:hover:not(:disabled) {
|
||||
background-color: var(--su-bg-hover);
|
||||
color: var(--su-text-main);
|
||||
}
|
||||
|
||||
/* 描边按钮:用于「有底色会糊」的位置,例如顶栏上的主题切换 */
|
||||
.outline {
|
||||
border-color: var(--su-border-strong);
|
||||
background-color: transparent;
|
||||
color: var(--su-text-main);
|
||||
}
|
||||
|
||||
.outline:hover:not(:disabled) {
|
||||
background-color: var(--su-bg-subtle);
|
||||
}
|
||||
|
||||
.ghost {
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.ghost:hover:not(:disabled) {
|
||||
background-color: var(--su-bg-subtle);
|
||||
color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
.danger {
|
||||
background-color: var(--su-color-danger);
|
||||
color: var(--su-white);
|
||||
}
|
||||
|
||||
.danger:hover:not(:disabled) {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
border: 2px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: var(--su-radius-pill);
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
/* 图标原地旋转:重新扫描这类「刷新动作」保留图标语义,不换成转圈 */
|
||||
.spinIcon {
|
||||
display: inline-flex;
|
||||
animation: spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 生产标准:尊重系统的「减少动态效果」 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.root {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.spinner,
|
||||
.spinIcon {
|
||||
animation-duration: 2.4s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import styles from './Button.module.css'
|
||||
|
||||
/**
|
||||
* 按钮。
|
||||
*
|
||||
* 四种语义、两种尺寸、两种形状(圆角 / 胶囊)。
|
||||
* 图标与文字之间由 `gap` 控制,loading 时用同色转圈替换图标位。
|
||||
*/
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg'
|
||||
export type ButtonShape = 'rounded' | 'pill'
|
||||
|
||||
export type ButtonProps = {
|
||||
variant?: ButtonVariant
|
||||
size?: ButtonSize
|
||||
/** 形状:rounded 6px 圆角 / pill 胶囊 */
|
||||
shape?: ButtonShape
|
||||
/** 占满一行 */
|
||||
block?: boolean
|
||||
/** 加载中:自动禁用,点击不触发 */
|
||||
loading?: boolean
|
||||
/** 图标原地旋转(不替换成转圈),用于「重新扫描」这类刷新动作 */
|
||||
spinning?: boolean
|
||||
/** 文字左侧的图标 */
|
||||
icon?: ReactNode
|
||||
children?: ReactNode
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement>
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
shape = 'rounded',
|
||||
block = false,
|
||||
loading = false,
|
||||
spinning = false,
|
||||
icon,
|
||||
children,
|
||||
className,
|
||||
disabled,
|
||||
type = 'button',
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
const classes = [
|
||||
styles.root,
|
||||
styles[variant],
|
||||
styles[size],
|
||||
shape === 'pill' ? styles.pill : undefined,
|
||||
block ? styles.block : undefined,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={classes}
|
||||
disabled={disabled === true || loading || spinning}
|
||||
aria-busy={loading || spinning || undefined}
|
||||
{...rest}
|
||||
>
|
||||
{loading ? (
|
||||
<span className={styles.spinner} aria-hidden="true" />
|
||||
) : spinning && icon !== undefined ? (
|
||||
<span className={styles.spinIcon} aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
{children !== undefined && <span>{children}</span>}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
.root {
|
||||
border: 1px solid var(--su-card-border);
|
||||
border-radius: var(--su-radius-md);
|
||||
background-color: var(--su-bg-card);
|
||||
box-shadow: var(--su-shadow-card);
|
||||
}
|
||||
|
||||
.none {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sm {
|
||||
padding: var(--su-space-3);
|
||||
}
|
||||
|
||||
.md {
|
||||
padding: var(--su-space-5);
|
||||
}
|
||||
|
||||
.lg {
|
||||
padding: var(--su-space-6);
|
||||
}
|
||||
|
||||
.hoverable {
|
||||
transition: box-shadow var(--su-duration-base) var(--su-ease-in-out);
|
||||
}
|
||||
|
||||
.hoverable:hover {
|
||||
box-shadow: var(--su-shadow-hover);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hoverable {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
|
||||
import styles from './Card.module.css'
|
||||
|
||||
/**
|
||||
* 卡片容器:白底、细边、极轻阴影。
|
||||
*
|
||||
* 只负责「一块内容」的外观,不带标题、不带操作——
|
||||
* 标题栏、角标这些由使用方组合(见展示站的「组合示例」)。
|
||||
*/
|
||||
export type CardProps = {
|
||||
as?: 'div' | 'section' | 'article' | 'aside'
|
||||
/** 内边距:none 用于自带 padding 的内容(如封面贴边) */
|
||||
padding?: 'none' | 'sm' | 'md' | 'lg'
|
||||
/** 悬停时阴影加深,用于可点击的整体卡片 */
|
||||
hoverable?: boolean
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
children?: ReactNode
|
||||
} & Omit<HTMLAttributes<HTMLElement>, 'className' | 'style' | 'children'>
|
||||
|
||||
export function Card({
|
||||
as: Tag = 'div',
|
||||
padding = 'md',
|
||||
hoverable = false,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...rest
|
||||
}: CardProps) {
|
||||
const classes = [styles.root, styles[padding], hoverable ? styles.hoverable : undefined, className]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<Tag className={classes} style={style} {...rest}>
|
||||
{children}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
.root {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-2);
|
||||
font-size: var(--su-font-size-md);
|
||||
line-height: var(--su-line-height-md);
|
||||
color: var(--su-text-main);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
color: var(--su-text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 原生 input 只留 1px,可视部分由 .box 画 */
|
||||
.input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.box {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 1px solid var(--su-border-strong);
|
||||
border-radius: var(--su-radius-xs);
|
||||
background-color: var(--su-bg-card);
|
||||
transition:
|
||||
background-color var(--su-duration-base) var(--su-ease-in-out),
|
||||
border-color var(--su-duration-base) var(--su-ease-in-out);
|
||||
}
|
||||
|
||||
/* 对勾用两道边框画,避免依赖图标字形的小尺寸渲染 */
|
||||
.box::after {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 4px;
|
||||
border-bottom: 2px solid var(--su-white);
|
||||
border-left: 2px solid var(--su-white);
|
||||
transform: rotate(-45deg) translateY(-1px);
|
||||
opacity: 0;
|
||||
transition: opacity var(--su-duration-fast) var(--su-ease-in-out);
|
||||
}
|
||||
|
||||
.input:checked + .box {
|
||||
border-color: var(--su-color-primary);
|
||||
background-color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
.input:checked + .box::after {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.input:focus-visible + .box {
|
||||
outline: var(--su-focus-ring);
|
||||
outline-offset: var(--su-focus-ring-offset);
|
||||
}
|
||||
|
||||
.disabled .box {
|
||||
background-color: var(--su-bg-light);
|
||||
}
|
||||
|
||||
.label {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.box,
|
||||
.box::after {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import styles from './Checkbox.module.css'
|
||||
|
||||
/**
|
||||
* 复选框:多选、开关型确认(「记住我」「订阅通知」)。
|
||||
*
|
||||
* 用原生 `input[type=checkbox]` 保证键盘与读屏行为,视觉部分自绘;
|
||||
* 点击文字也能勾选(label 包裹)。
|
||||
*/
|
||||
export type CheckboxProps = {
|
||||
checked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
disabled?: boolean
|
||||
/** 可见文字,同时作为无障碍名称 */
|
||||
label: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Checkbox({
|
||||
checked,
|
||||
onChange,
|
||||
disabled = false,
|
||||
label,
|
||||
className,
|
||||
}: CheckboxProps) {
|
||||
return (
|
||||
<label
|
||||
className={[styles.root, disabled ? styles.disabled : undefined, className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
<input
|
||||
className={styles.input}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
/>
|
||||
<span className={styles.box} aria-hidden="true" />
|
||||
<span className={styles.label}>{label}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
.root {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: var(--su-radius-cover);
|
||||
background-color: var(--su-bg-light);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* 贴边用在 Card padding="none" 里 */
|
||||
.flat {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.poster {
|
||||
aspect-ratio: 2 / 3;
|
||||
}
|
||||
|
||||
.article {
|
||||
aspect-ratio: 4 / 3;
|
||||
}
|
||||
|
||||
.wide {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: transform var(--su-duration-base) var(--su-ease-in-out);
|
||||
}
|
||||
|
||||
.zoom:hover .image {
|
||||
transform: scale(1.04);
|
||||
}
|
||||
|
||||
/* 参考站:悬停整块上浮 2px(不是缩放图片) */
|
||||
.lift {
|
||||
transition:
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.lift:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--su-text-sub);
|
||||
font-size: var(--su-font-size-lg);
|
||||
}
|
||||
|
||||
.caption {
|
||||
position: absolute;
|
||||
inset: auto 0 0;
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
padding: var(--su-space-5) var(--su-space-2) var(--su-space-2);
|
||||
background: var(--su-cover-scrim);
|
||||
color: var(--su-white);
|
||||
font-size: var(--su-font-size-sm);
|
||||
line-height: var(--su-line-height-sm);
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.image,
|
||||
.lift {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import styles from './Cover.module.css'
|
||||
|
||||
/**
|
||||
* 比例封面卡:图片 + 底部压暗层 + 标题。
|
||||
*
|
||||
* 海报(2:3)、图文(4:3)、宽图(16:9)都用它,避免每个列表各写一套
|
||||
* `aspect-ratio` + `object-fit` + 渐变遮罩。
|
||||
* 传 `onClick` 时整块变成按钮(可键盘操作);不传就是静态图。
|
||||
*/
|
||||
export type CoverRatio = 'poster' | 'article' | 'wide' | number
|
||||
|
||||
export type CoverProps = {
|
||||
src?: string
|
||||
alt: string
|
||||
/** 底部标题;不传则只显示图片 */
|
||||
caption?: ReactNode
|
||||
/** poster 2:3 / article 4:3 / wide 16:9,或直接给数字(宽高比) */
|
||||
ratio?: CoverRatio
|
||||
/** 圆角:默认使用 --su-radius-cover(8px);'none' 用于贴边容器 */
|
||||
radius?: 'cover' | 'none'
|
||||
/** 悬停时整块上浮 2px(参考站行为),默认 true */
|
||||
lift?: boolean
|
||||
/** 悬停时图片轻微放大(与 lift 二选一),默认 false */
|
||||
hoverZoom?: boolean
|
||||
/** 传了就把整块变成按钮 */
|
||||
onClick?: () => void
|
||||
/** 没有图片时的占位内容 */
|
||||
children?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Cover({
|
||||
src,
|
||||
alt,
|
||||
caption,
|
||||
ratio = 'article',
|
||||
radius = 'cover',
|
||||
lift = true,
|
||||
hoverZoom = false,
|
||||
onClick,
|
||||
children,
|
||||
className,
|
||||
}: CoverProps) {
|
||||
const classes = [
|
||||
styles.root,
|
||||
typeof ratio === 'string' ? styles[ratio] : undefined,
|
||||
radius === 'none' ? styles.flat : undefined,
|
||||
lift ? styles.lift : undefined,
|
||||
hoverZoom ? styles.zoom : undefined,
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
const style = typeof ratio === 'number' ? { aspectRatio: String(ratio) } : undefined
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{src !== undefined ? (
|
||||
<img className={styles.image} src={src} alt={alt} loading="lazy" />
|
||||
) : (
|
||||
<span className={styles.placeholder} aria-hidden="true">
|
||||
{children ?? alt.slice(0, 1)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{caption !== undefined && <span className={styles.caption}>{caption}</span>}
|
||||
</>
|
||||
)
|
||||
|
||||
if (onClick !== undefined) {
|
||||
return (
|
||||
<button type="button" className={classes} style={style} title={alt} onClick={onClick}>
|
||||
{content}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<figure className={classes} style={style}>
|
||||
{content}
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
.root {
|
||||
flex-shrink: 0;
|
||||
border-color: var(--su-border);
|
||||
}
|
||||
|
||||
.horizontal {
|
||||
width: 100%;
|
||||
border-top: 1px solid;
|
||||
}
|
||||
|
||||
.vertical {
|
||||
align-self: stretch;
|
||||
border-left: 1px solid;
|
||||
}
|
||||
|
||||
.dashed {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.solid {
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.horizontal.sm {
|
||||
margin: var(--su-space-2) 0;
|
||||
}
|
||||
|
||||
.horizontal.md {
|
||||
margin: var(--su-space-4) 0;
|
||||
}
|
||||
|
||||
.vertical.sm {
|
||||
margin: 0 var(--su-space-2);
|
||||
}
|
||||
|
||||
.vertical.md {
|
||||
margin: 0 var(--su-space-4);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import styles from './Divider.module.css'
|
||||
|
||||
/**
|
||||
* 分隔线:横向分割内容块,或纵向分割并排元素。
|
||||
*/
|
||||
export type DividerProps = {
|
||||
orientation?: 'horizontal' | 'vertical'
|
||||
variant?: 'solid' | 'dashed'
|
||||
spacing?: 'sm' | 'md'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Divider({
|
||||
orientation = 'horizontal',
|
||||
variant = 'solid',
|
||||
spacing = 'md',
|
||||
className,
|
||||
}: DividerProps) {
|
||||
const classes = [styles.root, styles[orientation], styles[variant], styles[spacing], className]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return <div className={classes} role="separator" aria-orientation={orientation} />
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
.root {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
max-width: 100vw;
|
||||
height: 100vh;
|
||||
max-height: 100vh;
|
||||
height: 100dvh;
|
||||
max-height: 100dvh;
|
||||
padding: 0;
|
||||
border: 1px solid var(--su-card-border);
|
||||
background-color: var(--su-bg-card);
|
||||
color: var(--su-text-main);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.root[open] {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
/* 贴在视口右侧 / 左侧:dialog 在 top layer 里默认居中,靠 margin 推边 */
|
||||
.right {
|
||||
margin: 0 0 0 auto;
|
||||
border-radius: var(--su-radius-md) 0 0 var(--su-radius-md);
|
||||
animation: drawer-in-right var(--su-duration-base) var(--su-ease-out);
|
||||
}
|
||||
|
||||
.left {
|
||||
margin: 0 auto 0 0;
|
||||
border-radius: 0 var(--su-radius-md) var(--su-radius-md) 0;
|
||||
animation: drawer-in-left var(--su-duration-base) var(--su-ease-out);
|
||||
}
|
||||
|
||||
.root::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.55);
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--su-space-3);
|
||||
padding: var(--su-space-4) var(--su-space-5);
|
||||
border-bottom: 1px solid var(--su-border);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--su-font-size-lg);
|
||||
line-height: var(--su-line-height-lg);
|
||||
font-weight: var(--su-font-weight-bold);
|
||||
}
|
||||
|
||||
.body {
|
||||
flex: 1;
|
||||
padding: var(--su-space-5);
|
||||
font-size: var(--su-font-size-md);
|
||||
line-height: var(--su-line-height-md);
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
justify-content: flex-end;
|
||||
gap: var(--su-space-2);
|
||||
padding: var(--su-space-4) var(--su-space-5);
|
||||
border-top: 1px solid var(--su-border);
|
||||
}
|
||||
|
||||
@keyframes drawer-in-right {
|
||||
from {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes drawer-in-left {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.right,
|
||||
.left {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { IconButton } from './IconButton'
|
||||
import styles from './Drawer.module.css'
|
||||
|
||||
/**
|
||||
* 抽屉:从侧边滑出的面板,适合「筛选 / 设置 / 详情」这类不打断主流程的内容。
|
||||
*
|
||||
* 和 `Modal` 一样基于原生 `<dialog>`(焦点陷阱、Esc、背景 inert),
|
||||
* 区别只在定位:贴边、全高。
|
||||
*/
|
||||
export type DrawerPlacement = 'right' | 'left'
|
||||
|
||||
export type DrawerProps = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
title?: ReactNode
|
||||
placement?: DrawerPlacement
|
||||
/** 宽度,px,默认 360 */
|
||||
width?: number
|
||||
children?: ReactNode
|
||||
footer?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Drawer({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
placement = 'right',
|
||||
width = 360,
|
||||
children,
|
||||
footer,
|
||||
className,
|
||||
}: DrawerProps) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const dialog = dialogRef.current
|
||||
if (dialog === null) return
|
||||
|
||||
if (open && !dialog.open) dialog.showModal()
|
||||
if (!open && dialog.open) dialog.close()
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const previous = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => {
|
||||
document.body.style.overflow = previous
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className={[styles.root, styles[placement], className].filter(Boolean).join(' ')}
|
||||
style={{ width: `${width}px` }}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault()
|
||||
onClose()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
if (event.target === dialogRef.current) onClose()
|
||||
}}
|
||||
>
|
||||
<header className={styles.head}>
|
||||
<h2 className={styles.title}>{title}</h2>
|
||||
<IconButton size="xs" label="关闭" icon={<Icon name="close_line" />} onClick={onClose} />
|
||||
</header>
|
||||
|
||||
{children !== undefined && <div className={styles.body}>{children}</div>}
|
||||
{footer !== undefined && <footer className={styles.footer}>{footer}</footer>}
|
||||
</dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--su-space-3);
|
||||
padding: var(--su-space-2) 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
font-size: var(--su-font-size-md);
|
||||
line-height: var(--su-line-height-md);
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.action {
|
||||
display: flex;
|
||||
gap: var(--su-space-2);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import styles from './Empty.module.css'
|
||||
|
||||
/**
|
||||
* 空态:一段说明文字 + 可选操作。
|
||||
*
|
||||
* 列表为空、搜索无结果、内容尚未准备时都用它,保证空态长得一样。
|
||||
*/
|
||||
export type EmptyProps = {
|
||||
text?: ReactNode
|
||||
/** 文字下方的操作,例如「清空搜索」 */
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Empty({ text = '暂无内容', action, className }: EmptyProps) {
|
||||
return (
|
||||
<div className={[styles.root, className].filter(Boolean).join(' ')}>
|
||||
<p className={styles.text}>{text}</p>
|
||||
{action !== undefined && <div className={styles.action}>{action}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
.root {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-2);
|
||||
}
|
||||
|
||||
.label {
|
||||
font-size: var(--su-font-size-sm);
|
||||
line-height: var(--su-line-height-sm);
|
||||
font-weight: var(--su-font-weight-bold);
|
||||
color: var(--su-text-main);
|
||||
}
|
||||
|
||||
.required {
|
||||
margin-left: 2px;
|
||||
color: var(--su-color-danger);
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: var(--su-font-size-xs);
|
||||
line-height: var(--su-line-height-xs);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: var(--su-font-size-xs);
|
||||
line-height: var(--su-line-height-xs);
|
||||
color: var(--su-color-danger);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import styles from './FormField.module.css'
|
||||
|
||||
/**
|
||||
* 表单字段:标签 + 控件 + 提示/错误。
|
||||
*
|
||||
* 只管排版与错误文案的位置,**不做校验**——校验逻辑留在页面,
|
||||
* 这里只负责在 `error` 出现时用 `role="alert"` 播报并标红。
|
||||
*/
|
||||
export type FormFieldProps = {
|
||||
label: ReactNode
|
||||
/** 关联控件 id(用 label 的 htmlFor) */
|
||||
htmlFor?: string
|
||||
/** 字段说明,错误出现时隐藏 */
|
||||
hint?: ReactNode
|
||||
/** 错误文案;有值时提示变红并获得 role="alert" */
|
||||
error?: ReactNode
|
||||
required?: boolean
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FormField({
|
||||
label,
|
||||
htmlFor,
|
||||
hint,
|
||||
error,
|
||||
required = false,
|
||||
children,
|
||||
className,
|
||||
}: FormFieldProps) {
|
||||
const hasError = error !== undefined && error !== null && error !== ''
|
||||
|
||||
return (
|
||||
<div className={[styles.root, className].filter(Boolean).join(' ')}>
|
||||
<label className={styles.label} htmlFor={htmlFor}>
|
||||
{label}
|
||||
{required && (
|
||||
<span className={styles.required} aria-hidden="true">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
{children}
|
||||
|
||||
{hasError ? (
|
||||
<span className={styles.error} role="alert">
|
||||
{error}
|
||||
</span>
|
||||
) : (
|
||||
hint !== undefined && <span className={styles.hint}>{hint}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
.root {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--su-grid-cols, 4), minmax(0, 1fr));
|
||||
gap: var(--su-grid-gap);
|
||||
}
|
||||
|
||||
.sm {
|
||||
--su-grid-gap: var(--su-space-2);
|
||||
}
|
||||
|
||||
.md {
|
||||
--su-grid-gap: var(--su-space-3);
|
||||
}
|
||||
|
||||
.lg {
|
||||
--su-grid-gap: var(--su-space-4);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.root {
|
||||
grid-template-columns: repeat(var(--su-grid-cols-tablet, 2), minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.root {
|
||||
grid-template-columns: repeat(var(--su-grid-cols-mobile, 1), minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import styles from './Grid.module.css'
|
||||
|
||||
/**
|
||||
* 网格:给「卡片列表」用的一致布局。
|
||||
*
|
||||
* 列数走 CSS 变量,断点收在组件里——页面不用再各写一份媒体查询。
|
||||
* 默认窄屏退化:≤1100px 取一半(至少 2 列),≤720px 收成一列。
|
||||
*/
|
||||
export type GridGap = 'sm' | 'md' | 'lg'
|
||||
|
||||
export type GridProps = {
|
||||
/** 宽屏列数 */
|
||||
columns?: number
|
||||
/** 中等屏列数,默认一半(至少 2) */
|
||||
tabletColumns?: number
|
||||
/** 窄屏列数,默认 1 */
|
||||
mobileColumns?: number
|
||||
gap?: GridGap
|
||||
/** 用 ul 渲染(配合 li 列表项,保持语义) */
|
||||
as?: 'div' | 'ul'
|
||||
role?: string
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
export function Grid({
|
||||
columns = 4,
|
||||
tabletColumns,
|
||||
mobileColumns = 1,
|
||||
gap = 'md',
|
||||
as: Tag = 'div',
|
||||
role,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
}: GridProps) {
|
||||
const vars: Record<string, string> = {
|
||||
'--su-grid-cols': String(columns),
|
||||
'--su-grid-cols-tablet': String(tabletColumns ?? Math.max(2, Math.ceil(columns / 2))),
|
||||
'--su-grid-cols-mobile': String(mobileColumns),
|
||||
}
|
||||
|
||||
const classes = [styles.root, styles[gap], className].filter(Boolean).join(' ')
|
||||
|
||||
return (
|
||||
<Tag className={classes} style={{ ...vars, ...style }} role={role}>
|
||||
{children}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import type { IconName } from '../theme/icons'
|
||||
|
||||
/**
|
||||
* 图标:参考站 iconfont 全量字形的轻封装。
|
||||
*
|
||||
* 颜色与字号都继承当前文字(`font-size` / `currentColor`),
|
||||
* 所以同一个图标可以直接放进按钮、标签或标题里,不需要单独调色。
|
||||
*
|
||||
* 可用名字见 `src/theme/icons.ts`(自动生成,446 个),
|
||||
* 文档站「图标」一节可以按名字过滤并点击复制。
|
||||
*/
|
||||
export type { IconName }
|
||||
|
||||
export type IconProps = {
|
||||
name: IconName
|
||||
} & HTMLAttributes<HTMLElement>
|
||||
|
||||
export function Icon({ name, className, ...rest }: IconProps) {
|
||||
const classes = ['su-iconfont', `su-icon-${name}`, className].filter(Boolean).join(' ')
|
||||
|
||||
// 图标一律视为装饰:语义由外层按钮的 aria-label 或相邻文字提供
|
||||
return <i className={classes} aria-hidden="true" {...rest} />
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
.root {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--su-icon-button-sm);
|
||||
height: var(--su-icon-button-sm);
|
||||
border-radius: var(--su-radius-pill);
|
||||
touch-action: manipulation;
|
||||
background-color: transparent;
|
||||
color: var(--su-text-sub);
|
||||
font-size: var(--su-font-size-lg);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--su-duration-base) var(--su-ease-in-out),
|
||||
background-color var(--su-duration-base) var(--su-ease-in-out);
|
||||
}
|
||||
|
||||
.root:focus-visible {
|
||||
outline: var(--su-focus-ring);
|
||||
outline-offset: var(--su-focus-ring-offset);
|
||||
}
|
||||
|
||||
.root:disabled {
|
||||
color: var(--su-text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 尺寸:参考站的 .btn-icon 是 28 / 36 / 44,图标字号 16 / 20 / 24 */
|
||||
.xs {
|
||||
width: var(--su-icon-button-xs);
|
||||
height: var(--su-icon-button-xs);
|
||||
font-size: var(--su-font-size-md);
|
||||
}
|
||||
|
||||
.sm {
|
||||
width: var(--su-icon-button-sm);
|
||||
height: var(--su-icon-button-sm);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.md {
|
||||
width: var(--su-icon-button-md);
|
||||
height: var(--su-icon-button-md);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.ghost {
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.ghost:hover:not(:disabled) {
|
||||
background-color: var(--su-bg-subtle);
|
||||
color: var(--su-text-main);
|
||||
}
|
||||
|
||||
.soft {
|
||||
background-color: var(--su-bg-light);
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.soft:hover:not(:disabled) {
|
||||
background-color: var(--su-bg-hover);
|
||||
color: var(--su-text-main);
|
||||
}
|
||||
|
||||
.root[data-spinning] > * {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.root {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.root[data-spinning] > * {
|
||||
animation-duration: 2.4s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import styles from './IconButton.module.css'
|
||||
|
||||
/**
|
||||
* 图标按钮:只放一个图标,所以 `label` 必填(写进 aria-label 与 title),
|
||||
* 避免出现屏幕阅读器读不出内容的按钮。
|
||||
*
|
||||
* 圆形,尺寸对齐参考站的 .btn-icon(28 / 36 / 44,图标 16 / 20 / 24)。
|
||||
*/
|
||||
export type IconButtonSize = 'xs' | 'sm' | 'md'
|
||||
|
||||
export type IconButtonProps = {
|
||||
/** 无障碍名称,同时作为原生 title */
|
||||
label: string
|
||||
icon: ReactNode
|
||||
variant?: 'ghost' | 'soft'
|
||||
size?: IconButtonSize
|
||||
/** 图标旋转(刷新类操作使用) */
|
||||
spinning?: boolean
|
||||
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'aria-label' | 'title'>
|
||||
|
||||
export function IconButton({
|
||||
label,
|
||||
icon,
|
||||
variant = 'ghost',
|
||||
size = 'sm',
|
||||
spinning = false,
|
||||
className,
|
||||
type = 'button',
|
||||
...rest
|
||||
}: IconButtonProps) {
|
||||
const classes = [styles.root, styles[variant], styles[size], className]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<button
|
||||
type={type}
|
||||
className={classes}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
data-spinning={spinning || undefined}
|
||||
{...rest}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
.root {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
background-color: var(--su-bg-light);
|
||||
}
|
||||
|
||||
.square {
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
.video {
|
||||
aspect-ratio: 16 / 9;
|
||||
}
|
||||
|
||||
.wide {
|
||||
aspect-ratio: 21 / 9;
|
||||
}
|
||||
|
||||
.fit-cover .img {
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.fit-contain .img {
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.r-none {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.r-sm {
|
||||
border-radius: var(--su-radius-sm);
|
||||
}
|
||||
|
||||
.r-md {
|
||||
border-radius: var(--su-radius-md);
|
||||
}
|
||||
|
||||
.r-cover {
|
||||
border-radius: var(--su-radius-cover);
|
||||
}
|
||||
|
||||
.img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--su-text-sub);
|
||||
font-size: var(--su-font-size-lg);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import styles from './Image.module.css'
|
||||
|
||||
/**
|
||||
* 图片:带比例、圆角、加载失败降级的 img。
|
||||
*
|
||||
* 和 `Cover` 的区别:Cover 是「封面卡」(标题 + 压暗层 + 悬停上浮),
|
||||
* Image 是正文 / 相册里的普通图片,不携带标题语义。
|
||||
*/
|
||||
export type ImageRatio = 'square' | 'video' | 'wide' | number
|
||||
export type ImageFit = 'cover' | 'contain'
|
||||
|
||||
export type ImageProps = {
|
||||
src: string
|
||||
alt: string
|
||||
/** square 1:1 / video 16:9 / wide 21:9,或直接给数字宽高比 */
|
||||
ratio?: ImageRatio
|
||||
/** 填充方式:cover 裁切(默认)/ contain 完整显示 */
|
||||
fit?: ImageFit
|
||||
/** 圆角:none / sm(6) / md(12) / cover(8) */
|
||||
radius?: 'none' | 'sm' | 'md' | 'cover'
|
||||
/** 加载失败时的占位内容 */
|
||||
fallback?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Image({
|
||||
src,
|
||||
alt,
|
||||
ratio = 'video',
|
||||
fit = 'cover',
|
||||
radius = 'sm',
|
||||
fallback,
|
||||
className,
|
||||
}: ImageProps) {
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
const classes = [
|
||||
styles.root,
|
||||
typeof ratio === 'string' ? styles[ratio] : undefined,
|
||||
styles[`fit-${fit}`],
|
||||
styles[`r-${radius}`],
|
||||
className,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
const style = typeof ratio === 'number' ? { aspectRatio: String(ratio) } : undefined
|
||||
|
||||
return (
|
||||
<div className={classes} style={style}>
|
||||
{failed ? (
|
||||
<span className={styles.fallback} aria-hidden="true">
|
||||
{fallback ?? alt.slice(0, 1)}
|
||||
</span>
|
||||
) : (
|
||||
<img
|
||||
className={styles.img}
|
||||
src={src}
|
||||
alt={alt}
|
||||
loading="lazy"
|
||||
onError={() => setFailed(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
.root {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-3);
|
||||
height: var(--su-control-height-md);
|
||||
padding: 0 var(--su-space-3);
|
||||
border: 1px solid var(--su-border-strong);
|
||||
border-radius: var(--su-radius-sm);
|
||||
background-color: var(--su-bg-card);
|
||||
transition: border-color var(--su-duration-base) var(--su-ease-in-out);
|
||||
}
|
||||
|
||||
.sm {
|
||||
height: var(--su-control-height-sm);
|
||||
}
|
||||
|
||||
.md {
|
||||
height: var(--su-control-height-md);
|
||||
}
|
||||
|
||||
.lg {
|
||||
height: var(--su-control-height-lg);
|
||||
}
|
||||
|
||||
/* 文本框的焦点只用「边框换主色」表达:
|
||||
输入框在点击时也会命中 :focus-visible,再叠一圈 outline 就会出现「双框」,
|
||||
与参考站不一致,也是用户实际遇到的 bug。 */
|
||||
.root:focus-within {
|
||||
border-color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--su-text-muted);
|
||||
font-size: var(--su-font-size-lg);
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
font-size: var(--su-font-size-md);
|
||||
color: var(--su-text-main);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--su-text-placeholder);
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
flex-shrink: 0;
|
||||
padding: 0 var(--su-space-2);
|
||||
border: 1px solid var(--su-border-strong);
|
||||
border-radius: var(--su-radius-xs);
|
||||
background-color: var(--su-bg-subtle);
|
||||
font-family: var(--su-font-mono);
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.clear {
|
||||
--clear-size: 20px;
|
||||
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--clear-size);
|
||||
height: var(--clear-size);
|
||||
border-radius: var(--su-radius-pill);
|
||||
color: var(--su-text-muted);
|
||||
font-size: var(--su-font-size-sm);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--su-duration-fast) var(--su-ease-in-out),
|
||||
background-color var(--su-duration-fast) var(--su-ease-in-out);
|
||||
}
|
||||
|
||||
.clear:hover {
|
||||
background-color: var(--su-bg-subtle);
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.root,
|
||||
.clear {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { CSSProperties, InputHTMLAttributes, Ref } from 'react'
|
||||
import { Icon, type IconName } from './Icon'
|
||||
import styles from './Input.module.css'
|
||||
|
||||
/**
|
||||
* 输入框:左侧图标 + 清除按钮 + 快捷键提示的组合式输入。
|
||||
*
|
||||
* 事件语义与原生 input 一致(`onChange` 拿到的是事件),
|
||||
* 需要「只关心字符串」时在使用方包一层,而不是在库里改语义。
|
||||
*
|
||||
* `className` 与 `style` 作用在外层容器上(设置宽度、外边距都用它),
|
||||
* 其余原生属性透传给内部的 input。
|
||||
*/
|
||||
export type InputSize = 'sm' | 'md' | 'lg'
|
||||
|
||||
export type InputProps = {
|
||||
/** 左侧前缀图标 */
|
||||
icon?: IconName
|
||||
/** 尺寸:32 / 36 / 44 高 */
|
||||
size?: InputSize
|
||||
/** 有内容时显示清除按钮 */
|
||||
clearable?: boolean
|
||||
onClear?: () => void
|
||||
/** 右侧提示,如 `⌘K`;仅作展示,不参与键盘处理 */
|
||||
shortcut?: string
|
||||
inputRef?: Ref<HTMLInputElement>
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'className' | 'style'>
|
||||
|
||||
export function Input({
|
||||
icon,
|
||||
size = 'md',
|
||||
clearable = false,
|
||||
onClear,
|
||||
shortcut,
|
||||
inputRef,
|
||||
className,
|
||||
style,
|
||||
value,
|
||||
...rest
|
||||
}: InputProps) {
|
||||
const hasValue = value !== undefined && value !== null && String(value) !== ''
|
||||
const showClear = clearable && hasValue && onClear !== undefined
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.root, styles[size], className].filter(Boolean).join(' ')}
|
||||
style={style}
|
||||
>
|
||||
{icon !== undefined && <Icon name={icon} className={styles.icon} />}
|
||||
|
||||
<input ref={inputRef} className={styles.input} value={value} {...rest} />
|
||||
|
||||
{shortcut !== undefined && <kbd className={styles.shortcut}>{shortcut}</kbd>}
|
||||
|
||||
{showClear && (
|
||||
<button type="button" className={styles.clear} aria-label="清空" onClick={onClear}>
|
||||
<Icon name="close_line" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/* 对齐参考站 .item-badge:12px 字、18px 高、4px 圆角、渐变底、白色字 */
|
||||
.root {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
height: 18px;
|
||||
padding: 0 var(--su-space-1);
|
||||
border-radius: var(--su-radius-xs);
|
||||
font-size: var(--su-font-size-xs);
|
||||
font-weight: var(--su-font-weight-strong);
|
||||
line-height: 1;
|
||||
color: var(--su-white);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hot {
|
||||
background-image: var(--su-gradient-hot);
|
||||
}
|
||||
|
||||
.sticky {
|
||||
background-image: var(--su-gradient-hot);
|
||||
}
|
||||
|
||||
.paid {
|
||||
background-image: var(--su-gradient-paid);
|
||||
}
|
||||
|
||||
.neutral {
|
||||
background-color: var(--su-bg-hover);
|
||||
color: var(--su-text-main);
|
||||
font-weight: var(--su-font-weight-bold);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { HTMLAttributes, ReactNode } from 'react'
|
||||
import styles from './ItemBadge.module.css'
|
||||
|
||||
/**
|
||||
* 状态角标:标题前的「置顶 / 付费 / HOT」这类小徽标。
|
||||
*
|
||||
* 和 `Badge`(数字角标)区分:这个是文字,更小、圆角 4px、带渐变底,
|
||||
* 对应参考站的 `.item-badge`(置顶/付费)与热门标记。
|
||||
*/
|
||||
export type ItemBadgeTone = 'hot' | 'sticky' | 'paid' | 'neutral'
|
||||
|
||||
export type ItemBadgeProps = {
|
||||
tone?: ItemBadgeTone
|
||||
children: ReactNode
|
||||
} & HTMLAttributes<HTMLSpanElement>
|
||||
|
||||
export function ItemBadge({ tone = 'hot', className, children, ...rest }: ItemBadgeProps) {
|
||||
return (
|
||||
<span className={[styles.root, styles[tone], className].filter(Boolean).join(' ')} {...rest}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
.root {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
/* 移动端地址栏收起/展开时,dvh 比 vh 稳定 */
|
||||
min-height: 100dvh;
|
||||
background-color: var(--su-bg-page);
|
||||
color: var(--su-text-main);
|
||||
}
|
||||
|
||||
/* 应用型界面:根节点锁视口,滚动交给内容区 */
|
||||
.screen {
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--layout-gutter, 0);
|
||||
}
|
||||
|
||||
.screen .container {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 整屏模式:内容区必须严格等于剩余高度,否则内部滚动会失效 */
|
||||
.screen .body {
|
||||
flex: 1;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.screen .main,
|
||||
.screen .sider,
|
||||
.screen .aside {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* 整屏模式两侧栏各自滚动,别把内容顶出视口 */
|
||||
.screen .sider,
|
||||
.screen .aside {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.screen .inner {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.header {
|
||||
flex-shrink: 0;
|
||||
z-index: var(--su-z-header);
|
||||
min-height: var(--layout-header-height, var(--su-header-height));
|
||||
background-color: var(--su-bg-page);
|
||||
}
|
||||
|
||||
/* 吸顶时用半透明底 + 模糊,滚动时内容从下面透出来 */
|
||||
.sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: color-mix(in srgb, var(--su-bg-page) 72%, transparent);
|
||||
backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* 实底页头:应用型界面常用,配一条分隔线 */
|
||||
.solid,
|
||||
.sticky.solid {
|
||||
/* 边框算进页头高度:60 + 1px 边框 = 61 会破坏「和参考站等高」的对齐 */
|
||||
min-height: calc(var(--layout-header-height) - 1px);
|
||||
background-color: var(--su-bg-card);
|
||||
backdrop-filter: none;
|
||||
border-bottom: 1px solid var(--su-border);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: stretch;
|
||||
min-height: 0;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--layout-gap, 0);
|
||||
}
|
||||
|
||||
.screen .body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sider {
|
||||
flex-shrink: 0;
|
||||
width: var(--layout-sider);
|
||||
}
|
||||
|
||||
.siderBordered {
|
||||
border-right: 1px solid var(--su-border);
|
||||
}
|
||||
|
||||
.main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.aside {
|
||||
flex-shrink: 0;
|
||||
width: var(--layout-aside);
|
||||
border-left: 1px solid var(--su-border);
|
||||
}
|
||||
|
||||
.inner {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.none {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.sm {
|
||||
padding: var(--su-space-3);
|
||||
}
|
||||
|
||||
.md {
|
||||
padding: var(--su-space-5);
|
||||
}
|
||||
|
||||
.lg {
|
||||
padding: var(--su-space-6);
|
||||
}
|
||||
|
||||
/* 内容区内部滚动:页头与侧栏保持固定 */
|
||||
.scroll {
|
||||
overflow-y: auto;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* 隐藏滚动条但仍可滚动(滚轮 / 触控板 / 键盘都正常) */
|
||||
.scrollHidden {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollHidden::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.footer {
|
||||
flex-shrink: 0;
|
||||
padding: var(--su-space-6);
|
||||
border-top: 1px solid var(--su-border);
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 右栏先失效:宽度感更强,优先保证正文可读 */
|
||||
@media (max-width: 1200px) {
|
||||
.aside {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--su-border);
|
||||
}
|
||||
}
|
||||
|
||||
/* 再收侧栏:两层都变成上下排列,内容不隐藏 */
|
||||
@media (max-width: 1100px) {
|
||||
.body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sider {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--su-border);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import type { CSSProperties, ReactNode } from 'react'
|
||||
import styles from './Layout.module.css'
|
||||
|
||||
/**
|
||||
* 页面外壳:页头 / 侧栏 / 内容 / 右栏 / 页脚。
|
||||
*
|
||||
* 只负责结构(区域划分、最大宽度、粘性页头、可选内部滚动),不写任何业务内容。
|
||||
* 常见配方(侧栏+内容、内容+右栏、三栏、单栏窄内容)见展示页 `#layout`。
|
||||
*
|
||||
* 响应式:
|
||||
* - ≤1200px:右栏(aside)堆到内容下方;
|
||||
* - ≤1100px:侧栏从并排变成堆到内容上方;
|
||||
* - 两者都保留内容,不靠隐藏来「适配」。
|
||||
*/
|
||||
export type LayoutProps = {
|
||||
/** 页头:整宽、默认吸顶 */
|
||||
header?: ReactNode
|
||||
/** 左侧栏:固定宽度、与内容并排 */
|
||||
sider?: ReactNode
|
||||
/** 右侧栏:固定宽度、与内容并排(资料聚合、目录、广告位) */
|
||||
aside?: ReactNode
|
||||
/** 页脚 */
|
||||
footer?: ReactNode
|
||||
/** 内容区 */
|
||||
children: ReactNode
|
||||
/** 内容最大宽度,默认 1280;'full' 表示铺满 */
|
||||
maxWidth?: number | 'full'
|
||||
/** 左侧栏宽度,默认 260 */
|
||||
siderWidth?: number
|
||||
/** 右侧栏宽度,默认 300 */
|
||||
asideWidth?: number
|
||||
/** 侧栏 / 内容 / 右栏之间的间距(px),默认 0 */
|
||||
gap?: number
|
||||
/** 内容区左右留白,默认 'md'(20px) */
|
||||
contentPadding?: 'none' | 'sm' | 'md' | 'lg'
|
||||
/** 内容区顶部偏移(px):给页头下方的悬浮搜索 / 工具条留位置 */
|
||||
contentOffset?: number
|
||||
/** contained 时容器左右留白(px),默认 0 */
|
||||
gutter?: number
|
||||
/** 页头最小高度(px),默认读 --su-header-height,即 60 */
|
||||
headerHeight?: number
|
||||
/** 页头样式:blur(半透明模糊,默认)/ solid(实底 + 下边框) */
|
||||
headerVariant?: 'blur' | 'solid'
|
||||
/** 页头是否吸顶,默认 true */
|
||||
stickyHeader?: boolean
|
||||
/**
|
||||
* 居中容器模式:给整页套一层 `maxWidth` 容器(页头 / 侧栏 / 内容一起居中)。
|
||||
* 默认 false,即页头通栏、内容自己限宽。
|
||||
*/
|
||||
contained?: boolean
|
||||
/** 侧栏右侧分隔线,默认 true */
|
||||
siderBordered?: boolean
|
||||
/**
|
||||
* 整屏模式:根节点占满视口高度。
|
||||
* 配合 `scrollable` 时内容区自己滚动,页头与侧栏固定(适合应用型界面)。
|
||||
*/
|
||||
height?: 'auto' | 'screen'
|
||||
/** 内容区内部滚动(需要 height='screen') */
|
||||
scrollable?: boolean
|
||||
/** 内部滚动时隐藏滚动条(应用型界面常用;不影响滚轮/键盘滚动) */
|
||||
hideScrollbar?: boolean
|
||||
/** 内容区 id,方便 ScrollTop 之类按 id 滚动 */
|
||||
contentId?: string
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
export function Layout({
|
||||
header,
|
||||
sider,
|
||||
aside,
|
||||
footer,
|
||||
children,
|
||||
maxWidth = 1280,
|
||||
siderWidth = 260,
|
||||
asideWidth = 300,
|
||||
gap = 0,
|
||||
contentPadding = 'md',
|
||||
contentOffset = 0,
|
||||
gutter = 0,
|
||||
headerHeight,
|
||||
headerVariant = 'blur',
|
||||
stickyHeader = true,
|
||||
contained = false,
|
||||
siderBordered = true,
|
||||
height = 'auto',
|
||||
scrollable = false,
|
||||
hideScrollbar = false,
|
||||
contentId,
|
||||
className,
|
||||
style,
|
||||
}: LayoutProps) {
|
||||
const maxWidthValue = maxWidth === 'full' ? 'none' : `${maxWidth}px`
|
||||
const vars: Record<string, string> = {
|
||||
'--layout-sider': `${siderWidth}px`,
|
||||
'--layout-aside': `${asideWidth}px`,
|
||||
'--layout-gap': `${gap}px`,
|
||||
'--layout-gutter': `${gutter}px`,
|
||||
'--layout-header-height': `${headerHeight ?? 60}px`,
|
||||
}
|
||||
const containerStyle = contained ? { maxWidth: maxWidthValue } : undefined
|
||||
const innerMaxWidth = contained ? 'none' : maxWidthValue
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.root, height === 'screen' ? styles.screen : undefined, className]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{ ...vars, ...style }}
|
||||
>
|
||||
{/* 页头通栏:不放进限宽容器,页头内部自己决定内容宽度 */}
|
||||
{header !== undefined && (
|
||||
<header
|
||||
className={[
|
||||
styles.header,
|
||||
stickyHeader ? styles.sticky : undefined,
|
||||
headerVariant === 'solid' ? styles.solid : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
>
|
||||
{header}
|
||||
</header>
|
||||
)}
|
||||
|
||||
<div className={contained ? styles.container : undefined} style={containerStyle}>
|
||||
<div className={styles.body}>
|
||||
{sider !== undefined && (
|
||||
<aside
|
||||
className={`${styles.sider}${siderBordered ? ` ${styles.siderBordered}` : ''}`}
|
||||
>
|
||||
{sider}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<main className={styles.main}>
|
||||
<div
|
||||
id={contentId}
|
||||
className={[
|
||||
styles.inner,
|
||||
styles[contentPadding],
|
||||
scrollable ? styles.scroll : undefined,
|
||||
scrollable && hideScrollbar ? styles.scrollHidden : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{ maxWidth: innerMaxWidth, marginTop: contentOffset }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{aside !== undefined && <aside className={styles.aside}>{aside}</aside>}
|
||||
</div>
|
||||
|
||||
{footer !== undefined && <footer className={styles.footer}>{footer}</footer>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
.root {
|
||||
font-size: var(--su-font-size-md);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-1);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
/* 子菜单项之间不留缝:旧版只有一级菜单有 4px 间隔 */
|
||||
.submenu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding-left: var(--su-space-6);
|
||||
list-style: none;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-2);
|
||||
width: 100%;
|
||||
height: var(--menu-item-height, 36px);
|
||||
padding: 0 var(--su-space-3);
|
||||
border-radius: var(--su-radius-cover);
|
||||
color: var(--su-text-sub);
|
||||
font-weight: var(--su-font-weight-bold);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--su-duration-base) var(--su-ease-in-out),
|
||||
background-color var(--su-duration-base) var(--su-ease-in-out);
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background-color: var(--su-color-primary-soft);
|
||||
color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
.item:focus-visible {
|
||||
outline: var(--su-focus-ring);
|
||||
outline-offset: var(--su-focus-ring-offset);
|
||||
}
|
||||
|
||||
.active {
|
||||
background-color: var(--su-color-primary-soft);
|
||||
color: var(--su-color-primary);
|
||||
font-weight: var(--su-font-weight-bold);
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-flex;
|
||||
font-size: var(--su-font-size-lg);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 折叠箭头用两道边框画(6×6 V 形),和参考站一致 */
|
||||
.caret {
|
||||
flex-shrink: 0;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
margin-right: 2px;
|
||||
border-left: 1px solid currentColor;
|
||||
border-bottom: 1px solid currentColor;
|
||||
opacity: 0.4;
|
||||
transform: rotate(315deg);
|
||||
transition:
|
||||
transform var(--su-duration-base) var(--su-ease-in-out),
|
||||
opacity var(--su-duration-base) var(--su-ease-in-out);
|
||||
}
|
||||
|
||||
.item:hover .caret {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.caretOpen {
|
||||
opacity: 0.7;
|
||||
transform: rotate(135deg);
|
||||
}
|
||||
|
||||
/* 子菜单展开:grid-template-rows 0fr → 1fr 做高度动画,不猜 max-height */
|
||||
.submenuWrap {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 0.28s ease;
|
||||
}
|
||||
|
||||
.submenuWrap[data-open='true'] {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
|
||||
.child {
|
||||
height: 32px;
|
||||
font-size: var(--su-font-size-sm);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.item,
|
||||
.caret,
|
||||
.submenuWrap {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import styles from './Menu.module.css'
|
||||
|
||||
/**
|
||||
* 侧栏菜单:一层折叠 + 两级结构,数据无关。
|
||||
*
|
||||
* 业务树(分区 → 分组)由页面转成 items;组件只负责展开/收起与高亮。
|
||||
*/
|
||||
export type MenuItem = {
|
||||
key: string
|
||||
label: ReactNode
|
||||
icon?: ReactNode
|
||||
children?: MenuItem[]
|
||||
}
|
||||
|
||||
export type MenuProps = {
|
||||
items: MenuItem[]
|
||||
activeKey?: string
|
||||
onSelect: (key: string) => void
|
||||
/** 默认展开的父级 key;含 activeKey 的父级会自动展开 */
|
||||
defaultOpenKeys?: string[]
|
||||
/** 一级菜单行高(px),默认 36;需要更松/更紧的密度时调它 */
|
||||
itemHeight?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Menu({
|
||||
items,
|
||||
activeKey,
|
||||
onSelect,
|
||||
defaultOpenKeys = [],
|
||||
itemHeight = 36,
|
||||
className,
|
||||
}: MenuProps) {
|
||||
const [openKeys, setOpenKeys] = useState<string[]>(() => {
|
||||
const parents = items
|
||||
.filter((item) => item.children?.some((child) => child.key === activeKey))
|
||||
.map((item) => item.key)
|
||||
|
||||
return [...new Set([...defaultOpenKeys, ...parents])]
|
||||
})
|
||||
|
||||
const toggle = (key: string) =>
|
||||
setOpenKeys((current) =>
|
||||
current.includes(key) ? current.filter((item) => item !== key) : [...current, key],
|
||||
)
|
||||
|
||||
return (
|
||||
<nav
|
||||
className={[styles.root, className].filter(Boolean).join(' ')}
|
||||
aria-label="菜单"
|
||||
style={{ '--menu-item-height': `${itemHeight}px` } as CSSProperties}
|
||||
>
|
||||
<ul className={styles.list}>
|
||||
{items.map((item) => {
|
||||
const hasChildren = item.children !== undefined && item.children.length > 0
|
||||
const open = openKeys.includes(item.key)
|
||||
|
||||
return (
|
||||
<li key={item.key}>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.item}
|
||||
aria-expanded={open}
|
||||
onClick={() => toggle(item.key)}
|
||||
>
|
||||
{item.icon !== undefined && (
|
||||
<span className={styles.icon} aria-hidden="true">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.label}>{item.label}</span>
|
||||
<span
|
||||
className={`${styles.caret}${open ? ` ${styles.caretOpen}` : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={[styles.item, item.key === activeKey ? styles.active : undefined]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-current={item.key === activeKey ? 'page' : undefined}
|
||||
onClick={() => onSelect(item.key)}
|
||||
>
|
||||
{item.icon !== undefined && (
|
||||
<span className={styles.icon} aria-hidden="true">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.label}>{item.label}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasChildren && (
|
||||
<div className={styles.submenuWrap} data-open={open ? 'true' : 'false'}>
|
||||
<ul className={styles.submenu} inert={open ? undefined : true}>
|
||||
{item.children?.map((child) => (
|
||||
<li key={child.key}>
|
||||
<button
|
||||
type="button"
|
||||
className={[
|
||||
styles.item,
|
||||
styles.child,
|
||||
child.key === activeKey ? styles.active : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
aria-current={child.key === activeKey ? 'page' : undefined}
|
||||
onClick={() => onSelect(child.key)}
|
||||
>
|
||||
<span className={styles.label}>{child.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user