feat: stash-ui 0.1.0(40 个组件 + 文档站 + 主题机制)

This commit is contained in:
stash
2026-10-28 20:44:18 +08:00
commit 52296b3368
153 changed files with 17129 additions and 0 deletions
+110
View File
@@ -0,0 +1,110 @@
/* 头像尺寸是组件内部的比例关系,用局部变量集中声明(不往全局令牌里塞) */
.root {
position: relative;
display: inline-flex;
flex-shrink: 0;
vertical-align: middle;
}
/* 裁切层:图片 / 字符块都在这里被圆角裁掉,角标画在它外面 */
.media {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
overflow: hidden;
border-radius: inherit;
background-color: var(--su-bg-light);
color: var(--su-text-sub);
}
.circle {
border-radius: var(--su-radius-pill);
}
.square {
border-radius: var(--su-radius-sm);
}
.sm {
--avatar-size: 24px;
--avatar-badge: 12px;
width: var(--avatar-size);
height: var(--avatar-size);
font-size: var(--su-font-size-sm);
}
.md {
--avatar-size: 36px;
--avatar-badge: 16px;
width: var(--avatar-size);
height: var(--avatar-size);
font-size: var(--su-font-size-md);
}
.lg {
--avatar-size: 48px;
--avatar-badge: 20px;
width: var(--avatar-size);
height: var(--avatar-size);
font-size: var(--su-font-size-lg);
}
/* 资料卡 / 详情页的大头像 */
.xl {
--avatar-size: 64px;
--avatar-badge: 20px;
width: var(--avatar-size);
height: var(--avatar-size);
font-size: 24px;
}
/* 无图时的品牌底色:浅色渐变 + 主色字,比纯灰更有识别度 */
.primary .media {
background: var(--su-avatar-gradient);
color: var(--su-color-primary);
}
/* 图标占位:尺寸跟随字体,颜色跟随 tone */
.icon {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 1.2em;
line-height: 1;
}
.image {
width: 100%;
height: 100%;
object-fit: cover;
}
.fallback {
font-weight: var(--su-font-weight-bold);
line-height: 1;
text-transform: uppercase;
}
/* 认证角标:橙底白勾,盖在头像右下角,带一圈与卡片同色的描边 */
.verified {
position: absolute;
right: -2px;
bottom: -2px;
display: grid;
place-items: center;
width: var(--avatar-badge);
height: var(--avatar-badge);
border: 2px solid var(--su-bg-card);
border-radius: var(--su-radius-pill);
background: var(--su-color-warning);
color: var(--su-white);
font-size: calc(var(--avatar-badge) * 0.55);
font-weight: var(--su-font-weight-strong);
line-height: 1;
}
+79
View File
@@ -0,0 +1,79 @@
import { useState, type ReactNode } from 'react'
import styles from './Avatar.module.css'
/**
* 头像 / 封面占位。
*
* 有图片就渲染图片;没图片、或图片加载失败时退化成字符块
* (也就是列表里常见的「首字母头像」)。
*
* 认证角标画在裁切层之外,所以不会被圆角切掉。
*/
export type AvatarSize = 'sm' | 'md' | 'lg' | 'xl'
export type AvatarShape = 'circle' | 'square'
export type AvatarTone = 'neutral' | 'primary'
export type AvatarProps = {
src?: string
alt?: string
/** 无图或加载失败时显示的字符,通常取名称首字 */
fallback?: string
/** 无图时显示图标(优先于 fallback),用于「用户 / 应用」这类缺省头像 */
icon?: ReactNode
size?: AvatarSize
shape?: AvatarShape
/** 无图时的底色:neutral 浅灰(默认)/ primary 品牌浅色渐变(资料卡用) */
tone?: AvatarTone
/** 右下角认证角标(✓) */
verified?: boolean
className?: string
}
export function Avatar({
src,
alt = '',
fallback = '•',
icon,
size = 'md',
shape = 'circle',
tone = 'neutral',
verified = false,
className,
}: AvatarProps) {
const [failed, setFailed] = useState(false)
const showImage = src !== undefined && !failed
const classes = [styles.root, styles[size], styles[shape], styles[tone], className]
.filter(Boolean)
.join(' ')
return (
<span className={classes}>
<span className={styles.media}>
{showImage ? (
<img
className={styles.image}
src={src}
alt={alt}
loading="lazy"
onError={() => setFailed(true)}
/>
) : icon !== undefined ? (
<span className={styles.icon} aria-hidden="true">
{icon}
</span>
) : (
<span className={styles.fallback} aria-hidden="true">
{fallback}
</span>
)}
</span>
{verified && (
<span className={styles.verified} aria-hidden="true">
✓
</span>
)}
</span>
)
}
+30
View File
@@ -0,0 +1,30 @@
.root {
--badge-height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: var(--badge-height);
height: var(--badge-height);
padding: 0 var(--su-space-1);
border-radius: var(--su-radius-pill);
font-size: var(--su-font-size-xs);
font-weight: var(--su-font-weight-bold);
line-height: 1;
font-variant-numeric: tabular-nums;
}
.danger {
background-color: var(--su-color-danger);
color: var(--su-white);
}
.primary {
background-color: var(--su-color-primary);
color: var(--su-white);
}
.neutral {
background-color: var(--su-bg-hover);
color: var(--su-text-sub);
}
+27
View File
@@ -0,0 +1,27 @@
import type { HTMLAttributes } from 'react'
import styles from './Badge.module.css'
/**
* 数量角标:分区标题旁的小圆标。
*
* 只负责「数字的样子」,贴在哪里由使用方决定(传 `className` 做绝对定位),
* 这样同一个角标既可以是内联的,也可以压在标题右上角。
*/
export type BadgeTone = 'danger' | 'primary' | 'neutral'
export type BadgeProps = {
count: number
tone?: BadgeTone
/** 超过这个数量显示为 `99+`,设为 0 表示不截断 */
max?: number
} & HTMLAttributes<HTMLSpanElement>
export function Badge({ count, tone = 'danger', max = 99, className, ...rest }: BadgeProps) {
const text = max > 0 && count > max ? `${max}+` : String(count)
return (
<span className={[styles.root, styles[tone], className].filter(Boolean).join(' ')} {...rest}>
{text}
</span>
)
}
+41
View File
@@ -0,0 +1,41 @@
.root {
font-size: var(--su-font-size-sm);
line-height: var(--su-line-height-sm);
}
.list {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--su-space-1);
list-style: none;
}
.item {
display: inline-flex;
align-items: center;
gap: var(--su-space-1);
}
.link {
color: var(--su-text-sub);
transition: color var(--su-duration-base) var(--su-ease-in-out);
}
.link:hover {
color: var(--su-color-primary);
}
.current {
color: var(--su-text-main);
}
.separator {
color: var(--su-text-muted);
}
@media (prefers-reduced-motion: reduce) {
.link {
transition: none;
}
}
+46
View File
@@ -0,0 +1,46 @@
import type { ReactNode } from 'react'
import styles from './Breadcrumb.module.css'
/** 面包屑:详情页的路径导航,最后一项是当前页(不可点)。 */
export type BreadcrumbItem = {
label: ReactNode
href?: string
}
export type BreadcrumbProps = {
items: BreadcrumbItem[]
/** 分隔符,默认 `/` */
separator?: ReactNode
className?: string
}
export function Breadcrumb({ items, separator = '/', className }: BreadcrumbProps) {
return (
<nav className={[styles.root, className].filter(Boolean).join(' ')} aria-label="面包屑">
<ol className={styles.list}>
{items.map((item, index) => {
const last = index === items.length - 1
return (
<li key={`${index}-${typeof item.label === 'string' ? item.label : ''}`} className={styles.item}>
{item.href !== undefined && !last ? (
<a className={styles.link} href={item.href}>
{item.label}
</a>
) : (
<span className={styles.current} aria-current={last ? 'page' : undefined}>
{item.label}
</span>
)}
{!last && (
<span className={styles.separator} aria-hidden="true">
{separator}
</span>
)}
</li>
)
})}
</ol>
</nav>
)
}
+149
View File
@@ -0,0 +1,149 @@
.root {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--su-space-2);
height: var(--su-control-height-md);
padding: 0 var(--su-space-5);
touch-action: manipulation;
border: 1px solid transparent;
border-radius: var(--su-radius-sm);
background-color: transparent;
font-family: var(--su-font-sans);
font-size: var(--su-font-size-md);
font-weight: var(--su-font-weight-bold);
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition:
color var(--su-duration-base) var(--su-ease-in-out),
background-color var(--su-duration-base) var(--su-ease-in-out),
border-color var(--su-duration-base) var(--su-ease-in-out),
opacity var(--su-duration-base) var(--su-ease-in-out);
}
.root:focus-visible {
outline: var(--su-focus-ring);
outline-offset: var(--su-focus-ring-offset);
}
/* 禁用态:参考站的做法是换成浅底 + 弱文字,而不是整体降透明度 */
.root:disabled {
border-color: var(--su-border-strong);
background-color: var(--su-bg-subtle);
color: var(--su-text-muted);
cursor: not-allowed;
}
.sm {
height: var(--su-control-height-sm);
padding: 0 var(--su-space-3);
font-size: var(--su-font-size-sm);
}
.md {
height: var(--su-control-height-md);
padding: 0 var(--su-space-5);
font-size: var(--su-font-size-md);
}
/* 大按钮:表单主操作 / 落地页 CTA;与 Input size="lg" 同高 */
.lg {
height: var(--su-control-height-lg);
padding: 0 var(--su-space-6);
font-size: var(--su-font-size-base);
}
/* 胶囊按钮:参考站的 .btn-rounded,登录、筛选这类主操作常用 */
.pill {
border-radius: var(--su-radius-pill);
padding: 0 var(--su-space-5);
}
.primary {
background-color: var(--su-color-primary);
color: var(--su-white);
}
/* 参考站的 .btn-primary:hover 是 opacity .8,不是改色 */
.primary:hover:not(:disabled) {
opacity: 0.8;
}
.secondary {
border-color: var(--su-border-strong);
background-color: var(--su-bg-light);
color: var(--su-text-secondary);
}
.secondary:hover:not(:disabled) {
background-color: var(--su-bg-hover);
color: var(--su-text-main);
}
/* 描边按钮:用于「有底色会糊」的位置,例如顶栏上的主题切换 */
.outline {
border-color: var(--su-border-strong);
background-color: transparent;
color: var(--su-text-main);
}
.outline:hover:not(:disabled) {
background-color: var(--su-bg-subtle);
}
.ghost {
color: var(--su-text-sub);
}
.ghost:hover:not(:disabled) {
background-color: var(--su-bg-subtle);
color: var(--su-color-primary);
}
.danger {
background-color: var(--su-color-danger);
color: var(--su-white);
}
.danger:hover:not(:disabled) {
opacity: 0.8;
}
.block {
display: flex;
width: 100%;
}
.spinner {
width: 1em;
height: 1em;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: var(--su-radius-pill);
animation: spin 0.8s linear infinite;
}
/* 图标原地旋转:重新扫描这类「刷新动作」保留图标语义,不换成转圈 */
.spinIcon {
display: inline-flex;
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* 生产标准:尊重系统的「减少动态效果」 */
@media (prefers-reduced-motion: reduce) {
.root {
transition: none;
}
.spinner,
.spinIcon {
animation-duration: 2.4s;
}
}
+75
View File
@@ -0,0 +1,75 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import styles from './Button.module.css'
/**
* 按钮。
*
* 四种语义、两种尺寸、两种形状(圆角 / 胶囊)。
* 图标与文字之间由 `gap` 控制,loading 时用同色转圈替换图标位。
*/
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
export type ButtonSize = 'sm' | 'md' | 'lg'
export type ButtonShape = 'rounded' | 'pill'
export type ButtonProps = {
variant?: ButtonVariant
size?: ButtonSize
/** 形状:rounded 6px 圆角 / pill 胶囊 */
shape?: ButtonShape
/** 占满一行 */
block?: boolean
/** 加载中:自动禁用,点击不触发 */
loading?: boolean
/** 图标原地旋转(不替换成转圈),用于「重新扫描」这类刷新动作 */
spinning?: boolean
/** 文字左侧的图标 */
icon?: ReactNode
children?: ReactNode
} & ButtonHTMLAttributes<HTMLButtonElement>
export function Button({
variant = 'primary',
size = 'md',
shape = 'rounded',
block = false,
loading = false,
spinning = false,
icon,
children,
className,
disabled,
type = 'button',
...rest
}: ButtonProps) {
const classes = [
styles.root,
styles[variant],
styles[size],
shape === 'pill' ? styles.pill : undefined,
block ? styles.block : undefined,
className,
]
.filter(Boolean)
.join(' ')
return (
<button
type={type}
className={classes}
disabled={disabled === true || loading || spinning}
aria-busy={loading || spinning || undefined}
{...rest}
>
{loading ? (
<span className={styles.spinner} aria-hidden="true" />
) : spinning && icon !== undefined ? (
<span className={styles.spinIcon} aria-hidden="true">
{icon}
</span>
) : (
icon
)}
{children !== undefined && <span>{children}</span>}
</button>
)
}
+36
View File
@@ -0,0 +1,36 @@
.root {
border: 1px solid var(--su-card-border);
border-radius: var(--su-radius-md);
background-color: var(--su-bg-card);
box-shadow: var(--su-shadow-card);
}
.none {
padding: 0;
}
.sm {
padding: var(--su-space-3);
}
.md {
padding: var(--su-space-5);
}
.lg {
padding: var(--su-space-6);
}
.hoverable {
transition: box-shadow var(--su-duration-base) var(--su-ease-in-out);
}
.hoverable:hover {
box-shadow: var(--su-shadow-hover);
}
@media (prefers-reduced-motion: reduce) {
.hoverable {
transition: none;
}
}
+39
View File
@@ -0,0 +1,39 @@
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
import styles from './Card.module.css'
/**
* 卡片容器:白底、细边、极轻阴影。
*
* 只负责「一块内容」的外观,不带标题、不带操作——
* 标题栏、角标这些由使用方组合(见展示站的「组合示例」)。
*/
export type CardProps = {
as?: 'div' | 'section' | 'article' | 'aside'
/** 内边距:none 用于自带 padding 的内容(如封面贴边) */
padding?: 'none' | 'sm' | 'md' | 'lg'
/** 悬停时阴影加深,用于可点击的整体卡片 */
hoverable?: boolean
className?: string
style?: CSSProperties
children?: ReactNode
} & Omit<HTMLAttributes<HTMLElement>, 'className' | 'style' | 'children'>
export function Card({
as: Tag = 'div',
padding = 'md',
hoverable = false,
className,
style,
children,
...rest
}: CardProps) {
const classes = [styles.root, styles[padding], hoverable ? styles.hoverable : undefined, className]
.filter(Boolean)
.join(' ')
return (
<Tag className={classes} style={style} {...rest}>
{children}
</Tag>
)
}
+80
View File
@@ -0,0 +1,80 @@
.root {
display: inline-flex;
align-items: center;
gap: var(--su-space-2);
font-size: var(--su-font-size-md);
line-height: var(--su-line-height-md);
color: var(--su-text-main);
cursor: pointer;
}
.disabled {
color: var(--su-text-muted);
cursor: not-allowed;
}
/* 原生 input 只留 1px,可视部分由 .box 画 */
.input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.box {
position: relative;
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border: 1px solid var(--su-border-strong);
border-radius: var(--su-radius-xs);
background-color: var(--su-bg-card);
transition:
background-color var(--su-duration-base) var(--su-ease-in-out),
border-color var(--su-duration-base) var(--su-ease-in-out);
}
/* 对勾用两道边框画,避免依赖图标字形的小尺寸渲染 */
.box::after {
content: '';
width: 8px;
height: 4px;
border-bottom: 2px solid var(--su-white);
border-left: 2px solid var(--su-white);
transform: rotate(-45deg) translateY(-1px);
opacity: 0;
transition: opacity var(--su-duration-fast) var(--su-ease-in-out);
}
.input:checked + .box {
border-color: var(--su-color-primary);
background-color: var(--su-color-primary);
}
.input:checked + .box::after {
opacity: 1;
}
.input:focus-visible + .box {
outline: var(--su-focus-ring);
outline-offset: var(--su-focus-ring-offset);
}
.disabled .box {
background-color: var(--su-bg-light);
}
.label {
user-select: none;
}
@media (prefers-reduced-motion: reduce) {
.box,
.box::after {
transition: none;
}
}
+43
View File
@@ -0,0 +1,43 @@
import type { ReactNode } from 'react'
import styles from './Checkbox.module.css'
/**
* 复选框:多选、开关型确认(「记住我」「订阅通知」)。
*
* 用原生 `input[type=checkbox]` 保证键盘与读屏行为,视觉部分自绘;
* 点击文字也能勾选(label 包裹)。
*/
export type CheckboxProps = {
checked: boolean
onChange: (checked: boolean) => void
disabled?: boolean
/** 可见文字,同时作为无障碍名称 */
label: ReactNode
className?: string
}
export function Checkbox({
checked,
onChange,
disabled = false,
label,
className,
}: CheckboxProps) {
return (
<label
className={[styles.root, disabled ? styles.disabled : undefined, className]
.filter(Boolean)
.join(' ')}
>
<input
className={styles.input}
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
/>
<span className={styles.box} aria-hidden="true" />
<span className={styles.label}>{label}</span>
</label>
)
}
+79
View File
@@ -0,0 +1,79 @@
.root {
position: relative;
display: block;
width: 100%;
overflow: hidden;
border-radius: var(--su-radius-cover);
background-color: var(--su-bg-light);
text-align: left;
}
/* 贴边用在 Card padding="none" 里 */
.flat {
border-radius: 0;
}
.poster {
aspect-ratio: 2 / 3;
}
.article {
aspect-ratio: 4 / 3;
}
.wide {
aspect-ratio: 16 / 9;
}
.image {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--su-duration-base) var(--su-ease-in-out);
}
.zoom:hover .image {
transform: scale(1.04);
}
/* 参考站:悬停整块上浮 2px(不是缩放图片) */
.lift {
transition:
transform 0.2s ease,
box-shadow 0.2s ease;
}
.lift:hover {
transform: translateY(-2px);
}
.placeholder {
display: grid;
place-items: center;
width: 100%;
height: 100%;
color: var(--su-text-sub);
font-size: var(--su-font-size-lg);
}
.caption {
position: absolute;
inset: auto 0 0;
display: -webkit-box;
overflow: hidden;
padding: var(--su-space-5) var(--su-space-2) var(--su-space-2);
background: var(--su-cover-scrim);
color: var(--su-white);
font-size: var(--su-font-size-sm);
line-height: var(--su-line-height-sm);
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
@media (prefers-reduced-motion: reduce) {
.image,
.lift {
transition: none;
}
}
+85
View File
@@ -0,0 +1,85 @@
import type { ReactNode } from 'react'
import styles from './Cover.module.css'
/**
* 比例封面卡:图片 + 底部压暗层 + 标题。
*
* 海报(2:3)、图文(4:3)、宽图(16:9)都用它,避免每个列表各写一套
* `aspect-ratio` + `object-fit` + 渐变遮罩。
* 传 `onClick` 时整块变成按钮(可键盘操作);不传就是静态图。
*/
export type CoverRatio = 'poster' | 'article' | 'wide' | number
export type CoverProps = {
src?: string
alt: string
/** 底部标题;不传则只显示图片 */
caption?: ReactNode
/** poster 2:3 / article 4:3 / wide 16:9,或直接给数字(宽高比) */
ratio?: CoverRatio
/** 圆角:默认使用 --su-radius-cover(8px);'none' 用于贴边容器 */
radius?: 'cover' | 'none'
/** 悬停时整块上浮 2px(参考站行为),默认 true */
lift?: boolean
/** 悬停时图片轻微放大(与 lift 二选一),默认 false */
hoverZoom?: boolean
/** 传了就把整块变成按钮 */
onClick?: () => void
/** 没有图片时的占位内容 */
children?: ReactNode
className?: string
}
export function Cover({
src,
alt,
caption,
ratio = 'article',
radius = 'cover',
lift = true,
hoverZoom = false,
onClick,
children,
className,
}: CoverProps) {
const classes = [
styles.root,
typeof ratio === 'string' ? styles[ratio] : undefined,
radius === 'none' ? styles.flat : undefined,
lift ? styles.lift : undefined,
hoverZoom ? styles.zoom : undefined,
className,
]
.filter(Boolean)
.join(' ')
const style = typeof ratio === 'number' ? { aspectRatio: String(ratio) } : undefined
const content = (
<>
{src !== undefined ? (
<img className={styles.image} src={src} alt={alt} loading="lazy" />
) : (
<span className={styles.placeholder} aria-hidden="true">
{children ?? alt.slice(0, 1)}
</span>
)}
{caption !== undefined && <span className={styles.caption}>{caption}</span>}
</>
)
if (onClick !== undefined) {
return (
<button type="button" className={classes} style={style} title={alt} onClick={onClick}>
{content}
</button>
)
}
return (
<figure className={classes} style={style}>
{content}
</figure>
)
}
+38
View File
@@ -0,0 +1,38 @@
.root {
flex-shrink: 0;
border-color: var(--su-border);
}
.horizontal {
width: 100%;
border-top: 1px solid;
}
.vertical {
align-self: stretch;
border-left: 1px solid;
}
.dashed {
border-style: dashed;
}
.solid {
border-style: solid;
}
.horizontal.sm {
margin: var(--su-space-2) 0;
}
.horizontal.md {
margin: var(--su-space-4) 0;
}
.vertical.sm {
margin: 0 var(--su-space-2);
}
.vertical.md {
margin: 0 var(--su-space-4);
}
+24
View File
@@ -0,0 +1,24 @@
import styles from './Divider.module.css'
/**
* 分隔线:横向分割内容块,或纵向分割并排元素。
*/
export type DividerProps = {
orientation?: 'horizontal' | 'vertical'
variant?: 'solid' | 'dashed'
spacing?: 'sm' | 'md'
className?: string
}
export function Divider({
orientation = 'horizontal',
variant = 'solid',
spacing = 'md',
className,
}: DividerProps) {
const classes = [styles.root, styles[orientation], styles[variant], styles[spacing], className]
.filter(Boolean)
.join(' ')
return <div className={classes} role="separator" aria-orientation={orientation} />
}
+95
View File
@@ -0,0 +1,95 @@
.root {
display: none;
flex-direction: column;
max-width: 100vw;
height: 100vh;
max-height: 100vh;
height: 100dvh;
max-height: 100dvh;
padding: 0;
border: 1px solid var(--su-card-border);
background-color: var(--su-bg-card);
color: var(--su-text-main);
overflow-y: auto;
}
.root[open] {
display: flex;
}
/* 贴在视口右侧 / 左侧:dialog 在 top layer 里默认居中,靠 margin 推边 */
.right {
margin: 0 0 0 auto;
border-radius: var(--su-radius-md) 0 0 var(--su-radius-md);
animation: drawer-in-right var(--su-duration-base) var(--su-ease-out);
}
.left {
margin: 0 auto 0 0;
border-radius: 0 var(--su-radius-md) var(--su-radius-md) 0;
animation: drawer-in-left var(--su-duration-base) var(--su-ease-out);
}
.root::backdrop {
background-color: rgba(0, 0, 0, 0.55);
}
.head {
display: flex;
flex-shrink: 0;
align-items: center;
justify-content: space-between;
gap: var(--su-space-3);
padding: var(--su-space-4) var(--su-space-5);
border-bottom: 1px solid var(--su-border);
}
.title {
font-size: var(--su-font-size-lg);
line-height: var(--su-line-height-lg);
font-weight: var(--su-font-weight-bold);
}
.body {
flex: 1;
padding: var(--su-space-5);
font-size: var(--su-font-size-md);
line-height: var(--su-line-height-md);
color: var(--su-text-sub);
}
.footer {
display: flex;
flex-shrink: 0;
justify-content: flex-end;
gap: var(--su-space-2);
padding: var(--su-space-4) var(--su-space-5);
border-top: 1px solid var(--su-border);
}
@keyframes drawer-in-right {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
@keyframes drawer-in-left {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@media (prefers-reduced-motion: reduce) {
.right,
.left {
animation: none;
}
}
+78
View File
@@ -0,0 +1,78 @@
import { useEffect, useRef, type ReactNode } from 'react'
import { Icon } from './Icon'
import { IconButton } from './IconButton'
import styles from './Drawer.module.css'
/**
* 抽屉:从侧边滑出的面板,适合「筛选 / 设置 / 详情」这类不打断主流程的内容。
*
* 和 `Modal` 一样基于原生 `<dialog>`(焦点陷阱、Esc、背景 inert),
* 区别只在定位:贴边、全高。
*/
export type DrawerPlacement = 'right' | 'left'
export type DrawerProps = {
open: boolean
onClose: () => void
title?: ReactNode
placement?: DrawerPlacement
/** 宽度,px,默认 360 */
width?: number
children?: ReactNode
footer?: ReactNode
className?: string
}
export function Drawer({
open,
onClose,
title,
placement = 'right',
width = 360,
children,
footer,
className,
}: DrawerProps) {
const dialogRef = useRef<HTMLDialogElement>(null)
useEffect(() => {
const dialog = dialogRef.current
if (dialog === null) return
if (open && !dialog.open) dialog.showModal()
if (!open && dialog.open) dialog.close()
}, [open])
useEffect(() => {
if (!open) return
const previous = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = previous
}
}, [open])
return (
<dialog
ref={dialogRef}
className={[styles.root, styles[placement], className].filter(Boolean).join(' ')}
style={{ width: `${width}px` }}
onCancel={(event) => {
event.preventDefault()
onClose()
}}
onClick={(event) => {
if (event.target === dialogRef.current) onClose()
}}
>
<header className={styles.head}>
<h2 className={styles.title}>{title}</h2>
<IconButton size="xs" label="关闭" icon={<Icon name="close_line" />} onClick={onClose} />
</header>
{children !== undefined && <div className={styles.body}>{children}</div>}
{footer !== undefined && <footer className={styles.footer}>{footer}</footer>}
</dialog>
)
}
+18
View File
@@ -0,0 +1,18 @@
.root {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--su-space-3);
padding: var(--su-space-2) 0;
}
.text {
font-size: var(--su-font-size-md);
line-height: var(--su-line-height-md);
color: var(--su-text-sub);
}
.action {
display: flex;
gap: var(--su-space-2);
}
+23
View File
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react'
import styles from './Empty.module.css'
/**
* 空态:一段说明文字 + 可选操作。
*
* 列表为空、搜索无结果、内容尚未准备时都用它,保证空态长得一样。
*/
export type EmptyProps = {
text?: ReactNode
/** 文字下方的操作,例如「清空搜索」 */
action?: ReactNode
className?: string
}
export function Empty({ text = '暂无内容', action, className }: EmptyProps) {
return (
<div className={[styles.root, className].filter(Boolean).join(' ')}>
<p className={styles.text}>{text}</p>
{action !== undefined && <div className={styles.action}>{action}</div>}
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
.root {
display: flex;
flex-direction: column;
gap: var(--su-space-2);
}
.label {
font-size: var(--su-font-size-sm);
line-height: var(--su-line-height-sm);
font-weight: var(--su-font-weight-bold);
color: var(--su-text-main);
}
.required {
margin-left: 2px;
color: var(--su-color-danger);
}
.hint {
font-size: var(--su-font-size-xs);
line-height: var(--su-line-height-xs);
color: var(--su-text-muted);
}
.error {
font-size: var(--su-font-size-xs);
line-height: var(--su-line-height-xs);
color: var(--su-color-danger);
}
+56
View File
@@ -0,0 +1,56 @@
import type { ReactNode } from 'react'
import styles from './FormField.module.css'
/**
* 表单字段:标签 + 控件 + 提示/错误。
*
* 只管排版与错误文案的位置,**不做校验**——校验逻辑留在页面,
* 这里只负责在 `error` 出现时用 `role="alert"` 播报并标红。
*/
export type FormFieldProps = {
label: ReactNode
/** 关联控件 id(用 label 的 htmlFor) */
htmlFor?: string
/** 字段说明,错误出现时隐藏 */
hint?: ReactNode
/** 错误文案;有值时提示变红并获得 role="alert" */
error?: ReactNode
required?: boolean
children: ReactNode
className?: string
}
export function FormField({
label,
htmlFor,
hint,
error,
required = false,
children,
className,
}: FormFieldProps) {
const hasError = error !== undefined && error !== null && error !== ''
return (
<div className={[styles.root, className].filter(Boolean).join(' ')}>
<label className={styles.label} htmlFor={htmlFor}>
{label}
{required && (
<span className={styles.required} aria-hidden="true">
*
</span>
)}
</label>
{children}
{hasError ? (
<span className={styles.error} role="alert">
{error}
</span>
) : (
hint !== undefined && <span className={styles.hint}>{hint}</span>
)}
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
.root {
display: grid;
grid-template-columns: repeat(var(--su-grid-cols, 4), minmax(0, 1fr));
gap: var(--su-grid-gap);
}
.sm {
--su-grid-gap: var(--su-space-2);
}
.md {
--su-grid-gap: var(--su-space-3);
}
.lg {
--su-grid-gap: var(--su-space-4);
}
@media (max-width: 1100px) {
.root {
grid-template-columns: repeat(var(--su-grid-cols-tablet, 2), minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.root {
grid-template-columns: repeat(var(--su-grid-cols-mobile, 1), minmax(0, 1fr));
}
}
+52
View File
@@ -0,0 +1,52 @@
import type { CSSProperties, ReactNode } from 'react'
import styles from './Grid.module.css'
/**
* 网格:给「卡片列表」用的一致布局。
*
* 列数走 CSS 变量,断点收在组件里——页面不用再各写一份媒体查询。
* 默认窄屏退化:≤1100px 取一半(至少 2 列),≤720px 收成一列。
*/
export type GridGap = 'sm' | 'md' | 'lg'
export type GridProps = {
/** 宽屏列数 */
columns?: number
/** 中等屏列数,默认一半(至少 2) */
tabletColumns?: number
/** 窄屏列数,默认 1 */
mobileColumns?: number
gap?: GridGap
/** 用 ul 渲染(配合 li 列表项,保持语义) */
as?: 'div' | 'ul'
role?: string
className?: string
style?: CSSProperties
children?: ReactNode
}
export function Grid({
columns = 4,
tabletColumns,
mobileColumns = 1,
gap = 'md',
as: Tag = 'div',
role,
className,
style,
children,
}: GridProps) {
const vars: Record<string, string> = {
'--su-grid-cols': String(columns),
'--su-grid-cols-tablet': String(tabletColumns ?? Math.max(2, Math.ceil(columns / 2))),
'--su-grid-cols-mobile': String(mobileColumns),
}
const classes = [styles.root, styles[gap], className].filter(Boolean).join(' ')
return (
<Tag className={classes} style={{ ...vars, ...style }} role={role}>
{children}
</Tag>
)
}
+24
View File
@@ -0,0 +1,24 @@
import type { HTMLAttributes } from 'react'
import type { IconName } from '../theme/icons'
/**
* 图标:参考站 iconfont 全量字形的轻封装。
*
* 颜色与字号都继承当前文字(`font-size` / `currentColor`),
* 所以同一个图标可以直接放进按钮、标签或标题里,不需要单独调色。
*
* 可用名字见 `src/theme/icons.ts`(自动生成,446 个),
* 文档站「图标」一节可以按名字过滤并点击复制。
*/
export type { IconName }
export type IconProps = {
name: IconName
} & HTMLAttributes<HTMLElement>
export function Icon({ name, className, ...rest }: IconProps) {
const classes = ['su-iconfont', `su-icon-${name}`, className].filter(Boolean).join(' ')
// 图标一律视为装饰:语义由外层按钮的 aria-label 或相邻文字提供
return <i className={classes} aria-hidden="true" {...rest} />
}
+84
View File
@@ -0,0 +1,84 @@
.root {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--su-icon-button-sm);
height: var(--su-icon-button-sm);
border-radius: var(--su-radius-pill);
touch-action: manipulation;
background-color: transparent;
color: var(--su-text-sub);
font-size: var(--su-font-size-lg);
cursor: pointer;
transition:
color var(--su-duration-base) var(--su-ease-in-out),
background-color var(--su-duration-base) var(--su-ease-in-out);
}
.root:focus-visible {
outline: var(--su-focus-ring);
outline-offset: var(--su-focus-ring-offset);
}
.root:disabled {
color: var(--su-text-muted);
cursor: not-allowed;
}
/* 尺寸:参考站的 .btn-icon 是 28 / 36 / 44,图标字号 16 / 20 / 24 */
.xs {
width: var(--su-icon-button-xs);
height: var(--su-icon-button-xs);
font-size: var(--su-font-size-md);
}
.sm {
width: var(--su-icon-button-sm);
height: var(--su-icon-button-sm);
font-size: 20px;
}
.md {
width: var(--su-icon-button-md);
height: var(--su-icon-button-md);
font-size: 24px;
}
.ghost {
background-color: transparent;
}
.ghost:hover:not(:disabled) {
background-color: var(--su-bg-subtle);
color: var(--su-text-main);
}
.soft {
background-color: var(--su-bg-light);
color: var(--su-text-sub);
}
.soft:hover:not(:disabled) {
background-color: var(--su-bg-hover);
color: var(--su-text-main);
}
.root[data-spinning] > * {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.root {
transition: none;
}
.root[data-spinning] > * {
animation-duration: 2.4s;
}
}
+48
View File
@@ -0,0 +1,48 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react'
import styles from './IconButton.module.css'
/**
* 图标按钮:只放一个图标,所以 `label` 必填(写进 aria-label 与 title),
* 避免出现屏幕阅读器读不出内容的按钮。
*
* 圆形,尺寸对齐参考站的 .btn-icon(28 / 36 / 44,图标 16 / 20 / 24)。
*/
export type IconButtonSize = 'xs' | 'sm' | 'md'
export type IconButtonProps = {
/** 无障碍名称,同时作为原生 title */
label: string
icon: ReactNode
variant?: 'ghost' | 'soft'
size?: IconButtonSize
/** 图标旋转(刷新类操作使用) */
spinning?: boolean
} & Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'aria-label' | 'title'>
export function IconButton({
label,
icon,
variant = 'ghost',
size = 'sm',
spinning = false,
className,
type = 'button',
...rest
}: IconButtonProps) {
const classes = [styles.root, styles[variant], styles[size], className]
.filter(Boolean)
.join(' ')
return (
<button
type={type}
className={classes}
title={label}
aria-label={label}
data-spinning={spinning || undefined}
{...rest}
>
{icon}
</button>
)
}
+58
View File
@@ -0,0 +1,58 @@
.root {
position: relative;
display: block;
width: 100%;
overflow: hidden;
background-color: var(--su-bg-light);
}
.square {
aspect-ratio: 1;
}
.video {
aspect-ratio: 16 / 9;
}
.wide {
aspect-ratio: 21 / 9;
}
.fit-cover .img {
object-fit: cover;
}
.fit-contain .img {
object-fit: contain;
}
.r-none {
border-radius: 0;
}
.r-sm {
border-radius: var(--su-radius-sm);
}
.r-md {
border-radius: var(--su-radius-md);
}
.r-cover {
border-radius: var(--su-radius-cover);
}
.img {
display: block;
width: 100%;
height: 100%;
}
.fallback {
display: grid;
place-items: center;
width: 100%;
height: 100%;
color: var(--su-text-sub);
font-size: var(--su-font-size-lg);
}
+67
View File
@@ -0,0 +1,67 @@
import { useState, type ReactNode } from 'react'
import styles from './Image.module.css'
/**
* 图片:带比例、圆角、加载失败降级的 img。
*
* 和 `Cover` 的区别:Cover 是「封面卡」(标题 + 压暗层 + 悬停上浮),
* Image 是正文 / 相册里的普通图片,不携带标题语义。
*/
export type ImageRatio = 'square' | 'video' | 'wide' | number
export type ImageFit = 'cover' | 'contain'
export type ImageProps = {
src: string
alt: string
/** square 1:1 / video 16:9 / wide 21:9,或直接给数字宽高比 */
ratio?: ImageRatio
/** 填充方式:cover 裁切(默认)/ contain 完整显示 */
fit?: ImageFit
/** 圆角:none / sm(6) / md(12) / cover(8) */
radius?: 'none' | 'sm' | 'md' | 'cover'
/** 加载失败时的占位内容 */
fallback?: ReactNode
className?: string
}
export function Image({
src,
alt,
ratio = 'video',
fit = 'cover',
radius = 'sm',
fallback,
className,
}: ImageProps) {
const [failed, setFailed] = useState(false)
const classes = [
styles.root,
typeof ratio === 'string' ? styles[ratio] : undefined,
styles[`fit-${fit}`],
styles[`r-${radius}`],
className,
]
.filter(Boolean)
.join(' ')
const style = typeof ratio === 'number' ? { aspectRatio: String(ratio) } : undefined
return (
<div className={classes} style={style}>
{failed ? (
<span className={styles.fallback} aria-hidden="true">
{fallback ?? alt.slice(0, 1)}
</span>
) : (
<img
className={styles.img}
src={src}
alt={alt}
loading="lazy"
onError={() => setFailed(true)}
/>
)}
</div>
)
}
+93
View File
@@ -0,0 +1,93 @@
.root {
display: flex;
align-items: center;
gap: var(--su-space-3);
height: var(--su-control-height-md);
padding: 0 var(--su-space-3);
border: 1px solid var(--su-border-strong);
border-radius: var(--su-radius-sm);
background-color: var(--su-bg-card);
transition: border-color var(--su-duration-base) var(--su-ease-in-out);
}
.sm {
height: var(--su-control-height-sm);
}
.md {
height: var(--su-control-height-md);
}
.lg {
height: var(--su-control-height-lg);
}
/* 文本框的焦点只用「边框换主色」表达:
输入框在点击时也会命中 :focus-visible,再叠一圈 outline 就会出现「双框」,
与参考站不一致,也是用户实际遇到的 bug。 */
.root:focus-within {
border-color: var(--su-color-primary);
}
.icon {
flex-shrink: 0;
color: var(--su-text-muted);
font-size: var(--su-font-size-lg);
}
.input {
flex: 1;
min-width: 0;
height: 100%;
border: none;
background: none;
font-family: inherit;
font-size: var(--su-font-size-md);
color: var(--su-text-main);
outline: none;
}
.input::placeholder {
color: var(--su-text-placeholder);
}
.shortcut {
flex-shrink: 0;
padding: 0 var(--su-space-2);
border: 1px solid var(--su-border-strong);
border-radius: var(--su-radius-xs);
background-color: var(--su-bg-subtle);
font-family: var(--su-font-mono);
font-size: var(--su-font-size-sm);
color: var(--su-text-muted);
}
.clear {
--clear-size: 20px;
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: var(--clear-size);
height: var(--clear-size);
border-radius: var(--su-radius-pill);
color: var(--su-text-muted);
font-size: var(--su-font-size-sm);
cursor: pointer;
transition:
color var(--su-duration-fast) var(--su-ease-in-out),
background-color var(--su-duration-fast) var(--su-ease-in-out);
}
.clear:hover {
background-color: var(--su-bg-subtle);
color: var(--su-text-sub);
}
@media (prefers-reduced-motion: reduce) {
.root,
.clear {
transition: none;
}
}
+64
View File
@@ -0,0 +1,64 @@
import type { CSSProperties, InputHTMLAttributes, Ref } from 'react'
import { Icon, type IconName } from './Icon'
import styles from './Input.module.css'
/**
* 输入框:左侧图标 + 清除按钮 + 快捷键提示的组合式输入。
*
* 事件语义与原生 input 一致(`onChange` 拿到的是事件),
* 需要「只关心字符串」时在使用方包一层,而不是在库里改语义。
*
* `className` 与 `style` 作用在外层容器上(设置宽度、外边距都用它),
* 其余原生属性透传给内部的 input。
*/
export type InputSize = 'sm' | 'md' | 'lg'
export type InputProps = {
/** 左侧前缀图标 */
icon?: IconName
/** 尺寸:32 / 36 / 44 高 */
size?: InputSize
/** 有内容时显示清除按钮 */
clearable?: boolean
onClear?: () => void
/** 右侧提示,如 `⌘K`;仅作展示,不参与键盘处理 */
shortcut?: string
inputRef?: Ref<HTMLInputElement>
className?: string
style?: CSSProperties
} & Omit<InputHTMLAttributes<HTMLInputElement>, 'size' | 'className' | 'style'>
export function Input({
icon,
size = 'md',
clearable = false,
onClear,
shortcut,
inputRef,
className,
style,
value,
...rest
}: InputProps) {
const hasValue = value !== undefined && value !== null && String(value) !== ''
const showClear = clearable && hasValue && onClear !== undefined
return (
<div
className={[styles.root, styles[size], className].filter(Boolean).join(' ')}
style={style}
>
{icon !== undefined && <Icon name={icon} className={styles.icon} />}
<input ref={inputRef} className={styles.input} value={value} {...rest} />
{shortcut !== undefined && <kbd className={styles.shortcut}>{shortcut}</kbd>}
{showClear && (
<button type="button" className={styles.clear} aria-label="清空" onClick={onClear}>
<Icon name="close_line" />
</button>
)}
</div>
)
}
+32
View File
@@ -0,0 +1,32 @@
/* 对齐参考站 .item-badge:12px 字、18px 高、4px 圆角、渐变底、白色字 */
.root {
display: inline-flex;
flex-shrink: 0;
align-items: center;
height: 18px;
padding: 0 var(--su-space-1);
border-radius: var(--su-radius-xs);
font-size: var(--su-font-size-xs);
font-weight: var(--su-font-weight-strong);
line-height: 1;
color: var(--su-white);
white-space: nowrap;
}
.hot {
background-image: var(--su-gradient-hot);
}
.sticky {
background-image: var(--su-gradient-hot);
}
.paid {
background-image: var(--su-gradient-paid);
}
.neutral {
background-color: var(--su-bg-hover);
color: var(--su-text-main);
font-weight: var(--su-font-weight-bold);
}
+23
View File
@@ -0,0 +1,23 @@
import type { HTMLAttributes, ReactNode } from 'react'
import styles from './ItemBadge.module.css'
/**
* 状态角标:标题前的「置顶 / 付费 / HOT」这类小徽标。
*
* 和 `Badge`(数字角标)区分:这个是文字,更小、圆角 4px、带渐变底,
* 对应参考站的 `.item-badge`(置顶/付费)与热门标记。
*/
export type ItemBadgeTone = 'hot' | 'sticky' | 'paid' | 'neutral'
export type ItemBadgeProps = {
tone?: ItemBadgeTone
children: ReactNode
} & HTMLAttributes<HTMLSpanElement>
export function ItemBadge({ tone = 'hot', className, children, ...rest }: ItemBadgeProps) {
return (
<span className={[styles.root, styles[tone], className].filter(Boolean).join(' ')} {...rest}>
{children}
</span>
)
}
+191
View File
@@ -0,0 +1,191 @@
.root {
position: relative;
display: flex;
flex-direction: column;
min-height: 100vh;
/* 移动端地址栏收起/展开时,dvh 比 vh 稳定 */
min-height: 100dvh;
background-color: var(--su-bg-page);
color: var(--su-text-main);
}
/* 应用型界面:根节点锁视口,滚动交给内容区 */
.screen {
height: 100vh;
height: 100dvh;
min-height: 0;
overflow: hidden;
}
.container {
display: flex;
flex: 1;
flex-direction: column;
width: 100%;
min-height: 0;
margin: 0 auto;
padding: 0 var(--layout-gutter, 0);
}
.screen .container {
min-height: 0;
overflow: hidden;
}
/* 整屏模式:内容区必须严格等于剩余高度,否则内部滚动会失效 */
.screen .body {
flex: 1;
align-items: stretch;
min-height: 0;
overflow: hidden;
}
.screen .main,
.screen .sider,
.screen .aside {
height: 100%;
min-height: 0;
}
/* 整屏模式两侧栏各自滚动,别把内容顶出视口 */
.screen .sider,
.screen .aside {
overflow-y: auto;
}
.screen .inner {
min-height: 0;
}
.header {
flex-shrink: 0;
z-index: var(--su-z-header);
min-height: var(--layout-header-height, var(--su-header-height));
background-color: var(--su-bg-page);
}
/* 吸顶时用半透明底 + 模糊,滚动时内容从下面透出来 */
.sticky {
position: sticky;
top: 0;
background-color: color-mix(in srgb, var(--su-bg-page) 72%, transparent);
backdrop-filter: blur(12px);
}
/* 实底页头:应用型界面常用,配一条分隔线 */
.solid,
.sticky.solid {
/* 边框算进页头高度:60 + 1px 边框 = 61 会破坏「和参考站等高」的对齐 */
min-height: calc(var(--layout-header-height) - 1px);
background-color: var(--su-bg-card);
backdrop-filter: none;
border-bottom: 1px solid var(--su-border);
}
.body {
display: flex;
flex: 1;
align-items: stretch;
min-height: 0;
flex-wrap: wrap;
gap: var(--layout-gap, 0);
}
.screen .body {
min-height: 0;
}
.sider {
flex-shrink: 0;
width: var(--layout-sider);
}
.siderBordered {
border-right: 1px solid var(--su-border);
}
.main {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
min-height: 0;
}
.aside {
flex-shrink: 0;
width: var(--layout-aside);
border-left: 1px solid var(--su-border);
}
.inner {
flex: 1;
width: 100%;
min-height: 0;
margin: 0 auto;
}
.none {
padding: 0;
}
.sm {
padding: var(--su-space-3);
}
.md {
padding: var(--su-space-5);
}
.lg {
padding: var(--su-space-6);
}
/* 内容区内部滚动:页头与侧栏保持固定 */
.scroll {
overflow-y: auto;
scroll-behavior: smooth;
}
/* 隐藏滚动条但仍可滚动(滚轮 / 触控板 / 键盘都正常) */
.scrollHidden {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollHidden::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.footer {
flex-shrink: 0;
padding: var(--su-space-6);
border-top: 1px solid var(--su-border);
font-size: var(--su-font-size-sm);
color: var(--su-text-muted);
text-align: center;
}
/* 右栏先失效:宽度感更强,优先保证正文可读 */
@media (max-width: 1200px) {
.aside {
width: 100%;
border-left: none;
border-top: 1px solid var(--su-border);
}
}
/* 再收侧栏:两层都变成上下排列,内容不隐藏 */
@media (max-width: 1100px) {
.body {
flex-direction: column;
}
.sider {
width: 100%;
border-right: none;
border-bottom: 1px solid var(--su-border);
}
}
+160
View File
@@ -0,0 +1,160 @@
import type { CSSProperties, ReactNode } from 'react'
import styles from './Layout.module.css'
/**
* 页面外壳:页头 / 侧栏 / 内容 / 右栏 / 页脚。
*
* 只负责结构(区域划分、最大宽度、粘性页头、可选内部滚动),不写任何业务内容。
* 常见配方(侧栏+内容、内容+右栏、三栏、单栏窄内容)见展示页 `#layout`。
*
* 响应式:
* - ≤1200px:右栏(aside)堆到内容下方;
* - ≤1100px:侧栏从并排变成堆到内容上方;
* - 两者都保留内容,不靠隐藏来「适配」。
*/
export type LayoutProps = {
/** 页头:整宽、默认吸顶 */
header?: ReactNode
/** 左侧栏:固定宽度、与内容并排 */
sider?: ReactNode
/** 右侧栏:固定宽度、与内容并排(资料聚合、目录、广告位) */
aside?: ReactNode
/** 页脚 */
footer?: ReactNode
/** 内容区 */
children: ReactNode
/** 内容最大宽度,默认 1280;'full' 表示铺满 */
maxWidth?: number | 'full'
/** 左侧栏宽度,默认 260 */
siderWidth?: number
/** 右侧栏宽度,默认 300 */
asideWidth?: number
/** 侧栏 / 内容 / 右栏之间的间距(px),默认 0 */
gap?: number
/** 内容区左右留白,默认 'md'(20px) */
contentPadding?: 'none' | 'sm' | 'md' | 'lg'
/** 内容区顶部偏移(px):给页头下方的悬浮搜索 / 工具条留位置 */
contentOffset?: number
/** contained 时容器左右留白(px),默认 0 */
gutter?: number
/** 页头最小高度(px),默认读 --su-header-height,即 60 */
headerHeight?: number
/** 页头样式:blur(半透明模糊,默认)/ solid(实底 + 下边框) */
headerVariant?: 'blur' | 'solid'
/** 页头是否吸顶,默认 true */
stickyHeader?: boolean
/**
* 居中容器模式:给整页套一层 `maxWidth` 容器(页头 / 侧栏 / 内容一起居中)。
* 默认 false,即页头通栏、内容自己限宽。
*/
contained?: boolean
/** 侧栏右侧分隔线,默认 true */
siderBordered?: boolean
/**
* 整屏模式:根节点占满视口高度。
* 配合 `scrollable` 时内容区自己滚动,页头与侧栏固定(适合应用型界面)。
*/
height?: 'auto' | 'screen'
/** 内容区内部滚动(需要 height='screen') */
scrollable?: boolean
/** 内部滚动时隐藏滚动条(应用型界面常用;不影响滚轮/键盘滚动) */
hideScrollbar?: boolean
/** 内容区 id,方便 ScrollTop 之类按 id 滚动 */
contentId?: string
className?: string
style?: CSSProperties
}
export function Layout({
header,
sider,
aside,
footer,
children,
maxWidth = 1280,
siderWidth = 260,
asideWidth = 300,
gap = 0,
contentPadding = 'md',
contentOffset = 0,
gutter = 0,
headerHeight,
headerVariant = 'blur',
stickyHeader = true,
contained = false,
siderBordered = true,
height = 'auto',
scrollable = false,
hideScrollbar = false,
contentId,
className,
style,
}: LayoutProps) {
const maxWidthValue = maxWidth === 'full' ? 'none' : `${maxWidth}px`
const vars: Record<string, string> = {
'--layout-sider': `${siderWidth}px`,
'--layout-aside': `${asideWidth}px`,
'--layout-gap': `${gap}px`,
'--layout-gutter': `${gutter}px`,
'--layout-header-height': `${headerHeight ?? 60}px`,
}
const containerStyle = contained ? { maxWidth: maxWidthValue } : undefined
const innerMaxWidth = contained ? 'none' : maxWidthValue
return (
<div
className={[styles.root, height === 'screen' ? styles.screen : undefined, className]
.filter(Boolean)
.join(' ')}
style={{ ...vars, ...style }}
>
{/* 页头通栏:不放进限宽容器,页头内部自己决定内容宽度 */}
{header !== undefined && (
<header
className={[
styles.header,
stickyHeader ? styles.sticky : undefined,
headerVariant === 'solid' ? styles.solid : undefined,
]
.filter(Boolean)
.join(' ')}
>
{header}
</header>
)}
<div className={contained ? styles.container : undefined} style={containerStyle}>
<div className={styles.body}>
{sider !== undefined && (
<aside
className={`${styles.sider}${siderBordered ? ` ${styles.siderBordered}` : ''}`}
>
{sider}
</aside>
)}
<main className={styles.main}>
<div
id={contentId}
className={[
styles.inner,
styles[contentPadding],
scrollable ? styles.scroll : undefined,
scrollable && hideScrollbar ? styles.scrollHidden : undefined,
]
.filter(Boolean)
.join(' ')}
style={{ maxWidth: innerMaxWidth, marginTop: contentOffset }}
>
{children}
</div>
</main>
{aside !== undefined && <aside className={styles.aside}>{aside}</aside>}
</div>
{footer !== undefined && <footer className={styles.footer}>{footer}</footer>}
</div>
</div>
)
}
+113
View File
@@ -0,0 +1,113 @@
.root {
font-size: var(--su-font-size-md);
}
.list {
display: flex;
flex-direction: column;
gap: var(--su-space-1);
list-style: none;
}
/* 子菜单项之间不留缝:旧版只有一级菜单有 4px 间隔 */
.submenu {
display: flex;
flex-direction: column;
padding-left: var(--su-space-6);
list-style: none;
min-height: 0;
overflow: hidden;
}
.item {
display: flex;
align-items: center;
gap: var(--su-space-2);
width: 100%;
height: var(--menu-item-height, 36px);
padding: 0 var(--su-space-3);
border-radius: var(--su-radius-cover);
color: var(--su-text-sub);
font-weight: var(--su-font-weight-bold);
text-align: left;
cursor: pointer;
transition:
color var(--su-duration-base) var(--su-ease-in-out),
background-color var(--su-duration-base) var(--su-ease-in-out);
}
.item:hover {
background-color: var(--su-color-primary-soft);
color: var(--su-color-primary);
}
.item:focus-visible {
outline: var(--su-focus-ring);
outline-offset: var(--su-focus-ring-offset);
}
.active {
background-color: var(--su-color-primary-soft);
color: var(--su-color-primary);
font-weight: var(--su-font-weight-bold);
}
.icon {
display: inline-flex;
font-size: var(--su-font-size-lg);
}
.label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 折叠箭头用两道边框画(6×6 V 形),和参考站一致 */
.caret {
flex-shrink: 0;
width: 6px;
height: 6px;
margin-right: 2px;
border-left: 1px solid currentColor;
border-bottom: 1px solid currentColor;
opacity: 0.4;
transform: rotate(315deg);
transition:
transform var(--su-duration-base) var(--su-ease-in-out),
opacity var(--su-duration-base) var(--su-ease-in-out);
}
.item:hover .caret {
opacity: 0.7;
}
.caretOpen {
opacity: 0.7;
transform: rotate(135deg);
}
/* 子菜单展开:grid-template-rows 0fr → 1fr 做高度动画,不猜 max-height */
.submenuWrap {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.28s ease;
}
.submenuWrap[data-open='true'] {
grid-template-rows: 1fr;
}
.child {
height: 32px;
font-size: var(--su-font-size-sm);
}
@media (prefers-reduced-motion: reduce) {
.item,
.caret,
.submenuWrap {
transition: none;
}
}
+127
View File
@@ -0,0 +1,127 @@
import { useState, type CSSProperties, type ReactNode } from 'react'
import styles from './Menu.module.css'
/**
* 侧栏菜单:一层折叠 + 两级结构,数据无关。
*
* 业务树(分区 → 分组)由页面转成 items;组件只负责展开/收起与高亮。
*/
export type MenuItem = {
key: string
label: ReactNode
icon?: ReactNode
children?: MenuItem[]
}
export type MenuProps = {
items: MenuItem[]
activeKey?: string
onSelect: (key: string) => void
/** 默认展开的父级 key;含 activeKey 的父级会自动展开 */
defaultOpenKeys?: string[]
/** 一级菜单行高(px),默认 36;需要更松/更紧的密度时调它 */
itemHeight?: number
className?: string
}
export function Menu({
items,
activeKey,
onSelect,
defaultOpenKeys = [],
itemHeight = 36,
className,
}: MenuProps) {
const [openKeys, setOpenKeys] = useState<string[]>(() => {
const parents = items
.filter((item) => item.children?.some((child) => child.key === activeKey))
.map((item) => item.key)
return [...new Set([...defaultOpenKeys, ...parents])]
})
const toggle = (key: string) =>
setOpenKeys((current) =>
current.includes(key) ? current.filter((item) => item !== key) : [...current, key],
)
return (
<nav
className={[styles.root, className].filter(Boolean).join(' ')}
aria-label="菜单"
style={{ '--menu-item-height': `${itemHeight}px` } as CSSProperties}
>
<ul className={styles.list}>
{items.map((item) => {
const hasChildren = item.children !== undefined && item.children.length > 0
const open = openKeys.includes(item.key)
return (
<li key={item.key}>
{hasChildren ? (
<button
type="button"
className={styles.item}
aria-expanded={open}
onClick={() => toggle(item.key)}
>
{item.icon !== undefined && (
<span className={styles.icon} aria-hidden="true">
{item.icon}
</span>
)}
<span className={styles.label}>{item.label}</span>
<span
className={`${styles.caret}${open ? ` ${styles.caretOpen}` : ''}`}
aria-hidden="true"
/>
</button>
) : (
<button
type="button"
className={[styles.item, item.key === activeKey ? styles.active : undefined]
.filter(Boolean)
.join(' ')}
aria-current={item.key === activeKey ? 'page' : undefined}
onClick={() => onSelect(item.key)}
>
{item.icon !== undefined && (
<span className={styles.icon} aria-hidden="true">
{item.icon}
</span>
)}
<span className={styles.label}>{item.label}</span>
</button>
)}
{hasChildren && (
<div className={styles.submenuWrap} data-open={open ? 'true' : 'false'}>
<ul className={styles.submenu} inert={open ? undefined : true}>
{item.children?.map((child) => (
<li key={child.key}>
<button
type="button"
className={[
styles.item,
styles.child,
child.key === activeKey ? styles.active : undefined,
]
.filter(Boolean)
.join(' ')}
aria-current={child.key === activeKey ? 'page' : undefined}
onClick={() => onSelect(child.key)}
>
<span className={styles.label}>{child.label}</span>
</button>
</li>
))}
</ul>
</div>
)}
</li>
)
})}
</ul>
</nav>
)
}
+92
View File
@@ -0,0 +1,92 @@
.root {
width: calc(100vw - 32px);
max-width: 100%;
/* 宿主页面的 reset 会把 UA 的 margin:auto 干掉,这里显式写回来才能居中 */
margin: auto;
max-height: calc(100vh - 64px);
padding: 0;
border: 1px solid var(--su-card-border);
border-radius: var(--su-radius-md);
background-color: var(--su-bg-card);
color: var(--su-text-main);
box-shadow: var(--su-shadow-pop);
overflow: auto;
}
.root[open] {
animation: modal-in var(--su-duration-base) var(--su-ease-out);
}
.root::backdrop {
background-color: rgba(0, 0, 0, 0.6);
animation: backdrop-in var(--su-duration-base) var(--su-ease-out);
}
.sm {
max-width: 300px;
}
.md {
max-width: 460px;
}
.lg {
max-width: 640px;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--su-space-3);
padding: var(--su-space-5) var(--su-space-6) 0;
}
.title {
font-size: var(--su-font-size-lg);
line-height: var(--su-line-height-lg);
font-weight: var(--su-font-weight-bold);
}
.body {
padding: var(--su-space-3) var(--su-space-6) 0;
font-size: var(--su-font-size-md);
line-height: var(--su-line-height-md);
color: var(--su-text-sub);
}
.footer {
display: flex;
justify-content: flex-end;
gap: var(--su-space-2);
padding: var(--su-space-5) var(--su-space-6);
}
@keyframes modal-in {
from {
opacity: 0;
transform: translateY(-12px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.root[open],
.root::backdrop {
animation: none;
}
}
+95
View File
@@ -0,0 +1,95 @@
import { useEffect, useId, useRef, type ReactNode } from 'react'
import { Icon } from './Icon'
import { IconButton } from './IconButton'
import styles from './Modal.module.css'
/**
* 弹窗:居中对话框 + 遮罩,对应参考站的 `.nice-tips`。
*
* 基于原生 `<dialog>`:焦点陷阱、Esc 关闭、背景 inert 都由浏览器提供,
* 我们只补样式、滚动锁和受控开关。
*/
export type ModalSize = 'sm' | 'md' | 'lg'
export type ModalProps = {
open: boolean
onClose: () => void
title: ReactNode
children?: ReactNode
/** 底部操作区,通常是「取消 / 确认」两个按钮 */
footer?: ReactNode
size?: ModalSize
/** 点遮罩是否关闭,默认 true */
closeOnOverlay?: boolean
className?: string
}
export function Modal({
open,
onClose,
title,
children,
footer,
size = 'md',
closeOnOverlay = true,
className,
}: ModalProps) {
const dialogRef = useRef<HTMLDialogElement>(null)
const titleId = useId()
// 受控开关:open 变化时同步原生 dialog 的显示状态
useEffect(() => {
const dialog = dialogRef.current
if (dialog === null) return
if (open && !dialog.open) dialog.showModal()
if (!open && dialog.open) dialog.close()
}, [open])
// 打开时锁背景滚动(原生 dialog 不负责这一条)
useEffect(() => {
if (!open) return
const previous = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = previous
}
}, [open])
return (
<dialog
ref={dialogRef}
className={[styles.root, styles[size], className].filter(Boolean).join(' ')}
aria-labelledby={titleId}
onCancel={(event) => {
// Esc:交给调用方决定状态,避免原生直接关掉导致受控状态不一致
event.preventDefault()
onClose()
}}
onClick={(event) => {
// 遮罩点击的 target 就是 dialog 本身,面板内的点击已被 stopPropagation
if (closeOnOverlay && event.target === dialogRef.current) onClose()
}}
>
<header className={styles.head}>
<h2 id={titleId} className={styles.title}>
{title}
</h2>
<IconButton size="xs" label="关闭" icon={<Icon name="close_line" />} onClick={onClose} />
</header>
{children !== undefined && (
<div className={styles.body} onClick={(event) => event.stopPropagation()}>
{children}
</div>
)}
{footer !== undefined && (
<footer className={styles.footer} onClick={(event) => event.stopPropagation()}>
{footer}
</footer>
)}
</dialog>
)
}
+54
View File
@@ -0,0 +1,54 @@
.root {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--su-space-1);
padding: var(--su-space-5);
border: 1px dashed var(--su-border);
border-radius: var(--su-radius-md);
background-color: var(--su-bg-notice);
color: var(--su-text-sub);
}
.info {
border-style: dashed;
}
.error {
border-style: solid;
border-color: var(--su-color-danger-border);
background-color: var(--su-bg-danger-soft);
}
.title {
font-size: var(--su-font-size-md);
font-weight: var(--su-font-weight-bold);
color: var(--su-text-main);
}
.error .title {
color: var(--su-color-danger);
}
.body {
font-size: var(--su-font-size-md);
line-height: var(--su-line-height-md);
}
.action {
margin-top: var(--su-space-2);
padding: var(--su-space-1) var(--su-space-3);
border: 1px solid var(--su-border);
border-radius: var(--su-radius-sm);
background-color: var(--su-bg-card);
font-size: var(--su-font-size-md);
cursor: pointer;
transition:
border-color var(--su-duration-fast) var(--su-ease-out),
color var(--su-duration-fast) var(--su-ease-out);
}
.action:hover {
border-color: var(--su-color-primary);
color: var(--su-color-primary);
}
+37
View File
@@ -0,0 +1,37 @@
import type { ReactNode } from 'react'
import styles from './Notice.module.css'
/**
* 提示条:说明型(虚线浅底)与错误型(浅红实线)。
*
* 错误态带 `role="alert"`,屏幕阅读器会立刻播报;
* 说明态不加 live region,避免每次渲染都被念一遍。
*/
export type NoticeTone = 'info' | 'error'
export type NoticeProps = {
tone?: NoticeTone
title: ReactNode
children?: ReactNode
action?: {
label: string
onClick: () => void
}
className?: string
}
export function Notice({ tone = 'info', title, children, action, className }: NoticeProps) {
const classes = [styles.root, styles[tone], className].filter(Boolean).join(' ')
return (
<div className={classes} role={tone === 'error' ? 'alert' : undefined}>
<strong className={styles.title}>{title}</strong>
{children !== undefined && <div className={styles.body}>{children}</div>}
{action !== undefined && (
<button type="button" className={styles.action} onClick={action.onClick}>
{action.label}
</button>
)}
</div>
)
}
+56
View File
@@ -0,0 +1,56 @@
.root {
display: inline-flex;
align-items: center;
gap: var(--su-space-1);
}
.item {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
padding: 0 var(--su-space-2);
border-radius: var(--su-radius-xs);
background-color: var(--su-bg-light);
color: var(--su-text-sub);
font-size: var(--su-font-size-sm);
font-variant-numeric: tabular-nums;
cursor: pointer;
transition:
color var(--su-duration-base) var(--su-ease-in-out),
background-color var(--su-duration-base) var(--su-ease-in-out);
}
.item:hover:not(:disabled) {
background-color: var(--su-color-primary);
color: var(--su-white);
}
.item:focus-visible {
outline: var(--su-focus-ring);
outline-offset: var(--su-focus-ring-offset);
}
.item:disabled {
color: var(--su-text-disabled);
cursor: not-allowed;
}
.current,
.current:hover:not(:disabled) {
background-color: var(--su-color-primary);
color: var(--su-white);
}
.gap {
min-width: 20px;
text-align: center;
color: var(--su-text-muted);
}
@media (prefers-reduced-motion: reduce) {
.item {
transition: none;
}
}
+94
View File
@@ -0,0 +1,94 @@
import { Icon } from './Icon'
import styles from './Pagination.module.css'
/**
* 分页:对齐参考站 `.page-numbers`(32×32、radius 4、当前项主色实底)。
*
* 只负责页码与前后翻页;总数文案、每页条数由页面自己摆。
*/
export type PaginationProps = {
/** 当前页,从 1 开始 */
page: number
/** 总条数 */
total: number
pageSize?: number
onChange: (page: number) => void
/** 当前页左右各显示几个页码,默认 1 */
siblings?: number
className?: string
}
type PageItem = number | 'gap-left' | 'gap-right'
/** 生成页码序列:首尾固定,中间按当前页展开,超出用省略号 */
function buildPages(page: number, pageCount: number, siblings: number): PageItem[] {
// 首页 + 尾页 + 当前页 + 左右各 siblings 个
const visible = siblings * 2 + 5
if (pageCount <= visible) {
return Array.from({ length: pageCount }, (_, index) => index + 1)
}
const left = Math.max(2, page - siblings)
const right = Math.min(pageCount - 1, page + siblings)
const items: PageItem[] = [1]
if (left > 2) items.push('gap-left')
for (let current = left; current <= right; current += 1) items.push(current)
if (right < pageCount - 1) items.push('gap-right')
items.push(pageCount)
return items
}
export function Pagination({
page,
total,
pageSize = 10,
onChange,
siblings = 1,
className,
}: PaginationProps) {
const pageCount = Math.max(1, Math.ceil(total / pageSize))
return (
<nav className={[styles.root, className].filter(Boolean).join(' ')} aria-label="分页">
<button
type="button"
className={styles.item}
disabled={page <= 1}
aria-label="上一页"
onClick={() => onChange(page - 1)}
>
<Icon name="arrow_left_line" />
</button>
{buildPages(page, pageCount, siblings).map((item) =>
typeof item === 'number' ? (
<button
key={item}
type="button"
className={`${styles.item}${item === page ? ` ${styles.current}` : ''}`}
aria-current={item === page ? 'page' : undefined}
onClick={() => onChange(item)}
>
{item}
</button>
) : (
<span key={item} className={styles.gap} aria-hidden="true">
…
</span>
),
)}
<button
type="button"
className={styles.item}
disabled={page >= pageCount}
aria-label="下一页"
onClick={() => onChange(page + 1)}
>
<Icon name="arrow_right_line" />
</button>
</nav>
)
}
+32
View File
@@ -0,0 +1,32 @@
.root {
position: relative;
display: inline-flex;
}
.trigger {
display: inline-flex;
}
.panel {
position: absolute;
top: calc(100% + 6px);
z-index: var(--su-z-float);
min-width: 180px;
max-width: min(320px, calc(100vw - 32px));
padding: var(--su-space-3);
border: 1px solid var(--su-card-border);
border-radius: var(--su-radius-sm);
background-color: var(--su-bg-card);
box-shadow: var(--su-shadow-pop);
font-size: var(--su-font-size-sm);
line-height: var(--su-line-height-sm);
color: var(--su-text-sub);
}
.bottom-start {
left: 0;
}
.bottom-end {
right: 0;
}
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import styles from './Popover.module.css'
/**
* 气泡卡片:点击触发器展开的浮层(菜单、说明、表单片段)。
*
* 内部维护展开状态;点外部或 Esc 关闭。定位只支持触发器的下方左/右对齐,
* 不做自动翻转(内容再复杂就该用 `Drawer` / `Modal`)。
*/
export type PopoverProps = {
content: ReactNode
children: ReactNode
placement?: 'bottom-start' | 'bottom-end'
className?: string
}
export function Popover({ content, children, placement = 'bottom-start', className }: PopoverProps) {
const [open, setOpen] = useState(false)
const rootRef = useRef<HTMLSpanElement>(null)
useEffect(() => {
if (!open) return
const onPointerDown = (event: MouseEvent) => {
if (rootRef.current !== null && !rootRef.current.contains(event.target as Node)) {
setOpen(false)
}
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', onPointerDown)
document.addEventListener('keydown', onKeyDown)
return () => {
document.removeEventListener('mousedown', onPointerDown)
document.removeEventListener('keydown', onKeyDown)
}
}, [open])
return (
<span ref={rootRef} className={[styles.root, className].filter(Boolean).join(' ')}>
<span className={styles.trigger} onClick={() => setOpen((current) => !current)}>
{children}
</span>
{open && (
<span className={`${styles.panel} ${styles[placement]}`} role="dialog">
{content}
</span>
)}
</span>
)
}
+58
View File
@@ -0,0 +1,58 @@
.root {
display: flex;
align-items: center;
gap: var(--su-space-3);
width: 100%;
}
.track {
flex: 1;
overflow: hidden;
border-radius: var(--su-radius-pill);
background-color: var(--su-bg-light);
}
.sm {
height: 4px;
}
.md {
height: 8px;
}
.bar {
height: 100%;
border-radius: var(--su-radius-pill);
transition: width var(--su-duration-base) var(--su-ease-in-out);
}
.primary {
background-color: var(--su-color-primary);
}
.success {
background-color: var(--su-color-success);
}
.warning {
background-color: var(--su-color-warning);
}
.danger {
background-color: var(--su-color-danger);
}
.label {
flex-shrink: 0;
min-width: 36px;
font-size: var(--su-font-size-sm);
color: var(--su-text-sub);
text-align: right;
font-variant-numeric: tabular-nums;
}
@media (prefers-reduced-motion: reduce) {
.bar {
transition: none;
}
}
+38
View File
@@ -0,0 +1,38 @@
import styles from './Progress.module.css'
/** 进度条:纯展示,百分比由调用方给(0–100,超出会被夹住)。 */
export type ProgressTone = 'primary' | 'success' | 'warning' | 'danger'
export type ProgressProps = {
percent: number
size?: 'sm' | 'md'
tone?: ProgressTone
/** 右侧显示百分比文字 */
showLabel?: boolean
className?: string
}
export function Progress({
percent,
size = 'md',
tone = 'primary',
showLabel = false,
className,
}: ProgressProps) {
const value = Math.min(100, Math.max(0, Math.round(percent)))
return (
<div className={[styles.root, className].filter(Boolean).join(' ')}>
<div
className={`${styles.track} ${styles[size]}`}
role="progressbar"
aria-valuenow={value}
aria-valuemin={0}
aria-valuemax={100}
>
<div className={`${styles.bar} ${styles[tone]}`} style={{ width: `${value}%` }} />
</div>
{showLabel && <span className={styles.label}>{value}%</span>}
</div>
)
}
+92
View File
@@ -0,0 +1,92 @@
.group {
display: flex;
gap: var(--su-space-3);
}
.column {
flex-direction: column;
align-items: flex-start;
}
.row {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: var(--su-space-4);
}
.root {
display: inline-flex;
align-items: center;
gap: var(--su-space-2);
font-size: var(--su-font-size-md);
line-height: var(--su-line-height-md);
color: var(--su-text-main);
cursor: pointer;
}
.disabled {
color: var(--su-text-muted);
cursor: not-allowed;
}
.input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.dot {
position: relative;
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border: 1px solid var(--su-border-strong);
border-radius: var(--su-radius-pill);
background-color: var(--su-bg-card);
transition: border-color var(--su-duration-base) var(--su-ease-in-out);
}
/* 选中:主色描边 + 中心实心点 */
.dot::after {
content: '';
width: 8px;
height: 8px;
border-radius: var(--su-radius-pill);
background-color: var(--su-color-primary);
transform: scale(0);
transition: transform var(--su-duration-fast) var(--su-ease-in-out);
}
.input:checked + .dot {
border-color: var(--su-color-primary);
}
.input:checked + .dot::after {
transform: scale(1);
}
.input:focus-visible + .dot {
outline: var(--su-focus-ring);
outline-offset: var(--su-focus-ring-offset);
}
.disabled .dot {
background-color: var(--su-bg-light);
}
.label {
user-select: none;
}
@media (prefers-reduced-motion: reduce) {
.dot,
.dot::after {
transition: none;
}
}
+66
View File
@@ -0,0 +1,66 @@
import { useId, type ReactNode } from 'react'
import styles from './Radio.module.css'
/**
* 单选组:互斥选项,键盘方向键在组内切换(原生 radio 行为)。
*
* 独占一个选项且需要「是/否」语义时用 `Switch`;多选用 `Checkbox`。
*/
export type RadioOption = {
value: string
label: ReactNode
disabled?: boolean
}
export type RadioGroupProps = {
value: string
onChange: (value: string) => void
options: RadioOption[]
/** 排列方向,默认纵向 */
direction?: 'row' | 'column'
disabled?: boolean
className?: string
}
export function RadioGroup({
value,
onChange,
options,
direction = 'column',
disabled = false,
className,
}: RadioGroupProps) {
const name = useId()
return (
<div
role="radiogroup"
className={[styles.group, styles[direction], className].filter(Boolean).join(' ')}
>
{options.map((option) => {
const itemDisabled = disabled || option.disabled === true
return (
<label
key={option.value}
className={[styles.root, itemDisabled ? styles.disabled : undefined]
.filter(Boolean)
.join(' ')}
>
<input
className={styles.input}
type="radio"
name={name}
value={option.value}
checked={value === option.value}
disabled={itemDisabled}
onChange={() => onChange(option.value)}
/>
<span className={styles.dot} aria-hidden="true" />
<span className={styles.label}>{option.label}</span>
</label>
)
})}
</div>
)
}
+51
View File
@@ -0,0 +1,51 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--su-space-2);
padding: var(--su-space-8) var(--su-space-6);
text-align: center;
}
.icon {
font-size: 48px;
line-height: 1;
}
.success {
color: var(--su-color-success);
}
.error {
color: var(--su-color-danger);
}
.warning {
color: var(--su-color-warning);
}
.info {
color: var(--su-color-primary);
}
.title {
font-size: var(--su-font-size-lg);
line-height: var(--su-line-height-lg);
font-weight: var(--su-font-weight-bold);
color: var(--su-text-main);
}
.description {
max-width: 48ch;
font-size: var(--su-font-size-md);
line-height: var(--su-line-height-md);
color: var(--su-text-sub);
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--su-space-2);
margin-top: var(--su-space-2);
}
+45
View File
@@ -0,0 +1,45 @@
import type { ReactNode } from 'react'
import { Icon, type IconName } from './Icon'
import styles from './Result.module.css'
/**
* 结果页:整块的「成功 / 失败 / 警示 / 说明」反馈。
*
* 用于提交完成、空权限、出错等整页状态;行内提示用 `Notice`,轻量反馈用 `toast`。
*/
export type ResultStatus = 'success' | 'error' | 'warning' | 'info'
const ICONS: Record<ResultStatus, IconName> = {
success: 'check_circle_fill',
error: 'close_circle_line',
warning: 'warning_line',
info: 'question_line',
}
export type ResultProps = {
status?: ResultStatus
title: ReactNode
description?: ReactNode
/** 底部操作(按钮组) */
actions?: ReactNode
className?: string
}
export function Result({
status = 'info',
title,
description,
actions,
className,
}: ResultProps) {
return (
<div className={[styles.root, className].filter(Boolean).join(' ')}>
<span className={`${styles.icon} ${styles[status]}`} aria-hidden="true">
<Icon name={ICONS[status]} />
</span>
<h2 className={styles.title}>{title}</h2>
{description !== undefined && <p className={styles.description}>{description}</p>}
{actions !== undefined && <div className={styles.actions}>{actions}</div>}
</div>
)
}
+46
View File
@@ -0,0 +1,46 @@
.root {
--scrolltop-size: 42px;
position: fixed;
top: 50%;
right: var(--su-space-6);
z-index: var(--su-z-float);
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--scrolltop-size);
height: var(--scrolltop-size);
border-radius: var(--su-radius-pill);
background-color: var(--su-bg-light);
color: var(--su-text-sub);
font-size: var(--su-font-size-lg);
cursor: pointer;
opacity: 0;
pointer-events: none;
transform: translateY(-50%);
transition:
opacity var(--su-duration-base) var(--su-ease-in-out),
color var(--su-duration-base) var(--su-ease-in-out),
background-color var(--su-duration-base) var(--su-ease-in-out);
}
.visible {
opacity: 1;
pointer-events: auto;
}
.root:hover {
background-color: var(--su-bg-hover);
color: var(--su-color-primary);
}
.root:focus-visible {
outline: var(--su-focus-ring);
outline-offset: var(--su-focus-ring-offset);
}
@media (prefers-reduced-motion: reduce) {
.root {
transition: none;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { Icon } from './Icon'
import styles from './ScrollTop.module.css'
/**
* 回到顶部:固定在右侧的圆形按钮,滚动后才淡入。
*
* 内容区滚动(桌面端)和窗口滚动(窄屏)都支持:
* 传 `targetId` 就滚那个容器,否则滚窗口。
*/
export type ScrollTopProps = {
visible: boolean
/** 滚动容器 id,例如内容区;不传则滚窗口 */
targetId?: string
label?: string
/** 距右侧的距离(px),默认 24 */
offset?: number
className?: string
}
export function ScrollTop({
visible,
targetId,
label = '回到顶部',
offset = 24,
className,
}: ScrollTopProps) {
const scrollToTop = () => {
const target = targetId === undefined ? null : document.getElementById(targetId)
if (target !== null) {
target.scrollTo({ top: 0, behavior: 'smooth' })
return
}
window.scrollTo({ top: 0, behavior: 'smooth' })
}
return (
<button
type="button"
className={[styles.root, visible ? styles.visible : undefined, className]
.filter(Boolean)
.join(' ')}
style={{ right: offset }}
title={label}
aria-label={label}
tabIndex={visible ? 0 : -1}
onClick={scrollToTop}
>
<Icon name="arrow_to_up_line" />
</button>
)
}
+77
View File
@@ -0,0 +1,77 @@
.root {
display: inline-flex;
flex-shrink: 0;
max-width: 100%;
padding: 2px;
border-radius: var(--su-radius-sm);
background-color: var(--su-bg-light);
gap: 2px;
overflow-x: auto;
scrollbar-width: none;
}
.root::-webkit-scrollbar {
display: none;
}
.block {
display: flex;
width: 100%;
}
.block .item {
flex: 1;
justify-content: center;
}
.item {
display: inline-flex;
align-items: center;
gap: var(--su-space-1);
height: 28px;
padding: 0 var(--su-space-3);
border-radius: calc(var(--su-radius-sm) - 2px);
font-size: var(--su-font-size-sm);
color: var(--su-text-sub);
white-space: nowrap;
cursor: pointer;
transition:
color var(--su-duration-base) var(--su-ease-in-out),
background-color var(--su-duration-base) var(--su-ease-in-out);
}
.item:hover:not(.active) {
color: var(--su-text-main);
}
.item:focus-visible {
outline: var(--su-focus-ring);
outline-offset: 1px;
}
.active {
background-color: var(--su-bg-card);
color: var(--su-text-main);
box-shadow: var(--su-shadow-card);
}
.sm .item {
height: 24px;
padding: 0 var(--su-space-2);
font-size: var(--su-font-size-xs);
}
.md .item {
height: 28px;
}
.icon {
display: inline-flex;
font-size: var(--su-font-size-md);
}
@media (prefers-reduced-motion: reduce) {
.item {
transition: none;
}
}
+62
View File
@@ -0,0 +1,62 @@
import type { ReactNode } from 'react'
import styles from './Segmented.module.css'
/**
* 分段控件:在几个视图之间切换(列表 / 网格、全部 / 收藏)。
*
* 只切展示,不切内容结构——需要切换内容分区时用 `Tabs`。
*/
export type SegmentedOption = {
value: string
label: ReactNode
icon?: ReactNode
}
export type SegmentedProps = {
options: SegmentedOption[]
value: string
onChange: (value: string) => void
size?: 'sm' | 'md'
/** 占满一行,选项等分 */
block?: boolean
className?: string
}
export function Segmented({
options,
value,
onChange,
size = 'md',
block = false,
className,
}: SegmentedProps) {
return (
<div
className={[styles.root, styles[size], block ? styles.block : undefined, className]
.filter(Boolean)
.join(' ')}
role="group"
>
{options.map((option) => {
const active = option.value === value
return (
<button
key={option.value}
type="button"
className={[styles.item, active ? styles.active : undefined].filter(Boolean).join(' ')}
aria-pressed={active}
onClick={() => onChange(option.value)}
>
{option.icon !== undefined && (
<span className={styles.icon} aria-hidden="true">
{option.icon}
</span>
)}
{option.label}
</button>
)
})}
</div>
)
}
+120
View File
@@ -0,0 +1,120 @@
.root {
position: relative;
display: inline-block;
min-width: 160px;
}
.trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--su-space-2);
width: 100%;
height: var(--su-control-height-md);
padding: 0 var(--su-space-3);
border: 1px solid var(--su-border-strong);
border-radius: var(--su-radius-sm);
background-color: var(--su-bg-card);
font-size: var(--su-font-size-md);
color: var(--su-text-main);
text-align: left;
cursor: pointer;
transition: border-color var(--su-duration-base) var(--su-ease-in-out);
}
.trigger:hover:not(:disabled) {
border-color: var(--su-border-strong);
background-color: var(--su-bg-subtle);
}
.triggerOpen {
border-color: var(--su-color-primary);
}
.trigger:focus-visible {
outline: var(--su-focus-ring);
outline-offset: var(--su-focus-ring-offset);
}
.trigger:disabled {
color: var(--su-text-muted);
cursor: not-allowed;
}
.placeholder {
color: var(--su-text-placeholder);
}
.value {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.caret {
flex-shrink: 0;
font-size: var(--su-font-size-sm);
color: var(--su-text-muted);
transition: transform var(--su-duration-base) var(--su-ease-in-out);
}
.triggerOpen .caret {
transform: rotate(180deg);
}
.list {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: var(--su-z-float);
display: flex;
flex-direction: column;
width: 100%;
max-height: 240px;
padding: var(--su-space-1);
border: 1px solid var(--su-card-border);
border-radius: var(--su-radius-sm);
background-color: var(--su-bg-card);
box-shadow: var(--su-shadow-pop);
overflow-y: auto;
list-style: none;
}
.option {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--su-space-2);
width: 100%;
padding: var(--su-space-2) var(--su-space-3);
border-radius: var(--su-radius-xs);
font-size: var(--su-font-size-md);
color: var(--su-text-main);
cursor: pointer;
}
.optionActive {
background-color: var(--su-bg-subtle);
}
.optionSelected {
color: var(--su-color-primary);
font-weight: var(--su-font-weight-bold);
}
.option:disabled {
color: var(--su-text-disabled);
cursor: not-allowed;
}
.check {
flex-shrink: 0;
font-size: var(--su-font-size-sm);
}
@media (prefers-reduced-motion: reduce) {
.trigger,
.caret {
transition: none;
}
}
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { Icon } from './Icon'
import styles from './Select.module.css'
/**
* 下拉选择:单值、受控。
*
* 触发器是原生 button,展开后是 `role="listbox"`;支持点击外部关闭、Esc 关闭、
* ↑↓ 移动、Enter 选择。多值/搜索场景目前不在范围内(用 Input + 列表组合)。
*/
export type SelectOption = {
value: string
label: ReactNode
disabled?: boolean
}
export type SelectProps = {
options: SelectOption[]
value?: string
onChange: (value: string) => void
placeholder?: string
disabled?: boolean
className?: string
}
export function Select({
options,
value,
onChange,
placeholder = '请选择',
disabled = false,
className,
}: SelectProps) {
const [open, setOpen] = useState(false)
const [activeIndex, setActiveIndex] = useState(-1)
const rootRef = useRef<HTMLDivElement>(null)
const selected = options.find((option) => option.value === value)
useEffect(() => {
if (!open) return
const onPointerDown = (event: MouseEvent) => {
if (rootRef.current !== null && !rootRef.current.contains(event.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', onPointerDown)
return () => document.removeEventListener('mousedown', onPointerDown)
}, [open])
const commit = (option: SelectOption) => {
if (option.disabled === true) return
onChange(option.value)
setOpen(false)
}
const move = (step: 1 | -1) => {
const enabled = options.filter((option) => option.disabled !== true)
if (enabled.length === 0) return
const currentIndex = enabled.findIndex((option) => option.value === value)
const base = currentIndex === -1 ? (step === 1 ? -1 : 0) : currentIndex
const next = enabled[(base + step + enabled.length) % enabled.length]
setActiveIndex(options.indexOf(next))
}
return (
<div
ref={rootRef}
className={[styles.root, className].filter(Boolean).join(' ')}
onKeyDown={(event) => {
if (event.key === 'Escape' && open) {
event.preventDefault()
setOpen(false)
}
if (event.key === 'ArrowDown') {
event.preventDefault()
if (!open) setOpen(true)
else move(1)
}
if (event.key === 'ArrowUp') {
event.preventDefault()
if (!open) setOpen(true)
else move(-1)
}
if (event.key === 'Enter' && open) {
event.preventDefault()
const option = options[activeIndex]
if (option !== undefined) commit(option)
}
}}
>
<button
type="button"
className={[styles.trigger, open ? styles.triggerOpen : undefined]
.filter(Boolean)
.join(' ')}
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled}
onClick={() => setOpen((current) => !current)}
>
<span className={selected === undefined ? styles.placeholder : styles.value}>
{selected?.label ?? placeholder}
</span>
<Icon name="arrow_down_line" className={styles.caret} />
</button>
{open && (
<ul className={styles.list} role="listbox">
{options.map((option, index) => {
const isSelected = option.value === value
return (
<li key={option.value}>
<button
type="button"
role="option"
aria-selected={isSelected}
disabled={option.disabled}
className={[
styles.option,
isSelected ? styles.optionSelected : undefined,
index === activeIndex ? styles.optionActive : undefined,
]
.filter(Boolean)
.join(' ')}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => commit(option)}
>
<span>{option.label}</span>
{isSelected && <Icon name="check_line" className={styles.check} />}
</button>
</li>
)
})}
</ul>
)}
</div>
)
}
+70
View File
@@ -0,0 +1,70 @@
.block {
position: relative;
display: block;
overflow: hidden;
background-color: var(--su-bg-light);
border-radius: var(--su-radius-xs);
}
/* 高光扫过:从 -100% 到 100%,不要用布局属性做动画 */
.block::after,
.line::after {
content: '';
position: absolute;
inset: 0;
background-image: linear-gradient(
90deg,
transparent 0%,
var(--su-skeleton-shine) 50%,
transparent 100%
);
transform: translateX(-100%);
animation: shimmer 1.4s var(--su-ease-in-out) infinite;
}
.rect {
position: relative;
width: 100%;
height: 120px;
border-radius: var(--su-radius-sm);
}
.circle {
position: relative;
width: 40px;
height: 40px;
border-radius: var(--su-radius-pill);
}
.text {
display: flex;
flex-direction: column;
gap: var(--su-space-2);
width: 100%;
}
.line {
position: relative;
display: block;
height: var(--su-line-height-sm);
overflow: hidden;
background-color: var(--su-bg-light);
border-radius: var(--su-radius-xs);
}
.short {
width: 62%;
}
@keyframes shimmer {
to {
transform: translateX(100%);
}
}
@media (prefers-reduced-motion: reduce) {
.block::after,
.line::after {
animation: none;
}
}
+60
View File
@@ -0,0 +1,60 @@
import type { CSSProperties } from 'react'
import styles from './Skeleton.module.css'
/**
* 骨架屏:内容加载前的占位,形状要和真实内容一致,避免加载完成后跳动。
*
* 只负责占位块;整块区域请用 `aria-busy="true"` 标记(组件自身对屏幕阅读器隐藏)。
*/
export type SkeletonVariant = 'text' | 'rect' | 'circle'
export type SkeletonProps = {
/** text 多行文本占位 / rect 图片封面 / circle 头像 */
variant?: SkeletonVariant
width?: number | string
height?: number | string
/** 仅 text:行数,最后一行会自动收短 */
lines?: number
className?: string
style?: CSSProperties
}
export function Skeleton({
variant = 'text',
width,
height,
lines = 1,
className,
style,
}: SkeletonProps) {
const size: CSSProperties = { ...style }
if (width !== undefined) size.width = typeof width === 'number' ? `${width}px` : width
if (height !== undefined) size.height = typeof height === 'number' ? `${height}px` : height
if (variant === 'text') {
const count = Math.max(1, lines)
return (
<div
className={[styles.text, className].filter(Boolean).join(' ')}
aria-hidden="true"
style={size}
>
{Array.from({ length: count }, (_, index) => (
<span
key={index}
className={`${styles.line}${index === count - 1 && count > 1 ? ` ${styles.short}` : ''}`}
/>
))}
</div>
)
}
return (
<span
className={[styles.block, styles[variant], className].filter(Boolean).join(' ')}
aria-hidden="true"
style={size}
/>
)
}
+63
View File
@@ -0,0 +1,63 @@
.root {
display: flex;
}
.row {
flex-direction: row;
}
.column {
flex-direction: column;
}
.wrap {
flex-wrap: wrap;
}
.gap-xs {
gap: var(--su-space-1);
}
.gap-sm {
gap: var(--su-space-2);
}
.gap-md {
gap: var(--su-space-3);
}
.gap-lg {
gap: var(--su-space-4);
}
.align-start {
align-items: flex-start;
}
.align-center {
align-items: center;
}
.align-end {
align-items: flex-end;
}
.align-stretch {
align-items: stretch;
}
.justify-start {
justify-content: flex-start;
}
.justify-center {
justify-content: center;
}
.justify-end {
justify-content: flex-end;
}
.justify-between {
justify-content: space-between;
}
+50
View File
@@ -0,0 +1,50 @@
import type { CSSProperties, ReactNode } from 'react'
import styles from './Space.module.css'
/**
* 间距容器:把一组元素按令牌里的间距排开。
*
* 页面里到处是「一行按钮」「一列元信息」,用 Space 比每个组件写一遍
* `display:flex; gap:8px` 更容易保持一致。
*/
export type SpaceGap = 'xs' | 'sm' | 'md' | 'lg'
export type SpaceProps = {
direction?: 'row' | 'column'
gap?: SpaceGap
wrap?: boolean
align?: 'start' | 'center' | 'end' | 'stretch'
justify?: 'start' | 'center' | 'end' | 'between'
className?: string
style?: CSSProperties
children?: ReactNode
}
export function Space({
direction = 'row',
gap = 'sm',
wrap = false,
align = 'center',
justify = 'start',
className,
style,
children,
}: SpaceProps) {
const classes = [
styles.root,
styles[direction],
styles[`gap-${gap}`],
styles[`align-${align}`],
styles[`justify-${justify}`],
wrap ? styles.wrap : undefined,
className,
]
.filter(Boolean)
.join(' ')
return (
<div className={classes} style={style}>
{children}
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
.root {
--spinner-size: 20px;
display: inline-block;
flex-shrink: 0;
width: var(--spinner-size);
height: var(--spinner-size);
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: var(--su-radius-pill);
color: var(--su-color-primary);
vertical-align: -0.125em;
animation: spin 0.8s linear infinite;
}
.sm {
--spinner-size: 14px;
}
.md {
--spinner-size: 20px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.root {
animation-duration: 2.4s;
}
}
+27
View File
@@ -0,0 +1,27 @@
import type { HTMLAttributes } from 'react'
import styles from './Spinner.module.css'
/**
* 加载指示:一个旋转的圆环。
*
* 按钮内部的 loading 由 `Button` 自己处理;这个组件用于「某块区域正在加载」
* (列表、面板、行内同步状态)。纯文字占位请用 `Skeleton`。
*/
export type SpinnerSize = 'sm' | 'md'
export type SpinnerProps = {
size?: SpinnerSize
/** 无障碍名称 */
label?: string
} & Omit<HTMLAttributes<HTMLSpanElement>, 'aria-label'>
export function Spinner({ size = 'md', label = '加载中', className, ...rest }: SpinnerProps) {
return (
<span
className={[styles.root, styles[size], className].filter(Boolean).join(' ')}
role="status"
aria-label={label}
{...rest}
/>
)
}
+76
View File
@@ -0,0 +1,76 @@
.root {
display: flex;
flex-direction: column;
gap: var(--su-space-4);
list-style: none;
}
.item {
position: relative;
display: flex;
gap: var(--su-space-3);
}
/* 连接线:从标记下方连到下一条 */
.item:not(:last-child)::before {
content: '';
position: absolute;
top: 26px;
bottom: calc(-1 * var(--su-space-4));
left: 11px;
width: 1px;
background-color: var(--su-border);
}
.marker {
position: relative;
z-index: 1;
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: 1px solid var(--su-border-strong);
border-radius: var(--su-radius-pill);
background-color: var(--su-bg-card);
font-size: var(--su-font-size-xs);
color: var(--su-text-muted);
}
.content {
display: flex;
flex-direction: column;
gap: 2px;
padding-top: 2px;
}
.title {
font-size: var(--su-font-size-md);
line-height: var(--su-line-height-md);
color: var(--su-text-main);
}
.description {
font-size: var(--su-font-size-sm);
line-height: var(--su-line-height-sm);
color: var(--su-text-muted);
}
/* 已完成 */
.done .marker {
border-color: var(--su-color-primary);
background-color: var(--su-color-primary);
color: var(--su-white);
}
/* 进行中 */
.active .marker {
border-color: var(--su-color-primary);
color: var(--su-color-primary);
font-weight: var(--su-font-weight-bold);
}
.active .title {
font-weight: var(--su-font-weight-bold);
}
+40
View File
@@ -0,0 +1,40 @@
import type { ReactNode } from 'react'
import { Icon } from './Icon'
import styles from './Steps.module.css'
/** 步骤条:纵向流程(发布流程、导入进度),`current` 从 0 开始。 */
export type StepItem = {
title: ReactNode
description?: ReactNode
}
export type StepsProps = {
items: StepItem[]
/** 当前进行到第几步 */
current: number
className?: string
}
export function Steps({ items, current, className }: StepsProps) {
return (
<ol className={[styles.root, className].filter(Boolean).join(' ')}>
{items.map((item, index) => {
const state = index < current ? 'done' : index === current ? 'active' : 'wait'
return (
<li key={index} className={`${styles.item} ${styles[state]}`}>
<span className={styles.marker} aria-hidden="true">
{state === 'done' ? <Icon name="check_line" /> : index + 1}
</span>
<span className={styles.content}>
<span className={styles.title}>{item.title}</span>
{item.description !== undefined && (
<span className={styles.description}>{item.description}</span>
)}
</span>
</li>
)
})}
</ol>
)
}
+63
View File
@@ -0,0 +1,63 @@
.root {
--switch-width: 40px;
--switch-height: 22px;
--switch-thumb: 16px;
position: relative;
display: inline-flex;
flex-shrink: 0;
align-items: center;
width: var(--switch-width);
height: var(--switch-height);
padding: 3px;
border-radius: var(--su-radius-pill);
background-color: var(--su-bg-hover);
cursor: pointer;
transition: background-color var(--su-duration-base) var(--su-ease-in-out);
}
.root:focus-visible {
outline: var(--su-focus-ring);
outline-offset: var(--su-focus-ring-offset);
}
.root:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sm {
--switch-width: 32px;
--switch-height: 18px;
--switch-thumb: 12px;
}
.md {
--switch-width: 40px;
--switch-height: 22px;
--switch-thumb: 16px;
}
.on {
background-color: var(--su-color-primary);
}
.thumb {
width: var(--switch-thumb);
height: var(--switch-thumb);
border-radius: var(--su-radius-pill);
background-color: var(--su-white);
transform: translateX(0);
transition: transform var(--su-duration-base) var(--su-ease-in-out);
}
.on .thumb {
transform: translateX(calc(var(--switch-width) - var(--switch-thumb) - 6px));
}
@media (prefers-reduced-motion: reduce) {
.root,
.thumb {
transition: none;
}
}
+44
View File
@@ -0,0 +1,44 @@
import styles from './Switch.module.css'
/**
* 开关:设置项的即时生效开关。
*
* 原生 `button` + `role="switch"`,空格/回车都能切换;
* 没有可见文字标签时,`label` 必填(进 aria-label)。
*/
export type SwitchSize = 'sm' | 'md'
export type SwitchProps = {
checked: boolean
onChange: (checked: boolean) => void
disabled?: boolean
size?: SwitchSize
/** 无障碍名称:开关旁边没有文字时必填 */
label?: string
className?: string
}
export function Switch({
checked,
onChange,
disabled = false,
size = 'md',
label,
className,
}: SwitchProps) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
className={[styles.root, styles[size], checked ? styles.on : undefined, className]
.filter(Boolean)
.join(' ')}
onClick={() => onChange(!checked)}
>
<span className={styles.thumb} aria-hidden="true" />
</button>
)
}
+43
View File
@@ -0,0 +1,43 @@
.wrap {
width: 100%;
overflow-x: auto;
border: 1px solid var(--su-card-border);
border-radius: var(--su-radius-md);
background-color: var(--su-bg-card);
}
.table {
width: 100%;
border-collapse: collapse;
font-size: var(--su-font-size-md);
}
.table th {
padding: var(--su-space-3) var(--su-space-4);
border-bottom: 1px solid var(--su-border);
background-color: var(--su-bg-subtle);
font-size: var(--su-font-size-sm);
font-weight: var(--su-font-weight-bold);
color: var(--su-text-sub);
white-space: nowrap;
}
.cell {
padding: var(--su-space-3) var(--su-space-4);
border-bottom: 1px solid var(--su-border);
color: var(--su-text-main);
}
.table tbody tr:last-child .cell {
border-bottom: none;
}
.table tbody tr:hover .cell {
background-color: var(--su-bg-subtle);
}
.empty {
padding: var(--su-space-8);
color: var(--su-text-muted);
text-align: center;
}
+83
View File
@@ -0,0 +1,83 @@
import type { ReactNode } from 'react'
import styles from './Table.module.css'
/**
* 表格:只做展示(列定义 + 行数据 + 空态),排序/分页由页面组合。
*
* 行数据用 `Record<string, ReactNode>` 而不是业务类型,
* 需要强类型时在页面里用泛型包装一层即可。
*/
export type TableColumn = {
key: string
title: ReactNode
/** 列宽,数字按 px 处理 */
width?: number | string
align?: 'left' | 'center' | 'right'
}
export type TableProps = {
columns: TableColumn[]
rows: Record<string, ReactNode>[]
/** 行的唯一键字段,默认 `id` */
rowKey?: string
emptyText?: ReactNode
className?: string
}
export function Table({
columns,
rows,
rowKey = 'id',
emptyText = '暂无数据',
className,
}: TableProps) {
return (
<div className={[styles.wrap, className].filter(Boolean).join(' ')}>
<table className={styles.table}>
<thead>
<tr>
{columns.map((column) => (
<th
key={column.key}
style={{
width:
column.width === undefined
? undefined
: typeof column.width === 'number'
? `${column.width}px`
: column.width,
textAlign: column.align ?? 'left',
}}
>
{column.title}
</th>
))}
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr>
<td className={styles.empty} colSpan={columns.length}>
{emptyText}
</td>
</tr>
) : (
rows.map((row, index) => (
<tr key={String(row[rowKey] ?? index)}>
{columns.map((column) => (
<td
key={column.key}
style={{ textAlign: column.align ?? 'left' }}
className={styles.cell}
>
{row[column.key]}
</td>
))}
</tr>
))
)}
</tbody>
</table>
</div>
)
}
+75
View File
@@ -0,0 +1,75 @@
.root {
display: flex;
align-items: center;
gap: var(--su-space-1);
overflow-x: auto;
}
.tab {
display: inline-flex;
flex-shrink: 0;
align-items: center;
height: 36px;
padding: 0 var(--su-space-3);
font-size: var(--su-font-size-md);
color: var(--su-text-sub);
white-space: nowrap;
cursor: pointer;
transition: color var(--su-duration-base) var(--su-ease-in-out);
}
.tab:hover:not(:disabled) {
color: var(--su-text-main);
}
.tab:focus-visible {
outline: var(--su-focus-ring);
outline-offset: -2px;
border-radius: var(--su-radius-xs);
}
.tab:disabled {
color: var(--su-text-disabled);
cursor: not-allowed;
}
/* line:底部 2px 主色指示条 */
.line {
border-bottom: 1px solid var(--su-border);
}
.line .tab {
margin-bottom: -1px;
border-bottom: 2px solid transparent;
}
.line .active {
border-bottom-color: var(--su-color-primary);
color: var(--su-color-primary);
font-weight: var(--su-font-weight-bold);
}
/* pill:浅底容器里选中项用白底 */
.pill {
padding: 2px;
border-radius: var(--su-radius-sm);
background-color: var(--su-bg-light);
}
.pill .tab {
height: 28px;
border-radius: calc(var(--su-radius-sm) - 2px);
font-size: var(--su-font-size-sm);
}
.pill .active {
background-color: var(--su-bg-card);
color: var(--su-text-main);
box-shadow: var(--su-shadow-card);
}
@media (prefers-reduced-motion: reduce) {
.tab {
transition: none;
}
}
+90
View File
@@ -0,0 +1,90 @@
import { useRef, type ReactNode } from 'react'
import styles from './Tabs.module.css'
/**
* 标签页:在几块内容之间切换(详情/评论、全部/草稿)。
*
* 只负责标签栏,面板内容由页面渲染(用 `role="tabpanel"` 配合 `aria-labelledby`)。
* 键盘 ← → 在标签之间移动,Home/End 跳到首尾。
*/
export type TabItem = {
key: string
label: ReactNode
disabled?: boolean
}
export type TabsProps = {
items: TabItem[]
activeKey: string
onChange: (key: string) => void
/** line:底部下划线(默认);pill:胶囊底 */
variant?: 'line' | 'pill'
className?: string
}
export function Tabs({ items, activeKey, onChange, variant = 'line', className }: TabsProps) {
const refs = useRef<Record<string, HTMLButtonElement | null>>({})
const focusAt = (key: string) => {
onChange(key)
refs.current[key]?.focus()
}
const move = (step: 1 | -1) => {
const enabled = items.filter((item) => item.disabled !== true)
const index = enabled.findIndex((item) => item.key === activeKey)
if (index === -1) return
const next = enabled[(index + step + enabled.length) % enabled.length]
focusAt(next.key)
}
return (
<div
className={[styles.root, styles[variant], className].filter(Boolean).join(' ')}
role="tablist"
>
{items.map((item) => {
const active = item.key === activeKey
return (
<button
key={item.key}
ref={(element) => {
refs.current[item.key] = element
}}
type="button"
role="tab"
aria-selected={active}
tabIndex={active ? 0 : -1}
disabled={item.disabled}
className={[styles.tab, active ? styles.active : undefined].filter(Boolean).join(' ')}
onClick={() => onChange(item.key)}
onKeyDown={(event) => {
if (event.key === 'ArrowRight') {
event.preventDefault()
move(1)
}
if (event.key === 'ArrowLeft') {
event.preventDefault()
move(-1)
}
if (event.key === 'Home') {
event.preventDefault()
const first = items.find((candidate) => candidate.disabled !== true)
if (first !== undefined) focusAt(first.key)
}
if (event.key === 'End') {
event.preventDefault()
const enabled = items.filter((candidate) => candidate.disabled !== true)
const last = enabled[enabled.length - 1]
if (last !== undefined) focusAt(last.key)
}
}}
>
{item.label}
</button>
)
})}
</div>
)
}
+85
View File
@@ -0,0 +1,85 @@
/* 对齐参考站 .tags-list li a:高度 24、字号 13/20、胶囊、浅色底 */
.root {
display: inline-flex;
align-items: center;
gap: var(--su-space-1);
height: 24px;
padding: 0 var(--su-space-3);
border-radius: var(--su-radius-pill);
font-size: var(--su-font-size-sm);
line-height: var(--su-line-height-sm);
white-space: nowrap;
}
/* 元信息前缀用的小方块(参考站的「本地 / 状态」标签) */
.rounded {
height: auto;
padding: 2px var(--su-space-2);
border-radius: var(--su-radius-xs);
font-size: var(--su-font-size-xs);
line-height: var(--su-line-height-xs);
}
.icon {
display: inline-flex;
font-size: var(--su-font-size-md);
}
.dot {
width: 6px;
height: 6px;
border-radius: var(--su-radius-pill);
background-color: currentColor;
}
/* ---------- soft:浅色底(默认) ---------- */
.soft.neutral {
background-color: var(--su-bg-subtle);
color: var(--su-text-sub);
}
.soft.primary {
background-color: var(--su-color-primary-soft);
color: var(--su-color-primary);
}
.soft.success {
background-color: var(--su-color-success-soft);
color: var(--su-color-success);
}
.soft.warning {
background-color: var(--su-color-warning-soft);
color: var(--su-color-warning-text);
}
.soft.danger {
background-color: var(--su-color-danger-soft);
color: var(--su-color-danger);
}
/* ---------- solid:实心底(参考站文章页的彩色标签) ---------- */
.solid.neutral {
background-color: var(--su-bg-hover);
color: var(--su-text-main);
}
.solid.primary {
background-color: var(--su-color-primary);
color: var(--su-white);
}
.solid.success {
background-color: var(--su-color-success);
color: var(--su-white);
}
.solid.warning {
background-color: var(--su-color-orange-dark);
color: var(--su-white);
}
.solid.danger {
background-color: var(--su-color-danger);
color: var(--su-white);
}
+52
View File
@@ -0,0 +1,52 @@
import type { HTMLAttributes, ReactNode } from 'react'
import styles from './Tag.module.css'
/**
* 标签:分类、标签、元信息。
*
* 对齐参考站的 `.tags-list li a`:24px 高、13px 字、胶囊形、浅灰底,
* 左侧可以带一个小图标。需要强调时用 `variant="solid"` 换成实心底。
*/
export type TagTone = 'neutral' | 'primary' | 'success' | 'warning' | 'danger'
export type TagVariant = 'soft' | 'solid'
export type TagShape = 'pill' | 'rounded'
export type TagProps = {
tone?: TagTone
/** soft 浅底(默认)/ solid 实底 */
variant?: TagVariant
/** pill 胶囊(默认,用于分类标签)/ rounded 小方块(用于元信息前缀) */
shape?: TagShape
/** 前置图标 */
icon?: ReactNode
/** 前置状态点(比 icon 更轻,用于「已连接 / 等待中」) */
dot?: boolean
children?: ReactNode
} & HTMLAttributes<HTMLSpanElement>
export function Tag({
tone = 'neutral',
variant = 'soft',
shape = 'pill',
icon,
dot = false,
className,
children,
...rest
}: TagProps) {
const classes = [styles.root, styles[shape], styles[variant], styles[tone], className]
.filter(Boolean)
.join(' ')
return (
<span className={classes} {...rest}>
{dot && <span className={styles.dot} aria-hidden="true" />}
{icon !== undefined && (
<span className={styles.icon} aria-hidden="true">
{icon}
</span>
)}
{children}
</span>
)
}
+68
View File
@@ -0,0 +1,68 @@
.root {
position: fixed;
bottom: var(--su-space-8);
left: 50%;
z-index: 110;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--su-space-2);
transform: translateX(-50%);
pointer-events: none;
}
.toast {
display: inline-flex;
align-items: center;
gap: var(--su-space-2);
max-width: min(90vw, 420px);
padding: var(--su-space-2) var(--su-space-4);
border: 1px solid var(--su-card-border);
border-radius: var(--su-radius-pill);
background-color: var(--su-bg-card);
box-shadow: var(--su-shadow-pop);
font-size: var(--su-font-size-md);
color: var(--su-text-main);
animation: toast-in var(--su-duration-base) var(--su-ease-out);
}
.icon {
flex-shrink: 0;
font-size: var(--su-font-size-lg);
}
.info .icon {
color: var(--su-color-primary);
}
.success .icon {
color: var(--su-color-success);
}
.error .icon {
color: var(--su-color-danger);
}
.message {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.toast {
animation: none;
}
}
+31
View File
@@ -0,0 +1,31 @@
import { useSyncExternalStore } from 'react'
import { Icon, type IconName } from './Icon'
import styles from './Toast.module.css'
import { getSnapshot, subscribe, type ToastTone } from './toast-store'
/**
* 轻提示的渲染容器:挂在应用根节点,只挂一次。
*
* 提示本身用命令式 API 弹出(`toast('已保存')`),见 `toast-store.ts`。
* 与 `Notice` 的区别:Notice 是页面内常驻提示,Toast 是操作反馈、几秒后自动消失。
*/
const ICONS: Record<ToastTone, IconName> = {
info: 'message_3_line',
success: 'check_circle_fill',
error: 'close_circle_line',
}
export function Toaster() {
const list = useSyncExternalStore(subscribe, getSnapshot, getSnapshot)
return (
<div className={styles.root} role="status" aria-live="polite">
{list.map((item) => (
<div key={item.id} className={`${styles.toast} ${styles[item.tone]}`}>
<Icon name={ICONS[item.tone]} className={styles.icon} />
<span className={styles.message}>{item.message}</span>
</div>
))}
</div>
)
}
+56
View File
@@ -0,0 +1,56 @@
.root {
position: relative;
display: inline-flex;
}
.bubble {
position: absolute;
left: 50%;
z-index: var(--su-z-float);
padding: var(--su-space-1) var(--su-space-2);
border-radius: var(--su-radius-sm);
background-color: var(--su-gray-8);
color: var(--su-white);
font-size: var(--su-font-size-xs);
line-height: var(--su-line-height-xs);
white-space: nowrap;
opacity: 0;
pointer-events: none;
transform: translate(-50%, 0);
transition:
opacity var(--su-duration-fast) var(--su-ease-in-out),
transform var(--su-duration-fast) var(--su-ease-in-out);
}
.top .bubble {
bottom: calc(100% + 6px);
}
.bottom .bubble {
top: calc(100% + 6px);
}
/* 触屏没有 hover:只在真能悬停的设备上响应 hover,键盘/点按统一走 focus-within */
@media (hover: hover) {
.root:hover .bubble {
opacity: 1;
}
.top:hover .bubble {
transform: translate(-50%, -2px);
}
}
.root:focus-within .bubble {
opacity: 1;
}
.top:focus-within .bubble {
transform: translate(-50%, -2px);
}
@media (prefers-reduced-motion: reduce) {
.bubble {
transition: none;
}
}
+29
View File
@@ -0,0 +1,29 @@
import type { ReactNode } from 'react'
import styles from './Tooltip.module.css'
/**
* 文字提示:hover 或键盘聚焦时出现的一行气泡。
*
* 纯 CSS 定位(没有浮层引擎),所以只支持 top / bottom 两个方向;
* 需要交互式内容时用 `Popover`。
*/
export type TooltipPlacement = 'top' | 'bottom'
export type TooltipProps = {
content: ReactNode
placement?: TooltipPlacement
/** 触发元素,建议是可聚焦的 button / a */
children: ReactNode
className?: string
}
export function Tooltip({ content, placement = 'top', children, className }: TooltipProps) {
return (
<span className={[styles.root, styles[placement], className].filter(Boolean).join(' ')}>
{children}
<span className={styles.bubble} role="tooltip">
{content}
</span>
</span>
)
}
+133
View File
@@ -0,0 +1,133 @@
.title {
margin: 0;
font-weight: var(--su-font-weight-bold);
color: var(--su-text-main);
}
.w-normal {
font-weight: var(--su-font-weight-normal);
}
.w-medium {
font-weight: var(--su-font-weight-bold);
}
.w-semibold {
font-weight: var(--su-font-weight-semibold);
}
.w-strong {
font-weight: var(--su-font-weight-strong);
}
.level1 {
font-size: var(--su-font-size-xl);
line-height: var(--su-line-height-xl);
}
.level2 {
font-size: var(--su-font-size-lg);
line-height: var(--su-line-height-lg);
}
.level3 {
font-size: var(--su-font-size-base);
line-height: var(--su-line-height-base);
}
.level4 {
font-size: var(--su-font-size-md);
line-height: var(--su-line-height-md);
}
.text {
display: inline-block;
}
.size-xs {
font-size: var(--su-font-size-xs);
line-height: var(--su-line-height-xs);
}
.size-sm {
font-size: var(--su-font-size-sm);
line-height: var(--su-line-height-sm);
}
.size-md {
font-size: var(--su-font-size-md);
line-height: var(--su-line-height-md);
}
.size-base {
font-size: var(--su-font-size-base);
line-height: var(--su-line-height-base);
}
.size-lg {
font-size: var(--su-font-size-lg);
line-height: var(--su-line-height-lg);
}
.tone-main {
color: var(--su-text-main);
}
.tone-secondary {
color: var(--su-text-secondary);
}
.tone-sub {
color: var(--su-text-sub);
}
.tone-muted {
color: var(--su-text-muted);
}
.strong {
font-weight: var(--su-font-weight-bold);
}
.truncate {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.paragraph {
margin: 0;
font-size: var(--su-font-size-base);
line-height: var(--su-line-height-base);
}
/* 段落字号:文章正文常用 md(14px),摘要常用 sm(13px) */
.paragraph.size-sm {
font-size: var(--su-font-size-sm);
}
.paragraph.size-md {
font-size: var(--su-font-size-md);
}
.paragraph.size-base {
font-size: var(--su-font-size-base);
}
/* 阅读型行高:1.9,只给长文正文用 */
.paragraph.relaxed {
line-height: var(--su-reading-line-height);
}
.paragraph.tone-main {
color: var(--su-text-main);
}
.paragraph.tone-secondary {
color: var(--su-text-secondary);
}
.paragraph.tone-sub {
color: var(--su-text-sub);
}
+144
View File
@@ -0,0 +1,144 @@
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
import styles from './Typography.module.css'
/**
* 排版:标题 / 正文 / 段落。
*
* 只做「字号 + 字重 + 颜色」的映射,不负责外边距——间距交给 Space 和页面。
* 字号取自参考站实测阶梯(13/14/16/18/24),不要在页面里另写 font-size。
*/
const TITLE_TAGS = { 1: 'h1', 2: 'h2', 3: 'h3', 4: 'h4' } as const
export type TitleLevel = 1 | 2 | 3 | 4
export type TitleWeight = 'normal' | 'medium' | 'semibold' | 'strong'
const WEIGHT_CLASS: Record<TitleWeight, string> = {
normal: 'w-normal',
medium: 'w-medium',
semibold: 'w-semibold',
strong: 'w-strong',
}
export type TitleProps = {
/** 1 = 页面标题(24/32),2 = 区块标题(18/26),3 = 卡片标题(16/24),4 = 小节(14/22) */
level?: TitleLevel
/** 字重:默认 medium(500);参考站的 600 用 semibold */
weight?: TitleWeight
/** 换语义标签(默认按 level 输出 h1–h4) */
as?: 'h1' | 'h2' | 'h3' | 'h4' | 'div'
children?: ReactNode
} & HTMLAttributes<HTMLElement>
export function Title({ level = 2, weight = 'medium', as, className, children, ...rest }: TitleProps) {
const Tag = as ?? TITLE_TAGS[level]
return (
<Tag
className={[
styles.title,
styles[`level${level}`],
styles[WEIGHT_CLASS[weight]],
className,
]
.filter(Boolean)
.join(' ')}
{...rest}
>
{children}
</Tag>
)
}
export type TextTone = 'main' | 'secondary' | 'sub' | 'muted'
export type TextSize = 'xs' | 'sm' | 'md' | 'base' | 'lg'
export type TextProps = {
/** 文字层级:主文 / 次级 / 说明 / 占位 */
tone?: TextTone
size?: TextSize
/** 加粗(主题的「加粗」是 500) */
strong?: boolean
/** 单行截断 */
truncate?: boolean
as?: 'span' | 'p' | 'div'
children?: ReactNode
} & HTMLAttributes<HTMLElement>
export function Text({
tone = 'main',
size = 'md',
strong = false,
truncate = false,
as: Tag = 'span',
className,
children,
...rest
}: TextProps) {
const classes = [
styles.text,
styles[`tone-${tone}`],
styles[`size-${size}`],
strong ? styles.strong : undefined,
truncate ? styles.truncate : undefined,
className,
]
.filter(Boolean)
.join(' ')
return (
<Tag className={classes} {...rest}>
{children}
</Tag>
)
}
export type ParagraphProps = {
tone?: 'main' | 'secondary' | 'sub'
/** 段落字号:sm 13 / md 14 / base 16(默认) */
size?: 'sm' | 'md' | 'base'
/** 行高:base(默认 24px)/ relaxed(1.9,长文正文用) */
leading?: 'base' | 'relaxed'
/** 最多显示几行,超出省略(文章摘要常用 2–3 行) */
lines?: number
children?: ReactNode
} & HTMLAttributes<HTMLParagraphElement>
export function Paragraph({
tone = 'sub',
size = 'base',
leading = 'base',
lines,
className,
style,
children,
...rest
}: ParagraphProps) {
const clamp: CSSProperties =
lines === undefined
? {}
: {
display: '-webkit-box',
WebkitBoxOrient: 'vertical',
WebkitLineClamp: lines,
overflow: 'hidden',
}
return (
<p
className={[
styles.paragraph,
styles[`size-${size}`],
leading === 'relaxed' ? styles.relaxed : undefined,
styles[`tone-${tone}`],
className,
]
.filter(Boolean)
.join(' ')}
style={{ ...clamp, ...style }}
{...rest}
>
{children}
</p>
)
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Toast 的极简外部 store:命令式 API + `useSyncExternalStore` 订阅。
*
* 拆成独立文件有两个原因:
* 1. 组件文件只导出组件(Fast Refresh 要求);
* 2. store 不依赖 React,便于在任意模块里调用 `toast(...)`。
*/
export type ToastTone = 'info' | 'success' | 'error'
export type ToastOptions = {
tone?: ToastTone
/** 毫秒,默认 2400 */
duration?: number
}
export type ToastItem = {
id: number
message: string
tone: ToastTone
}
let items: ToastItem[] = []
let nextId = 1
const listeners = new Set<() => void>()
function emit() {
for (const listener of listeners) listener()
}
export function subscribe(listener: () => void): () => void {
listeners.add(listener)
return () => {
listeners.delete(listener)
}
}
/** 返回当前列表;每次变更都会换成新数组,满足 useSyncExternalStore 的快照要求 */
export function getSnapshot(): ToastItem[] {
return items
}
export function dismissToast(id: number) {
items = items.filter((item) => item.id !== id)
emit()
}
function show(message: string, options: ToastOptions = {}): number {
const item: ToastItem = { id: nextId++, message, tone: options.tone ?? 'info' }
items = [...items, item]
emit()
window.setTimeout(() => dismissToast(item.id), options.duration ?? 2400)
return item.id
}
type ToastFn = {
(message: string, options?: ToastOptions): number
success: (message: string, options?: Omit<ToastOptions, 'tone'>) => number
error: (message: string, options?: Omit<ToastOptions, 'tone'>) => number
}
/** 弹一条轻提示,返回 id(可用于手动关闭) */
export const toast: ToastFn = Object.assign(show, {
success: (message: string, options: Omit<ToastOptions, 'tone'> = {}) =>
show(message, { ...options, tone: 'success' }),
error: (message: string, options: Omit<ToastOptions, 'tone'> = {}) =>
show(message, { ...options, tone: 'error' }),
})
+143
View File
@@ -0,0 +1,143 @@
/**
* Stash UI 组件库入口。
*
* 样式在这里统一引入:令牌 → 图标字体 → 各组件自己的 CSS Modules,
* 构建后合并成一份 `stash-ui.css`,使用方只需:
*
* ```ts
* import '@stash/ui/style.css'
* import { Button, Card } from '@stash/ui'
* ```
*/
import './theme/tokens.css'
import './theme/iconfont.css'
export { Avatar } from './components/Avatar'
export type { AvatarProps, AvatarShape, AvatarSize } from './components/Avatar'
export { Badge } from './components/Badge'
export type { BadgeProps, BadgeTone } from './components/Badge'
export { Button } from './components/Button'
export type { ButtonProps, ButtonShape, ButtonSize, ButtonVariant } from './components/Button'
export { Checkbox } from './components/Checkbox'
export type { CheckboxProps } from './components/Checkbox'
export { Card } from './components/Card'
export type { CardProps } from './components/Card'
export { Cover } from './components/Cover'
export type { CoverProps, CoverRatio } from './components/Cover'
export { Breadcrumb } from './components/Breadcrumb'
export type { BreadcrumbItem, BreadcrumbProps } from './components/Breadcrumb'
export { Drawer } from './components/Drawer'
export type { DrawerPlacement, DrawerProps } from './components/Drawer'
export { Divider } from './components/Divider'
export type { DividerProps } from './components/Divider'
export { Empty } from './components/Empty'
export type { EmptyProps } from './components/Empty'
export { Grid } from './components/Grid'
export type { GridGap, GridProps } from './components/Grid'
export { Image } from './components/Image'
export type { ImageFit, ImageProps, ImageRatio } from './components/Image'
export { FormField } from './components/FormField'
export type { FormFieldProps } from './components/FormField'
export { Icon } from './components/Icon'
export type { IconName, IconProps } from './components/Icon'
export { ICON_NAMES } from './theme/icons'
export { IconButton } from './components/IconButton'
export type { IconButtonProps, IconButtonSize } from './components/IconButton'
export { Input } from './components/Input'
export type { InputProps } from './components/Input'
export { Layout } from './components/Layout'
export type { LayoutProps } from './components/Layout'
export { ItemBadge } from './components/ItemBadge'
export type { ItemBadgeProps, ItemBadgeTone } from './components/ItemBadge'
export { Modal } from './components/Modal'
export type { ModalProps, ModalSize } from './components/Modal'
export { Menu } from './components/Menu'
export type { MenuItem, MenuProps } from './components/Menu'
export { Popover } from './components/Popover'
export type { PopoverProps } from './components/Popover'
export { Progress } from './components/Progress'
export type { ProgressProps, ProgressTone } from './components/Progress'
export { RadioGroup } from './components/Radio'
export type { RadioGroupProps, RadioOption } from './components/Radio'
export { Result } from './components/Result'
export type { ResultProps, ResultStatus } from './components/Result'
export { Segmented } from './components/Segmented'
export type { SegmentedOption, SegmentedProps } from './components/Segmented'
export { Select } from './components/Select'
export type { SelectOption, SelectProps } from './components/Select'
export { Steps } from './components/Steps'
export type { StepItem, StepsProps } from './components/Steps'
export { Table } from './components/Table'
export type { TableColumn, TableProps } from './components/Table'
export { Tabs } from './components/Tabs'
export type { TabItem, TabsProps } from './components/Tabs'
export { Toaster } from './components/Toast'
export { dismissToast, toast } from './components/toast-store'
export type { ToastOptions, ToastTone } from './components/toast-store'
export { Tooltip } from './components/Tooltip'
export type { TooltipPlacement, TooltipProps } from './components/Tooltip'
export { Notice } from './components/Notice'
export type { NoticeProps, NoticeTone } from './components/Notice'
export { Pagination } from './components/Pagination'
export type { PaginationProps } from './components/Pagination'
export { Skeleton } from './components/Skeleton'
export type { SkeletonProps, SkeletonVariant } from './components/Skeleton'
export { Spinner } from './components/Spinner'
export type { SpinnerProps, SpinnerSize } from './components/Spinner'
export { Switch } from './components/Switch'
export type { SwitchProps, SwitchSize } from './components/Switch'
export { Title, Text, Paragraph } from './components/Typography'
export type {
ParagraphProps,
TextProps,
TextSize,
TextTone,
TitleLevel,
TitleProps,
} from './components/Typography'
export { ScrollTop } from './components/ScrollTop'
export type { ScrollTopProps } from './components/ScrollTop'
export { Space } from './components/Space'
export type { SpaceGap, SpaceProps } from './components/Space'
export { Tag } from './components/Tag'
export type { TagProps, TagShape, TagTone, TagVariant } from './components/Tag'
Binary file not shown.
File diff suppressed because it is too large Load Diff
+455
View File
@@ -0,0 +1,455 @@
/**
* 自动生成,不要手改。
* 来源:参考站 iconfont(446 个字形),生成脚本见 .ref/gen-icons.mjs。
* 改图标集时重新跑脚本,并同步 site/sections/Icons.tsx。
*/
export const ICON_NAMES = [
'a-external_link_line1',
'a-right_small_2_fill',
'a-share_2_fill1',
'a-share_2_line1',
'ad_rectangle_fill',
'ad_rectangle_fill1',
'ad_rectangle_line',
'add_circle_fill',
'add_fill',
'add_line',
'add_square_line',
'airdrop_fill',
'airdrop_line',
'alarm_2_fill',
'alarm_2_line',
'alert_fill',
'alert_line',
'alipay',
'alipay_fill',
'alipay_line',
'and_fill',
'Android_2_fill',
'Android_2_line',
'android_fill',
'android_line',
'apple_fill',
'apple_intelligence_frame_fill',
'apple_intelligence_frame_line',
'apple_line',
'appstore_fill',
'appstore_line',
'arc_browser_fill',
'arc_browser_line',
'arrow_down_fill',
'arrow_down_line',
'arrow_left_fill',
'arrow_left_line',
'arrow_right_fill',
'arrow_right_line',
'arrow_right_up_circle_line',
'arrow_right_up_fill',
'arrow_right_up_line',
'arrow_to_up_fill',
'arrow_to_up_line',
'arrow_up_circle_line',
'arrow_up_fill',
'arrow_up_line',
'asterisk_fill',
'asterisk_line',
'at_fill',
'at_line',
'attachment_fill',
'attachment_line',
'award_fill',
'award_line',
'battery_automotive_line',
'behance_fill',
'behance_fill1',
'behance_line',
'behance_line1',
'bell_ringing_fill',
'bell_ringing_line',
'bilibili_fill',
'bilibili_line',
'bill_fill',
'bill_line',
'black_board_2_fill',
'black_board_2_line',
'bluesky_social_fill',
'bluesky_social_line',
'book_4_fill',
'book_4_line',
'book_6_fill',
'bookmarks_line',
'box_fill',
'box_line',
'calendar_2_fill',
'calendar_2_line',
'calendar_month_line',
'calendar_time_add_fill',
'calendar_time_add_line',
'calendar_time_add_line1',
'camera_2_line',
'camera_fill',
'camera_line',
'camera_rotate_line',
'carplay_fill',
'carplay_line',
'cash_line',
'celebrate_line',
'cellphone_2_fill',
'cellphone_2_line',
'cellphone_fill',
'cellphone_line',
'certificate_2_fill',
'certificate_2_line',
'certificate_line',
'chart_line_fill',
'chart_line_line',
'chart_pie_line',
'chat_1_line',
'chat_3_fill',
'chat_3_line',
'chat_4_line',
'check_2_line',
'check_circle_fill',
'check_circle_line',
'check_fill',
'check_line',
'chip_line',
'choice_line',
'chrome_fill',
'chrome_line',
'close_circle_line',
'close_line',
'close_square_fill',
'close_square_line',
'codepen_fill',
'codepen_line',
'coin_2_fill',
'coin_2_line',
'comment_line',
'compass_line',
'copilot_fill',
'copilot_line',
'copper_coin_fill',
'copper_coin_line',
'copper_coin_line1',
'copy_fill',
'copy_line',
'coupon_line',
'currency_cny_2_line',
'currency_cny_line',
'currency_dollar_fill',
'currency_dollar_line',
'delete_2_line',
'dingtalk_fill',
'dingtalk_line',
'directions_2_line',
'discord_fill',
'discord_line',
'douban-fill',
'down_line',
'down_small_line',
'download_fill',
'download_line',
'dribbble_fill',
'dribbble_line',
'drive_fill',
'drive_line',
'dropbox_fill',
'dropbox_line',
'earth_fill',
'earth_line',
'edge_fill',
'edge_line',
'edit_4_line',
'exit_line',
'eye_2_line',
'eye_close_line',
'eye_line',
'facebook_fill',
'facebook_line1',
'factory_fill',
'factory_line',
'fault_line',
'figma_fill',
'figma_line',
'file_download_fill',
'file_download_line',
'film_fill',
'film_line',
'firefox_fill',
'firefox_line',
'flag_3_line',
'flame_line',
'folder_more_line',
'follow_fill',
'follow_line',
'fullscreen_fill',
'fullscreen_line',
'git_lab_fill',
'git_lab_line',
'github_2_line',
'github_fill',
'github_line',
'google_fill',
'google_line',
'google_play_fill',
'google_play_line',
'grid_line',
'grok_line',
'group_2_line',
'group_line',
'gumroad_line',
'hashtag_fill',
'hashtag_line',
'Heading_1_fill',
'Heading_1_line',
'headphone_2_fill',
'headphone_2_line',
'heart_crack_line',
'heart_fill',
'heart_line',
'home_1_line',
'home_6_line',
'icon-loading_fill',
'inbox_fill',
'inbox_line',
'ins_fill',
'ins_line',
'instagram_fill',
'instagram_line',
'kakao_talk_line',
'key_2_line',
'keyhole_line',
'kuaishou',
'layers_fill',
'layers_line',
'left_line',
'left_small_line',
'lemon_squeezy_fill',
'lemon_squeezy_line',
'lightning_line',
'line_app_fill',
'line_app_line',
'linear_fill',
'linear_line',
'link_2_fill',
'link_2_line',
'link_3_fill',
'link_3_line',
'link_fill',
'link_line',
'linkedin_fill',
'linkedin_line',
'linux_fill',
'linux_line',
'live_fill',
'live_line',
'live_location_line',
'location_2_line',
'location_line',
'lock_line',
'mail_ai_line',
'mail_line',
'mail_send_line',
'mailbox_line',
'map_line',
'map_pin_line',
'mastercard_fill',
'mastodon_line',
'medal_line',
'medium_fill',
'medium_line',
'menu_line',
'message_1_line',
'message_3_line',
'messenger_fill',
'messenger_line',
'meta_fill',
'mingcute_fill',
'mingcute_line',
'minimize_fill',
'minimize_line',
'moment_fill',
'moment_line',
'moon_line',
'moon_stars_fill',
'moon_stars_line',
'more_1_line',
'more_3_line',
'more_4_line',
'music_line',
'navigation_line',
'netease_music_fill',
'netease_music_line',
'news_line',
'nfc_fill',
'nfc_line',
'nintendo_switch_fill',
'nintendo_switch_line',
'notification_line',
'notion_fill',
'openai_fill',
'openai_line',
'paypal_fill',
'paypal_line',
'pencil_3_line',
'pencil_line',
'phone_call_line',
'phone_line',
'pic_line',
'pig_money_line',
'pinterest_fill',
'pinterest_line',
'play_circle_fill',
'play_circle_line',
'play_fill',
'play_line',
'power_fill',
'power_line',
'power_line1',
'print_fill',
'print_line',
'profile_line',
'qq_fill',
'qq_line',
'qrcode_fill',
'qrcode_line',
'question_line',
'quote_left_line',
'quote_right_line',
'react_fill',
'react_line',
'receive_money_line',
'reddit_fill',
'reddit_line',
'refresh_3_line',
'refund_cny_line',
'refund_dollar_line',
'registered_line',
'repeat_line',
'restore_line',
'right_fill',
'right_line',
'right_small_fill',
'right_small_line',
'rocket_fill',
'rocket_line',
'safe_alert_line',
'safe_lock_line',
'safety_certificate_line',
'search_2_line',
'search_3_line',
'search_line',
'selector_horizontal_line',
'selector_vertical_line',
'send_line',
'service_line',
'settings_1_line',
'share_3_line',
'shopping_bag_2_line',
'shopping_bag_3_line',
'shopping_cart_1_line',
'siri_fill',
'siri_frame_fill',
'siri_frame_line',
'siri_line',
'snapchat_fill',
'snapchat_line',
'social_x_fill',
'social_x_line',
'social_x_line1',
'spotify_line',
'star_fill',
'star_line',
'stock_fill',
'stock_line',
'store_line',
'stripe_fill',
'stripe_line',
'sun_line',
'tag_line',
'taobao',
'telegram_fill',
'telegram_line',
'telegram_line1',
'threads_fill',
'threads_line',
'threads_line1',
'thumb_down_2_line',
'thumb_up_2_line',
'ticket_line',
'tiktok_fill',
'tiktok_line',
'time_fill',
'time_line',
'transfer_3_line',
'transfer_4_line',
'transfer_line',
'translate_2_ai_line',
'translate_2_line',
'translate_line',
'trello_board_fill',
'trello_board_line',
'triangle_line',
'trophy_line',
'truck_fill',
'truck_line',
'tumblr',
'twitter_fill',
'twitter_line',
'u585_mouseOver',
'up_line',
'up_small_line',
'upload_2_line',
'upload_line',
'usd_coin_USDC_line',
'user_1_line',
'user_2_line',
'user_3_line',
'user_edit_line',
'user_forbid_line',
'user_hide_fill',
'user_hide_line',
'user_search_line',
'user_security_line',
'user_visible_line',
'Vector',
'viber_messenger_fill',
'viber_messenger_line',
'video_fill',
'video_line',
'video_line1',
'VIP_1_line',
'VIP_3_line',
'visa_fill',
'visa_line',
'vkontakte_line',
'vscode_fill',
'vscode_line',
'vue_fill',
'vue_line',
'wallet_2_line',
'wallet_line',
'warning_line',
'web_line',
'wechat_fill',
'wechat_line',
'wechat_miniprogram_line',
'wechat_miniprogram_line1',
'wechat_pay_fill',
'wechat_pay_line',
'wechat_pay_line1',
'weibo_fill',
'weibo_line',
'whatsapp_line',
'windows_fill',
'xbox_fill',
'xbox_line',
'xiaohongshu-fill',
'youtube_fill',
'youtube_line',
'zcool',
'zhihu',
] as const
export type IconName = (typeof ICON_NAMES)[number]
+268
View File
@@ -0,0 +1,268 @@
/**
* Stash UI 设计令牌。
*
* 取值来自 Alright 主题的 `:root` 设计变量(Stash 控制台即按它实现),
* 与 `ALRIGHT-DESIGN.md` 的第 1 节一一对应:灰阶、语义色、圆角、阴影、字号、动效。
*
* 约定:
* - 前缀 `--su-`(stash ui),避免和使用方应用自己的变量重名;
* - 组件里只允许引用令牌,不写死色值 / 尺寸,换主题时只覆盖这个文件;
* - 想改主题:在使用方覆盖同名变量即可(例如 `:root[data-theme='dark']`)。
*/
:root {
/* ---------- 中性色(灰阶) ---------- */
--su-gray-1: #f7f8fa; /* 页面背景 */
--su-gray-2: #f6f6f6; /* 边框、浅底 */
--su-gray-3: #eeeeee; /* hover 底色 */
--su-gray-4: #c9cdd4; /* 分隔线、禁用 */
--su-gray-5: #86909c; /* 说明文字 */
--su-gray-6: #4e5969; /* 次级文字 */
--su-gray-7: #2c343c; /* 强调文字 */
--su-gray-8: #1d2129; /* 主文字 */
--su-white: #ffffff;
/* ---------- 品牌与语义色 ---------- */
--su-color-primary: #3858f6;
--su-color-primary-hover: #2f4de0;
--su-color-primary-soft: rgba(56, 88, 246, 0.1);
--su-color-danger: #ff3e3e;
--su-color-danger-hover: #e63535;
--su-color-danger-soft: rgba(255, 62, 62, 0.1);
--su-color-danger-border: rgba(255, 62, 62, 0.25);
--su-color-warning: #ffba3a;
--su-color-warning-soft: rgba(255, 186, 58, 0.14);
--su-color-warning-text: #a86a00;
--su-color-success: #4abd86;
--su-color-success-soft: rgba(74, 189, 134, 0.12);
--su-color-orange-dark: #ff8338;
--su-color-orange-light: #fffbe8;
/* ---------- 渐变(主题里只用于角标) ---------- */
/* 认证角标:参考站头像右下角的对勾 */
--su-gradient-verified: linear-gradient(135deg, #ffb74a, #fd900f);
/* 热门标记 / 置顶 / 付费:参考站的 .item-badge */
--su-gradient-hot: linear-gradient(135deg, #ff2f43, #ff6327);
--su-gradient-paid: linear-gradient(135deg, #ff952f, #ff662b);
--su-gradient-red: linear-gradient(to right, #ff6034, #ff3e3e);
/* ---------- 语义别名 ---------- */
--su-bg-page: var(--su-gray-1);
--su-bg-card: var(--su-white);
--su-bg-hover: var(--su-gray-3);
/* 浅色块:标签底、次要按钮底(对应参考站的 --bg-light,比页面底色深一档) */
--su-bg-subtle: var(--su-gray-1);
--su-bg-light: var(--su-gray-2);
/* 骨架屏扫光的高光色(深色下要更弱) */
--su-skeleton-shine: rgba(255, 255, 255, 0.5);
/* 说明型提示条的半透明白底(叠在页面底色上) */
--su-bg-notice: rgba(255, 255, 255, 0.7);
--su-bg-danger-soft: #fff5f5;
--su-text-main: var(--su-gray-8);
/* 次级正文:参考站的 .text-secondary 用的就是 gray-7 */
--su-text-secondary: var(--su-gray-7);
--su-text-sub: var(--su-gray-6);
/* 说明文字:参考站的 .text-muted 用的是 gray-6,这里保留 gray-5 给更弱的占位 */
--su-text-muted: var(--su-gray-5);
--su-text-placeholder: var(--su-gray-5);
--su-text-disabled: var(--su-gray-4);
--su-border: var(--su-gray-2);
--su-border-strong: var(--su-gray-3);
/* 卡片描边:参考站的 .box-block 用的是 gray-1(几乎看不见的那层) */
--su-card-border: var(--su-gray-1);
/* ---------- 圆角 ---------- */
--su-radius-xs: 4px;
--su-radius-sm: 6px;
--su-radius-md: 12px;
--su-radius-lg: 24px;
--su-radius-pill: 999px;
/* 封面卡圆角:比常规卡片小一档,贴图时不显胖 */
--su-radius-cover: 8px;
/* 资料卡头像的无图底色(参考站实测) */
--su-avatar-gradient: linear-gradient(135deg, #dfe4ff, #c9d0ff);
/* ---------- 布局 ----------
* 页面级骨架的常用值,Layout 与页面共用,避免各页面写死 1380 / 60。
*/
--su-layout-width: 1380px;
--su-header-height: 60px;
/* 阅读型正文的行高(文章详情);组件默认 24px 的普通行高 */
--su-reading-line-height: 1.9;
/* ---------- 阴影 ----------
* 参考站只有一层极轻的卡片阴影:0 6px 16px 0 rgb(27 26 49 / 1%),
* 悬停用 /3%,再重的阴影会破坏主题的「纸片感」。
*/
--su-shadow-card: 0 6px 16px 0 rgba(27, 26, 49, 0.01);
--su-shadow-hover: 0 6px 16px 0 rgba(27, 26, 49, 0.03);
--su-shadow-card-strong: 0 6px 16px 0 rgba(27, 26, 49, 0.02);
--su-shadow-pop: 0 8px 24px rgba(27, 26, 49, 0.12);
/* 封面底部压暗层:保证白色标题在任何图片上都可读 */
--su-cover-scrim: linear-gradient(
to top,
rgba(0, 0, 0, 0.85) 0%,
rgba(0, 0, 0, 0.4) 60%,
transparent 100%
);
/* ---------- 间距(4 的倍数) ---------- */
--su-space-1: 4px;
--su-space-2: 8px;
--su-space-3: 12px;
--su-space-4: 16px;
--su-space-5: 20px;
--su-space-6: 24px;
--su-space-8: 32px;
--su-space-10: 40px;
/* ---------- 字号阶梯 ----------
* 取自参考站实际渲染的 .text-* 工具类(body 16/24,组件内 13~18),
* 不是主题 tokens 里那套 10/12/14/16 的旧值。
*/
--su-font-size-xs: 12px;
--su-font-size-sm: 13px;
--su-font-size-md: 14px;
/* 内容正文字号:文章段落、卡片标题(参考站 body 16/24) */
--su-font-size-base: 16px;
--su-font-size-lg: 18px;
--su-font-size-xl: 24px;
--su-line-height-xs: 16px;
--su-line-height-sm: 20px;
--su-line-height-md: 22px;
--su-line-height-base: 24px;
--su-line-height-lg: 26px;
--su-line-height-xl: 32px;
/* 主题的「加粗」是 500 */
--su-font-weight-normal: 400;
--su-font-weight-bold: 500;
--su-font-weight-semibold: 600;
/* 只给字形类(如认证角标的 ✓)用的重字重 */
--su-font-weight-strong: 700;
--su-font-sans:
'LXGW WenKai', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
'Hiragino Sans GB', 'Microsoft YaHei', 'Source Han Sans CN', sans-serif;
--su-font-mono: ui-monospace, SFMono-Regular, Consolas, 'Liberation Mono', monospace;
/* ---------- 控件尺寸 ---------- */
/* 按钮 / 输入框 / 未来 Select 共用的外框高度,保证并排时对齐 */
--su-control-height-sm: 32px;
--su-control-height-md: 36px;
--su-control-height-lg: 44px;
/* 圆形图标按钮:参考站的 .btn-icon 是 28 / 36 / 44,字号 16 / 20 / 24 */
--su-icon-button-xs: 28px;
--su-icon-button-sm: 36px;
--su-icon-button-md: 44px;
/* ---------- 动效 ---------- */
--su-duration-fast: 0.2s;
--su-duration-base: 0.3s;
--su-ease-out: ease-out;
--su-ease-in: ease-in;
/* 参考站的颜色/背景过渡统一是 .3s ease-in-out */
--su-ease-in-out: ease-in-out;
/* ---------- 焦点环 ----------
* 参考站把 focus 的 box-shadow 干掉了(a11y 不合格),组件库自己补一圈:
* 键盘聚焦时 2px 主色描边 + 2px 间距,鼠标点击不出现。
*/
--su-focus-ring: 2px solid var(--su-color-primary);
--su-focus-ring-offset: 2px;
/* ---------- 层级 ---------- */
--su-z-header: 100;
--su-z-float: 90;
}
/**
* 暗色主题。
*
* 用法:在 `html` 上设置 `data-scheme="dark"`(或 `"auto"` 跟随系统)。
* 取值来自参考站 `body[data-scheme=dark]` 的实测映射:
* 灰阶整体反相、卡片底 #202022、描边 #303030,语义变量跟着翻转——
* 所以组件只要用语义令牌(bg-card / bg-subtle / text-* / border-*)就自动适配。
*/
:root[data-scheme='dark'] {
/* 灰阶反相(与参考站一致) */
--su-gray-5: #494949;
--su-gray-6: #303030;
--su-gray-7: #202022;
--su-gray-8: #0f0f11;
--su-bg-page: #0f0f11;
--su-bg-card: #202022;
--su-bg-subtle: #2a2a2e;
--su-bg-light: #303034;
--su-bg-hover: #3a3a3f;
--su-skeleton-shine: rgba(255, 255, 255, 0.08);
--su-bg-notice: rgba(32, 32, 34, 0.7);
--su-bg-danger-soft: #2c1a1c;
--su-text-main: #f7f8fa;
--su-text-secondary: #e5e6eb;
--su-text-sub: #c9cdd4;
--su-text-muted: #86909c;
--su-text-placeholder: #6b7280;
--su-text-disabled: #4e5560;
--su-border: #303033;
--su-border-strong: #3a3a3f;
--su-card-border: #2a2a2e;
/* 浅色底在深色下要提亮,否则标签几乎看不见 */
--su-color-primary-soft: rgba(56, 88, 246, 0.22);
--su-color-danger-soft: rgba(255, 62, 62, 0.16);
--su-color-success-soft: rgba(74, 189, 134, 0.16);
--su-color-warning-soft: rgba(255, 186, 58, 0.16);
--su-color-warning-text: #ffc96b;
--su-avatar-gradient: linear-gradient(135deg, rgba(56, 88, 246, 0.32), rgba(56, 88, 246, 0.14));
/* 深色下阴影只能靠更深的黑,透明度要高一些才看得出来 */
--su-shadow-card: 0 6px 16px 0 rgba(0, 0, 0, 0.45);
--su-shadow-hover: 0 6px 16px 0 rgba(0, 0, 0, 0.6);
--su-shadow-card-strong: 0 6px 16px 0 rgba(0, 0, 0, 0.55);
}
/* `data-scheme="auto"`:跟随系统 */
@media (prefers-color-scheme: dark) {
:root[data-scheme='auto'] {
--su-gray-5: #494949;
--su-gray-6: #303030;
--su-gray-7: #202022;
--su-gray-8: #0f0f11;
--su-bg-page: #0f0f11;
--su-bg-card: #202022;
--su-bg-subtle: #2a2a2e;
--su-bg-light: #303034;
--su-bg-hover: #3a3a3f;
--su-skeleton-shine: rgba(255, 255, 255, 0.08);
--su-bg-notice: rgba(32, 32, 34, 0.7);
--su-bg-danger-soft: #2c1a1c;
--su-text-main: #f7f8fa;
--su-text-secondary: #e5e6eb;
--su-text-sub: #c9cdd4;
--su-text-muted: #86909c;
--su-text-placeholder: #6b7280;
--su-text-disabled: #4e5560;
--su-border: #303033;
--su-border-strong: #3a3a3f;
--su-card-border: #2a2a2e;
--su-color-primary-soft: rgba(56, 88, 246, 0.22);
--su-color-danger-soft: rgba(255, 62, 62, 0.16);
--su-color-success-soft: rgba(74, 189, 134, 0.16);
--su-color-warning-soft: rgba(255, 186, 58, 0.16);
--su-color-warning-text: #ffc96b;
--su-avatar-gradient: linear-gradient(135deg, rgba(56, 88, 246, 0.32), rgba(56, 88, 246, 0.14));
--su-shadow-card: 0 6px 16px 0 rgba(0, 0, 0, 0.45);
--su-shadow-hover: 0 6px 16px 0 rgba(0, 0, 0, 0.6);
}
}