@@ -0,0 +1,288 @@
/**
* 插件公共 UI 组件库(@aso/plugin-sdk/ui)——宿主视觉规范的代码化。
*
* 仅限 renderer 组件槽位使用(组件渲染在宿主 DOM 里,直接复用宿主样式表);
* iframe 插件页是物理隔离文档,请参考 DESIGN.md §4 的 HTML 模式自行实现。
*
* 样式来源只有两类:
* - 宿主已生成的 Tailwind 类(DESIGN.md §2 类名词汇,全部经过存在性核验)
* - 语义 token 的内联变量(var(--accent) 等,用于宿主类词汇未覆盖的场合)
* 因此深浅主题自动跟随宿主,禁止出现硬编码色值。
*
* 等待/失败/空态铁律(docs/plugins/08-performance.md §4):一切异步区块必须呈现
* LoadingState / ErrorState / EmptyState 三态之一,禁止裸文本或无反馈。
*/
import { useState } from 'react'
import type { ButtonHTMLAttributes , ComponentProps , InputHTMLAttributes , ReactElement , ReactNode , SelectHTMLAttributes , TextareaHTMLAttributes } from 'react'
/** 拼接类名(过滤 falsy;轻量版 cn,不引依赖) */
export function cn ( . . . classes : Array < string | false | null | undefined > ) : string {
return classes . filter ( Boolean ) . join ( ' ' )
}
// ---- 等待态 ----
export interface SpinnerProps {
/** 直径 px,默认 14 */
size? : number
className? : string
}
/** 旋转指示器:强调色弧 + 弱色圆环 */
export function Spinner ( { size = 14 , className } : SpinnerProps ) : ReactElement {
return (
< span
role = "status"
aria-label = "加载中"
className = { cn ( 'inline-block shrink-0 animate-spin rounded-full border-line-strong' , className ) }
style = { { width : size , height : size , borderWidth : 2 , borderTopColor : 'var(--accent)' } }
/ >
)
}
export interface LoadingStateProps {
/** 默认「加载中…」 */
label? : ReactNode
className? : string
}
/** 区块级等待态:任何异步内容就绪前的统一占位 */
export function LoadingState ( { label = '加载中…' , className } : LoadingStateProps ) : ReactElement {
return (
< div className = { cn ( 'flex flex-col items-center justify-center gap-2 py-10 text-center' , className ) } >
< Spinner / >
< div className = "text-[11.5px] text-ink-3" > { label } < / div >
< / div >
)
}
// ---- 失败态 ----
export interface ErrorStateProps {
/** 默认「出错了」 */
title? : ReactNode
/** 错误详情(错误消息、排查建议),保留换行 */
description? : ReactNode
/** 提供则显示重试按钮 */
onRetry ? : ( ) = > void
retryLabel? : string
className? : string
}
/** 区块级失败态:错误必须可见、可重试,禁止静默吞掉 */
export function ErrorState ( { title = '出错了' , description , onRetry , retryLabel = '重试' , className } : ErrorStateProps ) : ReactElement {
return (
< div
className = { cn ( 'flex flex-col items-center justify-center gap-1.5 rounded-card border border-line bg-red/10 px-6 py-10 text-center' , className ) }
role = "alert"
>
< div className = "text-[12.5px] font-medium text-ink" > { title } < / div >
{ description !== undefined && description !== null && description !== '' && (
< div className = "max-w-full whitespace-pre-wrap break-words text-[11.5px] leading-4 text-ink-3" > { description } < / div >
) }
{ onRetry && (
< Button variant = "ghost" size = "sm" className = "mt-2" onClick = { onRetry } >
{ retryLabel }
< / Button >
) }
< / div >
)
}
// ---- 空态 ----
export interface EmptyStateProps {
title : ReactNode
/** 怎么产生的提示(如何让它不再为空) */
description? : ReactNode
/** 可选动作(如「新建」「去设置」) */
action? : ReactNode
className? : string
}
/** 区块级空态(DESIGN.md §4 标准模式的组件化) */
export function EmptyState ( { title , description , action , className } : EmptyStateProps ) : ReactElement {
return (
< div className = { cn ( 'flex flex-col items-center justify-center gap-1 rounded-card border border-dashed border-line py-12 text-center' , className ) } >
< div className = "text-[13px] font-medium text-ink-2" > { title } < / div >
{ description !== undefined && description !== null && description !== '' && (
< div className = "max-w-full break-words text-[11.5px] text-ink-3" > { description } < / div >
) }
{ action && < div className = "mt-2" > { action } < / div > }
< / div >
)
}
// ---- 按钮 ----
export interface ButtonProps extends ButtonHTMLAttributes < HTMLButtonElement > {
/** primary=强调实心 / ghost=描边(默认)/ danger=危险描边 */
variant ? : 'primary' | 'ghost' | 'danger'
/** sm=辅助按钮 11px / md=标准 11.5px(默认) */
size ? : 'sm' | 'md'
/** true 时禁用并显示旋转指示器(异步提交防重复点击) */
loading? : boolean
}
export function Button ( { variant = 'ghost' , size = 'md' , loading = false , disabled , className , children , . . . rest } : ButtonProps ) : ReactElement {
const variantCls =
variant === 'primary'
? 'bg-accent text-white hover:opacity-90'
: variant === 'danger'
? 'border border-line-strong text-red hover:bg-red/10'
: 'border border-line-strong text-ink hover:bg-hover'
const sizeCls = size === 'sm' ? 'px-2 py-1 text-[11px]' : 'px-3 py-1.5 text-[11.5px]'
return (
< button
type = "button"
disabled = { disabled || loading }
className = { cn (
'inline-flex items-center justify-center gap-1.5 rounded-control font-medium transition-colors disabled:pointer-events-none disabled:opacity-50' ,
variantCls ,
sizeCls ,
className
) }
{ ...rest }
>
{ loading && < Spinner size = { size === 'sm' ? 10 : 12 } / > }
{ children }
< / button >
)
}
// ---- 输入控件 ----
/** 单行输入(设置行/表单通用;与宿主设置页同款) */
export function Input ( { className , . . . rest } : InputHTMLAttributes < HTMLInputElement > ) : ReactElement {
return (
< input
className = { cn (
'w-full rounded-control border border-line-strong bg-surface px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3 disabled:opacity-50' ,
className
) }
{ ...rest }
/ >
)
}
export function Textarea ( { className , . . . rest } : TextareaHTMLAttributes < HTMLTextAreaElement > ) : ReactElement {
return (
< textarea
className = { cn (
'w-full resize-y rounded-control border border-line-strong bg-surface px-2.5 py-1.5 text-[12px] leading-4 text-ink outline-none placeholder:text-ink-3 disabled:opacity-50' ,
className
) }
{ ...rest }
/ >
)
}
export interface SelectProps extends SelectHTMLAttributes < HTMLSelectElement > {
options : Array < { value : string ; label : string } >
}
/** 下拉选择(原生 select,宿主输入框同款外观) */
export function Select ( { options , className , . . . rest } : SelectProps ) : ReactElement {
return (
< select
className = { cn (
'w-full rounded-control border border-line-strong bg-surface px-2.5 py-1.5 text-[12px] text-ink outline-none disabled:opacity-50' ,
className
) }
{ ...rest }
>
{ options . map ( ( opt ) = > (
< option key = { opt . value } value = { opt . value } >
{ opt . label }
< / option >
) ) }
< / select >
)
}
export interface SwitchProps {
/** 受控值;不传则内部自持(非受控) */
checked? : boolean
defaultChecked? : boolean
onCheckedChange ? : ( checked : boolean ) = > void
disabled? : boolean
'aria-label' ? : string
}
/** 开关(DESIGN.md §4 规格:h-5 w-9,滑块 size-4,开 accent / 关 line-strong) */
export function Switch ( { checked , defaultChecked , onCheckedChange , disabled , 'aria-label' : ariaLabel } : SwitchProps ) : ReactElement {
const [ inner , setInner ] = useState ( defaultChecked ? ? false )
const isControlled = checked !== undefined
const value = isControlled ? checked : inner
return (
< button
type = "button"
role = "switch"
aria-checked = { value }
aria-label = { ariaLabel }
disabled = { disabled }
onClick = { ( ) = > {
const next = ! value
if ( ! isControlled ) setInner ( next )
onCheckedChange ? . ( next )
} }
className = { cn (
'relative h-5 w-9 shrink-0 rounded-full transition-colors disabled:pointer-events-none disabled:opacity-50' ,
value ? 'bg-accent' : 'bg-line-strong'
) }
>
< span
className = "absolute top-0.5 size-4 rounded-full bg-white shadow transition-transform"
style = { { transform : ` translateX( ${ value ? 16 : 2 } px) ` } }
/ >
< / button >
)
}
// ---- 布局与展示 ----
/** 卡片容器(rounded-card + line 边框;内边距由内容自理) */
export function Card ( { className , children , . . . rest } : ComponentProps < 'div' > ) : ReactElement {
return (
< div className = { cn ( 'rounded-card border border-line bg-surface' , className ) } { ...rest } >
{ children }
< / div >
)
}
export interface SettingRowProps {
label : ReactNode
/** 次级说明(控件左侧标题下方) */
description? : ReactNode
/** 右侧控件(Switch / Input / Button / Select…) */
children? : ReactNode
className? : string
}
/** 设置行(DESIGN.md §4 标准模式:label 左 / 控件右) */
export function SettingRow ( { label , description , children , className } : SettingRowProps ) : ReactElement {
return (
< div className = { cn ( 'flex items-center justify-between gap-3 rounded-card border border-line px-3 py-2.5' , className ) } >
< div className = "min-w-0" >
< div className = "text-[12.5px] text-ink" > { label } < / div >
{ description !== undefined && description !== null && description !== '' && (
< div className = "mt-0.5 break-words text-[11px] leading-4 text-ink-3" > { description } < / div >
) }
< / div >
{ children }
< / div >
)
}
export interface BadgeProps {
/** neutral=灰 / accent=强调 / danger=危险 */
tone ? : 'neutral' | 'accent' | 'danger'
className? : string
children : ReactNode
}
/** 徽标(版本、来源、状态等元信息) */
export function Badge ( { tone = 'neutral' , className , children } : BadgeProps ) : ReactElement {
const toneCls = tone === 'accent' ? 'bg-accent/10 text-accent' : tone === 'danger' ? 'bg-red/10 text-red' : 'bg-hover text-ink-3'
return < span className = { cn ( 'rounded-control px-1.5 py-0.5 text-[10px]' , toneCls , className ) } > { children } < / span >
}