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:
2026-11-01 15:28:17 +08:00
parent 93bb5cbb28
commit 707cf98fe0
12 changed files with 524 additions and 0 deletions
+56
View File
@@ -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);
}
+66
View File
@@ -0,0 +1,66 @@
import styles from './Brand.module.css'
/**
* 站点品牌标识:图片 logo 或首字圆形标记 + 品牌文字。
*
* 只负责「长什么样」:传了 `href` 就是链接,不传渲染成纯展示元素,路由由使用方决定。
*/
export type BrandProps = {
/** 品牌文字;留空则只显示标记 */
name?: string
/** 没有图片时圆形标记里的字(1–4 个),留空取 name 首字 */
mark?: string
/** logo 图片地址;留空用 `mark` */
logo?: string
/** 点击跳转地址;不传则不渲染成链接 */
href?: string
/** 标记尺寸:md 32px / sm 24px */
size?: 'sm' | 'md'
/** 标记配色:solid 深底白字(默认)/ soft 主色浅底 */
tone?: 'solid' | 'soft'
/** 链接的无障碍名称,默认 `${name} 首页` */
label?: string
className?: string
}
export function Brand({
name = '',
mark = '',
logo = '',
href,
size = 'md',
tone = 'solid',
label,
className,
}: BrandProps) {
const brandName = name.trim()
const markText = mark.trim() || brandName.slice(0, 1) || 'S'
const classes = [styles.root, styles[size], styles[tone], className].filter(Boolean).join(' ')
const content = (
<>
{logo.trim() !== '' ? (
<img className={styles.logo} src={logo} alt="" />
) : (
<span className={styles.mark} aria-hidden="true">
{markText}
</span>
)}
{brandName !== '' && <span className={styles.name}>{brandName}</span>}
</>
)
if (href === undefined) {
return (
<span className={classes} aria-label={label ?? brandName}>
{content}
</span>
)
}
return (
<a className={classes} href={href} aria-label={label ?? `${brandName || '首页'} 首页`}>
{content}
</a>
)
}
+11
View File
@@ -0,0 +1,11 @@
.root {
width: 100%;
}
/* card 变体:内层就是库里的 Input,只把描边/圆角/阴影换成本站点「悬浮搜索条」的样子。
用 .root 派生选择器提高权重,保证盖过 Input 自己的 .root。 */
.root .card {
border-color: var(--su-border);
border-radius: var(--su-radius-md);
box-shadow: var(--su-shadow-card);
}
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, type Ref } from 'react'
import { Input } from './Input'
import styles from './SearchField.module.css'
/**
* 搜索框:带搜索图标、可清空、可挂快捷键提示。
*
* 与 `Input` 的区别是「语义」而不是外观:对外只给字符串(`value` / `onChange(string)`),
* 使用方不用再写 `event.target.value`;外观用 `variant` 切,不用改组件。
*/
export type SearchFieldProps = {
value: string
onChange: (value: string) => void
placeholder?: string
/** 右侧快捷键提示,如 `⌘K`(仅展示) */
shortcut?: string
/** 高度:36 / 44 */
size?: 'md' | 'lg'
/** plain = 与普通输入框一致;card = 实底 + 柔和阴影(悬浮搜索条的样子) */
variant?: 'plain' | 'card'
/** 最大宽度,默认 480 */
maxWidth?: number | string
/** 按下 Ctrl/Cmd + 该键时聚焦并全选(需要同时传 inputRef) */
hotkey?: string
/** Esc 清空,默认开 */
clearOnEscape?: boolean
inputRef?: Ref<HTMLInputElement>
/** 无障碍名称,默认取 placeholder */
label?: string
className?: string
id?: string
}
export function SearchField({
value,
onChange,
placeholder = '搜索…',
shortcut,
size = 'lg',
variant = 'plain',
maxWidth = 480,
hotkey,
clearOnEscape = true,
inputRef,
label,
className,
id,
}: SearchFieldProps) {
const classes = [styles.root, className].filter(Boolean).join(' ')
useEffect(() => {
if (hotkey === undefined || hotkey === '' || inputRef === undefined || inputRef === null) return
if (!('current' in inputRef)) return
const target = inputRef
const key = hotkey.toLowerCase()
const handleKeyDown = (event: KeyboardEvent) => {
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== key) return
const element = target.current
if (element === null) return
event.preventDefault()
element.focus()
element.select()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [hotkey, inputRef])
return (
<div className={classes} style={{ maxWidth }}>
<Input
id={id}
inputRef={inputRef}
size={size}
icon="search_3_line"
value={value}
placeholder={placeholder}
aria-label={label ?? placeholder}
shortcut={shortcut}
clearable
onClear={() => onChange('')}
spellCheck={false}
className={variant === 'card' ? styles.card : undefined}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (clearOnEscape && event.key === 'Escape') onChange('')
}}
/>
</div>
)
}
+51
View File
@@ -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;
}
}
+70
View File
@@ -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>
)
}
+9
View File
@@ -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'
+3
View File
@@ -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;