+ /** 无障碍名称,默认取 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}
+
+ {children}
+
+
+ {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;