feat: stash-ui 0.1.0(40 个组件 + 文档站 + 主题机制)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user