feat: shared UI component library (11 components)

Add Button, Card, Table, Badge, Modal, Drawer, Toast, Skeleton,
Toggle, Timeline, SearchBox — all using CSS variables from tokens.css
with className prop extension and exported Props interfaces.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
enzo
2026-07-22 16:31:14 +08:00
co-authored by Claude
parent fbee37635b
commit bd3d0147f5
11 changed files with 873 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
import type { ReactNode } from 'react'
export interface BadgeProps {
variant?: 'success' | 'danger' | 'warning' | 'info' | 'honey' | 'outline'
children: ReactNode
className?: string
}
const variantStyles: Record<string, { bg: string; color: string; border?: string }> = {
success: { bg: 'rgba(0, 200, 83, 0.15)', color: 'var(--green)' },
danger: { bg: 'rgba(255, 61, 61, 0.15)', color: 'var(--red)' },
warning: { bg: 'rgba(255, 171, 0, 0.15)', color: 'var(--yellow)' },
info: { bg: 'rgba(68, 138, 255, 0.15)', color: 'var(--blue)' },
honey: { bg: 'rgba(240, 185, 11, 0.15)', color: 'var(--honey)' },
outline: { bg: 'transparent', color: 'var(--text-dim)', border: '1px solid var(--border)' },
}
export default function Badge({ variant = 'outline', children, className = '' }: BadgeProps) {
const s = variantStyles[variant]
return (
<span
className={`inline-flex items-center px-[8px] py-[2px] rounded-[10px] text-[11px] font-semibold ${className}`}
style={{
backgroundColor: s.bg,
color: s.color,
border: s.border ?? 'none',
}}
>
{children}
</span>
)
}
+57
View File
@@ -0,0 +1,57 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'danger' | 'outline' | 'ghost'
size?: 'sm' | 'md'
loading?: boolean
children: ReactNode
}
export default function Button({
variant = 'primary',
size = 'md',
loading = false,
disabled = false,
children,
onClick,
className = '',
type = 'button',
...rest
}: ButtonProps) {
const base =
'inline-flex items-center justify-center rounded-lg font-semibold cursor-pointer border transition-all duration-150'
const variantStyles: Record<string, string> = {
primary:
'bg-[var(--honey)] text-black border-[var(--honey)] font-semibold hover:brightness-110',
danger:
'bg-transparent text-[var(--red)] border-[var(--red)] hover:bg-[var(--red)] hover:text-white',
outline:
'bg-transparent text-[var(--text-dim)] border-[var(--border)] hover:bg-[var(--bg-hover)]',
ghost:
'bg-transparent text-[var(--text-dim)] border-transparent hover:bg-[var(--bg-hover)]',
}
const sizeStyles: Record<string, string> = {
sm: 'px-[10px] py-[4px] text-[12px]',
md: 'px-[14px] py-[7px] text-[13px]',
}
const dimClass =
disabled || loading ? 'opacity-40 cursor-not-allowed pointer-events-none' : ''
return (
<button
type={type}
disabled={disabled || loading}
onClick={onClick}
className={`${base} ${variantStyles[variant]} ${sizeStyles[size]} ${dimClass} ${className}`}
{...rest}
>
{loading && (
<span className="absolute inline-block w-[14px] h-[14px] border-2 border-transparent border-t-current rounded-full animate-spin" />
)}
<span className={loading ? 'text-transparent' : ''}>{children}</span>
</button>
)
}
+64
View File
@@ -0,0 +1,64 @@
export interface CardProps {
label: string
value: string | number
valueColor?: 'green' | 'red' | 'yellow' | 'blue'
change?: string
changeDirection?: 'up' | 'down' | 'neutral'
onClick?: () => void
className?: string
}
const valueColorMap: Record<string, string> = {
green: 'var(--green)',
red: 'var(--red)',
yellow: 'var(--yellow)',
blue: 'var(--blue)',
}
const directionIcon: Record<string, string> = {
up: '↑',
down: '↓',
neutral: '→',
}
const directionColor: Record<string, string> = {
up: 'var(--green)',
down: 'var(--red)',
neutral: 'var(--text-dim)',
}
export default function Card({
label,
value,
valueColor,
change,
changeDirection,
onClick,
className = '',
}: CardProps) {
return (
<div
onClick={onClick}
className={`bg-[var(--bg-card)] rounded-[var(--radius)] p-4 ${onClick ? 'cursor-pointer hover:bg-[var(--bg-hover)]' : ''} transition-colors duration-150 ${className}`}
>
<div className="text-[12px] uppercase tracking-wider text-[var(--text-dim)]">
{label}
</div>
<div
className="text-[28px] font-bold leading-tight mt-1"
style={{ color: valueColor ? valueColorMap[valueColor] : undefined }}
>
{value}
</div>
{change !== undefined && changeDirection && (
<div
className="text-[12px] mt-1 flex items-center gap-1"
style={{ color: directionColor[changeDirection] }}
>
<span>{directionIcon[changeDirection]}</span>
<span>{change}</span>
</div>
)}
</div>
)
}
+94
View File
@@ -0,0 +1,94 @@
import { useEffect, useCallback, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
export interface DrawerProps {
open: boolean
onClose: () => void
title?: string
children: ReactNode
footer?: ReactNode
width?: string | number
className?: string
}
export default function Drawer({
open,
onClose,
title,
children,
footer,
width = 480,
className = '',
}: DrawerProps) {
const [mounted, setMounted] = useState(false)
const [visible, setVisible] = useState(false)
useEffect(() => {
if (open) {
setMounted(true)
requestAnimationFrame(() => setVisible(true))
} else {
setVisible(false)
const timer = setTimeout(() => setMounted(false), 250)
return () => clearTimeout(timer)
}
}, [open])
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
},
[onClose],
)
useEffect(() => {
if (open) {
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = ''
}
}, [open, handleKeyDown])
if (!mounted) return null
return createPortal(
<div className="fixed inset-0 z-[9999]">
<div
className={`absolute inset-0 transition-opacity duration-250 ${
visible ? 'opacity-100' : 'opacity-0'
}`}
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
/>
<div
className={`absolute top-0 right-0 h-full bg-[var(--bg-nav)] shadow-[var(--shadow-lg)] flex flex-col transition-transform duration-[250ms] ease-in-out ${className}`}
style={{
width: typeof width === 'number' ? `${width}px` : width,
transform: visible ? 'translateX(0)' : 'translateX(100%)',
}}
>
{title && (
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border)] shrink-0">
<h2 className="text-[15px] font-semibold text-[var(--text)]">{title}</h2>
<button
onClick={onClose}
className="text-[var(--text-dim)] hover:text-[var(--text)] text-[18px] leading-none cursor-pointer bg-transparent border-none"
>
✕
</button>
</div>
)}
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
{footer && (
<div className="px-5 py-4 border-t border-[var(--border)] shrink-0">
{footer}
</div>
)}
</div>
</div>,
document.body,
)
}
+94
View File
@@ -0,0 +1,94 @@
import { useEffect, useCallback, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import Button from './Button'
export interface ModalProps {
open: boolean
onClose: () => void
title?: string
children: ReactNode
footer?: ReactNode
confirmText?: string
onConfirm?: () => void
confirmDanger?: boolean
width?: string | number
className?: string
}
export default function Modal({
open,
onClose,
title,
children,
footer,
confirmText,
onConfirm,
confirmDanger,
width = 500,
className = '',
}: ModalProps) {
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
},
[onClose],
)
useEffect(() => {
if (open) {
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = ''
}
}, [open, handleKeyDown])
if (!open) return null
return createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center"
style={{ backgroundColor: 'rgba(0,0,0,0.6)' }}
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div
className={`bg-[var(--bg-nav)] rounded-[8px] shadow-[var(--shadow-modal)] w-full mx-4 flex flex-col max-h-[85vh] ${className}`}
style={{ maxWidth: typeof width === 'number' ? `${width}px` : width }}
onClick={(e) => e.stopPropagation()}
>
{title && (
<div className="flex items-center justify-between px-5 py-4 border-b border-[var(--border)]">
<h2 className="text-[15px] font-semibold text-[var(--text)]">{title}</h2>
<button
onClick={onClose}
className="text-[var(--text-dim)] hover:text-[var(--text)] text-[18px] leading-none cursor-pointer bg-transparent border-none"
>
✕
</button>
</div>
)}
<div className="flex-1 overflow-y-auto px-5 py-4">{children}</div>
{(footer || onConfirm) && (
<div className="flex items-center justify-end gap-2 px-5 py-4 border-t border-[var(--border)]">
{footer || (
<>
<Button variant="ghost" onClick={onClose}>
取消
</Button>
<Button
variant={confirmDanger ? 'danger' : 'primary'}
onClick={onConfirm}
>
{confirmText || '确认'}
</Button>
</>
)}
</div>
)}
</div>
</div>,
document.body,
)
}
+160
View File
@@ -0,0 +1,160 @@
import { useState, useRef, useEffect, useCallback, type ReactNode } from "react"
export interface SearchGroup {
label: string
items: { value: string; label: ReactNode }[]
}
export interface SearchBoxProps {
onSearch: (query: string) => SearchGroup[] | Promise<SearchGroup[]>
placeholder?: string
debounceMs?: number
className?: string
}
export default function SearchBox({
onSearch,
placeholder = "搜索...",
debounceMs = 300,
className = "",
}: SearchBoxProps) {
const [query, setQuery] = useState("")
const [groups, setGroups] = useState<SearchGroup[]>([])
const [open, setOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [activeIdx, setActiveIdx] = useState(-1)
const inputRef = useRef<HTMLInputElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const flatItems = groups.flatMap((g) => g.items)
const doSearch = useCallback(
async (q: string) => {
if (!q.trim()) {
setGroups([])
setOpen(false)
return
}
setLoading(true)
try {
const result = await onSearch(q)
setGroups(result)
setOpen(result.length > 0 && result.some((g) => g.items.length > 0))
setActiveIdx(-1)
} finally {
setLoading(false)
}
},
[onSearch],
)
useEffect(() => {
if (timerRef.current) clearTimeout(timerRef.current)
timerRef.current = setTimeout(() => doSearch(query), debounceMs)
return () => { if (timerRef.current) clearTimeout(timerRef.current) }
}, [query, debounceMs, doSearch])
useEffect(() => {
if (!open) setActiveIdx(-1)
}, [open])
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
panelRef.current &&
!panelRef.current.contains(e.target as Node) &&
inputRef.current &&
!inputRef.current.contains(e.target as Node)
) {
setOpen(false)
}
}
document.addEventListener("mousedown", handleClickOutside)
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!open || flatItems.length === 0) return
if (e.key === "ArrowDown") {
e.preventDefault()
setActiveIdx((prev) => (prev < flatItems.length - 1 ? prev + 1 : 0))
} else if (e.key === "ArrowUp") {
e.preventDefault()
setActiveIdx((prev) => (prev > 0 ? prev - 1 : flatItems.length - 1))
} else if (e.key === "Enter" && activeIdx >= 0) {
e.preventDefault()
setQuery(String(flatItems[activeIdx].label))
setOpen(false)
inputRef.current?.blur()
} else if (e.key === "Escape") {
setOpen(false)
inputRef.current?.blur()
}
}
let cursor = 0
return (
<div className={`relative ${className}`} style={{ width: "420px", maxWidth: "100%" }}>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={() => { if (groups.length > 0) setOpen(true) }}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="w-full h-[36px] px-3 text-[13px] rounded-lg border transition-colors duration-150 outline-none bg-[var(--bg-card)] text-[var(--text)] placeholder:text-[var(--text-muted)]"
style={{ borderColor: "var(--border)" }}
onFocusCapture={(e) => {
;(e.target as HTMLInputElement).style.borderColor = "var(--honey)"
}}
onBlurCapture={(e) => {
;(e.target as HTMLInputElement).style.borderColor = "var(--border)"
}}
/>
{loading && (
<span className="absolute right-3 top-1/2 -translate-y-1/2 w-[14px] h-[14px] border-2 border-[var(--border)] border-t-[var(--honey)] rounded-full animate-spin" />
)}
{open && !loading && (
<div
ref={panelRef}
className="absolute top-full left-0 right-0 mt-1 rounded-lg shadow-[var(--shadow-lg)] z-50 max-h-[320px] overflow-y-auto"
style={{ backgroundColor: "var(--bg-elevated)" }}
>
{groups.map((group, gi) => {
const startIdx = cursor
cursor += group.items.length
return (
<div key={gi}>
<div className="px-3 py-2 text-[11px] font-semibold uppercase text-[var(--text-dim)]">
{group.label}
</div>
{group.items.map((item, ii) => {
const idx = startIdx + ii
return (
<div
key={item.value}
className={`px-3 py-[7px] text-[13px] cursor-pointer transition-colors duration-100 ${
idx === activeIdx ? "bg-[var(--bg-hover)]" : ""
}`}
style={{ color: "var(--text)" }}
onMouseEnter={() => setActiveIdx(idx)}
onMouseDown={() => {
setQuery(String(item.label))
setOpen(false)
}}
>
{item.label}
</div>
)
})}
</div>
)
})}
</div>
)}
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
import type { ReactNode } from "react"
const styleId = "ui-skeleton-keyframes"
if (typeof document !== "undefined" && !document.getElementById(styleId)) {
const style = document.createElement("style")
style.id = styleId
style.textContent = `@keyframes shimmer { 0% { background-position: -200% 0; } 100% { background-position: 200% 0; } }`
document.head.appendChild(style)
}
export interface SkeletonProps {
variant?: "card" | "table" | "text"
count?: number
className?: string
}
function Bar({ className = "" }: { className?: string }) {
return (
<div
className={`relative overflow-hidden rounded-[var(--radius-sm)] bg-[var(--bg-hover)] ${className}`}
>
<div
className="absolute inset-0"
style={{
background:
"linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.06) 50%, transparent 100%)",
backgroundSize: "200% 100%",
animation: "shimmer 1500ms infinite",
}}
/>
</div>
)
}
function CardSkeleton() {
return (
<div className="bg-[var(--bg-card)] rounded-[var(--radius)] p-4 space-y-3">
<Bar className="h-3 w-1/3" />
<Bar className="h-8 w-1/2" />
<Bar className="h-3 w-1/4" />
</div>
)
}
function TableSkeleton({ rows }: { rows: number }) {
return (
<div className="space-y-3">
{Array.from({ length: rows }).map((_, i) => (
<Bar key={i} className="h-5 w-full" />
))}
</div>
)
}
function TextSkeleton({ count }: { count: number }) {
return (
<div className="space-y-2">
{Array.from({ length: count }).map((_, i) => (
<Bar key={i} className={`h-3 ${i % 2 === 0 ? "w-3/4" : "w-1/2"}`} />
))}
</div>
)
}
export default function Skeleton({
variant = "text",
count = 3,
className = "",
}: SkeletonProps) {
if (variant === "card")
return (
<div className={`space-y-4 ${className}`}>
{Array.from({ length: count }).map((_, i) => (
<CardSkeleton key={i} />
))}
</div>
)
if (variant === "table") return <TableSkeleton rows={count} />
return <TextSkeleton count={count} />
}
+84
View File
@@ -0,0 +1,84 @@
import type { ReactNode } from 'react'
export interface TableColumn {
key: string
label: string
width?: string | number
}
export interface TableProps {
columns: TableColumn[]
data: Record<string, ReactNode>[]
rowKey: string
onRowClick?: (record: Record<string, ReactNode>) => void
rowClassName?: (record: Record<string, ReactNode>) => string
emptyText?: string
className?: string
}
export default function Table({
columns,
data,
rowKey,
onRowClick,
rowClassName,
emptyText = '暂无数据',
className = '',
}: TableProps) {
return (
<div
className={`w-full overflow-auto rounded-[var(--radius)] border border-[var(--border)] ${className}`}
>
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
{columns.map((col) => (
<th
key={col.key}
className="sticky top-0 z-10 bg-[var(--bg-nav)] text-[12px] uppercase text-[var(--text-dim)] font-semibold text-left px-4 h-[44px] whitespace-nowrap"
style={col.width ? { width: col.width } : undefined}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{data.length === 0 ? (
<tr>
<td
colSpan={columns.length}
className="text-center text-[var(--text-dim)] py-8"
>
{emptyText}
</td>
</tr>
) : (
data.map((record) => {
const extra = rowClassName?.(record) ?? ''
return (
<tr
key={String(record[rowKey])}
onClick={() => onRowClick?.(record)}
className={`h-[44px] border-t border-[var(--border)] transition-colors duration-100 ${
onRowClick ? 'cursor-pointer' : ''
} hover:bg-[var(--bg-hover)] ${
extra.includes('row-danger')
? 'bg-[var(--bg-error)] hover:bg-[var(--bg-error)]'
: ''
}`}
>
{columns.map((col) => (
<td key={col.key} className="px-4">
{record[col.key]}
</td>
))}
</tr>
)
})
)}
</tbody>
</table>
</div>
)
}
+54
View File
@@ -0,0 +1,54 @@
import type { ReactNode } from "react"
export interface TimelineEvent {
time: string
type: string
label: ReactNode
color?: string
}
export interface TimelineProps {
events: TimelineEvent[]
className?: string
}
const defaultColors: Record<string, string> = {
green: "var(--green)",
red: "var(--red)",
orange: "var(--orange)",
blue: "var(--blue)",
}
export default function Timeline({ events, className = "" }: TimelineProps) {
return (
<div className={`flex items-center gap-0 ${className}`}>
{events.map((evt, i) => {
const dotColor = evt.color || defaultColors[evt.type] || "var(--text-dim)"
const isLast = i === events.length - 1
return (
<div key={i} className="flex items-center flex-1 min-w-0">
<div className="flex flex-col items-center">
<div
className="w-[8px] h-[8px] rounded-full transition-transform duration-150 hover:-translate-y-[2px] group relative"
style={{ backgroundColor: dotColor }}
>
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 text-[11px] whitespace-nowrap rounded-[var(--radius-sm)] opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none z-10"
style={{ backgroundColor: "var(--bg-elevated)", color: "var(--text)" }}>
{evt.time}
</div>
</div>
<div className="text-[11px] text-[var(--text-dim)] mt-1">{evt.label}</div>
</div>
{!isLast && (
<div
className="flex-1 h-[2px] mx-1 self-start mt-[3px]"
style={{ backgroundColor: "var(--border)" }}
/>
)}
</div>
)
})}
</div>
)
}
+110
View File
@@ -0,0 +1,110 @@
import { useState, useEffect, useCallback, type ReactNode } from "react"
import { createRoot } from "react-dom/client"
type ToastType = "success" | "error" | "warn" | "info"
interface ToastItem {
id: number
type: ToastType
message: string
}
type Listener = (item: ToastItem) => void
let emit: Listener | null = null
let seq = 0
function ToastStack() {
const [list, setList] = useState<ToastItem[]>([])
useEffect(() => {
emit = (item: ToastItem) => {
setList((prev) => {
const next = [...prev, item]
return next.length > 3 ? next.slice(-3) : next
})
if (item.type === "success") {
setTimeout(() => dismiss(item.id), 3000)
} else if (item.type === "error") {
setTimeout(() => dismiss(item.id), 5000)
}
}
return () => { emit = null }
}, [])
const dismiss = useCallback((id: number) => {
setList((prev) => prev.filter((t) => t.id !== id))
}, [])
if (list.length === 0) return null
return (
<div
className="fixed top-4 left-1/2 -translate-x-1/2 z-[99999] flex flex-col gap-2 w-full pointer-events-none"
style={{ maxWidth: "480px" }}
>
{list.map((item) => {
const bgMap: Record<ToastType, string> = {
success: "var(--bg-success)",
error: "var(--bg-error)",
warn: "var(--bg-warn)",
info: "var(--bg-info)",
}
const colorMap: Record<ToastType, string> = {
success: "var(--green)",
error: "var(--red)",
warn: "var(--yellow)",
info: "var(--blue)",
}
return (
<div
key={item.id}
className="pointer-events-auto flex items-center gap-2 px-4 py-3 rounded-[var(--radius)] shadow-[var(--shadow)] text-[13px] font-medium transition-all duration-250 animate-in"
style={{
backgroundColor: bgMap[item.type],
color: colorMap[item.type],
}}
>
<span className="flex-1">{item.message}</span>
<button
onClick={() => dismiss(item.id)}
className="bg-transparent border-none cursor-pointer text-inherit opacity-60 hover:opacity-100 text-[14px] leading-none p-0"
>
✕
</button>
</div>
)
})}
</div>
)
}
let mounted = false
function ensureMounted() {
if (mounted) return
mounted = true
const el = document.createElement("div")
el.id = "ui-toast-root"
document.body.appendChild(el)
createRoot(el).render(<ToastStack />)
}
function toast(type: ToastType, message: string) {
ensureMounted()
const id = ++seq
const tryEmit = () => {
if (emit) {
emit({ id, type, message })
} else {
requestAnimationFrame(() => tryEmit())
}
}
tryEmit()
}
export default {
success: (msg: string) => toast("success", msg),
error: (msg: string) => toast("error", msg),
warn: (msg: string) => toast("warn", msg),
info: (msg: string) => toast("info", msg),
}
+44
View File
@@ -0,0 +1,44 @@
export interface ToggleProps {
checked: boolean
onChange: (checked: boolean) => void
label?: string
disabled?: boolean
className?: string
}
export default function Toggle({
checked,
onChange,
label,
disabled = false,
className = "",
}: ToggleProps) {
return (
<label
className={`inline-flex items-center gap-[8px] text-[13px] ${
disabled ? "opacity-40 cursor-not-allowed" : "cursor-pointer"
} ${className}`}
>
<span
role="checkbox"
aria-checked={checked}
tabIndex={0}
onClick={() => !disabled && onChange(!checked)}
onKeyDown={(e) => {
if ((e.key === "Enter" || e.key === " ") && !disabled) {
e.preventDefault()
onChange(!checked)
}
}}
className="relative inline-block w-[44px] h-[24px] rounded-[12px] transition-colors duration-200"
style={{ backgroundColor: checked ? "var(--honey)" : "var(--bg-hover)" }}
>
<span
className="absolute top-[2px] left-[2px] w-[20px] h-[20px] rounded-full bg-white transition-transform duration-200"
style={{ transform: checked ? "translateX(20px)" : "translateX(0)" }}
/>
</span>
{label && <span className="text-[var(--text)] select-none">{label}</span>}
</label>
)
}