Compare commits

...
9 Commits
Author SHA1 Message Date
CNWei ba41cdf52e docs: README 组件清单补全并更正数量
- 组件表补上 Brand / Ticker / SearchField / EntryRow / EntryTile(此前只写进了 COMPONENTS.md)
- 组件总数 37 -> 51,与 src/components 下的 .tsx 数量一致
- SKILL.md 的 description 同步改为 51 个组件
2026-11-01 22:42:05 +08:00
CNWei 5eb1506650 feat(ui): 新增 Caret / SortableList,箭头画法收成一份
- Caret:6px CSS 折角(open 切朝向,size 可调),Menu 与 Select 改为复用它,
  两处各自的画法删掉,全库只剩一份
- SortableList:可拖拽排序的条目列表(items / activeKey / onSelect / onReorder),
  拖拽位置计算从应用里挪进组件,顺序没变不回调
- 展示页:菜单补「折叠箭头」,新增 #sortable 一节并挂到导航
- COMPONENTS.md 契约 3.48 / 3.49、组件地图、README 清单、变更记录同步
2026-11-01 22:07:31 +08:00
CNWei cc35400f67 feat(ui): 新增 TextButton / ToggleRow,Table 支持紧凑档
- TextButton:文字按钮(tone 主色/危险、可选图标、disabled),
  用于表格行内操作,hover 只加下划线
- ToggleRow:左标签 + 右开关的开关行,支持 hint 与 switchLabel
- Table:新增 size='md' | 'sm',sm 把行内边距收到 space-2
- 展示页:按钮补「文字按钮」、开关补「开关行」、表格补「紧凑档」;
  COMPONENTS.md 契约 3.46 / 3.47 与 README 清单同步
2026-11-01 21:59:13 +08:00
CNWei 851b36abf1 feat(ui): Ticker 支持 gap 参数,条目不再被 space-evenly 拉开
- 新增 gap(px),走 --ticker-gap;不传时仍是 --su-space-8
- 一份内容改成 flex-start + 尾部同间距 padding,循环处不会突然变宽
- 位移仍按实测的一份宽度,接缝不变
2026-11-01 20:44:29 +08:00
CNWei d5b7132605 feat(ui): Select 的下拉箭头改成与 Menu 一致的 CSS 折角
收起朝下、展开朝上,6px 折角 + 透明度过渡,替代原来的 arrow_down_line 图标。
影响面:所有 Select 的外观;交互与 API 不变。
2026-11-01 19:55:34 +08:00
CNWei 17a09f22b6 feat(ui): 新增 EntryRow / EntryTile,收掉应用里两套手写内容卡
- EntryRow:头像 + 标题(可带角标)+ 单行描述,整行可点;
  点击语义内置(无修饰键接管,按住 Ctrl/Cmd/Shift/Alt 保留浏览器默认)
- EntryTile:上图下文白卡,由 Card padding=none hoverable + Cover 拼成
- 展示页 #cards 补两个示例与 props,COMPONENTS.md 补契约与 changelog

兼容性:纯新增;应用侧的旧类名(.app-card / .ces-card / .ces-body)随之删除
2026-11-01 16:08:39 +08:00
CNWei 50056a7154 fix(ui): Brand 标记的前景改用卡片底色
深色下 --su-text-main 是近白色,写死的白字会整块糊掉;
改成 color: var(--su-bg-card) 后浅色仍是白字、深色自动变成深字。
2026-11-01 15:41:18 +08:00
CNWei 707cf98fe0 feat(ui): 新增站点外壳组件 Brand / SearchField / Ticker
把应用里各自手写的三块收进库,风格沿用前台、行为参数化:

- Brand:logo 图片或首字圆形标记 + 名称,href 决定是链接还是纯展示,size/tone 可选
- SearchField:语义版搜索框(onChange 直接给字符串),variant 切 plain / card,
  支持 shortcut 提示与 hotkey 聚焦
- Ticker:无限滚动信息条,内部三份内容按实测宽度位移,循环不跳帧;
  speed / paused / label 可配
- 令牌补展示级字号 --su-font-size-2xl / --su-line-height-2xl(32 / 40)
- 展示页新增 #site-chrome(Brand / Ticker / SearchField),COMPONENTS.md 补契约与 changelog
2026-11-01 15:28:17 +08:00
CNWei 93bb5cbb28 feat(ui): Layout 新增 toolbar 插槽
页头与页身之间的一条真实占位带(搜索 / 筛选 / 操作),宽度跟随容器,
contained 时与侧栏 + 内容同宽,height=screen 下不参与内容区滚动。
用它替代「搜索框 fixed + contentOffset 让位」的写法。

