Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba41cdf52e | ||
|
|
5eb1506650 | ||
|
|
cc35400f67 | ||
|
|
851b36abf1 | ||
|
|
d5b7132605 | ||
|
|
17a09f22b6 | ||
|
|
50056a7154 | ||
|
|
707cf98fe0 | ||
|
|
93bb5cbb28 |
@@ -90,6 +90,7 @@ L4 ──→ L3 ──→ L1
|
||||
| `--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`) |
|
||||
| `--su-font-size-2xl` / `--su-line-height-2xl` | 32 / 40 | 展示级装饰大字(封面占位字、引号),不参与正文排版 |
|
||||
|
||||
> 参考站的实际排版是 `13/20`、`14/22`、`16/24`、`18/26`、body `16/24`。
|
||||
> 组件库把「控件文字」定在 14、把「内容正文」定在 16:控件更紧凑,文章更好读。
|
||||
|
||||
@@ -77,7 +77,7 @@ stash-ui/
|
||||
│ │ ├── icons.ts # 446 个图标名(自动生成)
|
||||
│ │ ├── iconfont.css # 图标字体与字形(自动生成)
|
||||
│ │ └── assets/iconfont.woff2
|
||||
│ └── components/ # 37 个组件,每个 = Xxx.tsx + Xxx.module.css(见下方清单)
|
||||
│ └── components/ # 51 个组件,每个 = Xxx.tsx + Xxx.module.css(见下方清单)
|
||||
├── site/ # 展示站(不参与库构建)
|
||||
│ ├── index.html / demo.html # 文档站 / 整页示例
|
||||
│ ├── App.tsx # 左侧目录 + 右侧示例
|
||||
@@ -95,7 +95,13 @@ stash-ui/
|
||||
| --- | --- | --- |
|
||||
| `Button` | 操作按钮(圆角 / 胶囊,支持描边) | `variant` `size` `shape` `block` `loading` `icon` |
|
||||
| `IconButton` | 圆形图标按钮(28/36/44) | `label`(必填) `icon` `variant` `size` `spinning` |
|
||||
| `TextButton` | 文字按钮(行内操作,无外框) | `icon` `tone` `disabled` `title` `label` |
|
||||
| `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` |
|
||||
| `Image` | 带比例 / 圆角 / 失败降级的图片 | `ratio` `fit` `radius` `fallback` |
|
||||
| `FormField` | 表单字段排版(标签 + 提示 / 错误) | `label` `hint` `error` `required` |
|
||||
@@ -103,14 +109,17 @@ stash-ui/
|
||||
| `Skeleton` | 骨架屏占位 | `variant` `lines` `width` `height` |
|
||||
| `Spinner` | 加载指示 | `size` `label` |
|
||||
| `Switch` | 即时开关 | `checked` `onChange` `size` `label` |
|
||||
| `ToggleRow` | 开关行(左标签 + 右开关) | `label` `checked` `onChange` `hint` `switchLabel` |
|
||||
| `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` |
|
||||
| `Caret` | 折叠箭头(6px 折角) | `open` `size` |
|
||||
| `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` |
|
||||
| `ItemBadge` | 状态角标:置顶 / 付费 / HOT | `tone` |
|
||||
| `Badge` | 数字角标 | `count` `tone` `max` |
|
||||
|
||||
+116
-3
@@ -19,25 +19,34 @@
|
||||
| `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` |
|
||||
| `TextButton` | L2 | ✅ | 文字按钮(行内操作,无外框) | `icon` `tone` `disabled` `title` `label` |
|
||||
| `Input` | L2 | ✅ | 组合式输入框 | `icon` `clearable` `onClear` `shortcut` `inputRef` |
|
||||
| `Textarea` | L2 | ✅ | 多行文本(Markdown / 简介) | `rows` `resize` `invalid` |
|
||||
| `PasswordInput` | L2 | ✅ | 密码输入(锁图标 + 显隐切换) | `icon` `size` `toggle` |
|
||||
| `Switch` | L2 | ✅ | 即时生效的开关 | `checked` `onChange` `size` `label` |
|
||||
| `ToggleRow` | L2 | ✅ | 开关行:左标签 + 右开关 | `label` `checked` `onChange` `hint` `switchLabel` |
|
||||
| `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` |
|
||||
| `Caret` | L1 | ✅ | 折叠箭头(6px 折角,收起/展开) | `open` `size` |
|
||||
| `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` |
|
||||
| `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` |
|
||||
| `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` |
|
||||
| `Table` | L3 | ✅ | 只读数据表格 | `columns` `rows` `rowKey` `size` `emptyText` |
|
||||
| `SortableList` | L3 | ✅ | 可拖拽排序的条目列表 | `items` `activeKey` `onSelect` `onReorder` `disabled` |
|
||||
| `Notice` | L4 | ✅ | 说明 / 错误提示条 | `tone` `title` `action` |
|
||||
| `Empty` | L4 | ✅ | 空态 | `text` `action` |
|
||||
| `Result` | L4 | ✅ | 整块结果反馈 | `status` `title` `description` `actions` |
|
||||
@@ -443,8 +452,8 @@
|
||||
### 3.30 Table ✅
|
||||
|
||||
- **用途**:数据表格(只读)。
|
||||
- **Props**:`columns: { key, title, width?, align? }[]`、`rows: Record<string, ReactNode>[]`、`rowKey`(默认 `id`)、`emptyText`。
|
||||
- **规则**:排序 / 筛选 / 分页由页面组合,组件不内置交互;空数据渲染 `emptyText`。
|
||||
- **Props**:`columns: { key, title, width?, align? }[]`、`rows: Record<string, ReactNode>[]`、`rowKey`(默认 `id`)、`size: 'md' | 'sm'`(行高密度,默认 `md`)、`emptyText`。
|
||||
- **规则**:排序 / 筛选 / 分页由页面组合,组件不内置交互;空数据渲染 `emptyText`;单元格是任意 `ReactNode`,行内操作直接用 `TextButton` / `IconButton`。
|
||||
- **展示页**:`#table`
|
||||
|
||||
### 3.31 Result ✅
|
||||
@@ -500,6 +509,7 @@
|
||||
| prop | 类型 | 默认 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| `header` | `ReactNode` | — | 页头,默认吸顶(半透明 + 模糊) |
|
||||
| `toolbar` | `ReactNode` | — | 页头与页身之间的工具条(搜索 / 筛选 / 操作带),宽度随容器,不参与内容区滚动 |
|
||||
| `sider` | `ReactNode` | — | 左侧栏,固定宽度 |
|
||||
| `aside` | `ReactNode` | — | 右侧栏,固定宽度(目录 / 聚合 / 广告位) |
|
||||
| `footer` | `ReactNode` | — | 页脚 |
|
||||
@@ -522,6 +532,7 @@
|
||||
| `contentId` | `string` | — | 内容区 id(供 ScrollTop 等按 id 滚动) |
|
||||
|
||||
- **规则**:Layout 不写业务内容,也不带背景装饰(需要渐变/光晕时由页面自己放绝对定位层);响应式在组件内处理——≤1200px 右栏堆到内容下方、≤1100px 侧栏堆到内容上方,**内容都不隐藏**。
|
||||
- **`toolbar` 与 `contentOffset` 的分工**:`toolbar` 是**真实占位**的一层,搜索框照常参与文档流,侧栏与内容的顶边天然对齐(推荐);`contentOffset` 只用于「浮在页面上、不能占位」的场景(悬浮条、绝对定位的返回按钮),不要把两者叠加使用。
|
||||
- **常用配方**:顶栏+内容(首页)、左栏+内容(管理页)、内容+右栏(文章)、三栏(工作台)、窄内容(设置/阅读),展示页用 `Segmented` 切换。
|
||||
- **展示页**:`#layout`
|
||||
|
||||
@@ -599,6 +610,102 @@
|
||||
- **规则**:`onChange` 是原生事件(与 `Input` 一致),Enter 提交用 `onKeyDown`;切换按钮必须带 `aria-pressed` 与「显示/隐藏密码」名称,焦点环不得移除。
|
||||
- **展示页**:`#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. 组合模式(不进库,但必须照这个拼)
|
||||
@@ -700,3 +807,9 @@
|
||||
| 2026-10-29 | 新增 `Textarea`(`rows` / `resize` / `invalid`),与 `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-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` 的箭头外观不变 |
|
||||
|
||||
@@ -13,8 +13,10 @@ import { Icons } from './sections/Icons'
|
||||
import { Images } from './sections/Images'
|
||||
import { Inputs } from './sections/Inputs'
|
||||
import { Layouts } from './sections/Layouts'
|
||||
import { SiteChrome } from './sections/SiteChrome'
|
||||
import { Marks } from './sections/Marks'
|
||||
import { MenuSection } from './sections/MenuSection'
|
||||
import { Sortables } from './sections/Sortables'
|
||||
import { Modals } from './sections/Modals'
|
||||
import { Overview } from './sections/Overview'
|
||||
import { PageLayout } from './sections/PageLayout'
|
||||
@@ -82,6 +84,7 @@ const NAV = [
|
||||
{ id: 'breadcrumb', label: '面包屑 Breadcrumb' },
|
||||
{ id: 'tabs', label: '标签页 Tabs' },
|
||||
{ id: 'menu', label: '菜单 Menu' },
|
||||
{ id: 'sortable', label: '排序列表 SortableList' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -103,6 +106,7 @@ const NAV = [
|
||||
items: [
|
||||
{ id: 'layout', label: '页面骨架 Layout' },
|
||||
{ id: 'layouts', label: '网格与间距' },
|
||||
{ id: 'site-chrome', label: '站点外壳' },
|
||||
{ id: 'composition', label: '组合示例' },
|
||||
],
|
||||
},
|
||||
@@ -206,6 +210,7 @@ export function App() {
|
||||
<Marks />
|
||||
<Avatars />
|
||||
<Tables />
|
||||
<Sortables />
|
||||
<Results />
|
||||
<Progresses />
|
||||
<StepsSection />
|
||||
@@ -229,6 +234,7 @@ export function App() {
|
||||
<Tooltips />
|
||||
<PageLayout />
|
||||
<Layouts />
|
||||
<SiteChrome />
|
||||
<Composition />
|
||||
</main>
|
||||
|
||||
|
||||
@@ -562,6 +562,19 @@ code {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
@@ -126,6 +126,27 @@ export function Buttons() {
|
||||
</Space>
|
||||
</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
|
||||
rows={[
|
||||
{ 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: '图标旋转' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
+39
-1
@@ -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 { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
@@ -94,12 +94,50 @@ export function Cards() {
|
||||
</Card>
|
||||
</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
|
||||
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: '布局微调,例如绝对定位角标' },
|
||||
{ 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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
@@ -79,6 +79,28 @@ export function MenuSection() {
|
||||
</Card>
|
||||
</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
|
||||
rows={[
|
||||
{ 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)' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'open', type: 'boolean', defaultValue: 'false', desc: '展开状态(决定箭头朝向)' },
|
||||
{ name: 'size', type: 'number', defaultValue: '6', desc: '折角边长(px)' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
@@ -214,9 +214,50 @@ export function PageLayout() {
|
||||
</div>
|
||||
</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
|
||||
rows={[
|
||||
{ name: 'header', type: 'ReactNode', desc: '页头,默认吸顶' },
|
||||
{ name: 'toolbar', type: 'ReactNode', desc: '页头与页身之间的工具条(搜索 / 筛选 / 操作带),宽度随容器' },
|
||||
{ name: 'sider', type: 'ReactNode', desc: '左侧栏,固定宽度' },
|
||||
{ name: 'aside', type: 'ReactNode', desc: '右侧栏,固定宽度(目录 / 聚合 / 广告位)' },
|
||||
{ name: 'footer', type: 'ReactNode', desc: '页脚' },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { Space, Switch, Text } from '../../src'
|
||||
import { Space, Switch, Text, ToggleRow } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
@@ -58,6 +58,24 @@ export function Switches() {
|
||||
</Space>
|
||||
</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
|
||||
rows={[
|
||||
{ name: 'checked', type: 'boolean', required: true, desc: '受控状态' },
|
||||
@@ -67,6 +85,17 @@ export function Switches() {
|
||||
{ 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -56,6 +56,15 @@ export function Tables() {
|
||||
<Table columns={COLUMNS} rows={ROWS} rowKey="id" />
|
||||
</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
|
||||
title="空态"
|
||||
desc="空数据时渲染默认文案;也可以传 emptyText 换一句更具体的说明。"
|
||||
@@ -70,6 +79,7 @@ export function Tables() {
|
||||
{ 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: 'size', type: "'md' | 'sm'", defaultValue: "'md'", desc: '行高密度' },
|
||||
{ name: 'emptyText', type: 'ReactNode', defaultValue: "'暂无数据'", desc: '空态文案' },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -82,6 +82,13 @@
|
||||
border-bottom: 1px solid var(--su-border);
|
||||
}
|
||||
|
||||
/* 页头与页身之间的工具条:宽度随容器(contained 时与两栏同宽),
|
||||
在正常流里占位,不参与 body 的内部滚动(screen 模式下就是「固定带」)。 */
|
||||
.toolbar {
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
@@ -15,6 +15,11 @@ import styles from './Layout.module.css'
|
||||
export type LayoutProps = {
|
||||
/** 页头:整宽、默认吸顶 */
|
||||
header?: ReactNode
|
||||
/**
|
||||
* 工具条:页头与页身之间的一条带(搜索、筛选、面包屑下方的操作)。
|
||||
* 宽度跟随容器(`contained` 时与侧栏 + 内容同宽),不参与内容区滚动。
|
||||
*/
|
||||
toolbar?: ReactNode
|
||||
/** 左侧栏:固定宽度、与内容并排 */
|
||||
sider?: ReactNode
|
||||
/** 右侧栏:固定宽度、与内容并排(资料聚合、目录、广告位) */
|
||||
@@ -67,6 +72,7 @@ export type LayoutProps = {
|
||||
|
||||
export function Layout({
|
||||
header,
|
||||
toolbar,
|
||||
sider,
|
||||
aside,
|
||||
footer,
|
||||
@@ -124,6 +130,8 @@ export function Layout({
|
||||
)}
|
||||
|
||||
<div className={contained ? styles.container : undefined} style={containerStyle}>
|
||||
{toolbar !== undefined && <div className={styles.toolbar}>{toolbar}</div>}
|
||||
|
||||
<div className={styles.body}>
|
||||
{sider !== undefined && (
|
||||
<aside
|
||||
|
||||
@@ -64,30 +64,16 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 折叠箭头用两道边框画(6×6 V 形),和参考站一致 */
|
||||
/* 折叠箭头交给 Caret 组件画,这里只留间距与 hover */
|
||||
.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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, type CSSProperties, type ReactNode } from 'react'
|
||||
import { Caret } from './Caret'
|
||||
import styles from './Menu.module.css'
|
||||
|
||||
/**
|
||||
@@ -71,10 +72,7 @@ export function Menu({
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.label}>{item.label}</span>
|
||||
<span
|
||||
className={`${styles.caret}${open ? ` ${styles.caretOpen}` : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Caret open={open} className={styles.caret} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -51,15 +51,14 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 折角箭头交给 Caret 组件画,这里只留间距与 hover */
|
||||
.caret {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-muted);
|
||||
transition: transform var(--su-duration-base) var(--su-ease-in-out);
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.triggerOpen .caret {
|
||||
transform: rotate(180deg);
|
||||
.trigger:hover .caret {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.list {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Caret } from './Caret'
|
||||
import { Icon } from './Icon'
|
||||
import styles from './Select.module.css'
|
||||
|
||||
@@ -105,7 +106,7 @@ export function Select({
|
||||
<span className={selected === undefined ? styles.placeholder : styles.value}>
|
||||
{selected?.label ?? placeholder}
|
||||
</span>
|
||||
<Icon name="arrow_down_line" className={styles.caret} />
|
||||
<Caret open={open} className={styles.caret} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -41,3 +41,9 @@
|
||||
color: var(--su-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* 紧凑档:行高收一档,窄面板里五列也能放下 */
|
||||
.sm th,
|
||||
.sm .cell {
|
||||
padding: var(--su-space-2) var(--su-space-4);
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ export type TableProps = {
|
||||
rows: Record<string, ReactNode>[]
|
||||
/** 行的唯一键字段,默认 `id` */
|
||||
rowKey?: string
|
||||
/** 行高密度:md 常规、sm 紧凑(后台配置表用) */
|
||||
size?: 'md' | 'sm'
|
||||
emptyText?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
@@ -28,11 +30,12 @@ export function Table({
|
||||
columns,
|
||||
rows,
|
||||
rowKey = 'id',
|
||||
size = 'md',
|
||||
emptyText = '暂无数据',
|
||||
className,
|
||||
}: TableProps) {
|
||||
return (
|
||||
<div className={[styles.wrap, className].filter(Boolean).join(' ')}>
|
||||
<div className={[styles.wrap, styles[size], className].filter(Boolean).join(' ')}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -28,6 +28,33 @@ export type { CheckboxProps } from './components/Checkbox'
|
||||
export { Card } 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 type { CoverProps, CoverRatio } from './components/Cover'
|
||||
|
||||
|
||||
@@ -128,6 +128,8 @@
|
||||
--su-font-size-base: 16px;
|
||||
--su-font-size-lg: 18px;
|
||||
--su-font-size-xl: 24px;
|
||||
/* 展示级字号:封面占位字、引号这类装饰性大字,不用来排版正文 */
|
||||
--su-font-size-2xl: 32px;
|
||||
|
||||
--su-line-height-xs: 16px;
|
||||
--su-line-height-sm: 20px;
|
||||
@@ -135,6 +137,7 @@
|
||||
--su-line-height-base: 24px;
|
||||
--su-line-height-lg: 26px;
|
||||
--su-line-height-xl: 32px;
|
||||
--su-line-height-2xl: 40px;
|
||||
|
||||
/* 主题的「加粗」是 500 */
|
||||
--su-font-weight-normal: 400;
|
||||
|
||||
Reference in New Issue
Block a user