- 新建 components/ui/(Button/Input/Select/Modal/Tabs/Spinner/Skeleton 等),变体用联合类型约束,根治幻影变体 - admin/components.tsx 与 matches/ui.tsx 改为再导出壳,消除 Spinner 三份重复 - 日期工具收口到 lib/date.ts,滚动监听收口到 lib/useScroll.ts - index.css 引入 :root RGB 三元组令牌,tailwind.config 接 <alpha-value>(修复透明度修饰符静默失效) - 刘建毛草 5MB 全字库(jsDelivr 未锁版) → 1KB 自托管子集(仅「先知」二字,unicode-range 限定)
328 lines
11 KiB
TypeScript
328 lines
11 KiB
TypeScript
/**
|
|
* UI 基元层 —— 全站唯一的基础交互组件来源。
|
|
*
|
|
* 背景:此前只有业务级组件(Card/DataTable 等),基础交互控件全靠各页面
|
|
* 手写 className + `.btn` / `.field` 等 CSS 类维持一致。结果是:
|
|
* - `btn-ghost` 被使用但从未定义,样式静默失效(与早年的 btn-outline 同型)
|
|
* - Spinner 在前台/后台各复制一份
|
|
* - 弹窗焦点陷阱只在一个弹窗里有,另一个缺
|
|
* 组件化的意义在于「让错误在编译期暴露」,而不是靠人工守纪律。
|
|
*
|
|
* 设计约束:视觉输出与迁移前逐字一致(使用同一批 CSS 类),
|
|
* 本层只做「收口 + 类型约束」,不改设计语言。
|
|
*/
|
|
|
|
import { forwardRef, useEffect, useRef } from 'react'
|
|
import type {
|
|
ButtonHTMLAttributes,
|
|
InputHTMLAttributes,
|
|
SelectHTMLAttributes,
|
|
ReactNode,
|
|
MouseEventHandler,
|
|
} from 'react'
|
|
|
|
// ── 工具:CSS 类名拼接 ────────────────────────────────────────────
|
|
|
|
export function cx(...parts: Array<string | false | null | undefined>): string {
|
|
return parts.filter(Boolean).join(' ')
|
|
}
|
|
|
|
// ── Button ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 按钮变体。用联合类型约束,写错变体名会在编译期报错
|
|
* —— 这是根治 `btn-ghost` 那类「幻影变体」的关键。
|
|
*/
|
|
export type ButtonVariant = 'default' | 'solid' | 'outline' | 'danger' | 'ghost'
|
|
export type ButtonSize = 'default' | 'sm'
|
|
|
|
const VARIANT_CLASS: Record<ButtonVariant, string> = {
|
|
default: '',
|
|
solid: 'btn-solid',
|
|
outline: 'btn-outline',
|
|
danger: 'btn-danger',
|
|
ghost: 'btn-ghost',
|
|
}
|
|
|
|
const SIZE_CLASS: Record<ButtonSize, string> = {
|
|
default: '',
|
|
sm: 'btn-sm',
|
|
}
|
|
|
|
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
variant?: ButtonVariant
|
|
size?: ButtonSize
|
|
/** 显示加载态:自动禁用并前置 Spinner */
|
|
loading?: boolean
|
|
/** 占满父容器宽度 */
|
|
block?: boolean
|
|
/**
|
|
* 传入时渲染为 `<a>` 而非 `<button>`(链接样式的按钮)。
|
|
* 此时 disabled/loading 无效 —— 链接没有这两个语义。
|
|
*/
|
|
href?: string
|
|
children?: ReactNode
|
|
}
|
|
|
|
/**
|
|
* 按钮基元。默认输出 `.btn` 的方角纸片样式。
|
|
*
|
|
* 传 `href` 时渲染为 `<a>`(链接样式的按钮,如卡片里的「定位 →」跳转),
|
|
* 复用同一套变体类名。按钮语义(点击执行动作)与链接语义(导航到别处)
|
|
* 在 HTML 层不可互换,因此不硬造一个 `<button>` 假装链接。
|
|
*
|
|
* 注:`min-h-[44px]` 等尺寸细节仍由各页面按场景通过 className 覆盖
|
|
* (如弹窗按钮需更大触控目标),基元只保证变体与尺寸语义统一。
|
|
*/
|
|
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
|
{ variant = 'default', size = 'default', loading = false, block = false, href, className, children, disabled, ...rest },
|
|
ref,
|
|
) {
|
|
const cls = cx(
|
|
'btn',
|
|
VARIANT_CLASS[variant],
|
|
SIZE_CLASS[size],
|
|
block && 'w-full',
|
|
className,
|
|
)
|
|
|
|
if (href != null) {
|
|
// 锚点分支:只透传与 <a> 兼容且实际会用到的属性。
|
|
// 显式收窄而不是解构 rest —— button 属性(如 formAction)对锚点无意义,
|
|
// 且两类元素的 onClick 处理器参数类型不同,直接展开会让 TS 反变失配。
|
|
const a = rest as unknown as {
|
|
onClick?: MouseEventHandler<HTMLAnchorElement>
|
|
title?: string
|
|
'aria-label'?: string
|
|
target?: string
|
|
}
|
|
return (
|
|
<a
|
|
href={href}
|
|
onClick={a.onClick}
|
|
title={a.title}
|
|
aria-label={a['aria-label']}
|
|
target={a.target}
|
|
className={cls}
|
|
>
|
|
{children}
|
|
</a>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<button
|
|
ref={ref}
|
|
disabled={disabled || loading}
|
|
aria-busy={loading || undefined}
|
|
className={cls}
|
|
{...rest}
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<Spinner /> {children}
|
|
</>
|
|
) : (
|
|
children
|
|
)}
|
|
</button>
|
|
)
|
|
})
|
|
|
|
// ── Input / Select ───────────────────────────────────────────────
|
|
|
|
/**
|
|
* 输入框基元:统一 `.field` 方角样式。
|
|
*
|
|
* 无障碍提醒:请为输入框提供可访问名称 —— 要么传 `id` 并在外部配
|
|
* `<label htmlFor={id}>`,要么传 `label`(内部转 aria-label)。
|
|
*
|
|
* 背景:全站 20 个 `<input>` 里多数只有视觉上相邻的 `<label>`,
|
|
* 没有 `htmlFor`/`id` 关联。屏幕阅读器读出来只有「编辑框」,
|
|
* 在密码框场景下用户无法分辨「当前密码」与「确认新密码」。
|
|
*
|
|
* 注:多数页面目前仍直接写 `<input className="field">` 而非用本基元,
|
|
* 那些调用点需各自补 id/htmlFor(已在 admin 各页处理)。
|
|
*/
|
|
export interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
/** 无可见标签时的可访问名称(内部转 aria-label) */
|
|
label?: string
|
|
}
|
|
|
|
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|
{ className, label, 'aria-label': ariaLabel, ...rest },
|
|
ref,
|
|
) {
|
|
return (
|
|
<input
|
|
ref={ref}
|
|
aria-label={ariaLabel ?? label}
|
|
className={cx('field', className)}
|
|
{...rest}
|
|
/>
|
|
)
|
|
})
|
|
|
|
// 用 type 别名而非空 interface:空 interface 与父类型完全等价,
|
|
// 留着只会让「这里以后可能要加字段」的意图变成噪声。
|
|
export type SelectProps = SelectHTMLAttributes<HTMLSelectElement>
|
|
|
|
/** 下拉框基元:与 Input 同一 `.field` 语言。 */
|
|
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
|
{ className, children, ...rest },
|
|
ref,
|
|
) {
|
|
return (
|
|
<select ref={ref} className={cx('field', className)} {...rest}>
|
|
{children}
|
|
</select>
|
|
)
|
|
})
|
|
|
|
// ── Spinner ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* 加载指示。全站唯一来源(此前存在三份逐字节相同的副本:
|
|
* admin/components.tsx / pages/matches/ui.tsx / MatchPredictPanel.tsx)。
|
|
*/
|
|
export function Spinner({ className = '' }: { className?: string }) {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 20 20"
|
|
className={cx('h-3.5 w-3.5 animate-spin', className)}
|
|
fill="none"
|
|
aria-hidden="true"
|
|
>
|
|
<circle cx="10" cy="10" r="7.5" stroke="currentColor" strokeWidth="1.5" strokeOpacity="0.25" />
|
|
<path d="M17.5 10A7.5 7.5 0 0010 2.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
// ── Skeleton ─────────────────────────────────────────────────────
|
|
|
|
/** 骨架占位块 */
|
|
export function Skeleton({ className = '' }: { className?: string }) {
|
|
return <div className={cx('skeleton', className)} />
|
|
}
|
|
|
|
// ── 业务级组件:统一从本目录再导出,使 `components/ui` 成为唯一入口 ──
|
|
|
|
export { Card, CardHeader, CardBody } from './Card'
|
|
export { Badge } from './Badge'
|
|
export { StatCard, ProgressBar, AgentWeightsBar } from './Stat'
|
|
export { SectionHeader } from './SectionHeader'
|
|
export { Alert, ErrorBanner, describeError, EmptyState, EmptyText } from './Feedback'
|
|
export { DataTable, MobileCardList, ResponsiveTable } from './DataTable'
|
|
export { SkeletonRows } from './SkeletonRows'
|
|
export { Switch } from './Switch'
|
|
export { Tabs } from './Tabs'
|
|
export type { TabItem, TabsProps } from './Tabs'
|
|
|
|
// ── Modal ────────────────────────────────────────────────────────
|
|
|
|
export interface ModalProps {
|
|
open: boolean
|
|
onClose: () => void
|
|
/** 无障碍名称,会写入 aria-label */
|
|
label: string
|
|
children: ReactNode
|
|
/** 面板附加类名(控制最大宽度、对齐方式等) */
|
|
panelClassName?: string
|
|
/** 遮罩层附加类名 */
|
|
overlayClassName?: string
|
|
/** 点击遮罩是否关闭(默认 true) */
|
|
closeOnOverlay?: boolean
|
|
/** 显示入场动画(默认 true) */
|
|
animate?: boolean
|
|
}
|
|
|
|
/**
|
|
* 弹窗基元:一次性内置全部无障碍行为,使各弹窗表现天然一致。
|
|
*
|
|
* 内置能力(此前仅 PredictModal 完整具备,CommandPalette 缺前 3 项):
|
|
* - 焦点陷阱:Tab / Shift+Tab 循环限制在弹窗内
|
|
* - ESC 关闭
|
|
* - 背景滚动锁定(避免内层滚到底后带动底层页面)
|
|
* - 关闭后焦点归还给触发元素
|
|
* - 初始聚焦面板,键盘用户可直接 Tab 进入
|
|
*/
|
|
export function Modal({
|
|
open,
|
|
onClose,
|
|
label,
|
|
children,
|
|
panelClassName = '',
|
|
overlayClassName = '',
|
|
closeOnOverlay = true,
|
|
animate = true,
|
|
}: ModalProps) {
|
|
const panelRef = useRef<HTMLDivElement>(null)
|
|
const previouslyFocused = useRef<HTMLElement | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
|
|
previouslyFocused.current = document.activeElement as HTMLElement | null
|
|
panelRef.current?.focus()
|
|
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
onClose()
|
|
return
|
|
}
|
|
if (e.key === 'Tab') {
|
|
const focusables = panelRef.current?.querySelectorAll<HTMLElement>(
|
|
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
|
)
|
|
if (!focusables || focusables.length === 0) return
|
|
const first = focusables[0]
|
|
const last = focusables[focusables.length - 1]
|
|
if (e.shiftKey && document.activeElement === first) {
|
|
e.preventDefault()
|
|
last.focus()
|
|
} else if (!e.shiftKey && document.activeElement === last) {
|
|
e.preventDefault()
|
|
first.focus()
|
|
}
|
|
}
|
|
}
|
|
|
|
document.addEventListener('keydown', onKeyDown)
|
|
const prevOverflow = document.body.style.overflow
|
|
document.body.style.overflow = 'hidden'
|
|
|
|
return () => {
|
|
document.removeEventListener('keydown', onKeyDown)
|
|
document.body.style.overflow = prevOverflow
|
|
previouslyFocused.current?.focus()
|
|
}
|
|
}, [open, onClose])
|
|
|
|
if (!open) return null
|
|
|
|
return (
|
|
<div
|
|
className={cx(
|
|
'fixed inset-0 flex items-start justify-center bg-ink-900/50 p-4',
|
|
animate && 'modal-overlay-enter',
|
|
overlayClassName,
|
|
)}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={label}
|
|
onClick={e => {
|
|
if (closeOnOverlay && e.target === e.currentTarget) onClose()
|
|
}}
|
|
>
|
|
<div
|
|
ref={panelRef}
|
|
tabIndex={-1}
|
|
className={cx('relative outline-none', animate && 'modal-panel-enter', panelClassName)}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|