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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user