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
This commit is contained in:
@@ -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:控件更紧凑,文章更好读。
|
||||
|
||||
@@ -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` 有值时渲染 `<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`
|
||||
|
||||
---
|
||||
|
||||
## 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)。兼容性:纯新增,无破坏 |
|
||||
|
||||
@@ -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() {
|
||||
<Tooltips />
|
||||
<PageLayout />
|
||||
<Layouts />
|
||||
<SiteChrome />
|
||||
<Composition />
|
||||
</main>
|
||||
|
||||
|
||||
@@ -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,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);
|
||||
}
|
||||
@@ -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,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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<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}
|
||||
>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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