diff --git a/DESIGN.md b/DESIGN.md index cb8492b..250addd 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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:控件更紧凑,文章更好读。 diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 9584265..dc9e6c0 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -30,6 +30,9 @@ | `Tabs` | L2 | ✅ | 页内分区切换 | `items` `activeKey` `onChange` `variant` | | `Menu` | L2 | ✅ | 侧栏两级菜单(可折叠动画) | `items` `activeKey` `onSelect` `itemHeight` | | `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` | | `Cover` | L3 | ✅ | 比例封面卡(2:3 / 4:3 / 16:9 + 标题遮罩) | `ratio` `src` `caption` `lift` `onClick` | @@ -601,6 +604,66 @@ - **规则**:`onChange` 是原生事件(与 `Input` 一致),Enter 提交用 `onKeyDown`;切换按钮必须带 `aria-pressed` 与「显示/隐藏密码」名称,焦点环不得移除。 - **展示页**:`#inputs` +### 3.43 Brand ✅ + +- **用途**:页头左侧的品牌标识——图片 logo 或首字圆形标记 + 品牌文字。 +- **不该用在哪**:头像(用 `Avatar`);纯文字标题(用 `Title`)。 +- **解剖**:`inline-flex` 容器;`logo` 有值时渲染 ``,否则渲染首字标记;`href` 有值时整体是 ``,否则是 ``。 +- **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` | — | 透传内部输入框 | + +- **规则**:`onChange` 给字符串而不是事件——这是它和 `Input` 唯一的语义差别,别在组件里做过滤/请求。 +- **展示页**:`#site-chrome` + --- ## 4. 组合模式(不进库,但必须照这个拼) @@ -703,3 +766,4 @@ | 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)。兼容性:纯新增,无破坏 | diff --git a/site/App.tsx b/site/App.tsx index b23bab7..9509497 100644 --- a/site/App.tsx +++ b/site/App.tsx @@ -13,6 +13,7 @@ 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 { Modals } from './sections/Modals' @@ -103,6 +104,7 @@ const NAV = [ items: [ { id: 'layout', label: '页面骨架 Layout' }, { id: 'layouts', label: '网格与间距' }, + { id: 'site-chrome', label: '站点外壳' }, { id: 'composition', label: '组合示例' }, ], }, @@ -229,6 +231,7 @@ export function App() { + diff --git a/site/sections/SiteChrome.tsx b/site/sections/SiteChrome.tsx new file mode 100644 index 0000000..9eaa0d7 --- /dev/null +++ b/site/sections/SiteChrome.tsx @@ -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 ( +
+ +`} + > +
+ + + + +
+
+ + + {items.map((text) => {text})} +`} + > +
+ + + {TICKER_ITEMS.map((text) => ( + + {text} + + ))} + +
+
+ + +`} + > + + + 当前输入:{keyword === '' ? '(空)' : keyword} + + + ,不传是 ' }, + { 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)' }, + ]} + /> +
+ ) +} diff --git a/src/components/Brand.module.css b/src/components/Brand.module.css new file mode 100644 index 0000000..28964b2 --- /dev/null +++ b/src/components/Brand.module.css @@ -0,0 +1,56 @@ +.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); + background-color: var(--su-text-main); + color: var(--su-white); + 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); +} diff --git a/src/components/Brand.tsx b/src/components/Brand.tsx new file mode 100644 index 0000000..157c979 --- /dev/null +++ b/src/components/Brand.tsx @@ -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() !== '' ? ( + + ) : ( + + )} + {brandName !== '' && {brandName}} + + ) + + if (href === undefined) { + return ( + + {content} + + ) + } + + return ( +
+ {content} + + ) +} diff --git a/src/components/SearchField.module.css b/src/components/SearchField.module.css new file mode 100644 index 0000000..4768eba --- /dev/null +++ b/src/components/SearchField.module.css @@ -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); +} diff --git a/src/components/SearchField.tsx b/src/components/SearchField.tsx new file mode 100644 index 0000000..cb6b06a --- /dev/null +++ b/src/components/SearchField.tsx @@ -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 + /** 无障碍名称,默认取 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 ( +
+ onChange('')} + spellCheck={false} + className={variant === 'card' ? styles.card : undefined} + onChange={(event) => onChange(event.target.value)} + onKeyDown={(event) => { + if (clearOnEscape && event.key === 'Escape') onChange('') + }} + /> +
+ ) +} diff --git a/src/components/Ticker.module.css b/src/components/Ticker.module.css new file mode 100644 index 0000000..dd01123 --- /dev/null +++ b/src/components/Ticker.module.css @@ -0,0 +1,51 @@ +/* 跑马灯外壳:铺满可用宽度、两端渐隐,内部三份内容依次排列 */ +.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; +} + +/* 一份内容最少占满整条轨道,内容短的时候也不会挤成一小撮 */ +.group { + display: inline-flex; + align-items: center; + justify-content: space-evenly; + gap: var(--su-space-8); + min-width: 100%; +} + +/* 位移量由组件量出「一份」的宽度写进 --ticker-shift;回退值只兜第一帧 */ +@keyframes ticker { + from { + transform: translateX(0); + } + + to { + transform: translateX(var(--ticker-shift, -33.3333%)); + } +} + +@media (prefers-reduced-motion: reduce) { + .track { + animation: none; + } +} diff --git a/src/components/Ticker.tsx b/src/components/Ticker.tsx new file mode 100644 index 0000000..65e1705 --- /dev/null +++ b/src/components/Ticker.tsx @@ -0,0 +1,70 @@ +import { useEffect, useRef, type ReactNode } from 'react' +import styles from './Ticker.module.css' + +/** + * 跑马灯:把一组内容横向无限滚动,首尾相接。 + * + * 内部渲染三份相同内容,按「一份」的实测宽度做位移,所以循环处没有跳变; + * 容器宽度变化会重新测量。内容仍由使用方给(这里是外壳,不是排版组件)。 + */ +export type TickerProps = { + children: ReactNode + /** 滚完一份内容所需的秒数,默认 24(越大越慢) */ + speed?: number + /** 暂停滚动(保留进度) */ + paused?: boolean + /** 读屏用的整体描述,如「运行状态」 */ + label?: string + /** 内容组之间的间距,默认 --su-space-8 */ + className?: string +} + +export function Ticker({ children, speed = 24, paused = false, label, className }: TickerProps) { + const trackRef = useRef(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 ( +
+
+
{children}
+ + +
+
+ ) +} diff --git a/src/index.ts b/src/index.ts index 083656b..1206933 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,6 +28,15 @@ 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 { Cover } from './components/Cover' export type { CoverProps, CoverRatio } from './components/Cover' diff --git a/src/theme/tokens.css b/src/theme/tokens.css index d70eec4..b4987ee 100644 --- a/src/theme/tokens.css +++ b/src/theme/tokens.css @@ -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;