- Layout.tsx / Layout.module.css:新增 toolbar prop 与样式
- 展示页 #layout 增加「页头下的工具条」示例
- COMPONENTS.md 补契约、与 contentOffset 的分工说明、changelog
2026-11-01 15:15:14 +08:00
42 changed files with 1561 additions and 37 deletions
+1
View File
@@ -90,6 +90,7 @@ L4 ──→ L3 ──→ L1
| `--su-font-size-base` / `--su-line-height-base` | 16 / 24 | 内容正文、卡片标题(`Paragraph` / `Title level=3`) | | `--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-lg` / `--su-line-height-lg` | 18 / 26 | 区块标题、大图标 |
| `--su-font-size-xl` / `--su-line-height-xl` | 24 / 32 | 页面标题(`Title level=1`) | | `--su-font-size-xl` / `--su-line-height-xl` | 24 / 32 | 页面标题(`Title level=1`) |
| `--su-font-size-2xl` / `--su-line-height-2xl` | 32 / 40 | 展示级装饰大字(封面占位字、引号),不参与正文排版 |
> 参考站的实际排版是 `13/20`、`14/22`、`16/24`、`18/26`、body `16/24`。 > 参考站的实际排版是 `13/20`、`14/22`、`16/24`、`18/26`、body `16/24`。
> 组件库把「控件文字」定在 14、把「内容正文」定在 16:控件更紧凑,文章更好读。 > 组件库把「控件文字」定在 14、把「内容正文」定在 16:控件更紧凑,文章更好读。
+11 -2
View File
@@ -77,7 +77,7 @@ stash-ui/
│ │ ├── icons.ts # 446 个图标名(自动生成) │ │ ├── icons.ts # 446 个图标名(自动生成)
│ │ ├── iconfont.css # 图标字体与字形(自动生成) │ │ ├── iconfont.css # 图标字体与字形(自动生成)
│ │ └── assets/iconfont.woff2 │ │ └── assets/iconfont.woff2
│ └── components/ # 37 个组件,每个 = Xxx.tsx + Xxx.module.css(见下方清单) │ └── components/ # 51 个组件,每个 = Xxx.tsx + Xxx.module.css(见下方清单)
├── site/ # 展示站(不参与库构建) ├── site/ # 展示站(不参与库构建)
│ ├── index.html / demo.html # 文档站 / 整页示例 │ ├── index.html / demo.html # 文档站 / 整页示例
│ ├── App.tsx # 左侧目录 + 右侧示例 │ ├── App.tsx # 左侧目录 + 右侧示例
@@ -95,7 +95,13 @@ stash-ui/
| --- | --- | --- | | --- | --- | --- |
| `Button` | 操作按钮(圆角 / 胶囊,支持描边) | `variant` `size` `shape` `block` `loading` `icon` | | `Button` | 操作按钮(圆角 / 胶囊,支持描边) | `variant` `size` `shape` `block` `loading` `icon` |
| `IconButton` | 圆形图标按钮(28/36/44) | `label`(必填) `icon` `variant` `size` `spinning` | | `IconButton` | 圆形图标按钮(28/36/44) | `label`(必填) `icon` `variant` `size` `spinning` |
| `TextButton` | 文字按钮(行内操作,无外框) | `icon` `tone` `disabled` `title` `label` |
| `Card` | 白色容器(极浅描边) | `as` `padding` `hoverable` | | `Card` | 白色容器(极浅描边) | `as` `padding` `hoverable` |
| `EntryRow` | 条目行(头像 + 标题 + 描述,整行可点) | `title` `description` `href` `avatar` `badge` `onOpen` |
| `EntryTile` | 条目磁贴(上图下文白卡) | `title` `description` `cover` `ratio` `onOpen` |
| `Brand` | 站点品牌(logo / 首字标记 + 名称) | `name` `mark` `logo` `href` |
| `Ticker` | 无限滚动信息条 | `speed` `gap` `paused` `label` |
| `SearchField` | 语义版搜索框 | `value` `onChange` `variant` `hotkey` `inputRef` |
| `Cover` | 比例封面卡(2:3 / 4:3 / 16:9) | `ratio` `src` `caption` `lift` `onClick` | | `Cover` | 比例封面卡(2:3 / 4:3 / 16:9) | `ratio` `src` `caption` `lift` `onClick` |
| `Image` | 带比例 / 圆角 / 失败降级的图片 | `ratio` `fit` `radius` `fallback` | | `Image` | 带比例 / 圆角 / 失败降级的图片 | `ratio` `fit` `radius` `fallback` |
| `FormField` | 表单字段排版(标签 + 提示 / 错误) | `label` `hint` `error` `required` | | `FormField` | 表单字段排版(标签 + 提示 / 错误) | `label` `hint` `error` `required` |
@@ -103,14 +109,17 @@ stash-ui/
| `Skeleton` | 骨架屏占位 | `variant` `lines` `width` `height` | | `Skeleton` | 骨架屏占位 | `variant` `lines` `width` `height` |
| `Spinner` | 加载指示 | `size` `label` | | `Spinner` | 加载指示 | `size` `label` |
| `Switch` | 即时开关 | `checked` `onChange` `size` `label` | | `Switch` | 即时开关 | `checked` `onChange` `size` `label` |
| `ToggleRow` | 开关行(左标签 + 右开关) | `label` `checked` `onChange` `hint` `switchLabel` |
| `Checkbox` | 复选框 | `checked` `onChange` `label` | | `Checkbox` | 复选框 | `checked` `onChange` `label` |
| `RadioGroup` | 互斥单选组 | `options` `value` `onChange` `direction` | | `RadioGroup` | 互斥单选组 | `options` `value` `onChange` `direction` |
| `Select` | 受控下拉选择 | `options` `value` `onChange` `placeholder` | | `Select` | 受控下拉选择 | `options` `value` `onChange` `placeholder` |
| `Segmented` | 视图形态切换 | `options` `value` `onChange` `size` `block` | | `Segmented` | 视图形态切换 | `options` `value` `onChange` `size` `block` |
| `Tabs` | 页内分区切换 | `items` `activeKey` `onChange` `variant` | | `Tabs` | 页内分区切换 | `items` `activeKey` `onChange` `variant` |
| `Menu` | 侧栏两级菜单 | `items` `activeKey` `onSelect` | | `Menu` | 侧栏两级菜单 | `items` `activeKey` `onSelect` |
| `Caret` | 折叠箭头(6px 折角) | `open` `size` |
| `Breadcrumb` | 路径导航 | `items` `separator` | | `Breadcrumb` | 路径导航 | `items` `separator` |
| `Table` | 只读数据表格 | `columns` `rows` `rowKey` `emptyText` | | `Table` | 只读数据表格 | `columns` `rows` `rowKey` `size` `emptyText` |
| `SortableList` | 可拖拽排序的条目列表 | `items` `activeKey` `onSelect` `onReorder` `disabled` |
| `Tag` | 分类 / 标签 / 状态(浅底 / 实底) | `tone` `variant` `shape` `icon` `dot` | | `Tag` | 分类 / 标签 / 状态(浅底 / 实底) | `tone` `variant` `shape` `icon` `dot` |
| `ItemBadge` | 状态角标:置顶 / 付费 / HOT | `tone` | | `ItemBadge` | 状态角标:置顶 / 付费 / HOT | `tone` |
| `Badge` | 数字角标 | `count` `tone` `max` | | `Badge` | 数字角标 | `count` `tone` `max` |
+116 -3
View File
@@ -19,25 +19,34 @@
| `Typography` | L1 | ✅ | 标题 / 文字 / 段落(Title / Text / Paragraph) | `level` `tone` `size` `lines` | | `Typography` | L1 | ✅ | 标题 / 文字 / 段落(Title / Text / Paragraph) | `level` `tone` `size` `lines` |
| `Button` | L2 | ✅ | 操作按钮(圆角 / 胶囊) | `variant` `size` `shape` `block` `loading` `icon` | | `Button` | L2 | ✅ | 操作按钮(圆角 / 胶囊) | `variant` `size` `shape` `block` `loading` `icon` |
| `IconButton` | L2 | ✅ | 圆形图标按钮(28 / 36 / 44) | `label`(必填) `icon` `variant` `size` `spinning` | | `IconButton` | L2 | ✅ | 圆形图标按钮(28 / 36 / 44) | `label`(必填) `icon` `variant` `size` `spinning` |
| `TextButton` | L2 | ✅ | 文字按钮(行内操作,无外框) | `icon` `tone` `disabled` `title` `label` |
| `Input` | L2 | ✅ | 组合式输入框 | `icon` `clearable` `onClear` `shortcut` `inputRef` | | `Input` | L2 | ✅ | 组合式输入框 | `icon` `clearable` `onClear` `shortcut` `inputRef` |
| `Textarea` | L2 | ✅ | 多行文本(Markdown / 简介) | `rows` `resize` `invalid` | | `Textarea` | L2 | ✅ | 多行文本(Markdown / 简介) | `rows` `resize` `invalid` |
| `PasswordInput` | L2 | ✅ | 密码输入(锁图标 + 显隐切换) | `icon` `size` `toggle` | | `PasswordInput` | L2 | ✅ | 密码输入(锁图标 + 显隐切换) | `icon` `size` `toggle` |
| `Switch` | L2 | ✅ | 即时生效的开关 | `checked` `onChange` `size` `label` | | `Switch` | L2 | ✅ | 即时生效的开关 | `checked` `onChange` `size` `label` |
| `ToggleRow` | L2 | ✅ | 开关行:左标签 + 右开关 | `label` `checked` `onChange` `hint` `switchLabel` |
| `Checkbox` | L2 | ✅ | 复选框(多选 / 确认) | `checked` `onChange` `label` | | `Checkbox` | L2 | ✅ | 复选框(多选 / 确认) | `checked` `onChange` `label` |
| `RadioGroup` | L2 | ✅ | 互斥单选组 | `options` `value` `onChange` `direction` | | `RadioGroup` | L2 | ✅ | 互斥单选组 | `options` `value` `onChange` `direction` |
| `Select` | L2 | ✅ | 受控下拉选择 | `options` `value` `onChange` `placeholder` | | `Select` | L2 | ✅ | 受控下拉选择 | `options` `value` `onChange` `placeholder` |
| `Segmented` | L2 | ✅ | 视图形态切换 | `options` `value` `onChange` `size` `block` | | `Segmented` | L2 | ✅ | 视图形态切换 | `options` `value` `onChange` `size` `block` |
| `Tabs` | L2 | ✅ | 页内分区切换 | `items` `activeKey` `onChange` `variant` | | `Tabs` | L2 | ✅ | 页内分区切换 | `items` `activeKey` `onChange` `variant` |
| `Menu` | L2 | ✅ | 侧栏两级菜单(可折叠动画) | `items` `activeKey` `onSelect` `itemHeight` | | `Menu` | L2 | ✅ | 侧栏两级菜单(可折叠动画) | `items` `activeKey` `onSelect` `itemHeight` |
| `Caret` | L1 | ✅ | 折叠箭头(6px 折角,收起/展开) | `open` `size` |
| `Breadcrumb` | L3 | ✅ | 路径导航 | `items` `separator` | | `Breadcrumb` | L3 | ✅ | 路径导航 | `items` `separator` |
| `Brand` | L2 | ✅ | 品牌标识:logo / 首字标记 + 名称 | `name` `mark` `logo` `href` `size` `tone` |
| `SearchField` | L2 | ✅ | 搜索框(语义版 Input,对外只给字符串) | `value` `onChange` `variant` `size` `hotkey` |
| `Ticker` | L3 | ✅ | 无限滚动的信息条 | `children` `speed` `paused` `label` |
| `Layout` | L3 | ✅ | 页面骨架:页头 / 侧栏 / 内容 / 页脚 | `header` `sider` `aside` `maxWidth` `gap` `height` | | `Layout` | L3 | ✅ | 页面骨架:页头 / 侧栏 / 内容 / 页脚 | `header` `sider` `aside` `maxWidth` `gap` `height` |
| `Card` | L3 | ✅ | 白色容器(1px 极浅描边) | `as` `padding` `hoverable` | | `Card` | L3 | ✅ | 白色容器(1px 极浅描边) | `as` `padding` `hoverable` |
| `EntryRow` | L2 | ✅ | 条目行:头像 + 标题 + 描述,整行可点 | `title` `description` `href` `avatar` `badge` `onOpen` |
| `EntryTile` | L3 | ✅ | 条目磁贴:上图下文白卡 | `title` `description` `cover` `ratio` `onOpen` |
| `Cover` | L3 | ✅ | 比例封面卡(2:3 / 4:3 / 16:9 + 标题遮罩) | `ratio` `src` `caption` `lift` `onClick` | | `Cover` | L3 | ✅ | 比例封面卡(2:3 / 4:3 / 16:9 + 标题遮罩) | `ratio` `src` `caption` `lift` `onClick` |
| `Image` | L3 | ✅ | 带比例 / 圆角 / 失败降级的图片 | `ratio` `fit` `radius` `fallback` | | `Image` | L3 | ✅ | 带比例 / 圆角 / 失败降级的图片 | `ratio` `fit` `radius` `fallback` |
| `FormField` | L3 | ✅ | 表单字段排版(标签 + 控件 + 提示/错误) | `label` `hint` `error` `required` | | `FormField` | L3 | ✅ | 表单字段排版(标签 + 控件 + 提示/错误) | `label` `hint` `error` `required` |
| `Grid` | L3 | ✅ | 卡片列表网格 | `columns` `tabletColumns` `mobileColumns` `gap` `as` | | `Grid` | L3 | ✅ | 卡片列表网格 | `columns` `tabletColumns` `mobileColumns` `gap` `as` |
| `Space` | L3 | ✅ | 一维间距容器 | `direction` `gap` `wrap` `align` `justify` | | `Space` | L3 | ✅ | 一维间距容器 | `direction` `gap` `wrap` `align` `justify` |
| `Table` | L3 | ✅ | 只读数据表格 | `columns` `rows` `rowKey` `emptyText` | | `Table` | L3 | ✅ | 只读数据表格 | `columns` `rows` `rowKey` `size` `emptyText` |
| `SortableList` | L3 | ✅ | 可拖拽排序的条目列表 | `items` `activeKey` `onSelect` `onReorder` `disabled` |
| `Notice` | L4 | ✅ | 说明 / 错误提示条 | `tone` `title` `action` | | `Notice` | L4 | ✅ | 说明 / 错误提示条 | `tone` `title` `action` |
| `Empty` | L4 | ✅ | 空态 | `text` `action` | | `Empty` | L4 | ✅ | 空态 | `text` `action` |
| `Result` | L4 | ✅ | 整块结果反馈 | `status` `title` `description` `actions` | | `Result` | L4 | ✅ | 整块结果反馈 | `status` `title` `description` `actions` |
@@ -443,8 +452,8 @@
### 3.30 Table ✅ ### 3.30 Table ✅
- **用途**:数据表格(只读)。 - **用途**:数据表格(只读)。
- **Props**:`columns: { key, title, width?, align? }[]`、`rows: Record<string, ReactNode>[]`、`rowKey`(默认 `id`)、`emptyText`。 - **Props**:`columns: { key, title, width?, align? }[]`、`rows: Record<string, ReactNode>[]`、`rowKey`(默认 `id`)、`size: 'md' | 'sm'`(行高密度,默认 `md`)、`emptyText`。
- **规则**:排序 / 筛选 / 分页由页面组合,组件不内置交互;空数据渲染 `emptyText`。 - **规则**:排序 / 筛选 / 分页由页面组合,组件不内置交互;空数据渲染 `emptyText`;单元格是任意 `ReactNode`,行内操作直接用 `TextButton` / `IconButton`。
- **展示页**:`#table` - **展示页**:`#table`
### 3.31 Result ✅ ### 3.31 Result ✅
@@ -500,6 +509,7 @@
| prop | 类型 | 默认 | 说明 | | prop | 类型 | 默认 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `header` | `ReactNode` | — | 页头,默认吸顶(半透明 + 模糊) | | `header` | `ReactNode` | — | 页头,默认吸顶(半透明 + 模糊) |
| `toolbar` | `ReactNode` | — | 页头与页身之间的工具条(搜索 / 筛选 / 操作带),宽度随容器,不参与内容区滚动 |
| `sider` | `ReactNode` | — | 左侧栏,固定宽度 | | `sider` | `ReactNode` | — | 左侧栏,固定宽度 |
| `aside` | `ReactNode` | — | 右侧栏,固定宽度(目录 / 聚合 / 广告位) | | `aside` | `ReactNode` | — | 右侧栏,固定宽度(目录 / 聚合 / 广告位) |
| `footer` | `ReactNode` | — | 页脚 | | `footer` | `ReactNode` | — | 页脚 |
@@ -522,6 +532,7 @@
| `contentId` | `string` | — | 内容区 id(供 ScrollTop 等按 id 滚动) | | `contentId` | `string` | — | 内容区 id(供 ScrollTop 等按 id 滚动) |
- **规则**:Layout 不写业务内容,也不带背景装饰(需要渐变/光晕时由页面自己放绝对定位层);响应式在组件内处理——≤1200px 右栏堆到内容下方、≤1100px 侧栏堆到内容上方,**内容都不隐藏**。 - **规则**:Layout 不写业务内容,也不带背景装饰(需要渐变/光晕时由页面自己放绝对定位层);响应式在组件内处理——≤1200px 右栏堆到内容下方、≤1100px 侧栏堆到内容上方,**内容都不隐藏**。
- **`toolbar` 与 `contentOffset` 的分工**:`toolbar` 是**真实占位**的一层,搜索框照常参与文档流,侧栏与内容的顶边天然对齐(推荐);`contentOffset` 只用于「浮在页面上、不能占位」的场景(悬浮条、绝对定位的返回按钮),不要把两者叠加使用。
- **常用配方**:顶栏+内容(首页)、左栏+内容(管理页)、内容+右栏(文章)、三栏(工作台)、窄内容(设置/阅读),展示页用 `Segmented` 切换。 - **常用配方**:顶栏+内容(首页)、左栏+内容(管理页)、内容+右栏(文章)、三栏(工作台)、窄内容(设置/阅读),展示页用 `Segmented` 切换。
- **展示页**:`#layout` - **展示页**:`#layout`
@@ -599,6 +610,102 @@
- **规则**:`onChange` 是原生事件(与 `Input` 一致),Enter 提交用 `onKeyDown`;切换按钮必须带 `aria-pressed` 与「显示/隐藏密码」名称,焦点环不得移除。 - **规则**:`onChange` 是原生事件(与 `Input` 一致),Enter 提交用 `onKeyDown`;切换按钮必须带 `aria-pressed` 与「显示/隐藏密码」名称,焦点环不得移除。
- **展示页**:`#inputs` - **展示页**:`#inputs`
### 3.43 Brand ✅
- **用途**:页头左侧的品牌标识——图片 logo 或首字圆形标记 + 品牌文字。
- **不该用在哪**:头像(用 `Avatar`);纯文字标题(用 `Title`)。
- **解剖**:`inline-flex` 容器;`logo` 有值时渲染 `<img>`,否则渲染首字标记;`href` 有值时整体是 `<a>`,否则是 `<span>`。
- **Props**:
| prop | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `name` | `string` | `''` | 品牌文字,可留空只显示标记 |
| `mark` | `string` | `''` | 标记里的字,留空取 `name` 首字 |
| `logo` | `string` | `''` | logo 图片地址;留空用 `mark` |
| `href` | `string` | — | 传了渲染成链接 |
| `size` | `'sm' \| 'md'` | `'md'` | 标记 24 / 32 |
| `tone` | `'solid' \| 'soft'` | `'solid'` | 深底白字 / 主色浅底 |
| `label` | `string` | — | 链接的无障碍名称 |
- **规则**:品牌名可空(只有标记也要能读出来,用 `label`);图片的 `alt` 留空,名称由文字节点承担。
- **展示页**:`#site-chrome`
### 3.44 Ticker ✅
- **用途**:横向无限滚动的信息条(页头运行状态这类)。
- **不该用在哪**:需要用户逐条阅读的长文本(用 `Text`);通知(用 `Notice` / `Toast`)。
- **解剖**:容器 `overflow: hidden` + 两端 `mask` 渐隐;内部三份相同内容,按**第一份的实测宽度**做 `translateX`,所以循环处不跳帧;宽度变化由 `ResizeObserver` 重新测量。
- **Props**:
| prop | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `children` | `ReactNode` | 必填 | 一组内容,组件负责复制三份 |
| `speed` | `number` | `24` | 一份内容滚完的秒数 |
| `paused` | `boolean` | `false` | 暂停但保留进度 |
| `label` | `string` | — | 读屏用的整体描述 |
- **规则**:只复制不改造 `children`;后两份带 `aria-hidden`,读屏只读一遍;`prefers-reduced-motion` 下不滚。
- **展示页**:`#site-chrome`
### 3.45 SearchField ✅
- **用途**:搜索框——语义版 `Input`,对外只给字符串。
- **不该用在哪**:普通表单输入(用 `Input`);需要自定义后缀的场景(用 `Input` + `suffix`)。
- **解剖**:外层定宽容器 + `Input size="lg"`(搜索图标、清空按钮、快捷键提示);`variant="card"` 只换描边/圆角/阴影,不换结构。
- **Props**:
| prop | 类型 | 默认 | 说明 |
| --- | --- | --- | --- |
| `value` | `string` | 必填 | 受控值 |
| `onChange` | `(value: string) => void` | 必填 | 直接给字符串 |
| `placeholder` | `string` | `'搜索…'` | 占位文案,同时作为无障碍名称 |
| `size` | `'md' \| 'lg'` | `'lg'` | 36 / 44 高 |
| `variant` | `'plain' \| 'card'` | `'plain'` | 普通输入框 / 实底 + 柔和阴影 |
| `maxWidth` | `number \| string` | `480` | 最大宽度 |
| `shortcut` | `string` | — | 右侧快捷键提示(仅展示) |
| `hotkey` | `string` | — | Ctrl/Cmd + 该键聚焦并全选(需同时传 `inputRef`) |
| `clearOnEscape` | `boolean` | `true` | Esc 清空 |
| `inputRef` | `Ref<HTMLInputElement>` | — | 透传内部输入框 |
- **规则**:`onChange` 给字符串而不是事件——这是它和 `Input` 唯一的语义差别,别在组件里做过滤/请求。
- **展示页**:`#site-chrome`
### 3.46 TextButton ✅
- **用途**:文字按钮——看起来像链接、行为像按钮,用于表格行内操作(编辑 / 删除 / 新建子项)。
- **不该用在哪**:需要底色、尺寸或独立成行的地方(用 `Button`);只有图标的地方(用 `IconButton`)。
- **解剖**:`<button type="button">` + 可选前置图标;正文 `font-size-sm`,`tone` 决定主色 / 危险色。
- **Props**:`children`、`icon`、`tone: 'primary' | 'danger'`(默认 `primary`)、`disabled`、`onClick`、`title`、`label`。
- **规则**:hover 只加下划线,不加底色;`disabled` 用 `--su-text-disabled`;`focus-visible` 保留焦点环。
- **展示页**:`#buttons`
### 3.47 ToggleRow ✅
- **用途**:设置面板里「左标签 + 右开关」的一行。
- **不该用在哪**:需要用户点「保存」才生效的场景(用 `Checkbox` + 按钮)。
- **解剖**:`div` 两端对齐,左侧 `Text`(可加一行 `hint`),右侧 `Switch`。
- **Props**:`label`、`checked`、`onChange`、`switchLabel`(默认取 label 文本)、`hint`、`disabled`。
- **规则**:开关的无障碍名称必须有值,label 不是字符串时请显式传 `switchLabel`。
- **展示页**:`#switch`
### 3.48 Caret ✅
- **用途**:折叠箭头——一个 6px 的 CSS 折角,表示「可展开 / 已展开」。
- **不该用在哪**:带文字的完整按钮(用 `Button` / `TextButton`);方向指示(用 `Icon`)。
- **解剖**:单个 `span`,left + bottom 两道边框旋转成折角,`aria-hidden`。
- **Props**:`open`(默认 `false`,收起朝下 / 展开朝上)、`size`(折角边长,默认 6)。
- **规则**:纯装饰,无障碍状态由外层按钮的 `aria-expanded` 承担;`Menu` 与 `Select` 内部就用的它。
- **展示页**:`#menu`
### 3.49 SortableList ✅
- **用途**:一列可拖拽排序的条目(卡片列表、菜单排序、优先级调整)。
- **不该用在哪**:需要键盘完成排序的场景(本组件只做鼠标拖拽,需在外配上移 / 下移按钮);只读列表(用 `Menu` 或自己渲染)。
- **解剖**:容器 + 逐条 `button`(拖拽手柄 / 文案 / 右侧 meta),拖动时 `dragging`、经过时 `over`、选中时 `active`。
- **Props**:`items: { key, label, meta? }[]`、`activeKey`、`onSelect`、`onReorder(keys)`、`disabled`、`emptyText`。
- **规则**:顺序没变不触发 `onReorder`;向下拖插到目标之后(保证能拖到末尾);`disabled` 期间禁止拖动,仍可点击。
- **展示页**:`#sortable`
--- ---
## 4. 组合模式(不进库,但必须照这个拼) ## 4. 组合模式(不进库,但必须照这个拼)
@@ -700,3 +807,9 @@
| 2026-10-29 | 新增 `Textarea`(`rows` / `resize` / `invalid`),与 `Input` 共用边框与焦点样式。兼容性:纯新增 | | 2026-10-29 | 新增 `Textarea`(`rows` / `resize` / `invalid`),与 `Input` 共用边框与焦点样式。兼容性:纯新增 |
| 2026-10-29 | 新增 `PasswordInput`(`icon` / `size` / `toggle`):锁图标 + 显隐切换 + `aria-pressed`,事件语义与 `Input` 一致。兼容性:纯新增 | | 2026-10-29 | 新增 `PasswordInput`(`icon` / `size` / `toggle`):锁图标 + 显隐切换 + `aria-pressed`,事件语义与 `Input` 一致。兼容性:纯新增 |
| 2026-10-30 | **修复**:`PasswordInput` 的眼睛按钮移入输入框内部(`Input` 新增 `suffix` 插槽);`Input` 隐藏 Edge 原生 `::-ms-reveal` / `::-ms-clear`,避免与自带显隐按钮叠成两个眼睛。兼容性:`Input` 新增可选 prop,无破坏性;`PasswordInput` API 不变、仅外观变化 | | 2026-10-30 | **修复**:`PasswordInput` 的眼睛按钮移入输入框内部(`Input` 新增 `suffix` 插槽);`Input` 隐藏 Edge 原生 `::-ms-reveal` / `::-ms-clear`,避免与自带显隐按钮叠成两个眼睛。兼容性:`Input` 新增可选 prop,无破坏性;`PasswordInput` API 不变、仅外观变化 |
| 2026-10-31 | `Layout` 新增 `toolbar` 插槽:页头与页身之间的一条真实占位带(搜索 / 筛选 / 操作),宽度跟随容器(`contained` 时与侧栏 + 内容同宽),在 `height="screen"` 下不参与内部滚动。用它替代「搜索框 fixed + `contentOffset` 让位」的写法,页面不再需要用 JS 改 DOM 尺寸。兼容性:纯新增可选 prop,`contentOffset` 行为不变 |
| 2026-11-01 | 新增三个「站点外壳」组件,把应用里各自手写的一套收进库:`Brand`(logo / 首字标记 + 名称,`href` 可选)、`Ticker`(无限滚动信息条,按实测宽度位移,循环不跳帧)、`SearchField`(语义版搜索框,`variant` 切普通 / 悬浮条)。同时新增展示级令牌 `--su-font-size-2xl` / `--su-line-height-2xl`(32 / 40)。兼容性:纯新增,无破坏 |
| 2026-11-01 | 新增 `EntryRow` / `EntryTile`:把应用里两套手写内容卡(`.app-card` / `.ces-card`)收进库。两者共用同一套点击语义(无修饰键接管、按住修饰键保留默认)与悬停反馈;`EntryTile` 由 `Card padding="none" hoverable` + `Cover` 拼成。兼容性:纯新增 |
| 2026-11-01 | `Select` 的下拉箭头从 `arrow_down_line` 换成和 `Menu` 一致的 CSS 折角(6px、收起朝下 / 展开朝上)。影响面:所有 `Select` 的外观,交互不变 |
| 2026-11-01 | 新增 `TextButton`(文字按钮:`tone` 主色 / 危险色、可选图标)与 `ToggleRow`(左标签 + 右开关的开关行,支持 `hint`);`Table` 新增 `size: 'md' \| 'sm'` 紧凑档。目的是把后台配置页里手写的 `.group-action` / `.sidebar-toggle-row` / `<table>` 收进库。兼容性:纯新增,`Table` 默认行为不变 |
| 2026-11-01 | 新增 `Caret`(6px 折角,`Menu` 与 `Select` 内部改为复用它,画法只剩一份)与 `SortableList`(可拖拽排序列表,`onReorder` 回传新顺序)。兼容性:纯新增;`Menu` / `Select` 的箭头外观不变 |
+6
View File
@@ -13,8 +13,10 @@ import { Icons } from './sections/Icons'
import { Images } from './sections/Images' import { Images } from './sections/Images'
import { Inputs } from './sections/Inputs' import { Inputs } from './sections/Inputs'
import { Layouts } from './sections/Layouts' import { Layouts } from './sections/Layouts'
import { SiteChrome } from './sections/SiteChrome'
import { Marks } from './sections/Marks' import { Marks } from './sections/Marks'
import { MenuSection } from './sections/MenuSection' import { MenuSection } from './sections/MenuSection'
import { Sortables } from './sections/Sortables'
import { Modals } from './sections/Modals' import { Modals } from './sections/Modals'
import { Overview } from './sections/Overview' import { Overview } from './sections/Overview'
import { PageLayout } from './sections/PageLayout' import { PageLayout } from './sections/PageLayout'
@@ -82,6 +84,7 @@ const NAV = [
{ id: 'breadcrumb', label: '面包屑 Breadcrumb' }, { id: 'breadcrumb', label: '面包屑 Breadcrumb' },
{ id: 'tabs', label: '标签页 Tabs' }, { id: 'tabs', label: '标签页 Tabs' },
{ id: 'menu', label: '菜单 Menu' }, { id: 'menu', label: '菜单 Menu' },
{ id: 'sortable', label: '排序列表 SortableList' },
], ],
}, },
{ {
@@ -103,6 +106,7 @@ const NAV = [
items: [ items: [
{ id: 'layout', label: '页面骨架 Layout' }, { id: 'layout', label: '页面骨架 Layout' },
{ id: 'layouts', label: '网格与间距' }, { id: 'layouts', label: '网格与间距' },
{ id: 'site-chrome', label: '站点外壳' },
{ id: 'composition', label: '组合示例' }, { id: 'composition', label: '组合示例' },
], ],
}, },
@@ -206,6 +210,7 @@ export function App() {
<Marks /> <Marks />
<Avatars /> <Avatars />
<Tables /> <Tables />
<Sortables />
<Results /> <Results />
<Progresses /> <Progresses />
<StepsSection /> <StepsSection />
@@ -229,6 +234,7 @@ export function App() {
<Tooltips /> <Tooltips />
<PageLayout /> <PageLayout />
<Layouts /> <Layouts />
<SiteChrome />
<Composition /> <Composition />
</main> </main>
+13
View File
@@ -562,6 +562,19 @@ code {
gap: var(--su-space-1); gap: var(--su-space-1);
} }
/* 工具条:页头与页身之间的一条搜索带(toolbar 插槽) */
.layout-toolbar {
display: flex;
align-items: center;
justify-content: center;
padding: var(--su-space-3) var(--su-space-5);
border-bottom: 1px solid var(--su-border);
}
.layout-toolbar > * {
width: min(360px, 100%);
}
.layout-side { .layout-side {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+32 -1
View File
@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Button, Icon, IconButton, Space } from '../../src' import { Button, Icon, IconButton, Space, TextButton } from '../../src'
import { Demo } from '../components/Demo' import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable' import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section' import { Section } from '../components/Section'
@@ -126,6 +126,27 @@ export function Buttons() {
</Space> </Space>
</Demo> </Demo>
<Demo
title="文字按钮"
desc="看起来像链接、行为像按钮,用在表格行内操作这类不需要外框的地方;需要底色或独立操作时仍用 Button。"
layout="column"
code={`<TextButton icon={<Icon name="add_line" />}>新建子分组</TextButton>
<TextButton icon={<Icon name="edit_4_line" />}>编辑</TextButton>
<TextButton tone="danger" icon={<Icon name="delete_2_line" />}>删除</TextButton>
<TextButton disabled>禁用</TextButton>`}
>
<Space gap="lg" wrap>
<TextButton icon={<Icon name="add_line" />}>新建子分组</TextButton>
<TextButton icon={<Icon name="edit_4_line" />}>编辑</TextButton>
<TextButton tone="danger" icon={<Icon name="delete_2_line" />}>
删除
</TextButton>
<TextButton disabled icon={<Icon name="edit_4_line" />}>
禁用
</TextButton>
</Space>
</Demo>
<PropsTable <PropsTable
rows={[ rows={[
{ name: 'variant', type: "'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'", defaultValue: "'primary'", desc: '语义变体' }, { name: 'variant', type: "'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'", defaultValue: "'primary'", desc: '语义变体' },
@@ -148,6 +169,16 @@ export function Buttons() {
{ name: 'spinning', type: 'boolean', defaultValue: 'false', desc: '图标旋转' }, { name: 'spinning', type: 'boolean', defaultValue: 'false', desc: '图标旋转' },
]} ]}
/> />
<PropsTable
rows={[
{ name: 'icon', type: 'ReactNode', desc: '前置图标' },
{ name: 'tone', type: "'primary' | 'danger'", defaultValue: "'primary'", desc: '颜色语义' },
{ name: 'disabled', type: 'boolean', defaultValue: 'false', desc: '禁用态' },
{ name: 'onClick', type: '() => void', desc: '点击回调' },
{ name: 'title / label', type: 'string', desc: '悬停提示 / 无障碍名称' },
]}
/>
</Section> </Section>
) )
} }
+39 -1
View File
@@ -1,4 +1,4 @@
import { Badge, Card, Icon, IconButton, Space } from '../../src' import { Badge, Card, EntryRow, EntryTile, Icon, IconButton, ItemBadge, Space } from '../../src'
import { Demo } from '../components/Demo' import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable' import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section' import { Section } from '../components/Section'
@@ -94,12 +94,50 @@ export function Cards() {
</Card> </Card>
</Demo> </Demo>
<Demo
title="条目行与条目磁贴"
desc="同一份内容数据的两种版式:EntryRow 是横向一行(头像 + 标题 + 描述),EntryTile 是上图下文的白卡。点击语义一致——不带修饰键时接管点击,按住 Ctrl/Cmd 时保留浏览器默认行为。"
layout="column"
code={`<EntryRow title="GitHub" description="代码托管与开源社区" fallback="G" badge={<ItemBadge tone="hot">HOT</ItemBadge>} />
<EntryTile title="可塑性极强的玻璃" description="前沿科技" ratio={16 / 10} />`}
>
<EntryRow
title="GitHub"
description="代码托管与开源社区"
fallback="G"
badge={<ItemBadge tone="hot">HOT</ItemBadge>}
onOpen={() => undefined}
/>
<EntryRow title="知乎" description="深度内容问答社区" fallback="知" onOpen={() => undefined} />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 'var(--su-space-3)' }}>
<EntryTile
title="可塑性极强的玻璃,在设计师手里能变成什么"
description="前沿科技"
ratio={16 / 10}
onOpen={() => undefined}
/>
<EntryTile title="废旧共享单车如何变成微型共享…" description="环保设计" ratio={16 / 10} />
</div>
</Demo>
<PropsTable <PropsTable
rows={[ rows={[
{ name: 'as', type: "'div' | 'section' | 'article' | 'aside'", defaultValue: "'div'", desc: '渲染标签,按语义选' }, { name: 'as', type: "'div' | 'section' | 'article' | 'aside'", defaultValue: "'div'", desc: '渲染标签,按语义选' },
{ name: 'padding', type: "'none' | 'sm' | 'md' | 'lg'", defaultValue: "'md'", desc: '内边距档位' }, { name: 'padding', type: "'none' | 'sm' | 'md' | 'lg'", defaultValue: "'md'", desc: '内边距档位' },
{ name: 'hoverable', type: 'boolean', defaultValue: 'false', desc: '悬停加深阴影' }, { name: 'hoverable', type: 'boolean', defaultValue: 'false', desc: '悬停加深阴影' },
{ name: 'className / style', type: 'string / CSSProperties', desc: '布局微调,例如绝对定位角标' }, { name: 'className / style', type: 'string / CSSProperties', desc: '布局微调,例如绝对定位角标' },
{ name: 'EntryRow.title', type: 'string', required: true, desc: '标题' },
{ name: 'EntryRow.description', type: 'string', desc: '单行描述' },
{ name: 'EntryRow.href', type: 'string', desc: '整行链接地址' },
{ name: 'EntryRow.avatar / fallback', type: 'string', desc: '头像图片 / 首字占位' },
{ name: 'EntryRow.verified', type: 'boolean', desc: '头像右下角认证勾' },
{ name: 'EntryRow.badge', type: 'ReactNode', desc: '标题右侧角标(如 HOT)' },
{ name: 'EntryRow.onOpen', type: '() => void', desc: '接管点击;不传保持链接行为' },
{ name: 'EntryTile.title', type: 'string', required: true, desc: '标题' },
{ name: 'EntryTile.description', type: 'string', desc: '单行描述' },
{ name: 'EntryTile.cover', type: 'string', desc: '封面地址' },
{ name: 'EntryTile.ratio', type: 'number', defaultValue: '16 / 10', desc: '封面宽高比' },
{ name: 'EntryTile.onOpen', type: '() => void', desc: '点击整张卡' },
]} ]}
/> />
</Section> </Section>
+30 -1
View File
@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Card, Icon, Menu, Text } from '../../src' import { Card, Caret, Icon, Menu, Space, Text } from '../../src'
import { Demo } from '../components/Demo' import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable' import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section' import { Section } from '../components/Section'
@@ -79,6 +79,28 @@ export function MenuSection() {
</Card> </Card>
</Demo> </Demo>
<Demo
title="折叠箭头"
desc="Caret 是菜单、下拉、可折叠表格行共用的 6px 折角:收起朝下、展开朝上,纯装饰,无障碍状态由外层按钮的 aria-expanded 承担。"
code={`<Caret />
<Caret open />`}
>
<Space gap="lg">
<Space gap="sm" align="center">
<Caret />
<Text size="sm" tone="muted">
收起
</Text>
</Space>
<Space gap="sm" align="center">
<Caret open />
<Text size="sm" tone="muted">
展开
</Text>
</Space>
</Space>
</Demo>
<PropsTable <PropsTable
rows={[ rows={[
{ name: 'items', type: 'MenuItem[]', required: true, desc: '{ key, label, icon?, children? }' }, { name: 'items', type: 'MenuItem[]', required: true, desc: '{ key, label, icon?, children? }' },
@@ -88,6 +110,13 @@ export function MenuSection() {
{ name: 'itemHeight', type: 'number', defaultValue: '36', desc: '一级菜单行高(px)' }, { name: 'itemHeight', type: 'number', defaultValue: '36', desc: '一级菜单行高(px)' },
]} ]}
/> />
<PropsTable
rows={[
{ name: 'open', type: 'boolean', defaultValue: 'false', desc: '展开状态(决定箭头朝向)' },
{ name: 'size', type: 'number', defaultValue: '6', desc: '折角边长(px)' },
]}
/>
</Section> </Section>
) )
} }
+42 -1
View File
@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Card, Grid, Layout, Segmented, Space, Tag, Text, Title } from '../../src' import { Card, Grid, Input, Layout, Segmented, Space, Tag, Text, Title } from '../../src'
import { Demo } from '../components/Demo' import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable' import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section' import { Section } from '../components/Section'
@@ -214,9 +214,50 @@ export function PageLayout() {
</div> </div>
</Demo> </Demo>
<Demo
title="页头下的工具条"
desc="toolbar 是真实占位的一层:宽度跟随容器,坐在页头与页身之间;整屏模式下它不参与内容区滚动,搜索框不用再 fixed + 手算 offset。"
layout="column"
code={`<Layout
header={<Header />}
toolbar={<Search />}
sider={<Nav />}
height="screen"
scrollable
>
<Content />
</Layout>`}
>
<div className="layout-frame" style={{ height: 320, overflow: 'hidden' }}>
<Layout
header={<DemoHeader />}
toolbar={
<div className="layout-toolbar">
<Input size="lg" icon="search_3_line" placeholder="搜索本地项目…" readOnly />
</div>
}
sider={<DemoSider compact />}
headerHeight={56}
height="screen"
scrollable
hideScrollbar
style={{ height: 320 }}
>
<div className="layout-content">
{Array.from({ length: 6 }, (_, index) => (
<Card padding="md" key={index}>
<Text size="sm">第 {index + 1} 行:工具条固定在页头下方,只有这里滚动。</Text>
</Card>
))}
</div>
</Layout>
</div>
</Demo>
<PropsTable <PropsTable
rows={[ rows={[
{ name: 'header', type: 'ReactNode', desc: '页头,默认吸顶' }, { name: 'header', type: 'ReactNode', desc: '页头,默认吸顶' },
{ name: 'toolbar', type: 'ReactNode', desc: '页头与页身之间的工具条(搜索 / 筛选 / 操作带),宽度随容器' },
{ name: 'sider', type: 'ReactNode', desc: '左侧栏,固定宽度' }, { name: 'sider', type: 'ReactNode', desc: '左侧栏,固定宽度' },
{ name: 'aside', type: 'ReactNode', desc: '右侧栏,固定宽度(目录 / 聚合 / 广告位)' }, { name: 'aside', type: 'ReactNode', desc: '右侧栏,固定宽度(目录 / 聚合 / 广告位)' },
{ name: 'footer', type: 'ReactNode', desc: '页脚' }, { name: 'footer', type: 'ReactNode', desc: '页脚' },
+98
View File
@@ -0,0 +1,98 @@
import { useState } from 'react'
import { Brand, SearchField, Tag, Ticker } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
const TICKER_ITEMS = ['共 3 个本地应用', 'git_helper', '实时同步已连接', '上次扫描 12 秒前']
export function SiteChrome() {
const [keyword, setKeyword] = useState('')
return (
<Section
id="site-chrome"
title="站点外壳"
api="Brand / Ticker / SearchField"
desc="页面上「每站都要有、但和业务无关」的几块:品牌标识、顶部跑马灯、搜索框。它们只吃 props,不读路由也不请求接口。"
>
<Demo
title="品牌标识 Brand"
desc="有 logo 图片就用图片,没有就用首字圆形标记;传 href 变成链接,不传就是纯展示。"
layout="column"
code={`<Brand name="STASH" mark="S" href="/" />
<Brand name="Docs" mark="D" size="sm" tone="soft" />`}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--su-space-6)' }}>
<Brand name="STASH" mark="S" href="/" />
<Brand name="Docs" mark="D" size="sm" tone="soft" />
<Brand name="只有文字" />
<Brand mark="N" />
</div>
</Demo>
<Demo
title="跑马灯 Ticker"
desc="内部渲染三份内容、按实测宽度位移,首尾相接不跳帧;speed 是一份内容滚完的秒数。"
layout="column"
code={`<Ticker speed={24} label="运行状态">
{items.map((text) => <span key={text}>{text}</span>)}
</Ticker>`}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--su-space-2)', width: '100%' }}>
<Brand name="STASH" mark="S" />
<Ticker speed={18} label="运行状态示例">
{TICKER_ITEMS.map((text) => (
<span
key={text}
style={{ color: 'var(--su-text-sub)', fontSize: 'var(--su-font-size-sm)', whiteSpace: 'nowrap' }}
>
{text}
</span>
))}
</Ticker>
</div>
</Demo>
<Demo
title="搜索框 SearchField"
desc="语义版 Input:对外只给字符串;variant=card 是「悬浮搜索条」的实底+阴影,plain 与普通输入框一致。"
layout="column"
code={`<SearchField value={keyword} onChange={setKeyword} placeholder="搜索项目…" />
<SearchField value={keyword} onChange={setKeyword} variant="card" shortcut="⌘K" />`}
>
<SearchField value={keyword} onChange={setKeyword} placeholder="搜索项目…" maxWidth={360} />
<SearchField
value={keyword}
onChange={setKeyword}
placeholder="悬浮搜索条(card)"
variant="card"
shortcut="⌘K"
maxWidth={420}
/>
<Tag tone="neutral">当前输入:{keyword === '' ? '(空)' : keyword}</Tag>
</Demo>
<PropsTable
rows={[
{ name: 'Brand.name', type: 'string', desc: '品牌文字,可留空只显示标记' },
{ name: 'Brand.mark', type: 'string', desc: '标记里的字,留空取 name 首字' },
{ name: 'Brand.logo', type: 'string', desc: 'logo 图片地址;留空用 mark' },
{ name: 'Brand.href', type: 'string', desc: '传了渲染成 <a>,不传是 <span>' },
{ name: 'Brand.size', type: "'sm' | 'md'", defaultValue: "'md'", desc: '标记 24 / 32' },
{ name: 'Brand.tone', type: "'solid' | 'soft'", defaultValue: "'solid'", desc: '深底白字 / 主色浅底' },
{ name: 'Ticker.children', type: 'ReactNode', required: true, desc: '一组内容,组件负责复制三份' },
{ name: 'Ticker.speed', type: 'number', defaultValue: '24', desc: '一份内容滚完的秒数' },
{ name: 'Ticker.paused', type: 'boolean', defaultValue: 'false', desc: '暂停但保留进度' },
{ name: 'Ticker.label', type: 'string', desc: '读屏用的整体描述' },
{ name: 'SearchField.value', type: 'string', required: true, desc: '受控值' },
{ name: 'SearchField.onChange', type: '(value: string) => void', required: true, desc: '直接给字符串,不用取 event' },
{ name: 'SearchField.variant', type: "'plain' | 'card'", defaultValue: "'plain'", desc: '普通输入框 / 悬浮搜索条' },
{ name: 'SearchField.size', type: "'md' | 'lg'", defaultValue: "'lg'", desc: '36 / 44 高' },
{ name: 'SearchField.maxWidth', type: 'number | string', defaultValue: '480', desc: '最大宽度' },
{ name: 'SearchField.hotkey', type: 'string', desc: 'Ctrl/Cmd + 该键聚焦并全选(需同时传 inputRef)' },
]}
/>
</Section>
)
}
+88
View File
@@ -0,0 +1,88 @@
import { useState } from 'react'
import { SortableList, Text } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
const INITIAL = [
{ key: 'pin', label: '常用推荐', meta: 12 },
{ key: 'movie', label: '推荐电影', meta: 12 },
{ key: 'ces', label: 'CES 现场报道', meta: 12 },
{ key: 'tools', label: '开发工具', meta: 5 },
]
export function Sortables() {
const [items, setItems] = useState(INITIAL)
const [activeKey, setActiveKey] = useState('pin')
const reorder = (keys: string[]) => {
setItems((current) =>
keys
.map((key) => current.find((item) => item.key === key))
.filter((item): item is (typeof INITIAL)[number] => item !== undefined),
)
}
return (
<Section
id="sortable"
title="排序列表"
api="SortableList"
desc="一列可拖拽排序的条目:拖动改顺序、点击选中,顺序变化通过 onReorder 交回页面。只做鼠标拖拽,需要键盘排序时在外面配上移/下移按钮。"
>
<Demo
title="基础用法"
desc="拖住左侧手柄上下移动,松手后把新的 key 顺序交给 onReorder;点击选中项会高亮。"
layout="column"
code={`const [items, setItems] = useState(INITIAL)
<SortableList
items={items}
activeKey={activeKey}
onSelect={setActiveKey}
onReorder={(keys) => setItems(reorder(keys))}
/>`}
>
<div style={{ width: 280 }}>
<SortableList
items={items}
activeKey={activeKey}
onSelect={setActiveKey}
onReorder={reorder}
/>
</div>
</Demo>
<Demo
title="空态"
desc="items 为空时渲染 emptyText;不传就不显示空态。"
layout="column"
code={`<SortableList items={[]} onSelect={() => {}} onReorder={() => {}} emptyText="还没有卡片" />`}
>
<div style={{ width: 280 }}>
<SortableList
items={[]}
onSelect={() => undefined}
onReorder={() => undefined}
emptyText="还没有卡片"
/>
</div>
</Demo>
<PropsTable
rows={[
{ name: 'items', type: 'SortableItem[]', required: true, desc: '{ key, label, meta? }' },
{ name: 'onSelect', type: '(key: string) => void', required: true, desc: '点击选中' },
{ name: 'onReorder', type: '(keys: string[]) => void', required: true, desc: '拖拽后的完整顺序' },
{ name: 'activeKey', type: 'string', desc: '当前选中项' },
{ name: 'disabled', type: 'boolean', defaultValue: 'false', desc: '排序保存中,禁止再次拖动' },
{ name: 'emptyText', type: 'ReactNode', desc: '空态文案,不传则不渲染' },
]}
/>
<Text size="sm" tone="muted">
说明:拖拽只在条目之间生效;松手时若顺序没变不会触发 onReorder。
</Text>
</Section>
)
}
+30 -1
View File
@@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Space, Switch, Text } from '../../src' import { Space, Switch, Text, ToggleRow } from '../../src'
import { Demo } from '../components/Demo' import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable' import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section' import { Section } from '../components/Section'
@@ -58,6 +58,24 @@ export function Switches() {
</Space> </Space>
</Demo> </Demo>
<Demo
title="开关行"
desc="设置面板里每行一个开关时用 ToggleRow:左边标签(可带一行说明),右边开关,两端对齐,不用自己拼 Space。"
layout="column"
code={`<ToggleRow label="明暗切换" checked={on} onChange={setOn} />
<ToggleRow label="显示封面图" hint="列表里用封面代替首字" checked={showCover} onChange={setShowCover} />`}
>
<div style={{ width: 360 }}>
<ToggleRow label="明暗切换" checked={autoSync} onChange={setAutoSync} />
<ToggleRow
label="显示封面图"
hint="列表里用封面代替首字"
checked={showCover}
onChange={setShowCover}
/>
</div>
</Demo>
<PropsTable <PropsTable
rows={[ rows={[
{ name: 'checked', type: 'boolean', required: true, desc: '受控状态' }, { name: 'checked', type: 'boolean', required: true, desc: '受控状态' },
@@ -67,6 +85,17 @@ export function Switches() {
{ name: 'label', type: 'string', desc: '无障碍名称:旁边没有文字时必填' }, { name: 'label', type: 'string', desc: '无障碍名称:旁边没有文字时必填' },
]} ]}
/> />
<PropsTable
rows={[
{ name: 'label', type: 'ReactNode', required: true, desc: '左侧标签' },
{ name: 'checked', type: 'boolean', required: true, desc: '受控状态' },
{ name: 'onChange', type: '(checked: boolean) => void', required: true, desc: '切换回调' },
{ name: 'switchLabel', type: 'string', desc: '读屏用的开关名称,默认取 label' },
{ name: 'hint', type: 'ReactNode', desc: '标签下方的一行说明' },
{ name: 'disabled', type: 'boolean', defaultValue: 'false', desc: '禁用' },
]}
/>
</Section> </Section>
) )
} }
+10
View File
@@ -56,6 +56,15 @@ export function Tables() {
<Table columns={COLUMNS} rows={ROWS} rowKey="id" /> <Table columns={COLUMNS} rows={ROWS} rowKey="id" />
</Demo> </Demo>
<Demo
title="紧凑档"
desc="size='sm' 把行高收一档,配置面板那种列多、面板窄的场景用它。"
layout="column"
code={`<Table columns={COLUMNS} rows={ROWS} size="sm" />`}
>
<Table columns={COLUMNS} rows={ROWS} size="sm" />
</Demo>
<Demo <Demo
title="空态" title="空态"
desc="空数据时渲染默认文案;也可以传 emptyText 换一句更具体的说明。" desc="空数据时渲染默认文案;也可以传 emptyText 换一句更具体的说明。"
@@ -70,6 +79,7 @@ export function Tables() {
{ name: 'columns', type: 'TableColumn[]', required: true, desc: '{ key, title, width?, align? }' }, { name: 'columns', type: 'TableColumn[]', required: true, desc: '{ key, title, width?, align? }' },
{ name: 'rows', type: 'Record<string, ReactNode>[]', required: true, desc: '行数据,键对应列的 key' }, { name: 'rows', type: 'Record<string, ReactNode>[]', required: true, desc: '行数据,键对应列的 key' },
{ name: 'rowKey', type: 'string', defaultValue: "'id'", desc: '行的唯一键字段' }, { name: 'rowKey', type: 'string', defaultValue: "'id'", desc: '行的唯一键字段' },
{ name: 'size', type: "'md' | 'sm'", defaultValue: "'md'", desc: '行高密度' },
{ name: 'emptyText', type: 'ReactNode', defaultValue: "'暂无数据'", desc: '空态文案' }, { name: 'emptyText', type: 'ReactNode', defaultValue: "'暂无数据'", desc: '空态文案' },
]} ]}
/> />
+1 -1
View File
@@ -1,6 +1,6 @@
--- ---
name: stash-ui 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. description: Use, extend, or restyle the Stash UI component library (@stash/ui): a zero-dependency React design system with tokens, 51 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 # Stash UI
+58
View File
@@ -0,0 +1,58 @@
.root {
display: inline-flex;
align-items: center;
gap: var(--su-space-2);
color: var(--su-text-main);
text-decoration: none;
}
.mark {
display: grid;
place-items: center;
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: var(--su-radius-pill);
/* 前景取卡片底色而不是写死白色:深色下 text-main 变成近白色,
再配白字就整块糊掉了 */
background-color: var(--su-text-main);
color: var(--su-bg-card);
font-size: var(--su-font-size-base);
font-weight: var(--su-font-weight-strong);
}
.logo {
flex-shrink: 0;
width: 32px;
height: 32px;
border-radius: var(--su-radius-sm);
object-fit: contain;
}
.name {
font-size: var(--su-font-size-lg);
line-height: var(--su-line-height-lg);
letter-spacing: 0.04em;
}
/* 小号:标记 24,文字回到正文尺寸 */
.sm .mark,
.sm .logo {
width: 24px;
height: 24px;
}
.sm .mark {
font-size: var(--su-font-size-sm);
}
.sm .name {
font-size: var(--su-font-size-base);
line-height: var(--su-line-height-base);
}
/* 浅底标记:主色字 + 主色柔和底 */
.soft .mark {
background-color: var(--su-color-primary-soft);
color: var(--su-color-primary);
}
+66
View File
@@ -0,0 +1,66 @@
import styles from './Brand.module.css'
/**
* 站点品牌标识:图片 logo 或首字圆形标记 + 品牌文字。
*
* 只负责「长什么样」:传了 `href` 就是链接,不传渲染成纯展示元素,路由由使用方决定。
*/
export type BrandProps = {
/** 品牌文字;留空则只显示标记 */
name?: string
/** 没有图片时圆形标记里的字(1–4 个),留空取 name 首字 */
mark?: string
/** logo 图片地址;留空用 `mark` */
logo?: string
/** 点击跳转地址;不传则不渲染成链接 */
href?: string
/** 标记尺寸:md 32px / sm 24px */
size?: 'sm' | 'md'
/** 标记配色:solid 深底白字(默认)/ soft 主色浅底 */
tone?: 'solid' | 'soft'
/** 链接的无障碍名称,默认 `${name} 首页` */
label?: string
className?: string
}
export function Brand({
name = '',
mark = '',
logo = '',
href,
size = 'md',
tone = 'solid',
label,
className,
}: BrandProps) {
const brandName = name.trim()
const markText = mark.trim() || brandName.slice(0, 1) || 'S'
const classes = [styles.root, styles[size], styles[tone], className].filter(Boolean).join(' ')
const content = (
<>
{logo.trim() !== '' ? (
<img className={styles.logo} src={logo} alt="" />
) : (
<span className={styles.mark} aria-hidden="true">
{markText}
</span>
)}
{brandName !== '' && <span className={styles.name}>{brandName}</span>}
</>
)
if (href === undefined) {
return (
<span className={classes} aria-label={label ?? brandName}>
{content}
</span>
)
}
return (
<a className={classes} href={href} aria-label={label ?? `${brandName || '首页'} 首页`}>
{content}
</a>
)
}
+25
View File
@@ -0,0 +1,25 @@
/* 折角箭头:左 + 下两条边旋转而成,收起朝下、展开朝上 */
.root {
display: inline-block;
flex-shrink: 0;
width: 6px;
height: 6px;
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);
}
.open {
opacity: 0.7;
transform: rotate(135deg);
}
@media (prefers-reduced-motion: reduce) {
.root {
transition: none;
}
}
+27
View File
@@ -0,0 +1,27 @@
import styles from './Caret.module.css'
/**
* 折叠箭头:一个 6px 的 CSS 折角,表示「可展开 / 已展开」。
*
* Menu、Select、可折叠表格行里的箭头都用它,保证画法一致;
* 纯装饰,`aria-hidden`,无障碍状态由外层按钮的 `aria-expanded` 承担。
*/
export type CaretProps = {
/** 展开状态:收起朝下、展开朝上 */
open?: boolean
/** 尺寸,默认 6px */
size?: number
className?: string
}
export function Caret({ open = false, size, className }: CaretProps) {
return (
<span
className={[styles.root, open ? styles.open : undefined, className]
.filter(Boolean)
.join(' ')}
style={size === undefined ? undefined : { width: size, height: size }}
aria-hidden="true"
/>
)
}
+33
View File
@@ -0,0 +1,33 @@
.root {
display: flex;
align-items: center;
gap: var(--su-space-4);
padding: var(--su-space-2) 0;
border-radius: var(--su-radius-sm);
color: inherit;
text-decoration: none;
}
.root:hover .title {
color: var(--su-color-primary);
}
.info {
display: flex;
flex-direction: column;
gap: var(--su-space-1);
min-width: 0;
}
.titleRow {
display: inline-flex;
align-items: center;
gap: var(--su-space-2);
min-width: 0;
}
@media (prefers-reduced-motion: reduce) {
.root .title {
transition: none;
}
}
+69
View File
@@ -0,0 +1,69 @@
import type { ReactNode } from 'react'
import { Avatar } from './Avatar'
import { Text } from './Typography'
import styles from './EntryRow.module.css'
/**
* 条目行:头像 + 标题(可带角标)+ 单行描述,整行可点。
*
* 点击语义跟站点里的其它卡片一致:不带修饰键时接管点击(走 SPA 路由),
* 按住 Ctrl/Cmd/Shift/Alt 时保留浏览器默认行为(新标签打开原始链接)。
*/
export type EntryRowProps = {
title: string
description?: string
/** 不带修饰键点击时跳转的地址 */
href?: string
/** 头像图片;不传时用 fallback 首字 */
avatar?: string
fallback?: string
/** 头像右下角的认证勾 */
verified?: boolean
/** 标题右侧的角标,例如 `<ItemBadge tone="hot">HOT</ItemBadge>` */
badge?: ReactNode
/** 接管点击;不传则保持普通链接行为 */
onOpen?: () => void
className?: string
}
export function EntryRow({
title,
description,
href = '#',
avatar,
fallback,
verified = false,
badge,
onOpen,
className,
}: EntryRowProps) {
return (
<a
className={[styles.root, className].filter(Boolean).join(' ')}
href={href}
title={title}
onClick={(event) => {
if (onOpen === undefined) return
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
event.preventDefault()
onOpen()
}}
>
<Avatar size="lg" tone="primary" src={avatar} fallback={fallback} verified={verified} />
<span className={styles.info}>
<span className={styles.titleRow}>
<Text strong truncate className={styles.title}>
{title}
</Text>
{badge}
</span>
{description !== undefined && description !== '' && (
<Text size="xs" tone="muted" truncate>
{description}
</Text>
)}
</span>
</a>
)
}
+40
View File
@@ -0,0 +1,40 @@
.root {
overflow: hidden;
transition: transform var(--su-duration-fast) var(--su-ease-in-out);
}
/* 悬停时整张卡浮起 2px,与 Cover 的 lift 是同一套手感 */
.root:hover {
transform: translateY(-2px);
}
.body {
display: flex;
flex-direction: column;
gap: var(--su-space-1);
padding: var(--su-space-3);
text-align: left;
}
.bodyButton {
width: 100%;
border: none;
background: none;
font: inherit;
color: inherit;
cursor: pointer;
}
.bodyButton:hover {
background-color: var(--su-bg-subtle);
}
@media (prefers-reduced-motion: reduce) {
.root {
transition: none;
}
.root:hover {
transform: none;
}
}
+57
View File
@@ -0,0 +1,57 @@
import { Card } from './Card'
import { Cover } from './Cover'
import { Text } from './Typography'
import styles from './EntryTile.module.css'
/**
* 条目磁贴:上图下文的白卡,整张卡可点。
*
* 与 `EntryRow` 的区别是版式(横向一行 / 纵向一卡),交互语义一致:
* 不带修饰键时接管点击,按住修饰键保留浏览器默认行为。
*/
export type EntryTileProps = {
title: string
description?: string
cover?: string
/** 封面宽高比,默认 16 / 10 */
ratio?: number
/** 点击整张卡;不传则只是展示 */
onOpen?: () => void
className?: string
}
export function EntryTile({
title,
description,
cover,
ratio = 16 / 10,
onOpen,
className,
}: EntryTileProps) {
const body = (
<>
<Text strong truncate>
{title}
</Text>
{description !== undefined && description !== '' && (
<Text size="xs" tone="muted" truncate>
{description}
</Text>
)}
</>
)
return (
<Card padding="none" hoverable className={[styles.root, className].filter(Boolean).join(' ')}>
<Cover src={cover} alt={title} ratio={ratio} radius="none" onClick={onOpen} />
{onOpen === undefined ? (
<div className={styles.body}>{body}</div>
) : (
<button type="button" className={`${styles.body} ${styles.bodyButton}`} title={title} onClick={onOpen}>
{body}
</button>
)}
</Card>
)
}
+7
View File
@@ -82,6 +82,13 @@
border-bottom: 1px solid var(--su-border); border-bottom: 1px solid var(--su-border);
} }
/* 页头与页身之间的工具条:宽度随容器(contained 时与两栏同宽),
在正常流里占位,不参与 body 的内部滚动(screen 模式下就是「固定带」)。 */
.toolbar {
flex-shrink: 0;
width: 100%;
}
.body { .body {
display: flex; display: flex;
flex: 1; flex: 1;
+8
View File
@@ -15,6 +15,11 @@ import styles from './Layout.module.css'
export type LayoutProps = { export type LayoutProps = {
/** 页头:整宽、默认吸顶 */ /** 页头:整宽、默认吸顶 */
header?: ReactNode header?: ReactNode
/**
* 工具条:页头与页身之间的一条带(搜索、筛选、面包屑下方的操作)。
* 宽度跟随容器(`contained` 时与侧栏 + 内容同宽),不参与内容区滚动。
*/
toolbar?: ReactNode
/** 左侧栏:固定宽度、与内容并排 */ /** 左侧栏:固定宽度、与内容并排 */
sider?: ReactNode sider?: ReactNode
/** 右侧栏:固定宽度、与内容并排(资料聚合、目录、广告位) */ /** 右侧栏:固定宽度、与内容并排(资料聚合、目录、广告位) */
@@ -67,6 +72,7 @@ export type LayoutProps = {
export function Layout({ export function Layout({
header, header,
toolbar,
sider, sider,
aside, aside,
footer, footer,
@@ -124,6 +130,8 @@ export function Layout({
)} )}
<div className={contained ? styles.container : undefined} style={containerStyle}> <div className={contained ? styles.container : undefined} style={containerStyle}>
{toolbar !== undefined && <div className={styles.toolbar}>{toolbar}</div>}
<div className={styles.body}> <div className={styles.body}>
{sider !== undefined && ( {sider !== undefined && (
<aside <aside
+1 -15
View File
@@ -64,30 +64,16 @@
white-space: nowrap; white-space: nowrap;
} }
/* 折叠箭头用两道边框画(6×6 V 形),和参考站一致 */ /* 折叠箭头交给 Caret 组件画,这里只留间距与 hover */
.caret { .caret {
flex-shrink: 0; flex-shrink: 0;
width: 6px;
height: 6px;
margin-right: 2px; 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 { .item:hover .caret {
opacity: 0.7; opacity: 0.7;
} }
.caretOpen {
opacity: 0.7;
transform: rotate(135deg);
}
/* 子菜单展开:grid-template-rows 0fr → 1fr 做高度动画,不猜 max-height */ /* 子菜单展开:grid-template-rows 0fr → 1fr 做高度动画,不猜 max-height */
.submenuWrap { .submenuWrap {
display: grid; display: grid;
+2 -4
View File
@@ -1,4 +1,5 @@
import { useState, type CSSProperties, type ReactNode } from 'react' import { useState, type CSSProperties, type ReactNode } from 'react'
import { Caret } from './Caret'
import styles from './Menu.module.css' import styles from './Menu.module.css'
/** /**
@@ -71,10 +72,7 @@ export function Menu({
</span> </span>
)} )}
<span className={styles.label}>{item.label}</span> <span className={styles.label}>{item.label}</span>
<span <Caret open={open} className={styles.caret} />
className={`${styles.caret}${open ? ` ${styles.caretOpen}` : ''}`}
aria-hidden="true"
/>
</button> </button>
) : ( ) : (
<button <button
+11
View File
@@ -0,0 +1,11 @@
.root {
width: 100%;
}
/* card 变体:内层就是库里的 Input,只把描边/圆角/阴影换成本站点「悬浮搜索条」的样子。
用 .root 派生选择器提高权重,保证盖过 Input 自己的 .root。 */
.root .card {
border-color: var(--su-border);
border-radius: var(--su-radius-md);
box-shadow: var(--su-shadow-card);
}
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, type Ref } from 'react'
import { Input } from './Input'
import styles from './SearchField.module.css'
/**
* 搜索框:带搜索图标、可清空、可挂快捷键提示。
*
* 与 `Input` 的区别是「语义」而不是外观:对外只给字符串(`value` / `onChange(string)`),
* 使用方不用再写 `event.target.value`;外观用 `variant` 切,不用改组件。
*/
export type SearchFieldProps = {
value: string
onChange: (value: string) => void
placeholder?: string
/** 右侧快捷键提示,如 `⌘K`(仅展示) */
shortcut?: string
/** 高度:36 / 44 */
size?: 'md' | 'lg'
/** plain = 与普通输入框一致;card = 实底 + 柔和阴影(悬浮搜索条的样子) */
variant?: 'plain' | 'card'
/** 最大宽度,默认 480 */
maxWidth?: number | string
/** 按下 Ctrl/Cmd + 该键时聚焦并全选(需要同时传 inputRef) */
hotkey?: string
/** Esc 清空,默认开 */
clearOnEscape?: boolean
inputRef?: Ref<HTMLInputElement>
/** 无障碍名称,默认取 placeholder */
label?: string
className?: string
id?: string
}
export function SearchField({
value,
onChange,
placeholder = '搜索…',
shortcut,
size = 'lg',
variant = 'plain',
maxWidth = 480,
hotkey,
clearOnEscape = true,
inputRef,
label,
className,
id,
}: SearchFieldProps) {
const classes = [styles.root, className].filter(Boolean).join(' ')
useEffect(() => {
if (hotkey === undefined || hotkey === '' || inputRef === undefined || inputRef === null) return
if (!('current' in inputRef)) return
const target = inputRef
const key = hotkey.toLowerCase()
const handleKeyDown = (event: KeyboardEvent) => {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== key) return
const element = target.current
if (element === null) return
event.preventDefault()
element.focus()
element.select()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [hotkey, inputRef])
return (
<div className={classes} style={{ maxWidth }}>
<Input
id={id}
inputRef={inputRef}
size={size}
icon="search_3_line"
value={value}
placeholder={placeholder}
aria-label={label ?? placeholder}
shortcut={shortcut}
clearable
onClear={() => onChange('')}
spellCheck={false}
className={variant === 'card' ? styles.card : undefined}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (clearOnEscape && event.key === 'Escape') onChange('')
}}
/>
</div>
)
}
+4 -5
View File
@@ -51,15 +51,14 @@
white-space: nowrap; white-space: nowrap;
} }
/* 折角箭头交给 Caret 组件画,这里只留间距与 hover */
.caret { .caret {
flex-shrink: 0; flex-shrink: 0;
font-size: var(--su-font-size-sm); margin-right: 2px;
color: var(--su-text-muted);
transition: transform var(--su-duration-base) var(--su-ease-in-out);
} }
.triggerOpen .caret { .trigger:hover .caret {
transform: rotate(180deg); opacity: 0.7;
} }
.list { .list {
+2 -1
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from 'react' import { useEffect, useRef, useState, type ReactNode } from 'react'
import { Caret } from './Caret'
import { Icon } from './Icon' import { Icon } from './Icon'
import styles from './Select.module.css' import styles from './Select.module.css'
@@ -105,7 +106,7 @@ export function Select({
<span className={selected === undefined ? styles.placeholder : styles.value}> <span className={selected === undefined ? styles.placeholder : styles.value}>
{selected?.label ?? placeholder} {selected?.label ?? placeholder}
</span> </span>
<Icon name="arrow_down_line" className={styles.caret} /> <Caret open={open} className={styles.caret} />
</button> </button>
{open && ( {open && (
+82
View File
@@ -0,0 +1,82 @@
.root {
display: flex;
flex-direction: column;
gap: var(--su-space-1);
}
.item {
display: flex;
align-items: center;
gap: var(--su-space-2);
width: 100%;
padding: var(--su-space-2) var(--su-space-3);
border: none;
border-radius: var(--su-radius-sm);
background-color: transparent;
color: var(--su-text-main);
font-size: var(--su-font-size-md);
text-align: left;
cursor: pointer;
transition: background-color var(--su-duration-fast) var(--su-ease-out);
}
.item:hover {
background-color: var(--su-bg-card);
}
.item:focus-visible {
outline: var(--su-focus-ring);
outline-offset: var(--su-focus-ring-offset);
}
.active {
background-color: var(--su-bg-card);
color: var(--su-color-primary);
font-weight: var(--su-font-weight-strong);
}
.dragging {
opacity: 0.45;
}
.over {
outline: 1px dashed var(--su-color-primary);
outline-offset: -1px;
}
.handle {
flex: none;
color: var(--su-text-placeholder);
font-size: var(--su-font-size-sm);
line-height: 1;
letter-spacing: -2px;
cursor: grab;
}
.label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
flex: none;
color: var(--su-text-muted);
font-size: var(--su-font-size-sm);
font-weight: var(--su-font-weight-normal);
}
.empty {
margin: 0;
padding: var(--su-space-2) var(--su-space-3);
color: var(--su-text-muted);
font-size: var(--su-font-size-sm);
}
@media (prefers-reduced-motion: reduce) {
.item {
transition: none;
}
}
+117
View File
@@ -0,0 +1,117 @@
import { useState, type ReactNode } from 'react'
import styles from './SortableList.module.css'
/**
* 可拖拽排序的列表:每行一个条目,拖动改变顺序,点击选中。
*
* 数据无关:条目由页面给(key / label / meta),顺序变化通过 `onReorder`
* 把新的 key 数组交回页面,由页面决定怎么保存。
* 键盘可达性由「选中 + 上移/下移按钮」这类配套交互负责——本组件只提供鼠标拖拽,
* 需要键盘排序时请在外面另配上移/下移控件。
*/
export type SortableItem = {
key: string
label: ReactNode
/** 右侧的次要信息,例如条数 */
meta?: ReactNode
}
export type SortableListProps = {
items: SortableItem[]
/** 当前选中项 */
activeKey?: string
onSelect: (key: string) => void
/** 拖拽结束后的新顺序(完整的 key 数组) */
onReorder: (keys: string[]) => void
/** 排序保存中:期间禁止再次拖动 */
disabled?: boolean
/** 列表为空时的提示,不传则不渲染空态 */
emptyText?: ReactNode
className?: string
}
export function SortableList({
items,
activeKey,
onSelect,
onReorder,
disabled = false,
emptyText,
className,
}: SortableListProps) {
const [dragKey, setDragKey] = useState<string | null>(null)
const [overKey, setOverKey] = useState<string | null>(null)
const drop = (targetKey: string) => {
const from = dragKey
setDragKey(null)
setOverKey(null)
if (from === null || from === targetKey) return
const keys = items.map((item) => item.key)
const fromIndex = keys.indexOf(from)
const targetIndex = keys.indexOf(targetKey)
if (fromIndex < 0 || targetIndex < 0) return
const next = keys.filter((key) => key !== from)
const targetAfter = next.indexOf(targetKey)
// 向下拖插到目标之后(这样才能拖到列表末尾),向上拖插到目标之前
const insertAt = fromIndex < targetIndex ? targetAfter + 1 : targetAfter
next.splice(insertAt, 0, from)
if (next.every((key, index) => key === keys[index])) return
onReorder(next)
}
return (
<div className={[styles.root, className].filter(Boolean).join(' ')}>
{items.map((item) => {
const active = item.key === activeKey
const dragging = item.key === dragKey
const over = item.key === overKey && dragKey !== null && dragKey !== item.key
return (
<button
key={item.key}
type="button"
draggable={!disabled}
aria-current={active ? 'true' : undefined}
className={[styles.item, active ? styles.active : undefined, dragging ? styles.dragging : undefined, over ? styles.over : undefined]
.filter(Boolean)
.join(' ')}
onClick={() => onSelect(item.key)}
onDragStart={(event) => {
setDragKey(item.key)
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', item.key)
}}
onDragOver={(event) => {
if (disabled) return
event.preventDefault()
if (overKey !== item.key) setOverKey(item.key)
}}
onDrop={(event) => {
event.preventDefault()
if (disabled) return
drop(item.key)
}}
onDragEnd={() => {
setDragKey(null)
setOverKey(null)
}}
>
<span className={styles.handle} aria-hidden="true">
⋮⋮
</span>
<span className={styles.label}>{item.label}</span>
{item.meta !== undefined && <span className={styles.meta}>{item.meta}</span>}
</button>
)
})}
{items.length === 0 && emptyText !== undefined && (
<p className={styles.empty}>{emptyText}</p>
)}
</div>
)
}
+6
View File
@@ -41,3 +41,9 @@
color: var(--su-text-muted); color: var(--su-text-muted);
text-align: center; text-align: center;
} }
/* 紧凑档:行高收一档,窄面板里五列也能放下 */
.sm th,
.sm .cell {
padding: var(--su-space-2) var(--su-space-4);
}
+4 -1
View File
@@ -20,6 +20,8 @@ export type TableProps = {
rows: Record<string, ReactNode>[] rows: Record<string, ReactNode>[]
/** 行的唯一键字段,默认 `id` */ /** 行的唯一键字段,默认 `id` */
rowKey?: string rowKey?: string
/** 行高密度:md 常规、sm 紧凑(后台配置表用) */
size?: 'md' | 'sm'
emptyText?: ReactNode emptyText?: ReactNode
className?: string className?: string
} }
@@ -28,11 +30,12 @@ export function Table({
columns, columns,
rows, rows,
rowKey = 'id', rowKey = 'id',
size = 'md',
emptyText = '暂无数据', emptyText = '暂无数据',
className, className,
}: TableProps) { }: TableProps) {
return ( return (
<div className={[styles.wrap, className].filter(Boolean).join(' ')}> <div className={[styles.wrap, styles[size], className].filter(Boolean).join(' ')}>
<table className={styles.table}> <table className={styles.table}>
<thead> <thead>
<tr> <tr>
+47
View File
@@ -0,0 +1,47 @@
.root {
display: inline-flex;
align-items: center;
gap: var(--su-space-1);
padding: 0;
border: none;
background: none;
font-size: var(--su-font-size-sm);
line-height: var(--su-line-height-sm);
white-space: nowrap;
cursor: pointer;
transition: opacity var(--su-duration-fast) var(--su-ease-out);
}
.primary {
color: var(--su-color-primary);
}
.danger {
color: var(--su-color-danger);
}
.root:hover:not(:disabled) {
text-decoration: underline;
}
.root:focus-visible {
border-radius: var(--su-radius-xs);
outline: var(--su-focus-ring);
outline-offset: var(--su-focus-ring-offset);
}
.root:disabled {
color: var(--su-text-disabled);
cursor: not-allowed;
}
.icon {
display: inline-flex;
font-size: var(--su-font-size-md);
}
@media (prefers-reduced-motion: reduce) {
.root {
transition: none;
}
}
+54
View File
@@ -0,0 +1,54 @@
import type { ReactNode } from 'react'
import styles from './TextButton.module.css'
/**
* 文字按钮:看起来像链接,行为像按钮。
*
* 用于表格行内操作(编辑 / 删除 / 新建子项)这类「不需要按钮外框」的地方。
* 需要底色或图标的独立操作请用 `Button` / `IconButton`,不要拿它当主按钮。
*/
export type TextButtonTone = 'primary' | 'danger'
export type TextButtonProps = {
children: ReactNode
/** 前置图标,一般传 `<Icon name="edit_4_line" />` */
icon?: ReactNode
/** 主色(默认)/ 危险色 */
tone?: TextButtonTone
disabled?: boolean
onClick?: () => void
/** 悬停提示;行内只有图标时用它补充说明 */
title?: string
/** 无障碍名称,文字已经说清楚时可省略 */
label?: string
className?: string
}
export function TextButton({
children,
icon,
tone = 'primary',
disabled = false,
onClick,
title,
label,
className,
}: TextButtonProps) {
return (
<button
type="button"
className={[styles.root, styles[tone], className].filter(Boolean).join(' ')}
disabled={disabled}
onClick={onClick}
title={title}
aria-label={label}
>
{icon !== undefined && (
<span className={styles.icon} aria-hidden="true">
{icon}
</span>
)}
{children}
</button>
)
}
+53
View File
@@ -0,0 +1,53 @@
/* 跑马灯外壳:铺满可用宽度、两端渐隐,内部三份内容依次排列 */
.root {
flex: 1;
min-width: 0;
overflow: hidden;
mask-image: linear-gradient(
90deg,
transparent 0,
#000 var(--su-space-8),
#000 calc(100% - var(--su-space-8)),
transparent 100%
);
}
.track {
display: flex;
width: max-content;
animation-name: ticker;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
.paused {
animation-play-state: paused;
}
/* 一份内容:按 --ticker-gap 排布,尾部同样留一个间距,
这样「最后一条 → 下一份第一条」的距离和条目之间一致,循环处不会突然变宽 */
.group {
display: inline-flex;
flex: none;
align-items: center;
justify-content: flex-start;
gap: var(--ticker-gap, var(--su-space-8));
padding-right: var(--ticker-gap, var(--su-space-8));
}
/* 位移量由组件量出「一份」的宽度写进 --ticker-shift;回退值只兜第一帧 */
@keyframes ticker {
from {
transform: translateX(0);
}
to {
transform: translateX(var(--ticker-shift, -33.3333%));
}
}
@media (prefers-reduced-motion: reduce) {
.track {
animation: none;
}
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useRef, type CSSProperties, type ReactNode } from 'react'
import styles from './Ticker.module.css'
/**
* 跑马灯:把一组内容横向无限滚动,首尾相接。
*
* 内部渲染三份相同内容,按「一份」的实测宽度做位移,所以循环处没有跳变;
* 容器宽度变化会重新测量。内容仍由使用方给(这里是外壳,不是排版组件)。
*/
export type TickerProps = {
children: ReactNode
/** 滚完一份内容所需的秒数,默认 24(越大越慢) */
speed?: number
/** 内容条目之间的间距(px),默认用 --su-space-8 */
gap?: number
/** 暂停滚动(保留进度) */
paused?: boolean
/** 读屏用的整体描述,如「运行状态」 */
label?: string
/** 内容组之间的间距,默认 --su-space-8 */
className?: string
}
export function Ticker({
children,
speed = 24,
gap,
paused = false,
label,
className,
}: TickerProps) {
const trackRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const track = trackRef.current
if (track === null) return
// 位移必须等于「一份」的真实宽度:CSS 百分比是按轨道的 border-box 算的,
// 轨道宽度又由内容撑开,容易算错,所以直接量。
const sync = () => {
const group = track.firstElementChild
if (!(group instanceof HTMLElement)) return
track.style.setProperty('--ticker-shift', `${-group.getBoundingClientRect().width}px`)
}
sync()
const observer = new ResizeObserver(sync)
observer.observe(track)
window.addEventListener('resize', sync)
return () => {
observer.disconnect()
window.removeEventListener('resize', sync)
}
}, [children, speed])
const trackClasses = [styles.track, paused ? styles.paused : undefined].filter(Boolean).join(' ')
return (
<div
className={[styles.root, className].filter(Boolean).join(' ')}
role="marquee"
aria-label={label}
style={gap === undefined ? undefined : ({ '--ticker-gap': `${gap}px` } as CSSProperties)}
>
<div
ref={trackRef}
className={trackClasses}
style={{ animationDuration: `${speed}s` }}
>
<div className={styles.group}>{children}</div>
<div className={styles.group} aria-hidden="true">
{children}
</div>
<div className={styles.group} aria-hidden="true">
{children}
</div>
</div>
</div>
)
}
+14
View File
@@ -0,0 +1,14 @@
.root {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--su-space-3);
min-height: var(--su-control-height-md);
}
.text {
display: flex;
flex-direction: column;
gap: var(--su-space-1);
min-width: 0;
}
+55
View File
@@ -0,0 +1,55 @@
import type { ReactNode } from 'react'
import { Switch } from './Switch'
import { Text } from './Typography'
import styles from './ToggleRow.module.css'
/**
* 开关行:左边一行说明文字,右边一个开关,两端对齐。
*
* 设置面板里「每行一个开关」的场景用它,比自己拼 `Space justify="between"` 更整齐。
* 开关旁边没有文字标签时,`switchLabel` 必填(会进 aria-label)。
*/
export type ToggleRowProps = {
/** 显示的标签 */
label: ReactNode
checked: boolean
onChange: (checked: boolean) => void
/** 屏幕阅读器读到的开关名称,默认取 label 的文本 */
switchLabel?: string
disabled?: boolean
/** 标签下方的补充说明 */
hint?: ReactNode
className?: string
}
export function ToggleRow({
label,
checked,
onChange,
switchLabel,
disabled = false,
hint,
className,
}: ToggleRowProps) {
const accessibleLabel =
switchLabel ?? (typeof label === 'string' ? label : '开关')
return (
<div className={[styles.root, className].filter(Boolean).join(' ')}>
<span className={styles.text}>
<Text>{label}</Text>
{hint !== undefined && (
<Text size="xs" tone="muted">
{hint}
</Text>
)}
</span>
<Switch
checked={checked}
onChange={onChange}
disabled={disabled}
label={accessibleLabel}
/>
</div>
)
}
+27
View File
@@ -28,6 +28,33 @@ export type { CheckboxProps } from './components/Checkbox'
export { Card } from './components/Card' export { Card } from './components/Card'
export type { CardProps } from './components/Card' export type { CardProps } from './components/Card'
export { Brand } from './components/Brand'
export type { BrandProps } from './components/Brand'
export { SearchField } from './components/SearchField'
export type { SearchFieldProps } from './components/SearchField'
export { Ticker } from './components/Ticker'
export type { TickerProps } from './components/Ticker'
export { EntryRow } from './components/EntryRow'
export type { EntryRowProps } from './components/EntryRow'
export { EntryTile } from './components/EntryTile'
export type { EntryTileProps } from './components/EntryTile'
export { TextButton } from './components/TextButton'
export type { TextButtonProps, TextButtonTone } from './components/TextButton'
export { ToggleRow } from './components/ToggleRow'
export type { ToggleRowProps } from './components/ToggleRow'
export { Caret } from './components/Caret'
export type { CaretProps } from './components/Caret'
export { SortableList } from './components/SortableList'
export type { SortableListProps, SortableItem } from './components/SortableList'
export { Cover } from './components/Cover' export { Cover } from './components/Cover'
export type { CoverProps, CoverRatio } from './components/Cover' export type { CoverProps, CoverRatio } from './components/Cover'
+3
View File
@@ -128,6 +128,8 @@
--su-font-size-base: 16px; --su-font-size-base: 16px;
--su-font-size-lg: 18px; --su-font-size-lg: 18px;
--su-font-size-xl: 24px; --su-font-size-xl: 24px;
/* 展示级字号:封面占位字、引号这类装饰性大字,不用来排版正文 */
--su-font-size-2xl: 32px;
--su-line-height-xs: 16px; --su-line-height-xs: 16px;
--su-line-height-sm: 20px; --su-line-height-sm: 20px;
@@ -135,6 +137,7 @@
--su-line-height-base: 24px; --su-line-height-base: 24px;
--su-line-height-lg: 26px; --su-line-height-lg: 26px;
--su-line-height-xl: 32px; --su-line-height-xl: 32px;
--su-line-height-2xl: 40px;
/* 主题的「加粗」是 500 */ /* 主题的「加粗」是 500 */
--su-font-weight-normal: 400; --su-font-weight-normal: 400;