feat: stash-ui 0.1.0(40 个组件 + 文档站 + 主题机制)
This commit is contained in:
+238
@@ -0,0 +1,238 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Toaster } from '../src'
|
||||
import { Avatars } from './sections/Avatars'
|
||||
import { Breadcrumbs } from './sections/Breadcrumbs'
|
||||
import { Buttons } from './sections/Buttons'
|
||||
import { Cards } from './sections/Cards'
|
||||
import { Covers } from './sections/Covers'
|
||||
import { Checkboxes } from './sections/Checkboxes'
|
||||
import { Composition } from './sections/Composition'
|
||||
import { Drawers } from './sections/Drawers'
|
||||
import { Feedback } from './sections/Feedback'
|
||||
import { Icons } from './sections/Icons'
|
||||
import { Images } from './sections/Images'
|
||||
import { Inputs } from './sections/Inputs'
|
||||
import { Layouts } from './sections/Layouts'
|
||||
import { Marks } from './sections/Marks'
|
||||
import { MenuSection } from './sections/MenuSection'
|
||||
import { Modals } from './sections/Modals'
|
||||
import { Overview } from './sections/Overview'
|
||||
import { PageLayout } from './sections/PageLayout'
|
||||
import { Paginations } from './sections/Paginations'
|
||||
import { Progresses } from './sections/Progresses'
|
||||
import { RadioGroups } from './sections/RadioGroups'
|
||||
import { Results } from './sections/Results'
|
||||
import { ScrollTopSection } from './sections/ScrollTopSection'
|
||||
import { Segmenteds } from './sections/Segmenteds'
|
||||
import { Selects } from './sections/Selects'
|
||||
import { Skeletons } from './sections/Skeletons'
|
||||
import { Spinners } from './sections/Spinners'
|
||||
import { StepsSection } from './sections/StepsSection'
|
||||
import { Switches } from './sections/Switches'
|
||||
import { Tables } from './sections/Tables'
|
||||
import { TabsSection } from './sections/TabsSection'
|
||||
import { Toasts } from './sections/Toasts'
|
||||
import { Tooltips } from './sections/Tooltips'
|
||||
import { Typographies } from './sections/Typographies'
|
||||
|
||||
/**
|
||||
* 组件展示站:左侧目录 + 右侧示例。
|
||||
*
|
||||
* 页面只负责摆示例,所有样式都来自组件库本身;
|
||||
* 站点自己的排版样式收在 `app.css`,不污染组件。
|
||||
*/
|
||||
const NAV = [
|
||||
{
|
||||
title: '基础',
|
||||
items: [
|
||||
{ id: 'overview', label: '设计令牌' },
|
||||
{ id: 'typography', label: '排版 Typography' },
|
||||
{ id: 'icons', label: '图标 Icon' },
|
||||
{ id: 'buttons', label: '按钮 Button' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '展示',
|
||||
items: [
|
||||
{ id: 'cards', label: '卡片 Card' },
|
||||
{ id: 'cover', label: '封面卡 Cover' },
|
||||
{ id: 'image', label: '图片 Image' },
|
||||
{ id: 'marks', label: '标记 Tag / Badge' },
|
||||
{ id: 'avatars', label: '头像 Avatar' },
|
||||
{ id: 'table', label: '表格 Table' },
|
||||
{ id: 'result', label: '结果 Result' },
|
||||
{ id: 'progress', label: '进度 Progress' },
|
||||
{ id: 'steps', label: '步骤 Steps' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '表单',
|
||||
items: [
|
||||
{ id: 'inputs', label: '输入框 Input' },
|
||||
{ id: 'select', label: '下拉 Select' },
|
||||
{ id: 'switch', label: '开关 Switch' },
|
||||
{ id: 'checkbox', label: '复选框 Checkbox' },
|
||||
{ id: 'radio', label: '单选 Radio' },
|
||||
{ id: 'segmented', label: '分段 Segmented' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '导航',
|
||||
items: [
|
||||
{ id: 'breadcrumb', label: '面包屑 Breadcrumb' },
|
||||
{ id: 'tabs', label: '标签页 Tabs' },
|
||||
{ id: 'menu', label: '菜单 Menu' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '交互',
|
||||
items: [
|
||||
{ id: 'skeleton', label: '骨架屏 Skeleton' },
|
||||
{ id: 'spinner', label: '加载 Spinner' },
|
||||
{ id: 'feedback', label: '提示与空态' },
|
||||
{ id: 'toast', label: '轻提示 Toast' },
|
||||
{ id: 'modal', label: '弹窗 Modal' },
|
||||
{ id: 'drawer', label: '抽屉 Drawer' },
|
||||
{ id: 'pagination', label: '分页 Pagination' },
|
||||
{ id: 'scrolltop', label: '回到顶部' },
|
||||
{ id: 'tooltip', label: '工具提示 Tooltip' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '布局与组合',
|
||||
items: [
|
||||
{ id: 'layout', label: '页面骨架 Layout' },
|
||||
{ id: 'layouts', label: '网格与间距' },
|
||||
{ id: 'composition', label: '组合示例' },
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
const SECTION_IDS = NAV.flatMap((group) => group.items.map((item) => item.id))
|
||||
|
||||
/** 滚动监听:当前视口里最靠上的那一节高亮 */
|
||||
function useActiveSection(): string {
|
||||
const [active, setActive] = useState<string>(SECTION_IDS[0])
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((entry) => entry.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
|
||||
|
||||
if (visible.length > 0) setActive(visible[0].target.id)
|
||||
},
|
||||
{ rootMargin: '-72px 0px -68% 0px' },
|
||||
)
|
||||
|
||||
for (const id of SECTION_IDS) {
|
||||
const element = document.getElementById(id)
|
||||
if (element !== null) observer.observe(element)
|
||||
}
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
return active
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const active = useActiveSection()
|
||||
|
||||
return (
|
||||
<div className="doc-shell">
|
||||
<aside className="doc-sidebar">
|
||||
<a className="doc-brand" href="#overview">
|
||||
<span className="doc-brand-mark">S</span>
|
||||
<span>
|
||||
<strong>Stash UI</strong>
|
||||
<em>v0.1.0</em>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<p className="doc-brand-desc">
|
||||
一套按 Alright 主题打磨的基础组件,供 Stash 控制台与后续页面组合使用。
|
||||
</p>
|
||||
|
||||
<nav className="doc-nav" aria-label="组件目录">
|
||||
{NAV.map((group) => (
|
||||
<section key={group.title}>
|
||||
<h2>{group.title}</h2>
|
||||
<ul>
|
||||
{group.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<a href={`#${item.id}`} className={active === item.id ? 'is-active' : undefined}>
|
||||
{item.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<footer className="doc-sidebar-footer">
|
||||
令牌定义:<code>src/theme/tokens.css</code>
|
||||
<a className="doc-demo-link" href="/demo.html">
|
||||
整页拼装示例 →
|
||||
</a>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<main className="doc-main">
|
||||
<header className="doc-hero">
|
||||
<p className="doc-eyebrow">DESIGN SYSTEM</p>
|
||||
<h1>Stash UI</h1>
|
||||
<p className="doc-hero-desc">
|
||||
独立于应用的设计系统项目:令牌、基础组件、以及一个可以直接翻的组件展示页。
|
||||
组件只依赖令牌,不依赖任何页面数据,所以换页面、换应用都能直接拼。
|
||||
</p>
|
||||
<div className="doc-hero-meta">
|
||||
<span>40 个基础组件</span>
|
||||
<span>零运行时依赖</span>
|
||||
<span>CSS Modules + 设计令牌</span>
|
||||
<span>对齐 Alright 参考站</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Overview />
|
||||
<Typographies />
|
||||
<Icons />
|
||||
<Buttons />
|
||||
<Cards />
|
||||
<Covers />
|
||||
<Images />
|
||||
<Marks />
|
||||
<Avatars />
|
||||
<Tables />
|
||||
<Results />
|
||||
<Progresses />
|
||||
<StepsSection />
|
||||
<Inputs />
|
||||
<Selects />
|
||||
<Switches />
|
||||
<Checkboxes />
|
||||
<RadioGroups />
|
||||
<Segmenteds />
|
||||
<Breadcrumbs />
|
||||
<TabsSection />
|
||||
<MenuSection />
|
||||
<Skeletons />
|
||||
<Spinners />
|
||||
<Feedback />
|
||||
<Toasts />
|
||||
<Modals />
|
||||
<Drawers />
|
||||
<Paginations />
|
||||
<ScrollTopSection />
|
||||
<Tooltips />
|
||||
<PageLayout />
|
||||
<Layouts />
|
||||
<Composition />
|
||||
</main>
|
||||
|
||||
<Toaster />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+636
@@ -0,0 +1,636 @@
|
||||
:root {
|
||||
font-family: var(--su-font-sans);
|
||||
font-size: var(--su-font-size-md);
|
||||
line-height: var(--su-line-height-md);
|
||||
color: var(--su-text-main);
|
||||
background-color: var(--su-bg-page);
|
||||
color-scheme: light;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--su-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--su-font-mono);
|
||||
font-size: 0.92em;
|
||||
}
|
||||
|
||||
/* ---------- 文档骨架 ---------- */
|
||||
|
||||
.doc-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 248px minmax(0, 1fr);
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.doc-sidebar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-5);
|
||||
height: 100vh;
|
||||
padding: var(--su-space-8) var(--su-space-5);
|
||||
border-right: 1px solid var(--su-border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.doc-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-3);
|
||||
}
|
||||
|
||||
.doc-brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: var(--su-radius-sm);
|
||||
background-image: linear-gradient(135deg, #4d6bff, var(--su-color-primary));
|
||||
color: var(--su-white);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.doc-brand strong {
|
||||
display: block;
|
||||
font-size: var(--su-font-size-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.doc-brand em {
|
||||
font-size: var(--su-font-size-sm);
|
||||
font-style: normal;
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.doc-brand-desc {
|
||||
font-size: var(--su-font-size-sm);
|
||||
line-height: 1.7;
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.doc-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-5);
|
||||
}
|
||||
|
||||
.doc-nav h2 {
|
||||
margin-bottom: var(--su-space-2);
|
||||
font-size: var(--su-font-size-sm);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--su-text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.doc-nav ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.doc-nav a {
|
||||
display: block;
|
||||
padding: 6px var(--su-space-3);
|
||||
border-radius: var(--su-radius-sm);
|
||||
font-size: var(--su-font-size-md);
|
||||
color: var(--su-text-sub);
|
||||
transition:
|
||||
background-color var(--su-duration-fast) var(--su-ease-out),
|
||||
color var(--su-duration-fast) var(--su-ease-out);
|
||||
}
|
||||
|
||||
.doc-nav a:hover {
|
||||
background-color: var(--su-gray-2);
|
||||
color: var(--su-text-main);
|
||||
}
|
||||
|
||||
.doc-nav a.is-active {
|
||||
background-color: var(--su-color-primary-soft);
|
||||
color: var(--su-color-primary);
|
||||
font-weight: var(--su-font-weight-bold);
|
||||
}
|
||||
|
||||
.doc-sidebar-footer {
|
||||
margin-top: auto;
|
||||
padding-top: var(--su-space-4);
|
||||
border-top: 1px solid var(--su-border);
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.doc-demo-link {
|
||||
display: inline-block;
|
||||
margin-top: var(--su-space-2);
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--su-border-strong);
|
||||
border-radius: var(--su-radius-pill);
|
||||
color: var(--su-color-primary);
|
||||
transition:
|
||||
background-color var(--su-duration-fast) var(--su-ease-in-out),
|
||||
border-color var(--su-duration-fast) var(--su-ease-in-out);
|
||||
}
|
||||
|
||||
.doc-demo-link:hover {
|
||||
border-color: var(--su-color-primary);
|
||||
background-color: var(--su-color-primary-soft);
|
||||
}
|
||||
|
||||
/* ---------- 正文 ---------- */
|
||||
|
||||
.doc-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-6);
|
||||
padding: var(--su-space-10) var(--su-space-10) 160px;
|
||||
}
|
||||
|
||||
.doc-hero {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-3);
|
||||
padding-bottom: var(--su-space-6);
|
||||
border-bottom: 1px solid var(--su-border);
|
||||
}
|
||||
|
||||
.doc-eyebrow {
|
||||
font-family: var(--su-font-mono);
|
||||
font-size: var(--su-font-size-sm);
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
.doc-hero h1 {
|
||||
font-size: 40px;
|
||||
line-height: 1.2;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.doc-hero-desc {
|
||||
max-width: 68ch;
|
||||
font-size: var(--su-font-size-md);
|
||||
line-height: 1.8;
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.doc-hero-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--su-space-2);
|
||||
margin-top: var(--su-space-2);
|
||||
}
|
||||
|
||||
.doc-hero-meta span {
|
||||
padding: 3px 10px;
|
||||
border: 1px solid var(--su-border);
|
||||
border-radius: var(--su-radius-pill);
|
||||
background-color: var(--su-bg-card);
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.doc-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-4);
|
||||
scroll-margin-top: 24px;
|
||||
}
|
||||
|
||||
.doc-section-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--su-space-3);
|
||||
}
|
||||
|
||||
.doc-section-title h2 {
|
||||
font-size: var(--su-font-size-xl);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.doc-section-title code {
|
||||
color: var(--su-text-muted);
|
||||
font-size: var(--su-font-size-sm);
|
||||
}
|
||||
|
||||
.doc-section-desc {
|
||||
max-width: 76ch;
|
||||
color: var(--su-text-sub);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* ---------- 示例块 ---------- */
|
||||
|
||||
.demo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-3);
|
||||
padding-top: var(--su-space-5);
|
||||
border-top: 1px dashed var(--su-border);
|
||||
}
|
||||
|
||||
.demo:first-of-type {
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.demo-title {
|
||||
font-size: var(--su-font-size-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.demo-desc {
|
||||
font-size: var(--su-font-size-md);
|
||||
color: var(--su-text-muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.demo-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--su-space-3);
|
||||
padding: var(--su-space-6);
|
||||
border: 1px solid var(--su-border);
|
||||
border-radius: var(--su-radius-md);
|
||||
background-color: var(--su-bg-card);
|
||||
}
|
||||
|
||||
/*
|
||||
* 纵向示例用 grid 而不是 flex column:
|
||||
* flex 纵向容器会把子项(尤其是带 aspect-ratio 封面的 Grid)按内容高度压缩,
|
||||
* grid 的行高严格按内容算,卡片不会被裁掉一截。
|
||||
*/
|
||||
.demo-preview--column {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.demo-preview--muted {
|
||||
background-color: var(--su-gray-1);
|
||||
}
|
||||
|
||||
.demo-code {
|
||||
padding: var(--su-space-4);
|
||||
border-radius: var(--su-radius-md);
|
||||
background-color: #1d2129;
|
||||
color: #e5e6eb;
|
||||
font-family: var(--su-font-mono);
|
||||
font-size: var(--su-font-size-sm);
|
||||
line-height: 1.7;
|
||||
overflow-x: auto;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* ---------- 令牌表 ---------- */
|
||||
|
||||
.token-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
gap: var(--su-space-3);
|
||||
}
|
||||
|
||||
.token-swatch {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--su-border);
|
||||
border-radius: var(--su-radius-md);
|
||||
background-color: var(--su-bg-card);
|
||||
}
|
||||
|
||||
.token-swatch-color {
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.token-swatch-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--su-space-2) var(--su-space-3);
|
||||
}
|
||||
|
||||
.token-swatch-name {
|
||||
font-family: var(--su-font-mono);
|
||||
font-size: var(--su-font-size-sm);
|
||||
}
|
||||
|
||||
.token-swatch-value {
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.token-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--su-border);
|
||||
border-radius: var(--su-radius-md);
|
||||
background-color: var(--su-bg-card);
|
||||
}
|
||||
|
||||
.token-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-4);
|
||||
padding: var(--su-space-3) var(--su-space-4);
|
||||
border-top: 1px solid var(--su-border);
|
||||
}
|
||||
|
||||
.token-row:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.token-row-label {
|
||||
width: 160px;
|
||||
flex-shrink: 0;
|
||||
font-family: var(--su-font-mono);
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.token-row-demo {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: var(--su-space-3);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.token-row-note {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.token-bar {
|
||||
height: 8px;
|
||||
border-radius: var(--su-radius-pill);
|
||||
background-color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
.type-sample-xl {
|
||||
font-size: var(--su-font-size-xl);
|
||||
line-height: var(--su-line-height-xl);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.type-sample-lg {
|
||||
font-size: var(--su-font-size-lg);
|
||||
line-height: var(--su-line-height-lg);
|
||||
}
|
||||
|
||||
.type-sample-md {
|
||||
font-size: var(--su-font-size-md);
|
||||
line-height: var(--su-line-height-md);
|
||||
}
|
||||
|
||||
.type-sample-sm {
|
||||
font-size: var(--su-font-size-sm);
|
||||
line-height: var(--su-line-height-sm);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.radius-sample {
|
||||
width: 72px;
|
||||
height: 48px;
|
||||
border: 1px solid var(--su-border-strong);
|
||||
background-color: var(--su-gray-2);
|
||||
}
|
||||
|
||||
.shadow-sample {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 120px;
|
||||
height: 56px;
|
||||
border-radius: var(--su-radius-md);
|
||||
background-color: var(--su-bg-card);
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
/* ---------- Props 表 ---------- */
|
||||
|
||||
.props-table-wrap {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.props-table {
|
||||
width: 100%;
|
||||
min-width: 520px;
|
||||
border-collapse: collapse;
|
||||
border: 1px solid var(--su-border);
|
||||
border-radius: var(--su-radius-md);
|
||||
background-color: var(--su-bg-card);
|
||||
font-size: var(--su-font-size-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.props-table th,
|
||||
.props-table td {
|
||||
padding: var(--su-space-2) var(--su-space-4);
|
||||
border-top: 1px solid var(--su-border);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.props-table thead th {
|
||||
border-top: none;
|
||||
background-color: var(--su-gray-1);
|
||||
font-weight: 600;
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.props-table code {
|
||||
color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
.props-table .props-type {
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
/* ---------- 图标墙 ---------- */
|
||||
|
||||
.icon-count {
|
||||
align-self: center;
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.icon-wall {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
|
||||
gap: var(--su-space-2);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.icon-wall--scroll {
|
||||
max-height: 460px;
|
||||
padding-right: var(--su-space-1);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.icon-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--su-space-2);
|
||||
padding: 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-lg);
|
||||
color: var(--su-text-sub);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color var(--su-duration-fast) var(--su-ease-in-out),
|
||||
border-color var(--su-duration-fast) var(--su-ease-in-out),
|
||||
background-color var(--su-duration-fast) var(--su-ease-in-out);
|
||||
}
|
||||
|
||||
.icon-cell:hover {
|
||||
border-color: var(--su-color-primary);
|
||||
background-color: var(--su-color-primary-soft);
|
||||
color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
.icon-cell code {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
font-size: var(--su-font-size-xs);
|
||||
color: var(--su-text-muted);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon-empty {
|
||||
font-size: var(--su-font-size-md);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
/* ---------- Layout 示例的迷你页面 ---------- */
|
||||
|
||||
.layout-frame {
|
||||
height: 440px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--su-border);
|
||||
border-radius: var(--su-radius-md);
|
||||
background-color: var(--su-bg-page);
|
||||
}
|
||||
|
||||
.layout-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--su-space-4);
|
||||
height: 56px;
|
||||
padding: 0 var(--su-space-5);
|
||||
border-bottom: 1px solid var(--su-border);
|
||||
}
|
||||
|
||||
.layout-brand {
|
||||
font-size: var(--su-font-size-lg);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.layout-bar-actions {
|
||||
display: flex;
|
||||
gap: var(--su-space-1);
|
||||
}
|
||||
|
||||
.layout-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: var(--su-space-4);
|
||||
}
|
||||
|
||||
.layout-nav-item {
|
||||
padding: 6px var(--su-space-3);
|
||||
border-radius: var(--su-radius-sm);
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-sub);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 窄屏演示:页头只留当前项,模拟真实导航收起 */
|
||||
@media (max-width: 640px) {
|
||||
.layout-bar {
|
||||
padding: 0 var(--su-space-3);
|
||||
}
|
||||
|
||||
.layout-bar-actions .layout-nav-item:nth-child(n + 2) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.layout-nav-item.is-active {
|
||||
background-color: var(--su-color-primary-soft);
|
||||
color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
.layout-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-4);
|
||||
}
|
||||
|
||||
.layout-aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-2);
|
||||
padding: var(--su-space-5) var(--su-space-4);
|
||||
}
|
||||
|
||||
.layout-aside-item {
|
||||
padding: 2px var(--su-space-2);
|
||||
border-radius: var(--su-radius-xs);
|
||||
font-size: var(--su-font-size-xs);
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.layout-aside-item.is-active {
|
||||
background-color: var(--su-bg-light);
|
||||
color: var(--su-text-main);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.doc-shell {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.doc-sidebar {
|
||||
position: static;
|
||||
height: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--su-border);
|
||||
}
|
||||
|
||||
.doc-main {
|
||||
padding: var(--su-space-6) var(--su-space-5) 120px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* 一个用法示例:标题 + 说明 + 预览区 + 可选代码。
|
||||
*
|
||||
* 预览区默认横向排列,需要纵向(表单、长文)时传 `layout="column"`。
|
||||
*/
|
||||
type DemoProps = {
|
||||
title: string
|
||||
desc?: ReactNode
|
||||
/** 展示给使用方的代码片段 */
|
||||
code?: string
|
||||
layout?: 'row' | 'column'
|
||||
/** 灰底预览,用于白色组件(卡片)看得清边界 */
|
||||
muted?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Demo({ title, desc, code, layout = 'row', muted = false, children }: DemoProps) {
|
||||
const previewClasses = [
|
||||
'demo-preview',
|
||||
layout === 'column' ? 'demo-preview--column' : undefined,
|
||||
muted ? 'demo-preview--muted' : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<article className="demo">
|
||||
<h3 className="demo-title">{title}</h3>
|
||||
{desc !== undefined && <p className="demo-desc">{desc}</p>}
|
||||
|
||||
<div className={previewClasses}>{children}</div>
|
||||
|
||||
{code !== undefined && (
|
||||
<pre className="demo-code">
|
||||
<code>{code}</code>
|
||||
</pre>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Props 表:手写维护,保持和组件定义同步。
|
||||
*
|
||||
* 只列对外承诺的 props;内部实现用的字段不写进来。
|
||||
*/
|
||||
export type PropRow = {
|
||||
name: string
|
||||
type: string
|
||||
required?: boolean
|
||||
defaultValue?: string
|
||||
desc: string
|
||||
}
|
||||
|
||||
export function PropsTable({ rows }: { rows: PropRow[] }) {
|
||||
return (
|
||||
<div className="props-table-wrap">
|
||||
<table className="props-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>属性</th>
|
||||
<th>类型</th>
|
||||
<th>默认值</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr key={row.name}>
|
||||
<td>
|
||||
<code>{row.name}</code>
|
||||
{row.required === true && ' *'}
|
||||
</td>
|
||||
<td className="props-type">
|
||||
<code>{row.type}</code>
|
||||
</td>
|
||||
<td className="props-type">{row.defaultValue ?? '—'}</td>
|
||||
<td>{row.desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* 文档的一节:锚点 + 标题 + 说明。
|
||||
*
|
||||
* 每个组件一节,节内再用 Demo 摆具体用法,方便直接翻着找。
|
||||
*/
|
||||
type SectionProps = {
|
||||
/** 锚点 id,与左侧目录一致 */
|
||||
id: string
|
||||
title: string
|
||||
/** 右侧灰字,通常写组件名或导出路径 */
|
||||
api?: string
|
||||
desc?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Section({ id, title, api, desc, children }: SectionProps) {
|
||||
return (
|
||||
<section id={id} className="doc-section">
|
||||
<div className="doc-section-title">
|
||||
<h2>{title}</h2>
|
||||
{api !== undefined && <code>{api}</code>}
|
||||
</div>
|
||||
{desc !== undefined && <p className="doc-section-desc">{desc}</p>}
|
||||
{children}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/** 展示页用的示例数据:与真实业务无关,只为示例好看、够长 */
|
||||
|
||||
export type DemoApp = {
|
||||
id: string
|
||||
name: string
|
||||
desc: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export const DEMO_APPS: DemoApp[] = [
|
||||
{ id: 'github', name: 'GitHub', desc: '代码托管与开源社区', icon: 'G' },
|
||||
{ id: 'figma', name: 'Figma', desc: '多人协作的界面设计工具', icon: 'F' },
|
||||
{ id: 'notion', name: 'Notion', desc: '文档、知识库与项目管理', icon: 'N' },
|
||||
{ id: 'vercel', name: 'Vercel', desc: '前端部署与边缘网络', icon: 'V' },
|
||||
{ id: 'unsplash', name: 'Unsplash', desc: '免费高清图片素材', icon: 'U' },
|
||||
{ id: 'dribbble', name: 'Dribbble', desc: '设计师作品与灵感集', icon: 'D' },
|
||||
{ id: 'bilibili', name: '哔哩哔哩', desc: '视频社区与学习资源', icon: '哔' },
|
||||
{ id: 'stash', name: 'Stash', desc: '本地静态页面托管工具', icon: 'S' },
|
||||
]
|
||||
|
||||
export const DEMO_POSTERS = [
|
||||
{ id: 'p1', title: '星际穿越', tag: '推荐电影' },
|
||||
{ id: 'p2', title: '银翼杀手 2049', tag: '赛博朋克' },
|
||||
{ id: 'p3', title: '疯狂动物城', tag: '动画' },
|
||||
{ id: 'p4', title: '沙丘', tag: '科幻史诗' },
|
||||
]
|
||||
|
||||
export const DEMO_ARTICLES = [
|
||||
{ id: 'a1', title: 'CES 2026:折叠屏的第二次机会', meta: '2 天前' },
|
||||
{ id: 'a2', title: '把设计令牌落进 CSS 变量的四种做法', meta: '1 周前' },
|
||||
]
|
||||
|
||||
/** 内联 SVG 渐变封面:不依赖网络,离线也能看到图片态 */
|
||||
export const DEMO_COVER =
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='96' height='96'%3E%3Cdefs%3E%3ClinearGradient id='g' x1='0' y1='0' x2='1' y2='1'%3E%3Cstop offset='0' stop-color='%233858f6'/%3E%3Cstop offset='1' stop-color='%237c3aed'/%3E%3C/linearGradient%3E%3C/defs%3E%3Crect width='96' height='96' fill='url(%23g)'/%3E%3C/svg%3E"
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-scheme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<title>Stash 内容工作台 · 组件拼装示例</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.staticfile.net/lxgw-wenkai-webfont/1.7.0/style.min.css"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/demo/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,546 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Breadcrumb,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Drawer,
|
||||
Grid,
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
ItemBadge,
|
||||
Modal,
|
||||
Notice,
|
||||
Pagination,
|
||||
Paragraph,
|
||||
ScrollTop,
|
||||
Select,
|
||||
Skeleton,
|
||||
Space,
|
||||
Spinner,
|
||||
Switch,
|
||||
Tag,
|
||||
Text,
|
||||
Title,
|
||||
Toaster,
|
||||
toast,
|
||||
} from '../../src'
|
||||
import styles from './demo.module.css'
|
||||
|
||||
/**
|
||||
* 拼装示例:用组件库搭一个完整的三栏内容页。
|
||||
*
|
||||
* 只用 `@stash/ui` 的组件 + 这一页自己的布局样式,
|
||||
* 没有引入任何业务组件;浅色/暗色由 `<html data-scheme>` 切换。
|
||||
*/
|
||||
|
||||
const NAV = ['工作台', '常用推荐', '推荐电影', 'CES 现场报道']
|
||||
|
||||
const HOT_POSTS = [
|
||||
'iPhone 17e 最全汇总:最便宜的苹果 AI 手机',
|
||||
'二十年磨一剑,微软量子芯片碾压全球超算',
|
||||
'做饭将被 AI 彻底改变!AI 厨电成新风口',
|
||||
'对未来讲话?从 AWE 2024 看未来趋势',
|
||||
]
|
||||
|
||||
const POST_TAGS = ['未分类', '苹果新品', '闪电接口']
|
||||
|
||||
const GALLERY = [1, 2, 3, 4, 5, 6]
|
||||
|
||||
function useTheme() {
|
||||
// 支持 ?scheme=dark 直接进深色,方便截图和分享
|
||||
const [scheme, setScheme] = useState<'light' | 'dark'>(() =>
|
||||
new URLSearchParams(window.location.search).get('scheme') === 'dark' ? 'dark' : 'light',
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.scheme = scheme
|
||||
}, [scheme])
|
||||
|
||||
return {
|
||||
scheme,
|
||||
toggle: () => setScheme((current) => (current === 'light' ? 'dark' : 'light')),
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新时的占位卡片:结构和真实卡片一致,避免加载完成后跳动 */
|
||||
function SkeletonPost() {
|
||||
return (
|
||||
<Card padding="lg" aria-busy="true">
|
||||
<Space gap="sm">
|
||||
<Skeleton variant="circle" width={36} height={36} />
|
||||
<Space direction="column" gap="xs" align="start">
|
||||
<Skeleton width={96} />
|
||||
<Skeleton width={64} />
|
||||
</Space>
|
||||
</Space>
|
||||
<div style={{ marginTop: 'var(--su-space-4)' }}>
|
||||
<Skeleton lines={2} />
|
||||
</div>
|
||||
<div style={{ marginTop: 'var(--su-space-4)' }}>
|
||||
<Skeleton variant="rect" height={220} />
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export function Page() {
|
||||
const { scheme, toggle } = useTheme()
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const [removed, setRemoved] = useState(false)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [autoSync, setAutoSync] = useState(true)
|
||||
const [showCover, setShowCover] = useState(true)
|
||||
const [notify, setNotify] = useState(false)
|
||||
const [sort, setSort] = useState('new')
|
||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||
const [navOpen, setNavOpen] = useState(false)
|
||||
|
||||
const refreshFeed = () => {
|
||||
setLoading(true)
|
||||
window.setTimeout(() => setLoading(false), 1200)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const sync = () => setScrolled(window.scrollY > 40)
|
||||
window.addEventListener('scroll', sync, { passive: true })
|
||||
return () => window.removeEventListener('scroll', sync)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.glow} aria-hidden="true" />
|
||||
|
||||
<header className={styles.header}>
|
||||
<div className={styles.headerInner}>
|
||||
<a className={styles.brand} href="/demo.html">
|
||||
<span className={styles.brandMark}>S</span>
|
||||
<span className={styles.brandName}>STASH</span>
|
||||
</a>
|
||||
|
||||
<nav className={styles.nav} aria-label="主导航">
|
||||
{NAV.map((item, index) => (
|
||||
<a
|
||||
key={item}
|
||||
href="#top"
|
||||
className={`${styles.navLink}${index === 0 ? ` ${styles.navLinkActive}` : ''}`}
|
||||
onClick={(event) => event.preventDefault()}
|
||||
>
|
||||
{item}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className={styles.headerActions}>
|
||||
<span className={styles.onlyMobile}>
|
||||
<IconButton
|
||||
label="打开导航"
|
||||
icon={<Icon name="menu_line" />}
|
||||
onClick={() => setNavOpen(true)}
|
||||
/>
|
||||
</span>
|
||||
<IconButton label="搜索" icon={<Icon name="search_3_line" />} />
|
||||
<IconButton
|
||||
label="刷新列表"
|
||||
icon={<Icon name="refresh_3_line" />}
|
||||
spinning={loading}
|
||||
onClick={refreshFeed}
|
||||
/>
|
||||
<IconButton
|
||||
label={scheme === 'light' ? '切换到深色' : '切换到浅色'}
|
||||
icon={<Icon name={scheme === 'light' ? 'moon_line' : 'sun_line'} />}
|
||||
onClick={toggle}
|
||||
/>
|
||||
<IconButton
|
||||
label="设置"
|
||||
icon={<Icon name="settings_1_line" />}
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
/>
|
||||
<IconButton label="账号" icon={<Icon name="user_3_line" />} variant="soft" />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="top" className={styles.layout}>
|
||||
<aside className={styles.rail}>
|
||||
<Card padding="lg" className={styles.profile}>
|
||||
<Avatar size="lg" fallback="S" verified />
|
||||
<h1 className={styles.profileName}>Stash 工作台</h1>
|
||||
<Tag shape="rounded">管理员</Tag>
|
||||
<p className={styles.profileBio}>
|
||||
本地静态页面托管工具,内容库驱动分类、分组与条目;这一页全部由基础组件拼成。
|
||||
</p>
|
||||
<span className={styles.profileMeta}>IP 归属地:局域网</span>
|
||||
</Card>
|
||||
|
||||
<Grid columns={2} gap="sm">
|
||||
<Card padding="sm" hoverable>
|
||||
<span className={styles.statLabel}>文章</span>
|
||||
<strong className={styles.statValue}>16</strong>
|
||||
</Card>
|
||||
<Card padding="sm" hoverable>
|
||||
<span className={styles.statLabel}>评论</span>
|
||||
<strong className={styles.statValue}>0</strong>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
<Card padding="sm">
|
||||
<h2 className={styles.cardTitle}>🔗 社交媒体</h2>
|
||||
<Space gap="md">
|
||||
<IconButton label="链接" icon={<Icon name="link_line" />} size="xs" variant="soft" />
|
||||
<IconButton label="分享" icon={<Icon name="share_3_line" />} size="xs" variant="soft" />
|
||||
<IconButton label="浏览" icon={<Icon name="eye_line" />} size="xs" variant="soft" />
|
||||
<IconButton label="账号" icon={<Icon name="user_3_line" />} size="xs" variant="soft" />
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
<h2 className={styles.cardTitle}>⚙️ 偏好</h2>
|
||||
<Space direction="column" gap="md" align="stretch">
|
||||
<Space justify="between">
|
||||
<Text>自动同步</Text>
|
||||
<Switch checked={autoSync} onChange={setAutoSync} label="自动同步" />
|
||||
</Space>
|
||||
<Space justify="between">
|
||||
<Text>显示封面</Text>
|
||||
<Switch checked={showCover} onChange={setShowCover} label="显示封面" />
|
||||
</Space>
|
||||
<Checkbox checked={notify} onChange={setNotify} label="每周摘要提醒" />
|
||||
{autoSync && (
|
||||
<Space gap="sm">
|
||||
<Spinner size="sm" label="同步中" />
|
||||
<Text size="xs" tone="muted">
|
||||
正在同步内容库…
|
||||
</Text>
|
||||
</Space>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</aside>
|
||||
|
||||
<main className={styles.feed}>
|
||||
{removed && (
|
||||
<Notice
|
||||
title="已删除《本地内容库改版》(示例)"
|
||||
action={{ label: '撤销', onClick: () => setRemoved(false) }}
|
||||
>
|
||||
这里只是演示确认流程,没有真的删数据。
|
||||
</Notice>
|
||||
)}
|
||||
|
||||
<Breadcrumb
|
||||
items={[{ label: '首页', href: '#top' }, { label: '工作台' }, { label: '常用推荐' }]}
|
||||
/>
|
||||
|
||||
<Card padding="sm">
|
||||
<Space justify="between" wrap>
|
||||
<Space gap="sm" wrap>
|
||||
<Tag variant="solid" tone="primary" icon={<Icon name="search_3_line" />}>
|
||||
全部
|
||||
</Tag>
|
||||
<Tag>🛸 前沿科技</Tag>
|
||||
<Tag>🏠 建筑力学</Tag>
|
||||
<Tag>玩物志趣</Tag>
|
||||
</Space>
|
||||
<Space gap="sm" wrap>
|
||||
<Select
|
||||
value={sort}
|
||||
onChange={setSort}
|
||||
options={[
|
||||
{ value: 'new', label: '最近更新' },
|
||||
{ value: 'hot', label: '最热门' },
|
||||
{ value: 'name', label: '按名称' },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
icon="search_3_line"
|
||||
placeholder="筛选内容…"
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
onClear={() => setKeyword('')}
|
||||
clearable
|
||||
style={{ width: 220, maxWidth: '100%' }}
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<>
|
||||
<SkeletonPost />
|
||||
<SkeletonPost />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Card padding="lg">
|
||||
<Space gap="sm">
|
||||
<Avatar size="md" fallback="S" verified />
|
||||
<Space direction="column" gap="xs" align="start">
|
||||
<Text strong>Stash</Text>
|
||||
<Text size="xs" tone="muted">
|
||||
2 年前 · 局域网
|
||||
</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Title level={3} className={styles.postTitle}>
|
||||
<ItemBadge tone="sticky">置顶</ItemBadge>
|
||||
本地内容库改版:分区、分组与条目的三层结构
|
||||
</Title>
|
||||
|
||||
<Paragraph lines={2}>
|
||||
侧栏由内容树派生,首页卡片由 section_block 决定展示哪个节点;
|
||||
条目只存 node_key,所属分区由它推导,改编码时自动跟随。
|
||||
</Paragraph>
|
||||
|
||||
{showCover && (
|
||||
<Grid columns={2} gap="sm" className={styles.media}>
|
||||
<span className={`${styles.tile} ${styles.tileA}`} />
|
||||
<span className={`${styles.tile} ${styles.tileB}`} />
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Divider spacing="sm" />
|
||||
|
||||
<Space justify="between" wrap>
|
||||
<Space gap="sm">
|
||||
{POST_TAGS.map((tag, index) => (
|
||||
<Tag key={tag} variant="solid" tone={index === 0 ? 'primary' : 'danger'}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
|
||||
<Space gap="md">
|
||||
<span className={styles.action}>
|
||||
<Icon name="eye_line" />
|
||||
1,599
|
||||
</span>
|
||||
<span className={styles.action}>
|
||||
<Icon name="thumb_up_2_line" />
|
||||
22
|
||||
</span>
|
||||
<span className={styles.action}>
|
||||
<Icon name="share_3_line" />
|
||||
6
|
||||
</span>
|
||||
<IconButton
|
||||
size="xs"
|
||||
label="删除这篇内容"
|
||||
icon={<Icon name="delete_2_line" />}
|
||||
onClick={() => setConfirmOpen(true)}
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card padding="lg">
|
||||
<Space gap="sm">
|
||||
<Avatar size="md" fallback="S" />
|
||||
<Space direction="column" gap="xs" align="start">
|
||||
<Text strong>Stash</Text>
|
||||
<Text size="xs" tone="muted">
|
||||
3 天前 · 局域网
|
||||
</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
<Title level={3} className={styles.postTitle}>
|
||||
<ItemBadge tone="paid">付费</ItemBadge>
|
||||
主题与令牌:为什么把颜色收进 CSS 变量
|
||||
</Title>
|
||||
|
||||
<Paragraph lines={2}>
|
||||
组件只消费语义令牌(bg-card / text-main / border),
|
||||
换主题时只覆盖变量——暗色主题就是这么来的。
|
||||
</Paragraph>
|
||||
|
||||
<Notice
|
||||
title="付费内容"
|
||||
action={{ label: '登录后查看', onClick: () => undefined }}
|
||||
>
|
||||
本内容为付费内容,登录后可阅读全文。
|
||||
</Notice>
|
||||
|
||||
<Divider spacing="sm" />
|
||||
|
||||
<Space justify="between" wrap>
|
||||
<Space gap="sm">
|
||||
<Tag>设计系统</Tag>
|
||||
<Tag tone="success">已发布</Tag>
|
||||
</Space>
|
||||
<Space gap="md">
|
||||
<span className={styles.action}>
|
||||
<Icon name="eye_line" />
|
||||
842
|
||||
</span>
|
||||
<span className={styles.action}>
|
||||
<Icon name="thumb_up_2_line" />
|
||||
31
|
||||
</span>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Card padding="sm">
|
||||
<Space justify="between" wrap>
|
||||
<Space gap="sm">
|
||||
<Text size="sm" tone="muted">
|
||||
共 16 篇内容
|
||||
</Text>
|
||||
<Tag tone="neutral">第 {page} / 3 页</Tag>
|
||||
</Space>
|
||||
<Pagination page={page} total={16} pageSize={6} onChange={setPage} />
|
||||
</Space>
|
||||
</Card>
|
||||
</main>
|
||||
|
||||
<aside className={`${styles.rail} ${styles.railRight}`}>
|
||||
<Card padding="md">
|
||||
<h2 className={styles.cardTitle}>
|
||||
🔥 热门文章
|
||||
<Badge count={HOT_POSTS.length} tone="neutral" />
|
||||
</h2>
|
||||
|
||||
<ul className={styles.hotList}>
|
||||
{HOT_POSTS.map((title, index) => (
|
||||
<li key={title}>
|
||||
<a
|
||||
className={styles.hotItem}
|
||||
href="#top"
|
||||
onClick={(event) => event.preventDefault()}
|
||||
>
|
||||
<span className={styles.hotTitle}>{title}</span>
|
||||
<ItemBadge tone="hot">HOT</ItemBadge>
|
||||
</a>
|
||||
{index < HOT_POSTS.length - 1 && <Divider spacing="sm" />}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
<h2 className={styles.cardTitle}>🖼 图集</h2>
|
||||
<Grid columns={3} gap="sm">
|
||||
{GALLERY.map((item) => (
|
||||
<span key={item} className={`${styles.thumb} ${styles[`thumb${item}`]}`} />
|
||||
))}
|
||||
</Grid>
|
||||
</Card>
|
||||
|
||||
<Notice title="这一页怎么来的">
|
||||
全部由 Card / Tag / ItemBadge / Avatar / Grid / Divider / Button / Input / Modal /
|
||||
Pagination 拼装,没有任何业务组件;右上角可以切换深浅色。
|
||||
</Notice>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={confirmOpen}
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
title="删除这篇内容?"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setConfirmOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
setConfirmOpen(false)
|
||||
setRemoved(true)
|
||||
toast.success('已删除《本地内容库改版》')
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
删除后不可恢复,占位数据会被一起清掉。
|
||||
</Modal>
|
||||
|
||||
<Drawer
|
||||
open={drawerOpen}
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
title="设置"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setDrawerOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDrawerOpen(false)
|
||||
toast.success('设置已保存')
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Space direction="column" gap="lg" align="stretch">
|
||||
<Space justify="between">
|
||||
<Text>自动同步</Text>
|
||||
<Switch checked={autoSync} onChange={setAutoSync} label="自动同步" />
|
||||
</Space>
|
||||
<Space justify="between">
|
||||
<Text>显示封面</Text>
|
||||
<Switch checked={showCover} onChange={setShowCover} label="显示封面" />
|
||||
</Space>
|
||||
<Checkbox checked={notify} onChange={setNotify} label="每周摘要提醒" />
|
||||
<Space direction="column" gap="sm" align="stretch">
|
||||
<Text size="sm" tone="sub">
|
||||
默认排序
|
||||
</Text>
|
||||
<Select
|
||||
value={sort}
|
||||
onChange={setSort}
|
||||
options={[
|
||||
{ value: 'new', label: '最近更新' },
|
||||
{ value: 'hot', label: '最热门' },
|
||||
{ value: 'name', label: '按名称' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
</Space>
|
||||
</Drawer>
|
||||
|
||||
<Drawer open={navOpen} onClose={() => setNavOpen(false)} placement="left" width={280} title="导航">
|
||||
<Space direction="column" gap="sm" align="stretch">
|
||||
{NAV.map((item, index) => (
|
||||
<a
|
||||
key={item}
|
||||
href="#top"
|
||||
className={`${styles.drawerNav}${index === 0 ? ` ${styles.drawerNavActive}` : ''}`}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
setNavOpen(false)
|
||||
}}
|
||||
>
|
||||
{item}
|
||||
</a>
|
||||
))}
|
||||
<Divider spacing="sm" />
|
||||
<Text size="sm" tone="muted">
|
||||
窄屏下顶部导航会收进这个抽屉里;桌面端直接显示在页头。
|
||||
</Text>
|
||||
</Space>
|
||||
</Drawer>
|
||||
|
||||
<Toaster />
|
||||
|
||||
<ScrollTop visible={scrolled} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
/* 只放全局 reset 与页面底色;布局都在 demo.module.css */
|
||||
:root {
|
||||
font-family: var(--su-font-sans);
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--su-text-main);
|
||||
background-color: var(--su-bg-page);
|
||||
color-scheme: light;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
:root[data-scheme='dark'] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
background-color: var(--su-bg-page);
|
||||
color: var(--su-text-main);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: var(--su-focus-ring);
|
||||
outline-offset: var(--su-focus-ring-offset);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
/* 拼装示例的页面级布局:只做排版,不重写组件样式 */
|
||||
|
||||
.page {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
/* 顶部柔光:浅色下是蓝/橙/绿三团,深色下自动变淡 */
|
||||
.glow {
|
||||
position: fixed;
|
||||
inset: 0 0 auto;
|
||||
height: 420px;
|
||||
pointer-events: none;
|
||||
background:
|
||||
radial-gradient(560px 240px at 18% 0%, rgba(56, 88, 246, 0.18), transparent 70%),
|
||||
radial-gradient(520px 220px at 72% -6%, rgba(255, 186, 58, 0.16), transparent 72%),
|
||||
radial-gradient(460px 240px at 96% 6%, rgba(74, 189, 134, 0.14), transparent 70%);
|
||||
mask-image: linear-gradient(to bottom, #000 40%, transparent);
|
||||
}
|
||||
|
||||
.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: var(--su-z-header);
|
||||
border-bottom: 1px solid transparent;
|
||||
backdrop-filter: blur(12px);
|
||||
background: color-mix(in srgb, var(--su-bg-page) 72%, transparent);
|
||||
}
|
||||
|
||||
.headerInner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-6);
|
||||
max-width: 1280px;
|
||||
height: 64px;
|
||||
margin: 0 auto;
|
||||
padding: 0 var(--su-space-6);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-2);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.brandMark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: var(--su-radius-sm);
|
||||
background-image: linear-gradient(135deg, #4d6bff, var(--su-color-primary));
|
||||
color: var(--su-white);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brandName {
|
||||
font-size: var(--su-font-size-lg);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: var(--su-space-1);
|
||||
}
|
||||
|
||||
.navLink {
|
||||
padding: 6px var(--su-space-3);
|
||||
border-radius: var(--su-radius-sm);
|
||||
font-size: var(--su-font-size-md);
|
||||
color: var(--su-text-sub);
|
||||
transition:
|
||||
color var(--su-duration-base) var(--su-ease-in-out),
|
||||
background-color var(--su-duration-base) var(--su-ease-in-out);
|
||||
}
|
||||
|
||||
.navLink:hover {
|
||||
background-color: var(--su-bg-subtle);
|
||||
color: var(--su-text-main);
|
||||
}
|
||||
|
||||
.navLinkActive {
|
||||
background-color: var(--su-color-primary-soft);
|
||||
color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-2);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* 导航收进抽屉时,页头出现菜单按钮 */
|
||||
.onlyMobile {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.drawerNav {
|
||||
display: block;
|
||||
padding: var(--su-space-2) var(--su-space-3);
|
||||
border-radius: var(--su-radius-sm);
|
||||
font-size: var(--su-font-size-md);
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.drawerNavActive {
|
||||
background-color: var(--su-color-primary-soft);
|
||||
color: var(--su-color-primary);
|
||||
font-weight: var(--su-font-weight-bold);
|
||||
}
|
||||
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr) 300px;
|
||||
gap: var(--su-space-5);
|
||||
align-items: start;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: var(--su-space-6);
|
||||
}
|
||||
|
||||
.rail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-4);
|
||||
}
|
||||
|
||||
.feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--su-space-4);
|
||||
}
|
||||
|
||||
.profile {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: var(--su-space-2);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.profileName {
|
||||
font-size: var(--su-font-size-lg);
|
||||
line-height: var(--su-line-height-lg);
|
||||
font-weight: var(--su-font-weight-bold);
|
||||
}
|
||||
|
||||
.profileBio {
|
||||
margin-top: var(--su-space-2);
|
||||
font-size: var(--su-font-size-sm);
|
||||
line-height: var(--su-line-height-sm);
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.profileMeta {
|
||||
font-size: var(--su-font-size-xs);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
display: block;
|
||||
font-size: var(--su-font-size-xs);
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.statValue {
|
||||
display: block;
|
||||
margin-top: var(--su-space-2);
|
||||
font-size: var(--su-font-size-xl);
|
||||
line-height: var(--su-line-height-xl);
|
||||
font-weight: var(--su-font-weight-bold);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-2);
|
||||
margin-bottom: var(--su-space-3);
|
||||
font-size: var(--su-font-size-lg);
|
||||
line-height: var(--su-line-height-lg);
|
||||
font-weight: var(--su-font-weight-bold);
|
||||
}
|
||||
|
||||
.authorName {
|
||||
font-size: var(--su-font-size-md);
|
||||
font-weight: var(--su-font-weight-bold);
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: var(--su-font-size-xs);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.postTitle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--su-space-2);
|
||||
margin: var(--su-space-3) 0 var(--su-space-2);
|
||||
font-size: var(--su-font-size-lg);
|
||||
line-height: var(--su-line-height-lg);
|
||||
font-weight: var(--su-font-weight-bold);
|
||||
}
|
||||
|
||||
.excerpt {
|
||||
margin-bottom: var(--su-space-3);
|
||||
font-size: var(--su-font-size-md);
|
||||
line-height: var(--su-line-height-md);
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.media {
|
||||
margin-bottom: var(--su-space-3);
|
||||
}
|
||||
|
||||
.tile {
|
||||
display: block;
|
||||
aspect-ratio: 16 / 10;
|
||||
border-radius: var(--su-radius-sm);
|
||||
}
|
||||
|
||||
.tileA {
|
||||
background-image: linear-gradient(135deg, #3858f6, #7c3aed);
|
||||
}
|
||||
|
||||
.tileB {
|
||||
background-image: linear-gradient(135deg, #ff8338, #ff3e3e);
|
||||
}
|
||||
|
||||
.action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-1);
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-sub);
|
||||
}
|
||||
|
||||
.pageInfo {
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-muted);
|
||||
}
|
||||
|
||||
.hotList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hotItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--su-space-2);
|
||||
padding: var(--su-space-2) 0;
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-sub);
|
||||
transition: color var(--su-duration-base) var(--su-ease-in-out);
|
||||
}
|
||||
|
||||
.hotItem:hover {
|
||||
color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
.hotTitle {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
display: block;
|
||||
aspect-ratio: 1;
|
||||
border-radius: var(--su-radius-sm);
|
||||
}
|
||||
|
||||
.thumb1 {
|
||||
background-image: linear-gradient(135deg, #3858f6, #22d3ee);
|
||||
}
|
||||
|
||||
.thumb2 {
|
||||
background-image: linear-gradient(135deg, #ff8338, #ffba3a);
|
||||
}
|
||||
|
||||
.thumb3 {
|
||||
background-image: linear-gradient(135deg, #4abd86, #0ea5e9);
|
||||
}
|
||||
|
||||
.thumb4 {
|
||||
background-image: linear-gradient(135deg, #7c3aed, #db2777);
|
||||
}
|
||||
|
||||
.thumb5 {
|
||||
background-image: linear-gradient(135deg, #f59e0b, #ef4444);
|
||||
}
|
||||
|
||||
.thumb6 {
|
||||
background-image: linear-gradient(135deg, #0ea5e9, #6366f1);
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.layout {
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.railRight {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.onlyMobile {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.layout {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.rail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.headerInner {
|
||||
gap: var(--su-space-3);
|
||||
padding: 0 var(--su-space-4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import '../../src/theme/tokens.css'
|
||||
import '../../src/theme/iconfont.css'
|
||||
import './demo.css'
|
||||
import { Page } from './Page'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<Page />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<title>Stash UI · 设计系统与组件库</title>
|
||||
<!-- 与宿主应用同一份正文字体;加载失败时回退系统字体 -->
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.staticfile.net/lxgw-wenkai-webfont/1.7.0/style.min.css"
|
||||
crossorigin="anonymous"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import '../src/theme/tokens.css'
|
||||
import '../src/theme/iconfont.css'
|
||||
import './app.css'
|
||||
import { App } from './App'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Avatar, Grid, Icon, Space } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
import { DEMO_APPS, DEMO_COVER } from '../data/demo'
|
||||
|
||||
export function Avatars() {
|
||||
return (
|
||||
<Section
|
||||
id="avatars"
|
||||
title="头像"
|
||||
api="Avatar"
|
||||
desc="有图显示图片,没图(或图片加载失败)退化成首字色块——列表里最常见的两种状态都由它兜住。"
|
||||
>
|
||||
<Demo
|
||||
title="尺寸与形状"
|
||||
desc="sm 24px 用于紧凑列表,md 36px、lg 48px 用于卡片与详情头部;圆形用于人 / 应用,方形用于图标类封面。"
|
||||
code={`<Avatar size="sm" fallback="G" />
|
||||
<Avatar size="md" fallback="G" />
|
||||
<Avatar size="lg" fallback="G" />`}
|
||||
>
|
||||
<Avatar size="sm" fallback="G" />
|
||||
<Avatar size="md" fallback="G" />
|
||||
<Avatar size="lg" fallback="G" />
|
||||
<Avatar shape="square" size="md" fallback="N" />
|
||||
<Avatar shape="square" size="lg" fallback="F" />
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="图片与降级"
|
||||
desc="第三张故意给一个坏地址,用来验证加载失败会退回首字;最后一颗带认证角标。"
|
||||
code={`<Avatar src={cover} alt="GitHub" fallback="G" />
|
||||
<Avatar src="/broken.png" fallback="?" />
|
||||
<Avatar verified size="lg" fallback="S" />`}
|
||||
>
|
||||
<Avatar src={DEMO_COVER} alt="渐变封面" size="lg" />
|
||||
<Avatar src="/not-exist.png" alt="" size="lg" fallback="?" />
|
||||
<Avatar verified size="lg" fallback="S" />
|
||||
<Avatar verified size="md" fallback="B" />
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="列表里的组合"
|
||||
desc="头像 + 两行文字是应用条目的基本骨架:这一行本身不带底色和描边,靠间距分组。"
|
||||
layout="column"
|
||||
code={`<Space gap="md">
|
||||
<Avatar size="lg" fallback="G" />
|
||||
<Space direction="column" gap="xs" align="start">
|
||||
<strong>GitHub</strong>
|
||||
<span>代码托管与开源社区</span>
|
||||
</Space>
|
||||
</Space>`}
|
||||
>
|
||||
<Grid columns={2} gap="lg">
|
||||
{DEMO_APPS.slice(0, 4).map((app) => (
|
||||
<Space key={app.id} gap="md">
|
||||
<Avatar size="lg" fallback={app.icon} />
|
||||
<Space direction="column" gap="xs" align="start">
|
||||
<span style={{ fontWeight: 500 }}>{app.name}</span>
|
||||
<span style={{ color: 'var(--su-text-muted)', fontSize: 12 }}>{app.desc}</span>
|
||||
</Space>
|
||||
</Space>
|
||||
))}
|
||||
</Grid>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="大尺寸与底色"
|
||||
desc="xl 64px 用于资料卡;无图时可以用 icon 代替字符,tone='primary' 换成品牌浅渐变底。"
|
||||
code={`<Avatar size="xl" tone="primary" icon={<Icon name="user_3_line" />} verified />
|
||||
<Avatar size="xl" fallback="S" verified />`}
|
||||
>
|
||||
<Space gap="lg" align="end">
|
||||
<Avatar size="xl" tone="primary" icon={<Icon name="user_3_line" />} verified />
|
||||
<Avatar size="xl" fallback="S" verified />
|
||||
<Avatar size="lg" tone="primary" icon={<Icon name="folder_more_line" />} />
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'src', type: 'string', desc: '图片地址;加载失败自动降级' },
|
||||
{ name: 'alt', type: 'string', defaultValue: "''", desc: '图片替代文本' },
|
||||
{ name: 'fallback', type: 'string', defaultValue: "'•'", desc: '无图时的字符' },
|
||||
{ name: 'icon', type: 'ReactNode', desc: '无图时的图标(优先于 fallback)' },
|
||||
{ name: 'size', type: "'sm' | 'md' | 'lg' | 'xl'", defaultValue: "'md'", desc: '24 / 36 / 48 / 64px' },
|
||||
{ name: 'tone', type: "'neutral' | 'primary'", defaultValue: "'neutral'", desc: '无图底色:浅灰 / 品牌浅渐变' },
|
||||
{ name: 'shape', type: "'circle' | 'square'", defaultValue: "'circle'", desc: '圆形 / 圆角方形' },
|
||||
{ name: 'verified', type: 'boolean', defaultValue: 'false', desc: '右下角认证角标' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Breadcrumb } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
export function Breadcrumbs() {
|
||||
return (
|
||||
<Section
|
||||
id="breadcrumb"
|
||||
title="面包屑"
|
||||
api="Breadcrumb"
|
||||
desc="详情页的路径导航:中间项可点,最后一项是当前页(aria-current=page)。"
|
||||
>
|
||||
<Demo
|
||||
title="基础用法"
|
||||
desc="items 里只有最后一项会忽略 href;分隔符默认 `/`。"
|
||||
layout="column"
|
||||
code={`<Breadcrumb
|
||||
items={[
|
||||
{ label: '首页', href: '/' },
|
||||
{ label: '常用推荐', href: '/c/recommend' },
|
||||
{ label: '常用站点' },
|
||||
]}
|
||||
/>`}
|
||||
>
|
||||
<Breadcrumb
|
||||
items={[
|
||||
{ label: '首页', href: '#top' },
|
||||
{ label: '常用推荐', href: '#top' },
|
||||
{ label: '常用站点' },
|
||||
]}
|
||||
/>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="自定义分隔符"
|
||||
desc="separator 可以是字符或图标;过深的路径在窄屏会换行。"
|
||||
code={`<Breadcrumb separator="›" items={items} />`}
|
||||
>
|
||||
<Breadcrumb
|
||||
separator="›"
|
||||
items={[
|
||||
{ label: '工作台', href: '#top' },
|
||||
{ label: '内容库', href: '#top' },
|
||||
{ label: '推荐电影' },
|
||||
]}
|
||||
/>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'items', type: 'BreadcrumbItem[]', required: true, desc: '{ label, href? }[]' },
|
||||
{ name: 'separator', type: 'ReactNode', defaultValue: "'/'", desc: '分隔符' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Icon, IconButton, Space } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
export function Buttons() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const loadOnce = () => {
|
||||
setLoading(true)
|
||||
window.setTimeout(() => setLoading(false), 1200)
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="buttons"
|
||||
title="按钮"
|
||||
api="Button / IconButton"
|
||||
desc="五种语义、两种尺寸、两种形状;图标按钮是圆形的,只放一个图标,所以无障碍名称必填。交互对齐参考站:hover 用 opacity .8(主色/危险),过渡 .3s ease-in-out。"
|
||||
>
|
||||
<Demo
|
||||
title="语义变体"
|
||||
desc="一个页面里主要操作只出现一次 primary,其余用 secondary / outline / ghost。"
|
||||
code={`<Button>保存</Button>
|
||||
<Button variant="secondary">取消</Button>
|
||||
<Button variant="outline">更多</Button>
|
||||
<Button variant="ghost">详情</Button>
|
||||
<Button variant="danger">删除</Button>`}
|
||||
>
|
||||
<Button>保存</Button>
|
||||
<Button variant="secondary">取消</Button>
|
||||
<Button variant="outline">更多</Button>
|
||||
<Button variant="ghost">详情</Button>
|
||||
<Button variant="danger">删除</Button>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="形状"
|
||||
desc="rounded(默认,6px 圆角)用于工具型操作;pill 用于登录、筛选这类主操作——参考站的全部分类筛选和登录按钮都是胶囊。"
|
||||
code={`<Button shape="pill">登录</Button>
|
||||
<Button shape="pill" variant="secondary">全部</Button>
|
||||
<Button shape="pill" variant="ghost">重置</Button>`}
|
||||
>
|
||||
<Button shape="pill">登录</Button>
|
||||
<Button shape="pill" variant="secondary">
|
||||
全部
|
||||
</Button>
|
||||
<Button shape="pill" variant="ghost">
|
||||
重置
|
||||
</Button>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="尺寸"
|
||||
desc="sm 用于卡片内的次级操作,md 用于页面级操作;两档高度分别是 32 / 36。"
|
||||
code={`<Button size="sm">小号</Button>
|
||||
<Button size="md">中号</Button>`}
|
||||
>
|
||||
<Button size="sm">小号按钮</Button>
|
||||
<Button size="md">中号按钮</Button>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="状态"
|
||||
desc="loading 会自动禁用点击;disabled 换成浅底弱文字(参考站的做法),不做整体降透明度。"
|
||||
code={`<Button loading={loading} onClick={loadOnce}>提交</Button>
|
||||
<Button disabled>不可用</Button>`}
|
||||
>
|
||||
<Button loading={loading} onClick={loadOnce}>
|
||||
点我加载
|
||||
</Button>
|
||||
<Button disabled>不可用</Button>
|
||||
<Button variant="secondary" disabled>
|
||||
不可用(次要)
|
||||
</Button>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="带图标与整行"
|
||||
desc="图标放在文字左侧;block 让按钮占满容器宽度(表单底部常用)。"
|
||||
layout="column"
|
||||
code={`<Button icon={<Icon name="refresh_3_line" />}>重新扫描</Button>
|
||||
<Button shape="pill" icon={<Icon name="arrow_right_up_line" />}>打开接口</Button>`}
|
||||
>
|
||||
<Space gap="md" wrap>
|
||||
<Button icon={<Icon name="refresh_3_line" />}>重新扫描</Button>
|
||||
<Button shape="pill" variant="secondary" icon={<Icon name="arrow_right_up_line" />}>
|
||||
打开接口
|
||||
</Button>
|
||||
</Space>
|
||||
<Button block icon={<Icon name="link_line" />}>
|
||||
占满一行的按钮
|
||||
</Button>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="图标按钮"
|
||||
desc="xs / sm / md 是 28 / 36 / 44 的圆形,图标字号 16 / 20 / 24;ghost 用于顶栏,soft 用于需要一点底色的场景。"
|
||||
code={`<IconButton label="搜索" icon={<Icon name="search_3_line" />} />
|
||||
<IconButton label="更多" icon={<Icon name="more_3_line" />} variant="soft" />
|
||||
<IconButton label="关闭" icon={<Icon name="close_line" />} size="xs" />
|
||||
<IconButton label="刷新中" icon={<Icon name="refresh_3_line" />} spinning variant="soft" />`}
|
||||
>
|
||||
<IconButton label="搜索" icon={<Icon name="search_3_line" />} />
|
||||
<IconButton label="用户" icon={<Icon name="user_3_line" />} />
|
||||
<IconButton label="更多" icon={<Icon name="more_3_line" />} variant="soft" />
|
||||
<IconButton label="小号" icon={<Icon name="close_line" />} size="xs" />
|
||||
<IconButton label="大号" icon={<Icon name="share_3_line" />} size="md" variant="soft" />
|
||||
<IconButton label="刷新中" icon={<Icon name="refresh_3_line" />} spinning variant="soft" />
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="尺寸与进行中"
|
||||
desc="sm 32 / md 36 / lg 44;spinning 让图标原地旋转(刷新类动作),loading 则换成转圈。"
|
||||
code={`<Button size="lg">大按钮</Button>
|
||||
<Button spinning icon={<Icon name="refresh_3_line" />}>重新扫描</Button>`}
|
||||
>
|
||||
<Space gap="md" wrap>
|
||||
<Button size="sm">小号 32</Button>
|
||||
<Button size="md">默认 36</Button>
|
||||
<Button size="lg">大号 44</Button>
|
||||
<Button variant="secondary" spinning icon={<Icon name="refresh_3_line" />}>
|
||||
重新扫描
|
||||
</Button>
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'variant', type: "'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'", defaultValue: "'primary'", desc: '语义变体' },
|
||||
{ name: 'size', type: "'sm' | 'md' | 'lg'", defaultValue: "'md'", desc: '32 / 36 / 44px 高' },
|
||||
{ name: 'spinning', type: 'boolean', defaultValue: 'false', desc: '图标原地旋转(不换转圈)' },
|
||||
{ name: 'shape', type: "'rounded' | 'pill'", defaultValue: "'rounded'", desc: '6px 圆角 / 胶囊' },
|
||||
{ name: 'block', type: 'boolean', defaultValue: 'false', desc: '占满一行' },
|
||||
{ name: 'loading', type: 'boolean', defaultValue: 'false', desc: '加载中,自动禁用' },
|
||||
{ name: 'icon', type: 'ReactNode', desc: '文字左侧图标' },
|
||||
{ name: '…rest', type: 'ButtonHTMLAttributes', desc: '透传原生属性(type 默认 button)' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'label', type: 'string', required: true, desc: '无障碍名称,同时作为 title' },
|
||||
{ name: 'icon', type: 'ReactNode', required: true, desc: '按钮里的图标' },
|
||||
{ name: 'variant', type: "'ghost' | 'soft'", defaultValue: "'ghost'", desc: '是否有底色' },
|
||||
{ name: 'size', type: "'xs' | 'sm' | 'md'", defaultValue: "'sm'", desc: '28 / 36 / 44px 圆形' },
|
||||
{ name: 'spinning', type: 'boolean', defaultValue: 'false', desc: '图标旋转' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { Badge, Card, Icon, IconButton, Space } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
export function Cards() {
|
||||
return (
|
||||
<Section
|
||||
id="cards"
|
||||
title="卡片"
|
||||
api="Card"
|
||||
desc="白色容器 + 1px 细边 + 极轻阴影,是页面里的基本承载单位。Card 只负责容器,标题栏、角标、操作区由使用方组合。"
|
||||
>
|
||||
<Demo
|
||||
title="基础用法"
|
||||
desc="默认 md 内边距(20px)。放在白底页面上时,靠 1px 描边而不是阴影区分边界,和主题一致。"
|
||||
muted
|
||||
layout="column"
|
||||
code={`<Card>
|
||||
<p>卡片内容</p>
|
||||
</Card>`}
|
||||
>
|
||||
<Card>
|
||||
<Space direction="column" gap="sm" align="start">
|
||||
<strong style={{ fontWeight: 500 }}>常用推荐</strong>
|
||||
<span style={{ color: 'var(--su-text-muted)' }}>
|
||||
卡片是最小布局单元,内容完全由使用方决定。
|
||||
</span>
|
||||
</Space>
|
||||
</Card>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="内边距"
|
||||
desc="padding=none 用于封面贴边的卡片;sm 用于紧凑列表;lg 用于详情页大块内容。"
|
||||
code={`<Card padding="none">…</Card>
|
||||
<Card padding="sm">…</Card>
|
||||
<Card padding="lg">…</Card>`}
|
||||
>
|
||||
<Card padding="sm">
|
||||
<span style={{ color: 'var(--su-text-muted)' }}>sm · 12px</span>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<span style={{ color: 'var(--su-text-muted)' }}>md · 20px</span>
|
||||
</Card>
|
||||
<Card padding="lg">
|
||||
<span style={{ color: 'var(--su-text-muted)' }}>lg · 24px</span>
|
||||
</Card>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="可点击卡片"
|
||||
desc="hoverable 只加深阴影,不改变尺寸,避免列表在悬停时抖动。"
|
||||
code={`<Card hoverable>整张卡片可点击</Card>`}
|
||||
>
|
||||
<Card hoverable padding="sm">
|
||||
<span style={{ color: 'var(--su-text-muted)' }}>把鼠标放上来</span>
|
||||
</Card>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="组合出标题栏"
|
||||
desc="卡片本身不带标题栏;色条 + 标题 + 角标 + 更多按钮是一组可复用的「分区头部」,这里用基础组件拼出来。"
|
||||
muted
|
||||
layout="column"
|
||||
code={`<Card>
|
||||
<Space justify="between">
|
||||
<Space gap="md">
|
||||
<span style={{ width: 4, height: 16, background: 'var(--su-color-primary)' }} />
|
||||
<strong>常用站点</strong>
|
||||
<Badge count={18} />
|
||||
</Space>
|
||||
<IconButton label="查看全部" icon={<Icon name="more_3_line" />} />
|
||||
</Space>
|
||||
…
|
||||
</Card>`}
|
||||
>
|
||||
<Card>
|
||||
<Space justify="between">
|
||||
<Space gap="md">
|
||||
<span
|
||||
style={{
|
||||
width: 4,
|
||||
height: 16,
|
||||
borderRadius: 2,
|
||||
backgroundColor: 'var(--su-color-primary)',
|
||||
}}
|
||||
/>
|
||||
<strong style={{ fontWeight: 500 }}>常用站点</strong>
|
||||
<Badge count={18} />
|
||||
</Space>
|
||||
<IconButton label="查看全部" icon={<Icon name="more_3_line" />} />
|
||||
</Space>
|
||||
</Card>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'as', type: "'div' | 'section' | 'article' | 'aside'", defaultValue: "'div'", desc: '渲染标签,按语义选' },
|
||||
{ name: 'padding', type: "'none' | 'sm' | 'md' | 'lg'", defaultValue: "'md'", desc: '内边距档位' },
|
||||
{ name: 'hoverable', type: 'boolean', defaultValue: 'false', desc: '悬停加深阴影' },
|
||||
{ name: 'className / style', type: 'string / CSSProperties', desc: '布局微调,例如绝对定位角标' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useState } from 'react'
|
||||
import { Checkbox, Space, Text } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
export function Checkboxes() {
|
||||
const [remember, setRemember] = useState(true)
|
||||
const [notify, setNotify] = useState(false)
|
||||
const [selected, setSelected] = useState<string[]>(['design'])
|
||||
|
||||
const toggle = (value: string) =>
|
||||
setSelected((current) =>
|
||||
current.includes(value) ? current.filter((item) => item !== value) : [...current, value],
|
||||
)
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="checkbox"
|
||||
title="复选框"
|
||||
api="Checkbox"
|
||||
desc="多选与「记住我 / 订阅」这类需要确认的选项。用原生 input[type=checkbox],键盘、读屏、点击文字都可勾选。"
|
||||
>
|
||||
<Demo
|
||||
title="基础用法"
|
||||
desc="label 同时作为可见文字和无障碍名称;受控写法与 Switch 一致。"
|
||||
layout="column"
|
||||
code={`<Checkbox checked={remember} onChange={setRemember} label="记住登录状态" />
|
||||
<Checkbox checked={notify} onChange={setNotify} label="有新内容时提醒我" />`}
|
||||
>
|
||||
<Checkbox checked={remember} onChange={setRemember} label="记住登录状态" />
|
||||
<Checkbox checked={notify} onChange={setNotify} label="有新内容时提醒我" />
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="多选组"
|
||||
desc="一列用 Space 排列;组本身不需要额外容器,用方向 + 间距控制即可。"
|
||||
layout="column"
|
||||
code={`{options.map((item) => (
|
||||
<Checkbox
|
||||
key={item.value}
|
||||
checked={selected.includes(item.value)}
|
||||
onChange={() => toggle(item.value)}
|
||||
label={item.label}
|
||||
/>
|
||||
))}`}
|
||||
>
|
||||
<Space direction="column" gap="sm" align="start">
|
||||
{[
|
||||
{ value: 'design', label: '设计资源' },
|
||||
{ value: 'dev', label: '开发工具' },
|
||||
{ value: 'movie', label: '电影片单' },
|
||||
].map((item) => (
|
||||
<Checkbox
|
||||
key={item.value}
|
||||
checked={selected.includes(item.value)}
|
||||
onChange={() => toggle(item.value)}
|
||||
label={item.label}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
<Text size="sm" tone="muted">
|
||||
已选 {selected.length} 项
|
||||
</Text>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="禁用"
|
||||
desc="禁用态下点击文字也不会改变值;只降对比度,不隐藏。"
|
||||
code={`<Checkbox checked onChange={() => {}} disabled label="不可修改(已选)" />`}
|
||||
>
|
||||
<Space gap="lg" wrap>
|
||||
<Checkbox checked onChange={() => undefined} disabled label="不可修改(已选)" />
|
||||
<Checkbox checked={false} onChange={() => undefined} disabled label="不可修改(未选)" />
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'checked', type: 'boolean', required: true, desc: '受控状态' },
|
||||
{ name: 'onChange', type: '(checked: boolean) => void', required: true, desc: '勾选回调' },
|
||||
{ name: 'label', type: 'ReactNode', required: true, desc: '可见文字,同时作为无障碍名称' },
|
||||
{ name: 'disabled', type: 'boolean', defaultValue: 'false', desc: '禁用' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
/* 组合示例用的页面级样式:只演示「怎么拼」,不属于组件库 */
|
||||
|
||||
.sectionHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: var(--su-space-4);
|
||||
}
|
||||
|
||||
.headMain {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--su-space-3);
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 4px;
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
background-color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
.titleWrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: var(--su-font-size-lg);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
left: 100%;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.appRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
min-width: 0;
|
||||
padding: 6px 0;
|
||||
border-radius: var(--su-radius-sm);
|
||||
}
|
||||
|
||||
.appRow:hover .appName {
|
||||
color: var(--su-color-primary);
|
||||
}
|
||||
|
||||
.appInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.appName {
|
||||
font-size: var(--su-font-size-md);
|
||||
font-weight: 500;
|
||||
transition: color var(--su-duration-fast) var(--su-ease-out);
|
||||
}
|
||||
|
||||
.appDesc {
|
||||
overflow: hidden;
|
||||
font-size: var(--su-font-size-sm);
|
||||
color: var(--su-text-muted);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.posterCard {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.posterCover {
|
||||
display: block;
|
||||
aspect-ratio: 2 / 3;
|
||||
overflow: hidden;
|
||||
background-color: var(--su-gray-2);
|
||||
}
|
||||
|
||||
.posterCover img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.posterBody {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--su-space-1);
|
||||
padding: var(--su-space-2) var(--su-space-3) var(--su-space-3);
|
||||
}
|
||||
|
||||
.posterTitle {
|
||||
font-size: var(--su-font-size-md);
|
||||
font-weight: 500;
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Grid,
|
||||
Icon,
|
||||
IconButton,
|
||||
Input,
|
||||
ItemBadge,
|
||||
Space,
|
||||
Tag,
|
||||
} from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { Section } from '../components/Section'
|
||||
import { DEMO_APPS, DEMO_COVER, DEMO_POSTERS } from '../data/demo'
|
||||
import styles from './Composition.module.css'
|
||||
|
||||
/**
|
||||
* 组合示例:把基础组件拼成真实页面里的几块结构。
|
||||
*
|
||||
* 这一节不放新组件,只演示「怎么拼」——
|
||||
* 后期做新页面时,从这里复制结构再改数据即可。
|
||||
*/
|
||||
export function Composition() {
|
||||
return (
|
||||
<Section
|
||||
id="composition"
|
||||
title="组合示例"
|
||||
api="页面配方"
|
||||
desc="基础组件只提供外观与行为,页面结构靠组合。下面三块都是 Stash 控制台里真实存在的结构,可以直接当模板用。"
|
||||
>
|
||||
<Demo
|
||||
title="分区卡片(首页)"
|
||||
desc="Card 提供容器,标题栏由「色条 + 标题 + 角标 + 更多按钮」拼出来,内容区用 Grid 排应用行。角标压在标题右上角,靠绝对定位。"
|
||||
muted
|
||||
layout="column"
|
||||
code={`<Card>
|
||||
<header className={styles.sectionHead}>
|
||||
<Space gap="md">
|
||||
<span className={styles.bar} />
|
||||
<ItemBadge tone="sticky">置顶</ItemBadge>
|
||||
<span className={styles.titleWrap}>
|
||||
<span className={styles.title}>常用站点</span>
|
||||
<Badge count={18} className={styles.badge} />
|
||||
</span>
|
||||
</Space>
|
||||
<IconButton label="查看全部" icon={<Icon name="more_3_line" />} />
|
||||
</header>
|
||||
|
||||
<Grid columns={4} gap="md">
|
||||
{apps.map((app) => (
|
||||
<a className={styles.appRow} key={app.id} href="#">
|
||||
<Avatar size="lg" fallback={app.icon} />
|
||||
<span className={styles.appInfo}>
|
||||
<span className={styles.appName}>{app.name}</span>
|
||||
<span className={styles.appDesc}>{app.desc}</span>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</Grid>
|
||||
</Card>`}
|
||||
>
|
||||
<Card padding="lg">
|
||||
<header className={styles.sectionHead}>
|
||||
<div className={styles.headMain}>
|
||||
<span className={styles.bar} />
|
||||
<ItemBadge tone="sticky">置顶</ItemBadge>
|
||||
<span className={styles.titleWrap}>
|
||||
<span className={styles.title}>常用站点</span>
|
||||
<Badge count={18} className={styles.badge} />
|
||||
</span>
|
||||
</div>
|
||||
<IconButton label="查看全部" icon={<Icon name="more_3_line" />} />
|
||||
</header>
|
||||
|
||||
<Grid columns={4} gap="md">
|
||||
{DEMO_APPS.slice(0, 4).map((app) => (
|
||||
<a
|
||||
className={styles.appRow}
|
||||
key={app.id}
|
||||
href="#composition"
|
||||
onClick={(event) => event.preventDefault()}
|
||||
>
|
||||
<Avatar size="lg" fallback={app.icon} />
|
||||
<span className={styles.appInfo}>
|
||||
<span className={styles.appName}>{app.name}</span>
|
||||
<span className={styles.appDesc}>{app.desc}</span>
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</Grid>
|
||||
</Card>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="筛选工具条"
|
||||
desc="搜索框 + 条件标签 + 操作按钮:左侧输入,右侧动作,中间用 Space 的 justify=between 撑开。"
|
||||
muted
|
||||
layout="column"
|
||||
code={`<Card padding="sm">
|
||||
<Space justify="between" wrap>
|
||||
<Space gap="sm">
|
||||
<Input icon="search_3_line" placeholder="筛选项目…" />
|
||||
<Tag tone="neutral">全部 42</Tag>
|
||||
</Space>
|
||||
<Space gap="sm">
|
||||
<Button variant="ghost" size="sm">重置</Button>
|
||||
<Button size="sm">应用</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>`}
|
||||
>
|
||||
<Card padding="sm">
|
||||
<Space justify="between" wrap>
|
||||
<Space gap="sm">
|
||||
<Input icon="search_3_line" placeholder="筛选项目…" style={{ width: 220 }} />
|
||||
<Tag tone="neutral">全部 42</Tag>
|
||||
</Space>
|
||||
<Space gap="sm">
|
||||
<Button variant="ghost" size="sm">
|
||||
重置
|
||||
</Button>
|
||||
<Button size="sm">应用</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="海报卡(图文列表)"
|
||||
desc="Card padding=none 承载贴边封面,图片用 aspect-ratio 固定比例,避免加载前后跳动;标题与标签放卡片底部。"
|
||||
muted
|
||||
layout="column"
|
||||
code={`<Grid columns={4} gap="md">
|
||||
{posters.map((poster) => (
|
||||
<Card padding="none" className={styles.posterCard} key={poster.id}>
|
||||
<span className={styles.posterCover}>
|
||||
<img src={cover} alt="" />
|
||||
</span>
|
||||
<span className={styles.posterBody}>
|
||||
<span className={styles.posterTitle}>{poster.title}</span>
|
||||
<Tag shape="pill" tone="neutral">{poster.tag}</Tag>
|
||||
</span>
|
||||
</Card>
|
||||
))}
|
||||
</Grid>`}
|
||||
>
|
||||
<Grid columns={4} gap="md">
|
||||
{DEMO_POSTERS.map((poster) => (
|
||||
<Card padding="none" className={styles.posterCard} key={poster.id}>
|
||||
<span className={styles.posterCover}>
|
||||
<img src={DEMO_COVER} alt="" />
|
||||
</span>
|
||||
<span className={styles.posterBody}>
|
||||
<span className={styles.posterTitle}>{poster.title}</span>
|
||||
<Tag shape="pill" tone="neutral">
|
||||
{poster.tag}
|
||||
</Tag>
|
||||
</span>
|
||||
</Card>
|
||||
))}
|
||||
</Grid>
|
||||
</Demo>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { Card, Cover, Space } from '@stash/ui'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
/** 演示用的渐变色块,避免依赖外部图片 */
|
||||
const swatch = (from: string, to: string) =>
|
||||
`data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="600"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${from}"/><stop offset="1" stop-color="${to}"/></linearGradient></defs><rect width="400" height="600" fill="url(#g)"/></svg>`,
|
||||
)}`
|
||||
|
||||
export function Covers() {
|
||||
return (
|
||||
<Section
|
||||
id="cover"
|
||||
title="封面卡"
|
||||
api="Cover"
|
||||
desc="比例封面卡:图片 + 底部压暗层 + 标题。海报、图文、宽图共用一套,避免每个列表各写一份 aspect-ratio 和渐变遮罩。"
|
||||
>
|
||||
<Demo
|
||||
title="三种比例"
|
||||
desc="poster 2:3(海报)、article 4:3(图文)、wide 16:9(横幅);标题最多两行,超出省略。"
|
||||
code={`<Cover ratio="poster" src={cover} alt="海报" caption="海报 2:3" />
|
||||
<Cover ratio="article" src={cover} alt="图文" caption="图文 4:3" />
|
||||
<Cover ratio="wide" src={cover} alt="横幅" caption="横幅 16:9" />`}
|
||||
>
|
||||
<Space gap="md" align="start">
|
||||
<div style={{ width: 120 }}>
|
||||
<Cover
|
||||
ratio="poster"
|
||||
src={swatch('#7c5cff', '#c04bff')}
|
||||
alt="海报示例"
|
||||
caption="海报 2:3"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 180 }}>
|
||||
<Cover
|
||||
ratio="article"
|
||||
src={swatch('#1f6feb', '#4abd86')}
|
||||
alt="图文示例"
|
||||
caption="图文 4:3"
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 220 }}>
|
||||
<Cover
|
||||
ratio="wide"
|
||||
src={swatch('#ff7a45', '#ffba3a')}
|
||||
alt="横幅示例"
|
||||
caption="横幅 16:9"
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="贴边 + 可点击"
|
||||
desc="放在 Card padding='none' 里时传 radius='none';传 onClick 整块变成按钮,支持键盘操作。"
|
||||
layout="column"
|
||||
code={`<Card padding="none">
|
||||
<Cover radius="none" ratio="article" onClick={open} caption="可点击的封面" />
|
||||
</Card>`}
|
||||
>
|
||||
<Card padding="none" style={{ maxWidth: 280 }}>
|
||||
<Cover
|
||||
radius="none"
|
||||
ratio="article"
|
||||
src={swatch('#3f51b5', '#00bcd4')}
|
||||
alt="可点击的封面"
|
||||
caption="可点击的封面"
|
||||
onClick={() => undefined}
|
||||
/>
|
||||
</Card>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'ratio', type: "'poster' | 'article' | 'wide' | number", defaultValue: "'article'", desc: '2:3 / 4:3 / 16:9 / 自定义宽高比' },
|
||||
{ name: 'src', type: 'string', desc: '图片地址,不传显示占位字符' },
|
||||
{ name: 'alt', type: 'string', required: true, desc: '图片说明,同时作为按钮 title' },
|
||||
{ name: 'caption', type: 'ReactNode', desc: '底部标题(最多两行)' },
|
||||
{ name: 'radius', type: "'cover' | 'none'", defaultValue: "'cover'", desc: '8px / 贴边' },
|
||||
{ name: 'lift', type: 'boolean', defaultValue: 'true', desc: '悬停整块上浮 2px(参考站行为)' },
|
||||
{ name: 'hoverZoom', type: 'boolean', defaultValue: 'false', desc: '悬停放大图片(与 lift 二选一)' },
|
||||
{ name: 'onClick', type: '() => void', desc: '传了就把整块变成按钮' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Checkbox, Drawer, Space, Switch, Text } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
export function Drawers() {
|
||||
const [rightOpen, setRightOpen] = useState(false)
|
||||
const [leftOpen, setLeftOpen] = useState(false)
|
||||
const [autoSync, setAutoSync] = useState(true)
|
||||
const [notify, setNotify] = useState(false)
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="drawer"
|
||||
title="抽屉"
|
||||
api="Drawer"
|
||||
desc="从侧边滑出的面板:筛选、设置、详情。基于原生 <dialog>(焦点陷阱、Esc、背景 inert),贴边全高,不打断主流程。"
|
||||
>
|
||||
<Demo
|
||||
title="右侧设置"
|
||||
desc="标题栏带关闭按钮;底部操作区可选。点遮罩或按 Esc 也会关闭。"
|
||||
code={`const [open, setOpen] = useState(false)
|
||||
|
||||
<Button onClick={() => setOpen(true)}>打开设置</Button>
|
||||
<Drawer open={open} onClose={() => setOpen(false)} title="偏好设置"
|
||||
footer={<><Button variant="ghost" onClick={() => setOpen(false)}>取消</Button>
|
||||
<Button onClick={() => setOpen(false)}>保存</Button></>}>
|
||||
…
|
||||
</Drawer>`}
|
||||
>
|
||||
<Space gap="md" wrap>
|
||||
<Button onClick={() => setRightOpen(true)}>打开设置</Button>
|
||||
<Button variant="secondary" onClick={() => setLeftOpen(true)}>
|
||||
左侧抽屉
|
||||
</Button>
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'open', type: 'boolean', required: true, desc: '受控开关' },
|
||||
{ name: 'onClose', type: '() => void', required: true, desc: 'Esc / 遮罩 / 关闭按钮都会调用' },
|
||||
{ name: 'title', type: 'ReactNode', desc: '标题' },
|
||||
{ name: 'placement', type: "'right' | 'left'", defaultValue: "'right'", desc: '贴哪一边' },
|
||||
{ name: 'width', type: 'number', defaultValue: '360', desc: '宽度(px)' },
|
||||
{ name: 'footer', type: 'ReactNode', desc: '底部操作区' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Drawer
|
||||
open={rightOpen}
|
||||
onClose={() => setRightOpen(false)}
|
||||
title="偏好设置"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setRightOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={() => setRightOpen(false)}>保存</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Space direction="column" gap="lg" align="stretch">
|
||||
<Space justify="between">
|
||||
<Text>自动同步</Text>
|
||||
<Switch checked={autoSync} onChange={setAutoSync} label="自动同步" />
|
||||
</Space>
|
||||
<Checkbox checked={notify} onChange={setNotify} label="每周摘要提醒" />
|
||||
<Text size="sm" tone="muted">
|
||||
抽屉内容通常是表单或筛选器;需要打断流程的确认请用 Modal。
|
||||
</Text>
|
||||
</Space>
|
||||
</Drawer>
|
||||
|
||||
<Drawer
|
||||
open={leftOpen}
|
||||
onClose={() => setLeftOpen(false)}
|
||||
placement="left"
|
||||
width={300}
|
||||
title="导航"
|
||||
>
|
||||
<Text size="sm" tone="muted">
|
||||
左侧抽屉常用于移动端导航;桌面端优先用常驻侧栏。
|
||||
</Text>
|
||||
</Drawer>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Button, Empty, Notice, Space } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
export function Feedback() {
|
||||
return (
|
||||
<Section
|
||||
id="feedback"
|
||||
title="提示与空态"
|
||||
api="Notice / Empty"
|
||||
desc="页面里最常见的两种「没有内容」:一种是正常的空,一种是出了错。别让它们长得一样。"
|
||||
>
|
||||
<Demo
|
||||
title="空态"
|
||||
desc="空态只说明情况;带操作的空态能让用户立刻回到有内容的路径上。"
|
||||
layout="column"
|
||||
code={`<Empty text="暂无内容" />
|
||||
<Empty
|
||||
text="没有匹配「设计」的内容"
|
||||
action={<Button size="sm" variant="secondary">清空搜索</Button>}
|
||||
/>`}
|
||||
>
|
||||
<Empty text="暂无内容" />
|
||||
<Empty
|
||||
text="没有匹配「设计」的内容,换个关键字试试。"
|
||||
action={
|
||||
<Space gap="sm">
|
||||
<Button size="sm" variant="secondary">
|
||||
清空搜索
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost">
|
||||
查看全部
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="说明与错误"
|
||||
desc="错误态带 role=alert,屏幕阅读器会立刻播报;说明态没有这个角色,避免每次渲染都被念。"
|
||||
layout="column"
|
||||
code={`<Notice title="内容库读取失败">…</Notice>
|
||||
<Notice tone="error" title="连不上 Stash 服务" action={{ label: '重试', onClick }}>…</Notice>`}
|
||||
>
|
||||
<Notice title="还没有项目">在托管目录下建一个子目录并放入 index.html,刷新后就会出现在这里。</Notice>
|
||||
<Notice
|
||||
tone="error"
|
||||
title="连不上 Stash 服务"
|
||||
action={{ label: '重试', onClick: () => window.location.reload() }}
|
||||
>
|
||||
Failed to fetch /api/projects
|
||||
</Notice>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'tone', type: "'info' | 'error'", defaultValue: "'info'", desc: '说明 / 错误' },
|
||||
{ name: 'title', type: 'ReactNode', required: true, desc: '标题行' },
|
||||
{ name: 'children', type: 'ReactNode', desc: '正文' },
|
||||
{ name: 'action', type: '{ label: string; onClick: () => void }', desc: '底部操作按钮' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'text', type: 'ReactNode', defaultValue: "'暂无内容'", desc: '空态文字' },
|
||||
{ name: 'action', type: 'ReactNode', desc: '文字下方的操作区' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { ICON_NAMES, Icon, Input, Space, type IconName } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
export function Icons() {
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [copied, setCopied] = useState<IconName | null>(null)
|
||||
|
||||
const visible = useMemo(() => {
|
||||
const query = keyword.trim().toLowerCase()
|
||||
return query === '' ? ICON_NAMES : ICON_NAMES.filter((name) => name.includes(query))
|
||||
}, [keyword])
|
||||
|
||||
const copy = (name: IconName) => {
|
||||
const task = navigator.clipboard?.writeText(name)
|
||||
task?.catch(() => undefined)
|
||||
setCopied(name)
|
||||
window.setTimeout(() => setCopied((current) => (current === name ? null : current)), 1200)
|
||||
}
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="icons"
|
||||
title="图标"
|
||||
api="Icon"
|
||||
desc={`参考站 iconfont 的全量字形(${ICON_NAMES.length} 个,字体 52KB)。线性和填充成对出现,命名规则是「名字_风格」:search_line / search_fill、arrow_left_line / arrow_left_fill。点任意一格可以复制图标名。`}
|
||||
>
|
||||
<Demo
|
||||
title="全部字形"
|
||||
desc="按名字过滤(试试 arrow、fill、user),点击复制 `<Icon name=… />` 里的名字。"
|
||||
layout="column"
|
||||
code={`<Icon name="search_3_line" />
|
||||
<Icon name="arrow_left_line" style={{ fontSize: 20 }} />`}
|
||||
>
|
||||
<Space gap="md" wrap>
|
||||
<Input
|
||||
icon="search_3_line"
|
||||
placeholder="过滤图标名,如 arrow / fill / user…"
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
clearable
|
||||
onClear={() => setKeyword('')}
|
||||
style={{ width: 300 }}
|
||||
/>
|
||||
<span className="icon-count">
|
||||
{visible.length} / {ICON_NAMES.length}
|
||||
</span>
|
||||
</Space>
|
||||
|
||||
<div className="icon-wall icon-wall--scroll">
|
||||
{visible.map((name) => (
|
||||
<button
|
||||
type="button"
|
||||
className="icon-cell"
|
||||
key={name}
|
||||
title={`复制 ${name}`}
|
||||
onClick={() => copy(name)}
|
||||
>
|
||||
<Icon name={name} />
|
||||
<code>{copied === name ? '已复制' : name}</code>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{visible.length === 0 && (
|
||||
<p className="icon-empty">没有匹配「{keyword.trim()}」的图标,换个关键词试试。</p>
|
||||
)}
|
||||
</Demo>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Grid, Image, Space, Text } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
const svg = (from: string, to: string) =>
|
||||
`data:image/svg+xml;utf8,${encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="800" height="450"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${from}"/><stop offset="1" stop-color="${to}"/></linearGradient></defs><rect width="800" height="450" fill="url(#g)"/></svg>`,
|
||||
)}`
|
||||
|
||||
export function Images() {
|
||||
return (
|
||||
<Section
|
||||
id="image"
|
||||
title="图片"
|
||||
api="Image"
|
||||
desc="带比例、圆角、加载失败降级的图片。正文配图、相册、缩略图用它;带标题遮罩的封面卡用 Cover。"
|
||||
>
|
||||
<Demo
|
||||
title="比例"
|
||||
desc="square 1:1、video 16:9、wide 21:9,也可以直接传数字(宽高比),容器宽度自适应。"
|
||||
layout="column"
|
||||
code={`<Image ratio="square" src={src} alt="方形" />
|
||||
<Image ratio="video" src={src} alt="16:9" />
|
||||
<Image ratio={4 / 3} src={src} alt="4:3" />`}
|
||||
>
|
||||
<Grid columns={3} gap="md">
|
||||
<Image ratio="square" src={svg('#3f51b5', '#00bcd4')} alt="方形示例" radius="md" />
|
||||
<Image ratio="video" src={svg('#ff7a45', '#ffba3a')} alt="16:9 示例" radius="md" />
|
||||
<Image ratio={4 / 3} src={svg('#7c5cff', '#c04bff')} alt="4:3 示例" radius="md" />
|
||||
</Grid>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="填充方式与圆角"
|
||||
desc="cover 裁切填满(默认),contain 完整显示(多用于截图 / 图表);圆角四档。"
|
||||
layout="column"
|
||||
code={`<Image fit="contain" radius="none" src={src} alt="完整显示" />
|
||||
<Image fit="cover" radius="cover" src={src} alt="裁切" />`}
|
||||
>
|
||||
<Grid columns={2} gap="md">
|
||||
<Image
|
||||
fit="contain"
|
||||
radius="none"
|
||||
ratio="video"
|
||||
src={svg('#1f6feb', '#4abd86')}
|
||||
alt="contain 示例"
|
||||
/>
|
||||
<Image fit="cover" radius="cover" ratio="video" src={svg('#ff4d4f', '#ff952f')} alt="cover 示例" />
|
||||
</Grid>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="加载失败"
|
||||
desc="图片地址失效时退化为占位块,不会出现破图;可传 fallback 换成文案或图标。"
|
||||
layout="column"
|
||||
code={`<Image src="/not-exists.png" alt="加载失败" fallback="图" />`}
|
||||
>
|
||||
<Space gap="md" align="start">
|
||||
<div style={{ width: 160 }}>
|
||||
<Image src="/not-exists.png" alt="加载失败" ratio="square" radius="md" fallback="图" />
|
||||
</div>
|
||||
<Text size="sm" tone="muted">
|
||||
失败后显示 fallback(这里传了「图」)
|
||||
</Text>
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'src', type: 'string', required: true, desc: '图片地址' },
|
||||
{ name: 'alt', type: 'string', required: true, desc: '替代文本' },
|
||||
{
|
||||
name: 'ratio',
|
||||
type: "'square' | 'video' | 'wide' | number",
|
||||
defaultValue: "'video'",
|
||||
desc: '1:1 / 16:9 / 21:9 / 自定义宽高比',
|
||||
},
|
||||
{ name: 'fit', type: "'cover' | 'contain'", defaultValue: "'cover'", desc: '裁切 / 完整显示' },
|
||||
{ name: 'radius', type: "'none' | 'sm' | 'md' | 'cover'", defaultValue: "'sm'", desc: '0 / 6 / 12 / 8px' },
|
||||
{ name: 'fallback', type: 'ReactNode', desc: '加载失败时的占位内容' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, FormField, Icon, Input, Space, Text } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
export function Inputs() {
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [host, setHost] = useState('127.0.0.1:8080')
|
||||
const [email, setEmail] = useState('not-an-email')
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="inputs"
|
||||
title="输入框"
|
||||
api="Input"
|
||||
desc="左侧图标 + 清除按钮 + 快捷键提示的组合式输入。事件语义与原生 input 一致,需要字符串时在使用方包一层。"
|
||||
>
|
||||
<Demo
|
||||
title="带图标与清除"
|
||||
desc="清空按钮只在有内容且传了 onClear 时出现,避免不可用的空按钮。"
|
||||
layout="column"
|
||||
code={`<Input
|
||||
icon="search_3_line"
|
||||
placeholder="搜索本地项目…"
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
clearable
|
||||
onClear={() => setKeyword('')}
|
||||
/>`}
|
||||
>
|
||||
<Space gap="md" wrap>
|
||||
<Input
|
||||
icon="search_3_line"
|
||||
placeholder="搜索本地项目…"
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
clearable
|
||||
onClear={() => setKeyword('')}
|
||||
style={{ width: 320 }}
|
||||
/>
|
||||
<span style={{ alignSelf: 'center', color: 'var(--su-text-muted)', fontSize: 12 }}>
|
||||
当前值:{keyword === '' ? '(空)' : keyword}
|
||||
</span>
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="快捷键提示"
|
||||
desc="shortcut 只是右侧展示,键盘监听由页面自己挂(例如 Ctrl/Cmd + K 聚焦)。"
|
||||
layout="column"
|
||||
code={`<Input icon="search_3_line" shortcut="⌘K" placeholder="搜索…" />
|
||||
<Input icon="link_line" value={host} onChange={(event) => setHost(event.target.value)} />`}
|
||||
>
|
||||
<Input icon="search_3_line" shortcut="⌘K" placeholder="搜索…" style={{ width: 320 }} />
|
||||
<Input
|
||||
icon="link_line"
|
||||
value={host}
|
||||
onChange={(event) => setHost(event.target.value)}
|
||||
style={{ width: 320 }}
|
||||
/>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="禁用与表单行"
|
||||
desc="输入框与按钮可以并排成一行操作区;禁用态整块降透明度,仍保留可读性。"
|
||||
layout="column"
|
||||
code={`<Space gap="sm" wrap>
|
||||
<Input placeholder="输入项目名…" />
|
||||
<Button icon={<Icon name="search_3_line" />}>搜索</Button>
|
||||
</Space>
|
||||
|
||||
<Input disabled value="只读" />`}
|
||||
>
|
||||
<Space gap="sm" wrap>
|
||||
<Input placeholder="输入项目名…" style={{ width: 240 }} />
|
||||
<Button icon={<Icon name="search_3_line" />}>搜索</Button>
|
||||
</Space>
|
||||
<Input disabled value="不可编辑" style={{ width: 240 }} />
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="尺寸"
|
||||
desc="32 / 36 / 44 三档;搜索这类主入口用 lg,表单行用 md,表格内筛选用 sm。"
|
||||
layout="column"
|
||||
code={`<Input size="sm" placeholder="小号 32" />
|
||||
<Input size="md" placeholder="默认 36" />
|
||||
<Input size="lg" placeholder="大号 44(搜索框)" />`}
|
||||
>
|
||||
<Input size="sm" placeholder="小号 32" style={{ width: 320 }} />
|
||||
<Input size="md" placeholder="默认 36" style={{ width: 320 }} />
|
||||
<Input size="lg" placeholder="大号 44(搜索框)" style={{ width: 320 }} />
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="表单字段"
|
||||
desc="FormField 负责「标签 + 控件 + 提示/错误」的排版;校验逻辑留在页面,错误出现时自动 role=alert。"
|
||||
layout="column"
|
||||
code={`<FormField label="邮箱" htmlFor="email" hint="我们只用它发周报" error={error}>
|
||||
<Input id="email" value={email} onChange={...} />
|
||||
</FormField>`}
|
||||
>
|
||||
<div style={{ width: 320, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<FormField label="项目名" htmlFor="project" required hint="显示在侧栏与首页卡片标题">
|
||||
<Input id="project" placeholder="例如:常用站点" />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
label="邮箱"
|
||||
htmlFor="email"
|
||||
hint="我们只用它发周报"
|
||||
error={email.includes('@') ? undefined : '邮箱格式不正确'}
|
||||
>
|
||||
<Input id="email" value={email} onChange={(event) => setEmail(event.target.value)} />
|
||||
</FormField>
|
||||
|
||||
<Text size="xs" tone="muted">
|
||||
当前值:{email}
|
||||
</Text>
|
||||
</div>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'icon', type: 'IconName', desc: '左侧前缀图标' },
|
||||
{ name: 'size', type: "'sm' | 'md' | 'lg'", defaultValue: "'md'", desc: '32 / 36 / 44px' },
|
||||
{ name: 'clearable', type: 'boolean', defaultValue: 'false', desc: '有内容时显示清除按钮' },
|
||||
{ name: 'onClear', type: '() => void', desc: '点击清除;不传则不显示按钮' },
|
||||
{ name: 'shortcut', type: 'string', desc: '右侧快捷键提示(仅展示)' },
|
||||
{ name: 'inputRef', type: 'Ref<HTMLInputElement>', desc: '拿到真实 input,用于聚焦' },
|
||||
{ name: 'className / style', type: 'string / CSSProperties', desc: '作用在外层容器上(宽度、外边距)' },
|
||||
{ name: '…rest', type: 'InputHTMLAttributes', desc: '透传原生属性,onChange 是原生事件' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Divider, Grid, Space, Tag } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
import { DEMO_POSTERS } from '../data/demo'
|
||||
|
||||
const BLOCKS = [1, 2, 3, 4, 5, 6, 7, 8]
|
||||
|
||||
function Block({ index, tall = false }: { index: number; tall?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
height: tall ? 96 : 56,
|
||||
borderRadius: 'var(--su-radius-sm)',
|
||||
background: 'var(--su-gray-2)',
|
||||
color: 'var(--su-text-muted)',
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{index}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function Layouts() {
|
||||
return (
|
||||
<Section
|
||||
id="layouts"
|
||||
title="网格与间距"
|
||||
api="Grid / Space / Divider"
|
||||
desc="页面布局只保留三件套:给卡片列表用的 Grid(列数与断点收在组件里),给元素间距用的 Space,以及分隔内容块的 Divider。"
|
||||
>
|
||||
<Demo
|
||||
title="网格列数"
|
||||
desc="窄屏自动退化:≤1100px 取一半(至少 2 列),≤720px 收成一列;也可以显式指定 tabletColumns / mobileColumns。"
|
||||
layout="column"
|
||||
code={`<Grid columns={4} gap="md">…</Grid>
|
||||
<Grid columns={6} tabletColumns={3} mobileColumns={2} gap="sm">…</Grid>`}
|
||||
>
|
||||
<Grid columns={4} gap="md">
|
||||
{BLOCKS.slice(0, 4).map((index) => (
|
||||
<Block key={index} index={index} />
|
||||
))}
|
||||
</Grid>
|
||||
<Grid columns={6} tabletColumns={3} mobileColumns={2} gap="sm">
|
||||
{BLOCKS.map((index) => (
|
||||
<Block key={index} index={index} />
|
||||
))}
|
||||
</Grid>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="列表语义"
|
||||
desc="传 as='ul' 时用 li 做列表项,屏幕阅读器能读出条目数;纯展示容器用默认的 div。"
|
||||
layout="column"
|
||||
code={`<Grid as="ul" role="list" columns={4} gap="md">
|
||||
{items.map((item) => <li key={item.id}>…</li>)}
|
||||
</Grid>`}
|
||||
>
|
||||
<Grid as="ul" role="list" columns={4} gap="md">
|
||||
{DEMO_POSTERS.map((poster) => (
|
||||
<li key={poster.id} style={{ listStyle: 'none' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: 'var(--su-space-2)',
|
||||
padding: 'var(--su-space-3)',
|
||||
border: '1px solid var(--su-border)',
|
||||
borderRadius: 'var(--su-radius-md)',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 500 }}>{poster.title}</span>
|
||||
<Tag shape="pill" tone="neutral">
|
||||
{poster.tag}
|
||||
</Tag>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</Grid>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="间距"
|
||||
desc="Space 只是 flex + gap 的封装,方向、间距、对齐都用语义化的档位表达,避免每个页面各写一套像素值。"
|
||||
layout="column"
|
||||
code={`<Space gap="md">
|
||||
<Tag>本地</Tag>
|
||||
<span>docs</span>
|
||||
</Space>
|
||||
|
||||
<Space direction="column" gap="sm" align="start">…</Space>`}
|
||||
>
|
||||
<Space gap="md">
|
||||
<Tag>本地</Tag>
|
||||
<span>docs</span>
|
||||
<Tag tone="success" dot>
|
||||
已连接
|
||||
</Tag>
|
||||
</Space>
|
||||
<Space direction="column" gap="sm" align="start">
|
||||
<span style={{ fontWeight: 500 }}>竖向排列</span>
|
||||
<span style={{ color: 'var(--su-text-muted)' }}>适合标题 + 描述这类两行结构</span>
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="分隔线"
|
||||
desc="横向分隔内容块,纵向分隔并排元素;虚线用于「弱分隔」,和实线区分层级。"
|
||||
layout="column"
|
||||
code={`<Divider />
|
||||
<Divider variant="dashed" spacing="sm" />
|
||||
<Space gap="sm"><span>左</span><Divider orientation="vertical" /><span>右</span></Space>`}
|
||||
>
|
||||
<Divider />
|
||||
<Divider variant="dashed" spacing="sm" />
|
||||
<Space gap="sm">
|
||||
<span>左</span>
|
||||
<Divider orientation="vertical" />
|
||||
<span>右</span>
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'columns', type: 'number', defaultValue: '4', desc: '宽屏列数' },
|
||||
{ name: 'tabletColumns', type: 'number', defaultValue: 'columns / 2(至少 2)', desc: '≤1100px 列数' },
|
||||
{ name: 'mobileColumns', type: 'number', defaultValue: '1', desc: '≤720px 列数' },
|
||||
{ name: 'gap', type: "'sm' | 'md' | 'lg'", defaultValue: "'md'", desc: '间距档位' },
|
||||
{ name: 'as', type: "'div' | 'ul'", defaultValue: "'div'", desc: '列表语义' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { Badge, Icon, ItemBadge, Space, Tag } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
export function Marks() {
|
||||
return (
|
||||
<Section
|
||||
id="marks"
|
||||
title="标记"
|
||||
api="Tag / ItemBadge / Badge"
|
||||
desc="三种标记各有分工:Tag 是分类与元信息(浅底胶囊,13px),ItemBadge 是标题前的状态角标(置顶/付费/HOT,渐变,12px),Badge 是纯数字。"
|
||||
>
|
||||
<Demo
|
||||
title="标签:浅底(默认)"
|
||||
desc="对齐参考站的 .tags-list:24px 高、13px 字、胶囊形、gray-1 浅底 + gray-6 文字。neutral 是默认态。"
|
||||
code={`<Tag>未分类</Tag>
|
||||
<Tag tone="primary">前端</Tag>
|
||||
<Tag tone="success">已连接</Tag>
|
||||
<Tag tone="warning">待确认</Tag>
|
||||
<Tag tone="danger">已删除</Tag>`}
|
||||
>
|
||||
<Tag>未分类</Tag>
|
||||
<Tag tone="primary">前端</Tag>
|
||||
<Tag tone="success">已连接</Tag>
|
||||
<Tag tone="warning">待确认</Tag>
|
||||
<Tag tone="danger">已删除</Tag>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="标签:实底与图标"
|
||||
desc="文章页的分类标签是实底白字;icon 插槽用于放分类小图标(参考站的标签左侧都有图标)。"
|
||||
code={`<Tag variant="solid" tone="primary">iPhone 17e</Tag>
|
||||
<Tag variant="solid" tone="danger">苹果新品</Tag>
|
||||
<Tag variant="solid" tone="success">设计</Tag>
|
||||
<Tag icon={<Icon name="link_line" />}>链接</Tag>`}
|
||||
>
|
||||
<Tag variant="solid" tone="primary">
|
||||
iPhone 17e
|
||||
</Tag>
|
||||
<Tag variant="solid" tone="danger">
|
||||
苹果新品
|
||||
</Tag>
|
||||
<Tag variant="solid" tone="success">
|
||||
设计
|
||||
</Tag>
|
||||
<Tag variant="solid" tone="warning">
|
||||
付费
|
||||
</Tag>
|
||||
<Tag icon={<Icon name="link_line" />}>链接</Tag>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="状态角标"
|
||||
desc="标题前的「置顶 / 付费 / HOT」:18px 高、12px 加粗白字、4px 圆角、红/橙渐变底,同一行里放在标题左边。"
|
||||
code={`<ItemBadge tone="sticky">置顶</ItemBadge>
|
||||
<ItemBadge tone="paid">付费</ItemBadge>
|
||||
<ItemBadge tone="hot">HOT</ItemBadge>
|
||||
<ItemBadge tone="neutral">草稿</ItemBadge>`}
|
||||
>
|
||||
<Space gap="md" wrap>
|
||||
<ItemBadge tone="sticky">
|
||||
置顶
|
||||
</ItemBadge>
|
||||
<ItemBadge tone="paid">
|
||||
付费
|
||||
</ItemBadge>
|
||||
<ItemBadge tone="hot">
|
||||
HOT
|
||||
</ItemBadge>
|
||||
<ItemBadge tone="neutral">
|
||||
草稿
|
||||
</ItemBadge>
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="数量角标"
|
||||
desc="纯数字,18px 高、12px 字;超过 99 显示 99+,需要压住标题右上角时传 className 自己定位。"
|
||||
code={`<Badge count={18} />
|
||||
<Badge count={1280} />
|
||||
<Badge count={7} tone="primary" />
|
||||
<Badge count={7} tone="neutral" />`}
|
||||
>
|
||||
<Badge count={18} />
|
||||
<Badge count={1280} />
|
||||
<Badge count={7} tone="primary" />
|
||||
<Badge count={7} tone="neutral" />
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="组合成标题行"
|
||||
desc="状态角标 + 标题 + 分类标签,是列表条目最常见的一行;间距用 Space,不要给角标加 margin。"
|
||||
layout="column"
|
||||
code={`<Space gap="sm">
|
||||
<ItemBadge tone="paid">付费</ItemBadge>
|
||||
<strong>iPhone 17e 最全汇总</strong>
|
||||
</Space>
|
||||
|
||||
<Space gap="sm">
|
||||
<Tag variant="solid" tone="primary">未分类</Tag>
|
||||
<Tag variant="solid" tone="danger">苹果新品</Tag>
|
||||
</Space>`}
|
||||
>
|
||||
<Space gap="sm">
|
||||
<ItemBadge tone="paid">
|
||||
付费
|
||||
</ItemBadge>
|
||||
<span style={{ fontSize: 16, fontWeight: 500 }}>iPhone 17e 最全汇总:最便宜的苹果 AI 手机</span>
|
||||
</Space>
|
||||
<Space gap="sm">
|
||||
<Tag variant="solid" tone="primary">
|
||||
未分类
|
||||
</Tag>
|
||||
<Tag variant="solid" tone="danger">
|
||||
苹果新品
|
||||
</Tag>
|
||||
<Tag variant="solid" tone="danger">
|
||||
闪电接口
|
||||
</Tag>
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="组合成顶栏信息"
|
||||
desc="「标签 + 文本」是顶栏信息条的固定搭配;前缀标签用 rounded 小方块,和分类胶囊区分开。"
|
||||
code={`<Space gap="md">
|
||||
<Tag shape="rounded">本地</Tag>
|
||||
<span>docs</span>
|
||||
</Space>`}
|
||||
>
|
||||
<Space gap="md">
|
||||
<Tag shape="rounded">本地</Tag>
|
||||
<span>docs</span>
|
||||
</Space>
|
||||
<Space gap="md">
|
||||
<Tag shape="rounded" tone="success">
|
||||
状态
|
||||
</Tag>
|
||||
<span>实时同步已连接</span>
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'tone', type: "'neutral' | 'primary' | 'success' | 'warning' | 'danger'", defaultValue: "'neutral'", desc: '语义配色' },
|
||||
{ name: 'variant', type: "'soft' | 'solid'", defaultValue: "'soft'", desc: '浅底 / 实底白字' },
|
||||
{ name: 'shape', type: "'pill' | 'rounded'", defaultValue: "'pill'", desc: '胶囊 / 小方块' },
|
||||
{ name: 'icon', type: 'ReactNode', desc: '前置图标' },
|
||||
{ name: '…rest', type: 'HTMLAttributes<HTMLSpanElement>', desc: '透传原生属性' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'tone', type: "'hot' | 'sticky' | 'paid' | 'neutral'", defaultValue: "'hot'", desc: '红渐变 / 红渐变 / 橙渐变 / 灰底' },
|
||||
{ name: 'children', type: 'ReactNode', required: true, desc: '角标文字' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'count', type: 'number', required: true, desc: '数字' },
|
||||
{ name: 'tone', type: "'danger' | 'primary' | 'neutral'", defaultValue: "'danger'", desc: '配色' },
|
||||
{ name: 'max', type: 'number', defaultValue: '99', desc: '超过显示 N+;0 表示不截断' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useState } from 'react'
|
||||
import { Card, Icon, Menu, Text } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
const ITEMS = [
|
||||
{
|
||||
key: 'content',
|
||||
label: '内容',
|
||||
icon: <Icon name="folder_more_line" />,
|
||||
children: [
|
||||
{ key: 'recommend', label: '常用推荐' },
|
||||
{ key: 'movie', label: '推荐电影' },
|
||||
{ key: 'ces', label: 'CES 现场报道' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
label: '系统',
|
||||
icon: <Icon name="settings_1_line" />,
|
||||
children: [
|
||||
{ key: 'profile', label: '资料' },
|
||||
{ key: 'data', label: '内容库' },
|
||||
],
|
||||
},
|
||||
{ key: 'about', label: '关于', icon: <Icon name="question_line" /> },
|
||||
]
|
||||
|
||||
export function MenuSection() {
|
||||
const [active, setActive] = useState('recommend')
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="menu"
|
||||
title="菜单"
|
||||
api="Menu"
|
||||
desc="侧栏菜单:一层折叠 + 两级结构,数据无关——业务树由页面转成 items。含 activeKey 的父级会自动展开。"
|
||||
>
|
||||
<Demo
|
||||
title="基础用法"
|
||||
desc="父级是折叠按钮(aria-expanded),子项是导航项(aria-current=page)。"
|
||||
code={`const [active, setActive] = useState('recommend')
|
||||
|
||||
<Menu items={ITEMS} activeKey={active} onSelect={setActive} />`}
|
||||
>
|
||||
<Card padding="sm" style={{ width: 240 }}>
|
||||
<Menu items={ITEMS} activeKey={active} onSelect={setActive} />
|
||||
</Card>
|
||||
<Text size="sm" tone="muted">
|
||||
当前:{active}
|
||||
</Text>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="默认展开"
|
||||
desc="defaultOpenKeys 指定初始展开的父级;用户手动折叠后以用户操作为准。"
|
||||
code={`<Menu items={ITEMS} activeKey="data" onSelect={() => {}} defaultOpenKeys={['system']} />`}
|
||||
>
|
||||
<Card padding="sm" style={{ width: 240 }}>
|
||||
<Menu items={ITEMS} activeKey="data" onSelect={() => undefined} defaultOpenKeys={['system']} />
|
||||
</Card>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="行高"
|
||||
desc="默认 36px;需要更松的密度(如资料较多、点击目标更大)时传 itemHeight。"
|
||||
layout="column"
|
||||
code={`<Menu items={ITEMS} activeKey="data" onSelect={() => {}} itemHeight={44} />`}
|
||||
>
|
||||
<Card padding="sm" style={{ width: 240 }}>
|
||||
<Menu
|
||||
items={ITEMS}
|
||||
activeKey="data"
|
||||
onSelect={() => undefined}
|
||||
defaultOpenKeys={['system']}
|
||||
itemHeight={44}
|
||||
/>
|
||||
</Card>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'items', type: 'MenuItem[]', required: true, desc: '{ key, label, icon?, children? }' },
|
||||
{ name: 'activeKey', type: 'string', desc: '当前选中项' },
|
||||
{ name: 'onSelect', type: '(key: string) => void', required: true, desc: '选中回调' },
|
||||
{ name: 'defaultOpenKeys', type: 'string[]', desc: '初始展开的父级 key' },
|
||||
{ name: 'itemHeight', type: 'number', defaultValue: '36', desc: '一级菜单行高(px)' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Modal, Notice, Space } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
export function Modals() {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false)
|
||||
const [infoOpen, setInfoOpen] = useState(false)
|
||||
const [deleted, setDeleted] = useState(false)
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="modal"
|
||||
title="弹窗"
|
||||
api="Modal"
|
||||
desc="基于原生 <dialog>:焦点陷阱、Esc 关闭、背景 inert 由浏览器提供,组件补样式、滚动锁和受控开关。对应参考站的 .nice-tips(遮罩 60% 黑、内容 12px 圆角、上滑淡入)。"
|
||||
>
|
||||
<Demo
|
||||
title="确认弹窗"
|
||||
desc="危险操作要二次确认;确认后由调用方关闭并把结果反馈给页面(这里用 Notice 示意)。"
|
||||
layout="column"
|
||||
code={`const [open, setOpen] = useState(false)
|
||||
|
||||
<Modal
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
title="删除这篇内容?"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setOpen(false)}>取消</Button>
|
||||
<Button variant="danger" onClick={handleDelete}>删除</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
删除后不可恢复,占位数据会被一起清掉。
|
||||
</Modal>`}
|
||||
>
|
||||
<Space gap="md" wrap>
|
||||
<Button variant="danger" onClick={() => setConfirmOpen(true)}>
|
||||
删除内容
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setInfoOpen(true)}>
|
||||
打开说明弹窗
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{deleted && (
|
||||
<Notice title="示例:已删除" action={{ label: '撤销', onClick: () => setDeleted(false) }}>
|
||||
这里只是演示确认流程,没有真的删数据。
|
||||
</Notice>
|
||||
)}
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="尺寸与长内容"
|
||||
desc="sm 300 / md 460 / lg 640;内容多时弹窗内部不滚动、由页面决定(这里演示 lg 的排版)。"
|
||||
layout="column"
|
||||
code={`<Modal size="lg" title="关于这套组件" open={open} onClose={close}>…</Modal>`}
|
||||
>
|
||||
<Button variant="secondary" onClick={() => setInfoOpen(true)}>
|
||||
打开 lg 弹窗
|
||||
</Button>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'open', type: 'boolean', required: true, desc: '受控开关' },
|
||||
{ name: 'onClose', type: '() => void', required: true, desc: 'Esc / 遮罩 / 关闭按钮都会调用' },
|
||||
{ name: 'title', type: 'ReactNode', required: true, desc: '标题,同时作为 aria-labelledby' },
|
||||
{ name: 'children', type: 'ReactNode', desc: '正文' },
|
||||
{ name: 'footer', type: 'ReactNode', desc: '底部操作区(通常放按钮)' },
|
||||
{ name: 'size', type: "'sm' | 'md' | 'lg'", defaultValue: "'md'", desc: '300 / 460 / 640' },
|
||||
{ name: 'closeOnOverlay', type: 'boolean', defaultValue: 'true', desc: '点遮罩是否关闭' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={confirmOpen}
|
||||
onClose={() => setConfirmOpen(false)}
|
||||
title="删除这篇内容?"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setConfirmOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
setConfirmOpen(false)
|
||||
setDeleted(true)
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
删除后不可恢复,占位数据会被一起清掉。
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
open={infoOpen}
|
||||
onClose={() => setInfoOpen(false)}
|
||||
title="关于这套组件"
|
||||
size="lg"
|
||||
footer={
|
||||
<Button onClick={() => setInfoOpen(false)}>知道了</Button>
|
||||
}
|
||||
>
|
||||
<p>
|
||||
组件只依赖令牌,不依赖任何业务数据;页面用 Card / Grid / Space 组合,弹窗只负责「打断」。
|
||||
</p>
|
||||
<p style={{ marginTop: 12 }}>
|
||||
关闭方式有三种:右上角按钮、Esc、点击遮罩。打开时背景滚动会被锁住,关闭后恢复。
|
||||
</p>
|
||||
</Modal>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
/** 令牌总览:所有组件只消费这些变量,换主题即改这一处 */
|
||||
const SEMANTIC_COLORS = [
|
||||
{ name: '--su-color-primary', value: '#3858f6', use: '主色:链接、选中、色条' },
|
||||
{ name: '--su-color-primary-soft', value: 'rgba(56,88,246,.1)', use: '主色浅底' },
|
||||
{ name: '--su-color-danger', value: '#ff3e3e', use: '错误、数量角标' },
|
||||
{ name: '--su-color-warning', value: '#ffba3a', use: '提醒、强调' },
|
||||
{ name: '--su-color-success', value: '#4abd86', use: '在线、成功' },
|
||||
{ name: '--su-bg-card', value: '#ffffff', use: '卡片、输入框底色' },
|
||||
]
|
||||
|
||||
const GRAYS = [
|
||||
{ name: '--su-gray-1', value: '#f7f8fa' },
|
||||
{ name: '--su-gray-2', value: '#f6f6f6' },
|
||||
{ name: '--su-gray-3', value: '#eeeeee' },
|
||||
{ name: '--su-gray-4', value: '#c9cdd4' },
|
||||
{ name: '--su-gray-5', value: '#86909c' },
|
||||
{ name: '--su-gray-6', value: '#4e5969' },
|
||||
{ name: '--su-gray-7', value: '#2c343c' },
|
||||
{ name: '--su-gray-8', value: '#1d2129' },
|
||||
]
|
||||
|
||||
const RADII = [
|
||||
{ name: '--su-radius-xs', value: '4px', use: '标签、角标' },
|
||||
{ name: '--su-radius-sm', value: '6px', use: '按钮、输入框' },
|
||||
{ name: '--su-radius-md', value: '12px', use: '卡片、面板' },
|
||||
{ name: '--su-radius-pill', value: '999px', use: '胶囊、头像' },
|
||||
]
|
||||
|
||||
const TYPE_SCALE = [
|
||||
{ token: 'xl', size: '24 / 32', cls: 'type-sample-xl', sample: '页面大标题' },
|
||||
{ token: 'lg', size: '16 / 24', cls: 'type-sample-lg', sample: '分区标题、条目标题' },
|
||||
{ token: 'md', size: '14 / 20', cls: 'type-sample-md', sample: '正文、按钮、导航' },
|
||||
{ token: 'sm', size: '12 / 16', cls: 'type-sample-sm', sample: '描述、元信息、标签' },
|
||||
]
|
||||
|
||||
const SPACES = [
|
||||
{ name: '--su-space-1', value: 4 },
|
||||
{ name: '--su-space-2', value: 8 },
|
||||
{ name: '--su-space-3', value: 12 },
|
||||
{ name: '--su-space-4', value: 16 },
|
||||
{ name: '--su-space-5', value: 20 },
|
||||
{ name: '--su-space-6', value: 24 },
|
||||
{ name: '--su-space-8', value: 32 },
|
||||
]
|
||||
|
||||
export function Overview() {
|
||||
return (
|
||||
<Section
|
||||
id="overview"
|
||||
title="设计令牌"
|
||||
api="src/theme/tokens.css"
|
||||
desc="组件的颜色、间距、字号、圆角、动效全部来自 CSS 变量,前缀统一是 --su-。换主题只需要覆盖同名变量,不用改组件。"
|
||||
>
|
||||
<div className="demo">
|
||||
<h3 className="demo-title">语义色</h3>
|
||||
<div className="token-grid">
|
||||
{SEMANTIC_COLORS.map((color) => (
|
||||
<div className="token-swatch" key={color.name}>
|
||||
<div
|
||||
className="token-swatch-color"
|
||||
style={{ backgroundColor: `var(${color.name})` }}
|
||||
/>
|
||||
<div className="token-swatch-meta">
|
||||
<code className="token-swatch-name">{color.name}</code>
|
||||
<span className="token-swatch-value">{color.value}</span>
|
||||
<span className="token-swatch-value">{color.use}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="demo">
|
||||
<h3 className="demo-title">中性色</h3>
|
||||
<div className="token-grid">
|
||||
{GRAYS.map((gray) => (
|
||||
<div className="token-swatch" key={gray.name}>
|
||||
<div className="token-swatch-color" style={{ backgroundColor: `var(${gray.name})` }} />
|
||||
<div className="token-swatch-meta">
|
||||
<code className="token-swatch-name">{gray.name}</code>
|
||||
<span className="token-swatch-value">{gray.value}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="demo">
|
||||
<h3 className="demo-title">圆角与阴影</h3>
|
||||
<div className="token-list">
|
||||
{RADII.map((radius) => (
|
||||
<div className="token-row" key={radius.name}>
|
||||
<code className="token-row-label">{radius.name}</code>
|
||||
<div className="token-row-demo">
|
||||
<span className="radius-sample" style={{ borderRadius: `var(${radius.name})` }} />
|
||||
<span className="token-swatch-value">{radius.use}</span>
|
||||
</div>
|
||||
<span className="token-row-note">{radius.value}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="token-row">
|
||||
<code className="token-row-label">--su-shadow-card</code>
|
||||
<div className="token-row-demo">
|
||||
<span className="shadow-sample" style={{ boxShadow: 'var(--su-shadow-card)' }}>
|
||||
常态
|
||||
</span>
|
||||
<span className="shadow-sample" style={{ boxShadow: 'var(--su-shadow-hover)' }}>
|
||||
悬停
|
||||
</span>
|
||||
</div>
|
||||
<span className="token-row-note">两层,都很克制</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="demo">
|
||||
<h3 className="demo-title">字号阶梯</h3>
|
||||
<div className="token-list">
|
||||
{TYPE_SCALE.map((type) => (
|
||||
<div className="token-row" key={type.token}>
|
||||
<code className="token-row-label">{`--su-font-size-${type.token}`}</code>
|
||||
<div className="token-row-demo">
|
||||
<span className={type.cls}>{type.sample}</span>
|
||||
</div>
|
||||
<span className="token-row-note">{type.size} px</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="token-row">
|
||||
<code className="token-row-label">--su-font-weight-bold</code>
|
||||
<div className="token-row-demo">
|
||||
<span className="type-sample-md" style={{ fontWeight: 500 }}>
|
||||
主题的「加粗」是 500,不是 700
|
||||
</span>
|
||||
</div>
|
||||
<span className="token-row-note">500</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="demo">
|
||||
<h3 className="demo-title">间距</h3>
|
||||
<div className="token-list">
|
||||
{SPACES.map((space) => (
|
||||
<div className="token-row" key={space.name}>
|
||||
<code className="token-row-label">{space.name}</code>
|
||||
<div className="token-row-demo">
|
||||
<span className="token-bar" style={{ width: `${space.value * 4}px` }} />
|
||||
</div>
|
||||
<span className="token-row-note">{space.value}px</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="demo">
|
||||
<h3 className="demo-title">动效</h3>
|
||||
<div className="token-list">
|
||||
<div className="token-row">
|
||||
<code className="token-row-label">--su-duration-fast</code>
|
||||
<div className="token-row-demo">
|
||||
<span className="token-swatch-value">0.2s:颜色、背景变化</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="token-row">
|
||||
<code className="token-row-label">--su-duration-base</code>
|
||||
<div className="token-row-demo">
|
||||
<span className="token-swatch-value">0.3s:展开收起、淡入淡出</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<pre className="demo-code">
|
||||
<code>{`/* 使用方覆盖主题:只需重写变量 */
|
||||
:root[data-theme='dark'] {
|
||||
--su-bg-page: #17171a;
|
||||
--su-bg-card: #232324;
|
||||
--su-text-main: #f2f3f5;
|
||||
}`}</code>
|
||||
</pre>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { useState } from 'react'
|
||||
import { Card, Grid, Layout, Segmented, Space, Tag, Text, Title } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
/** 文档里的迷你页面内容:只为了把 Layout 的结构摆出来 */
|
||||
|
||||
function DemoHeader() {
|
||||
return (
|
||||
<div className="layout-bar">
|
||||
<span className="layout-brand">STASH</span>
|
||||
<span className="layout-bar-actions">
|
||||
<span className="layout-nav-item is-active">工作台</span>
|
||||
<span className="layout-nav-item">常用推荐</span>
|
||||
<span className="layout-nav-item">设置</span>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DemoSider({ compact = false }: { compact?: boolean }) {
|
||||
return (
|
||||
<div className="layout-side">
|
||||
<span className="layout-nav-item is-active">常用站点</span>
|
||||
<span className="layout-nav-item">前端工具</span>
|
||||
{!compact && <span className="layout-nav-item">设计资源</span>}
|
||||
{!compact && <span className="layout-nav-item">推荐电影</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DemoAside() {
|
||||
return (
|
||||
<div className="layout-aside">
|
||||
<Text size="sm" strong>
|
||||
目录
|
||||
</Text>
|
||||
<span className="layout-aside-item is-active">一、内容库结构</span>
|
||||
<span className="layout-aside-item">二、令牌与主题</span>
|
||||
<span className="layout-aside-item">三、暗色模式</span>
|
||||
<Text size="xs" tone="muted">
|
||||
共 3 节
|
||||
</Text>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DemoArticle() {
|
||||
return (
|
||||
<div className="layout-content">
|
||||
<Title level={3}>内容库结构</Title>
|
||||
<Text size="sm" tone="sub">
|
||||
分区 → 分组 → 条目三层;侧栏由内容树派生,首页卡片由 section_block 决定。
|
||||
</Text>
|
||||
<Space gap="sm">
|
||||
<Tag>设计系统</Tag>
|
||||
<Tag tone="success">已发布</Tag>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DemoCards() {
|
||||
return (
|
||||
<div className="layout-content">
|
||||
<Title level={3}>常用站点</Title>
|
||||
<Text size="sm" tone="sub">
|
||||
内容区放任何组件:卡片、列表、表格。Layout 只负责它坐在哪里。
|
||||
</Text>
|
||||
<Grid columns={2} gap="sm">
|
||||
<Card padding="md">
|
||||
<Text strong>GitHub</Text>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<Text strong>Figma</Text>
|
||||
</Card>
|
||||
</Grid>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const RECIPES = [
|
||||
{ value: 'top', label: '顶栏 + 内容' },
|
||||
{ value: 'sider', label: '左栏 + 内容' },
|
||||
{ value: 'aside', label: '内容 + 右栏' },
|
||||
{ value: 'three', label: '三栏' },
|
||||
{ value: 'narrow', label: '窄内容' },
|
||||
] as const
|
||||
|
||||
type RecipeValue = (typeof RECIPES)[number]['value']
|
||||
|
||||
const CODE: Record<RecipeValue, string> = {
|
||||
top: `<Layout header={<Header />} footer={<Footer />}>
|
||||
<Grid columns={2}>…</Grid>
|
||||
</Layout>`,
|
||||
sider: `<Layout header={<Header />} sider={<Nav />} siderWidth={180}>
|
||||
<Title>常用站点</Title>
|
||||
</Layout>`,
|
||||
aside: `<Layout header={<Header />} aside={<Toc />} maxWidth={960}>
|
||||
<Article />
|
||||
</Layout>`,
|
||||
three: `<Layout
|
||||
header={<Header />}
|
||||
sider={<Nav />}
|
||||
aside={<Toc />}
|
||||
siderWidth={160}
|
||||
asideWidth={200}
|
||||
>
|
||||
<Content />
|
||||
</Layout>`,
|
||||
narrow: `<Layout header={<Header />} maxWidth={640}>
|
||||
<Settings />
|
||||
</Layout>`,
|
||||
}
|
||||
|
||||
export function PageLayout() {
|
||||
const [recipe, setRecipe] = useState<RecipeValue>('three')
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="layout"
|
||||
title="页面骨架"
|
||||
api="Layout"
|
||||
desc="页头 / 侧栏 / 内容 / 右栏 / 页脚的外壳:只做结构(区域、最大宽度、吸顶页头),不写业务内容。下面五个是实际项目里最常用的配方。响应式:≤1200px 右栏堆到内容下方,≤1100px 侧栏堆到内容上方,内容都不隐藏。"
|
||||
>
|
||||
<Demo
|
||||
title="五种常用布局"
|
||||
desc="切换配方看结构差异:顶栏+内容(首页)、左栏+内容(管理页)、内容+右栏(文章)、三栏(工作台)、窄内容(设置/阅读)。"
|
||||
layout="column"
|
||||
code={CODE[recipe]}
|
||||
>
|
||||
<Segmented options={[...RECIPES]} value={recipe} onChange={(value) => setRecipe(value as RecipeValue)} />
|
||||
|
||||
<div className="layout-frame" style={{ height: 440 }}>
|
||||
<Layout
|
||||
header={<DemoHeader />}
|
||||
sider={recipe === 'sider' || recipe === 'three' ? <DemoSider compact /> : undefined}
|
||||
aside={recipe === 'aside' || recipe === 'three' ? <DemoAside /> : undefined}
|
||||
footer={<span>© 2026 Stash</span>}
|
||||
siderWidth={recipe === 'three' ? 150 : 180}
|
||||
asideWidth={190}
|
||||
maxWidth={recipe === 'narrow' ? 560 : recipe === 'aside' ? 900 : 1280}
|
||||
style={{ minHeight: 420 }}
|
||||
>
|
||||
{recipe === 'aside' || recipe === 'three' ? <DemoArticle /> : null}
|
||||
{recipe === 'top' || recipe === 'sider' ? <DemoCards /> : null}
|
||||
{recipe === 'narrow' ? (
|
||||
<div className="layout-content">
|
||||
<Title level={4}>设置</Title>
|
||||
<Text size="sm" tone="sub">
|
||||
单栏 + 560 最大宽度,阅读型页面常用这个组合。
|
||||
</Text>
|
||||
<Card padding="md">
|
||||
<Text size="sm">自动同步</Text>
|
||||
</Card>
|
||||
<Card padding="md">
|
||||
<Text size="sm">每周摘要提醒</Text>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</Layout>
|
||||
</div>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="页头可关闭吸顶"
|
||||
desc="长表单页可以把 stickyHeader 关掉,避免页头一直挡住内容。"
|
||||
layout="column"
|
||||
code={`<Layout header={<Header />} stickyHeader={false} maxWidth={720}>
|
||||
<Form />
|
||||
</Layout>`}
|
||||
>
|
||||
<div className="layout-frame" style={{ height: 300 }}>
|
||||
<Layout header={<DemoHeader />} maxWidth={720} stickyHeader={false} style={{ minHeight: 280 }}>
|
||||
<div className="layout-content">
|
||||
<Title level={4}>新建条目</Title>
|
||||
<Text size="sm" tone="sub">
|
||||
stickyHeader=false:页头随内容滚走,适合长表单 / 长文阅读。
|
||||
</Text>
|
||||
</div>
|
||||
</Layout>
|
||||
</div>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="整屏 + 内部滚动"
|
||||
desc="height='screen' 锁视口、页头与侧栏固定;scrollable 让内容区自己滚,hideScrollbar 隐藏滚动条(应用型界面)。"
|
||||
layout="column"
|
||||
code={`<Layout height="screen" scrollable hideScrollbar contentId="demo-scroll"
|
||||
header={<Header />} sider={<Nav />} style={{ height: 320 }}>
|
||||
<LongContent />
|
||||
</Layout>`}
|
||||
>
|
||||
<div className="layout-frame" style={{ height: 320, overflow: 'hidden' }}>
|
||||
<Layout
|
||||
header={<DemoHeader />}
|
||||
sider={<DemoSider compact />}
|
||||
headerHeight={56}
|
||||
height="screen"
|
||||
scrollable
|
||||
hideScrollbar
|
||||
contentId="demo-scroll"
|
||||
style={{ height: 320 }}
|
||||
>
|
||||
<div className="layout-content">
|
||||
{Array.from({ length: 12 }, (_, index) => (
|
||||
<Card padding="md" key={index}>
|
||||
<Text size="sm">第 {index + 1} 行:只有内容区滚动,页头和侧栏保持不动。</Text>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</Layout>
|
||||
</div>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'header', type: 'ReactNode', desc: '页头,默认吸顶' },
|
||||
{ name: 'sider', type: 'ReactNode', desc: '左侧栏,固定宽度' },
|
||||
{ name: 'aside', type: 'ReactNode', desc: '右侧栏,固定宽度(目录 / 聚合 / 广告位)' },
|
||||
{ name: 'footer', type: 'ReactNode', desc: '页脚' },
|
||||
{ name: 'children', type: 'ReactNode', required: true, desc: '内容区' },
|
||||
{ name: 'maxWidth', type: "number | 'full'", defaultValue: '1280', desc: '内容最大宽度' },
|
||||
{ name: 'siderWidth', type: 'number', defaultValue: '260', desc: '左侧栏宽度' },
|
||||
{ name: 'asideWidth', type: 'number', defaultValue: '300', desc: '右侧栏宽度' },
|
||||
{ name: 'stickyHeader', type: 'boolean', defaultValue: 'true', desc: '页头是否吸顶' },
|
||||
{ name: 'contained', type: 'boolean', defaultValue: 'false', desc: '页身套居中容器(页头仍通栏)' },
|
||||
{ name: 'gap', type: 'number', defaultValue: '0', desc: '侧栏 / 内容 / 右栏间距(px)' },
|
||||
{ name: 'contentPadding', type: "'none' | 'sm' | 'md' | 'lg'", defaultValue: "'md'", desc: '内容留白 0 / 12 / 20 / 24' },
|
||||
{ name: 'contentOffset', type: 'number', defaultValue: '0', desc: '内容区顶部偏移(给悬浮搜索留位)' },
|
||||
{ name: 'gutter', type: 'number', defaultValue: '0', desc: 'contained 容器的左右留白' },
|
||||
{ name: 'headerHeight', type: 'number', defaultValue: '60', desc: '页头最小高度' },
|
||||
{ name: 'headerVariant', type: "'blur' | 'solid'", defaultValue: "'blur'", desc: '半透明模糊 / 实底 + 边框' },
|
||||
{ name: 'siderBordered', type: 'boolean', defaultValue: 'true', desc: '侧栏右侧分隔线' },
|
||||
{ name: 'height', type: "'auto' | 'screen'", defaultValue: "'auto'", desc: 'screen 锁视口' },
|
||||
{ name: 'scrollable', type: 'boolean', defaultValue: 'false', desc: '内容区内部滚动' },
|
||||
{ name: 'hideScrollbar', type: 'boolean', defaultValue: 'false', desc: '内部滚动条隐藏' },
|
||||
{ name: 'contentId', type: 'string', desc: '内容区 id(ScrollTop 用)' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { useState } from 'react'
|
||||
import { Card, Pagination, Space } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
export function Paginations() {
|
||||
const [page, setPage] = useState(1)
|
||||
const [longPage, setLongPage] = useState(6)
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="pagination"
|
||||
title="分页"
|
||||
api="Pagination"
|
||||
desc="对齐参考站 .page-numbers:32×32 方块、4px 圆角、当前页主色实底白字;页数多时自动用省略号收起。"
|
||||
>
|
||||
<Demo
|
||||
title="基础用法"
|
||||
desc="总数 + 每页条数算出页数;上一页/下一页在边界自动禁用。"
|
||||
layout="column"
|
||||
code={`const [page, setPage] = useState(1)
|
||||
|
||||
<Pagination page={page} total={42} pageSize={10} onChange={setPage} />`}
|
||||
>
|
||||
<Space justify="between" wrap>
|
||||
<span style={{ color: 'var(--su-text-muted)', fontSize: 13 }}>
|
||||
第 {page} 页 / 共 5 页
|
||||
</span>
|
||||
<Pagination page={page} total={42} pageSize={10} onChange={setPage} />
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="页数很多"
|
||||
desc="当前页左右各留 1 个页码,首尾固定,其余折叠成省略号。"
|
||||
layout="column"
|
||||
code={`<Pagination page={page} total={200} pageSize={10} onChange={setPage} />`}
|
||||
>
|
||||
<Pagination page={longPage} total={200} pageSize={10} onChange={setLongPage} />
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="放进卡片底部"
|
||||
desc="分页通常和「共 N 条」一起出现,用 Space justify=between 撑开即可。"
|
||||
layout="column"
|
||||
code={`<Card padding="sm">
|
||||
<Space justify="between">
|
||||
<span>共 16 条内容</span>
|
||||
<Pagination page={page} total={16} pageSize={6} onChange={setPage} />
|
||||
</Space>
|
||||
</Card>`}
|
||||
>
|
||||
<Card padding="sm">
|
||||
<Space justify="between" wrap>
|
||||
<span style={{ color: 'var(--su-text-muted)', fontSize: 13 }}>共 16 条内容</span>
|
||||
<Pagination page={Math.min(page, 3)} total={16} pageSize={6} onChange={setPage} />
|
||||
</Space>
|
||||
</Card>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'page', type: 'number', required: true, desc: '当前页,从 1 开始' },
|
||||
{ name: 'total', type: 'number', required: true, desc: '总条数' },
|
||||
{ name: 'pageSize', type: 'number', defaultValue: '10', desc: '每页条数' },
|
||||
{ name: 'onChange', type: '(page: number) => void', required: true, desc: '翻页回调' },
|
||||
{ name: 'siblings', type: 'number', defaultValue: '1', desc: '当前页左右各显示几个页码' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Progress, Space, Text } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
export function Progresses() {
|
||||
return (
|
||||
<Section
|
||||
id="progress"
|
||||
title="进度"
|
||||
api="Progress"
|
||||
desc="纯展示的进度条:任务进度、存储占用、导入比例。百分比由调用方给,组件只负责夹在 0–100 之间。"
|
||||
>
|
||||
<Demo
|
||||
title="颜色与尺寸"
|
||||
desc="tone 表达语义:主色(进行中)、成功(完成)、警示、危险(超限)。sm 4px 用于行内,md 8px 用于卡片。"
|
||||
layout="column"
|
||||
code={`<Progress percent={32} />
|
||||
<Progress percent={64} tone="success" />
|
||||
<Progress percent={88} tone="warning" size="sm" />`}
|
||||
>
|
||||
<Progress percent={32} />
|
||||
<Progress percent={64} tone="success" />
|
||||
<Progress percent={88} tone="warning" size="sm" />
|
||||
<Progress percent={100} tone="danger" />
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="带百分比文案"
|
||||
desc="showLabel 在右侧补百分比;行内场景可以和说明文字用 Space 排成一行。"
|
||||
layout="column"
|
||||
code={`<Progress percent={72} showLabel />
|
||||
<Space gap="sm" style={{ width: '100%' }}>
|
||||
<Text size="sm" tone="sub">存储占用</Text>
|
||||
<Progress percent={45} size="sm" />
|
||||
</Space>`}
|
||||
>
|
||||
<Progress percent={72} showLabel />
|
||||
<Space gap="sm">
|
||||
<Text size="sm" tone="sub">
|
||||
存储占用
|
||||
</Text>
|
||||
<Progress percent={45} size="sm" />
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'percent', type: 'number', required: true, desc: '0–100,超出会被夹住' },
|
||||
{ name: 'size', type: "'sm' | 'md'", defaultValue: "'md'", desc: '4 / 8px' },
|
||||
{ name: 'tone', type: "'primary' | 'success' | 'warning' | 'danger'", defaultValue: "'primary'", desc: '语义配色' },
|
||||
{ name: 'showLabel', type: 'boolean', defaultValue: 'false', desc: '右侧显示百分比' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react'
|
||||
import { RadioGroup, Text } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 'grid', label: '网格视图' },
|
||||
{ value: 'list', label: '列表视图' },
|
||||
{ value: 'compact', label: '紧凑视图(暂不可用)', disabled: true },
|
||||
]
|
||||
|
||||
export function RadioGroups() {
|
||||
const [view, setView] = useState('grid')
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="radio"
|
||||
title="单选"
|
||||
api="RadioGroup"
|
||||
desc="互斥选项组:原生 radio,键盘方向键在组内切换。单个「开/关」用 Switch,多选用 Checkbox。"
|
||||
>
|
||||
<Demo
|
||||
title="基础用法"
|
||||
desc="纵向排列适合设置项;横向排列适合 2–3 个短选项。"
|
||||
layout="column"
|
||||
code={`const [view, setView] = useState('grid')
|
||||
|
||||
<RadioGroup value={view} onChange={setView} options={OPTIONS} />
|
||||
<RadioGroup value={view} onChange={setView} options={OPTIONS} direction="row" />`}
|
||||
>
|
||||
<RadioGroup value={view} onChange={setView} options={OPTIONS} />
|
||||
<RadioGroup value={view} onChange={setView} options={OPTIONS} direction="row" />
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="禁用"
|
||||
desc="整组禁用时,所有选项都不可选;单个选项禁用只影响它自己。"
|
||||
code={`<RadioGroup value="grid" onChange={() => {}} options={OPTIONS} disabled />`}
|
||||
>
|
||||
<RadioGroup value="grid" onChange={() => undefined} options={OPTIONS} disabled />
|
||||
<Text size="sm" tone="muted">
|
||||
整组禁用后仍能读出当前选中项
|
||||
</Text>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'options', type: 'RadioOption[]', required: true, desc: '{ value, label, disabled? }' },
|
||||
{ name: 'value', type: 'string', required: true, desc: '当前选中值' },
|
||||
{ name: 'onChange', type: '(value: string) => void', required: true, desc: '选择回调' },
|
||||
{ name: 'direction', type: "'row' | 'column'", defaultValue: "'column'", desc: '排列方向' },
|
||||
{ name: 'disabled', type: 'boolean', defaultValue: 'false', desc: '禁用整组' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Button, Result, Space } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
export function Results() {
|
||||
return (
|
||||
<Section
|
||||
id="result"
|
||||
title="结果"
|
||||
api="Result"
|
||||
desc="整块的完成 / 失败反馈:提交完成、权限不足、出错。行内提示用 Notice,轻量反馈用 toast,整块状态才用 Result。"
|
||||
>
|
||||
<Demo
|
||||
title="四种状态"
|
||||
desc="success / error / warning / info 只影响图标与颜色,正文结构一致。"
|
||||
layout="column"
|
||||
code={`<Result status="success" title="已发布" description="内容已同步到内容库。" />`}
|
||||
>
|
||||
<Space direction="column" gap="lg" align="stretch">
|
||||
<Result status="success" title="已发布" description="内容已同步到内容库,5 秒内会推送到首页。" />
|
||||
<Result status="error" title="保存失败" description="内容库结构版本不匹配,请删除数据库后重启。" />
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="带操作"
|
||||
desc="actions 放按钮组;按钮顺序与弹窗一致——次要操作在左、主操作在右。"
|
||||
layout="column"
|
||||
code={`<Result
|
||||
status="warning"
|
||||
title="还没有内容"
|
||||
description="建一个分区,再往里加条目。"
|
||||
actions={<><Button variant="ghost">看文档</Button><Button>新建分区</Button></>}
|
||||
/>`}
|
||||
>
|
||||
<Result
|
||||
status="warning"
|
||||
title="还没有内容"
|
||||
description="先在内容库里建一个分区,再往分区里加条目。"
|
||||
actions={
|
||||
<>
|
||||
<Button variant="ghost">看文档</Button>
|
||||
<Button>新建分区</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'status', type: "'success' | 'error' | 'warning' | 'info'", defaultValue: "'info'", desc: '状态' },
|
||||
{ name: 'title', type: 'ReactNode', required: true, desc: '标题' },
|
||||
{ name: 'description', type: 'ReactNode', desc: '说明文字' },
|
||||
{ name: 'actions', type: 'ReactNode', desc: '底部操作区' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useState } from 'react'
|
||||
import { ScrollTop } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
export function ScrollTopSection() {
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="scrolltop"
|
||||
title="回到顶部"
|
||||
api="ScrollTop"
|
||||
desc="固定在视口右侧中部的圆形按钮,可见性由页面根据滚动位置控制。真实位置在页面右侧,不在下面的预览区里。"
|
||||
>
|
||||
<Demo
|
||||
title="显隐控制"
|
||||
desc="桌面端滚的是内容容器(传 targetId),窄屏滚的是窗口(不传);两种都支持,避免滚错了不生效。"
|
||||
code={`<ScrollTop visible={scrolled} targetId="content-scroll" />`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setVisible((current) => !current)}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--su-border-strong)',
|
||||
background: 'var(--su-bg-card)',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{visible ? '隐藏' : '显示'}回到顶部按钮
|
||||
</button>
|
||||
<span style={{ alignSelf: 'center', color: 'var(--su-text-muted)', fontSize: 12 }}>
|
||||
点左侧按钮,右下角会淡入 / 淡出
|
||||
</span>
|
||||
</Demo>
|
||||
|
||||
<ScrollTop visible={visible} />
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'visible', type: 'boolean', required: true, desc: '是否显示' },
|
||||
{ name: 'targetId', type: 'string', desc: '滚动容器 id;不传则滚窗口' },
|
||||
{ name: 'label', type: 'string', defaultValue: "'回到顶部'", desc: '无障碍名称与 title' },
|
||||
{ name: 'offset', type: 'number', defaultValue: '24', desc: '距右侧距离(px)' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState } from 'react'
|
||||
import { Icon, Segmented, Text } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 'grid', label: '网格', icon: <Icon name="grid_line" /> },
|
||||
{ value: 'list', label: '列表', icon: <Icon name="menu_line" /> },
|
||||
]
|
||||
|
||||
export function Segmenteds() {
|
||||
const [view, setView] = useState('grid')
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="segmented"
|
||||
title="分段"
|
||||
api="Segmented"
|
||||
desc="在几个视图之间切换(网格/列表、全部/收藏)。只切展示形态,不切内容分区——后者用 Tabs。"
|
||||
>
|
||||
<Demo
|
||||
title="基础用法"
|
||||
desc="选中项白底 + 轻阴影,容器浅灰底;选项可以带图标。"
|
||||
code={`const [view, setView] = useState('grid')
|
||||
|
||||
<Segmented options={OPTIONS} value={view} onChange={setView} />`}
|
||||
>
|
||||
<Segmented options={OPTIONS} value={view} onChange={setView} />
|
||||
<Text size="sm" tone="muted">
|
||||
当前:{view}
|
||||
</Text>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="尺寸与占满整行"
|
||||
desc="sm 用于卡片内;block 让选项等分宽度(窄容器或移动端常用)。"
|
||||
layout="column"
|
||||
code={`<Segmented size="sm" options={OPTIONS} value={view} onChange={setView} />
|
||||
<Segmented block options={OPTIONS} value={view} onChange={setView} />`}
|
||||
>
|
||||
<Segmented size="sm" options={OPTIONS} value={view} onChange={setView} />
|
||||
<Segmented block options={OPTIONS} value={view} onChange={setView} />
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'options', type: 'SegmentedOption[]', required: true, desc: '{ value, label, icon? }' },
|
||||
{ name: 'value', type: 'string', required: true, desc: '当前值' },
|
||||
{ name: 'onChange', type: '(value: string) => void', required: true, desc: '切换回调' },
|
||||
{ name: 'size', type: "'sm' | 'md'", defaultValue: "'md'", desc: '24 / 28px 高' },
|
||||
{ name: 'block', type: 'boolean', defaultValue: 'false', desc: '占满一行,选项等分' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from 'react'
|
||||
import { Select, Space, Text } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
const OPTIONS = [
|
||||
{ value: 'new', label: '最近更新' },
|
||||
{ value: 'hot', label: '最热门' },
|
||||
{ value: 'name', label: '按名称' },
|
||||
{ value: 'none', label: '(暂不可用)', disabled: true },
|
||||
]
|
||||
|
||||
export function Selects() {
|
||||
const [sort, setSort] = useState('new')
|
||||
const [kind, setKind] = useState<string | undefined>(undefined)
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="select"
|
||||
title="下拉选择"
|
||||
api="Select"
|
||||
desc="受控的单值选择:触发器是原生 button,展开后是 listbox;支持点击外部关闭、Esc、↑↓ 移动、Enter 选择。"
|
||||
>
|
||||
<Demo
|
||||
title="基础用法"
|
||||
desc="不传 value 时显示 placeholder;禁用的选项不可点、不可选。"
|
||||
code={`const [sort, setSort] = useState('new')
|
||||
|
||||
<Select options={OPTIONS} value={sort} onChange={setSort} />`}
|
||||
>
|
||||
<Space gap="md" wrap>
|
||||
<Select options={OPTIONS} value={sort} onChange={setSort} />
|
||||
<Text size="sm" tone="muted">
|
||||
当前:{OPTIONS.find((option) => option.value === sort)?.label}
|
||||
</Text>
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="占位与禁用"
|
||||
desc="placeholder 只在没有选中值时出现;整个控件 disabled 时不可展开。"
|
||||
code={`<Select options={OPTIONS} value={kind} onChange={setKind} placeholder="选择类型" />
|
||||
<Select options={OPTIONS} value={kind} onChange={setKind} disabled />`}
|
||||
>
|
||||
<Space gap="md" wrap>
|
||||
<Select options={OPTIONS} value={kind} onChange={setKind} placeholder="选择类型" />
|
||||
<Select options={OPTIONS} value={kind} onChange={setKind} disabled />
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'options', type: 'SelectOption[]', required: true, desc: '{ value, label, disabled? }' },
|
||||
{ name: 'value', type: 'string', desc: '当前值' },
|
||||
{ name: 'onChange', type: '(value: string) => void', required: true, desc: '选择回调' },
|
||||
{ name: 'placeholder', type: 'string', defaultValue: "'请选择'", desc: '未选中时的文案' },
|
||||
{ name: 'disabled', type: 'boolean', defaultValue: 'false', desc: '禁用整个控件' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Card, Skeleton, Space } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
export function Skeletons() {
|
||||
return (
|
||||
<Section
|
||||
id="skeleton"
|
||||
title="骨架屏"
|
||||
api="Skeleton"
|
||||
desc="内容加载前的占位。形状要和真实内容一致,否则加载完成后会跳动;整块区域用 aria-busy 标记,骨架本身对屏幕阅读器隐藏。"
|
||||
>
|
||||
<Demo
|
||||
title="三种形状"
|
||||
desc="text 是文本行(最后一行自动收短),rect 是封面/图片,circle 是头像。"
|
||||
layout="column"
|
||||
code={`<Skeleton variant="text" lines={3} />
|
||||
<Skeleton variant="rect" height={120} />
|
||||
<Skeleton variant="circle" width={48} height={48} />`}
|
||||
>
|
||||
<Space gap="lg" wrap>
|
||||
<div style={{ width: 240 }}>
|
||||
<Skeleton variant="text" lines={3} />
|
||||
</div>
|
||||
<Skeleton variant="rect" width={200} height={120} />
|
||||
<Skeleton variant="circle" width={48} height={48} />
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="卡片骨架"
|
||||
desc="按真实卡片的结构拼:头像 + 两行标题 + 封面 + 两行摘要。加载完成后把整块换成真实内容即可。"
|
||||
layout="column"
|
||||
code={`<Card aria-busy="true">
|
||||
<Space gap="sm">
|
||||
<Skeleton variant="circle" width={36} height={36} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Skeleton lines={2} />
|
||||
</div>
|
||||
</Space>
|
||||
<Skeleton variant="rect" height={160} style={{ marginTop: 16 }} />
|
||||
<Skeleton lines={2} style={{ marginTop: 16 }} />
|
||||
</Card>`}
|
||||
>
|
||||
<Space direction="column" gap="md" align="stretch">
|
||||
<Card padding="lg" aria-busy="true">
|
||||
<Space gap="sm">
|
||||
<Skeleton variant="circle" width={36} height={36} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Skeleton lines={2} />
|
||||
</div>
|
||||
</Space>
|
||||
<div style={{ marginTop: 'var(--su-space-4)' }}>
|
||||
<Skeleton variant="rect" height={160} />
|
||||
</div>
|
||||
<div style={{ marginTop: 'var(--su-space-4)' }}>
|
||||
<Skeleton lines={2} />
|
||||
</div>
|
||||
</Card>
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'variant', type: "'text' | 'rect' | 'circle'", defaultValue: "'text'", desc: '形状' },
|
||||
{ name: 'lines', type: 'number', defaultValue: '1', desc: '仅 text:行数' },
|
||||
{ name: 'width', type: 'number | string', desc: '宽度;rect 默认 100%,circle 默认 40' },
|
||||
{ name: 'height', type: 'number | string', desc: '高度;rect 默认 120' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useState } from 'react'
|
||||
import { Button, Space, Spinner, Text } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
export function Spinners() {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="spinner"
|
||||
title="加载中"
|
||||
api="Spinner"
|
||||
desc="整块区域或行内的加载指示。按钮内部的 loading 由 Button 自己处理,文字占位用 Skeleton——三者分工不同。"
|
||||
>
|
||||
<Demo
|
||||
title="尺寸"
|
||||
desc="sm 14px 用于行内(跟着文字基线走),md 20px 用于区块。"
|
||||
code={`<Spinner size="sm" />
|
||||
<Spinner size="md" />`}
|
||||
>
|
||||
<Space gap="lg">
|
||||
<Spinner size="sm" />
|
||||
<Spinner size="md" />
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="行内状态"
|
||||
desc="和一句说明放在一起时,用 Space 排;Spinner 自带 role=status,读屏会读出 label。"
|
||||
layout="column"
|
||||
code={`<Space gap="sm">
|
||||
<Spinner size="sm" label="正在同步" />
|
||||
<Text size="sm" tone="sub">正在同步内容库…</Text>
|
||||
</Space>`}
|
||||
>
|
||||
<Space gap="sm">
|
||||
<Spinner size="sm" label="正在同步" />
|
||||
<Text size="sm" tone="sub">
|
||||
正在同步内容库…
|
||||
</Text>
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="整块加载"
|
||||
desc="区域级加载时给出一个居中的 Spinner + 文案;按钮里的 loading 不要用 Spinner 代替。"
|
||||
layout="column"
|
||||
code={`{loading && (
|
||||
<Space direction="column" gap="sm" justify="center">
|
||||
<Spinner />
|
||||
<Text size="sm" tone="muted">正在读取…</Text>
|
||||
</Space>
|
||||
)}`}
|
||||
>
|
||||
<Space direction="column" gap="sm" align="center">
|
||||
<Button size="sm" variant="secondary" loading={loading} onClick={() => {
|
||||
setLoading(true)
|
||||
window.setTimeout(() => setLoading(false), 1200)
|
||||
}}>
|
||||
模拟加载
|
||||
</Button>
|
||||
<Space gap="sm">
|
||||
<Spinner />
|
||||
<Text size="sm" tone="muted">
|
||||
{loading ? '正在读取…' : '待命中'}
|
||||
</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'size', type: "'sm' | 'md'", defaultValue: "'md'", desc: '14 / 20px' },
|
||||
{ name: 'label', type: 'string', defaultValue: "'加载中'", desc: '无障碍名称' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Card, Steps } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
const ITEMS = [
|
||||
{ title: '建立内容库', description: '首次启动自动建表并灌入种子' },
|
||||
{ title: '调整分区与分组', description: '用 SQLite 工具直接改 node 表' },
|
||||
{ title: '配置首页卡片', description: 'section_block 决定展示哪个节点' },
|
||||
{ title: '发布', description: '条目 body 写 Markdown 即可' },
|
||||
]
|
||||
|
||||
export function StepsSection() {
|
||||
return (
|
||||
<Section
|
||||
id="steps"
|
||||
title="步骤"
|
||||
api="Steps"
|
||||
desc="纵向流程:已完成打勾、当前项高亮、未开始置灰。适合发布流程、导入向导这类有顺序的任务。"
|
||||
>
|
||||
<Demo
|
||||
title="进行中"
|
||||
desc="current 从 0 开始;index < current 视为已完成。"
|
||||
layout="column"
|
||||
code={`<Steps items={items} current={1} />`}
|
||||
>
|
||||
<Card padding="lg">
|
||||
<Steps items={ITEMS} current={1} />
|
||||
</Card>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="全部完成"
|
||||
desc="current 超出最后一项时,所有步骤都显示为已完成。"
|
||||
layout="column"
|
||||
code={`<Steps items={items} current={items.length} />`}
|
||||
>
|
||||
<Card padding="lg">
|
||||
<Steps items={ITEMS} current={ITEMS.length} />
|
||||
</Card>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'items', type: 'StepItem[]', required: true, desc: '{ title, description? }' },
|
||||
{ name: 'current', type: 'number', required: true, desc: '当前步骤索引,从 0 开始' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useState } from 'react'
|
||||
import { Space, Switch, Text } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
export function Switches() {
|
||||
const [autoSync, setAutoSync] = useState(true)
|
||||
const [showCover, setShowCover] = useState(false)
|
||||
const [compact] = useState(true)
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="switch"
|
||||
title="开关"
|
||||
api="Switch"
|
||||
desc="设置项的即时开关:改了立刻生效,不需要「保存」。需要用户确认的场景用 Checkbox + 提交按钮,不要用开关。"
|
||||
>
|
||||
<Demo
|
||||
title="基础用法"
|
||||
desc="受控组件:checked + onChange。旁边有文字说明时,文字用 Text,开关的 label 仍要传(供读屏)。"
|
||||
layout="column"
|
||||
code={`<Space justify="between" style={{ width: 280 }}>
|
||||
<Text>自动同步</Text>
|
||||
<Switch checked={autoSync} onChange={setAutoSync} label="自动同步" />
|
||||
</Space>`}
|
||||
>
|
||||
<Space justify="between" style={{ width: 300 }}>
|
||||
<Text>自动同步</Text>
|
||||
<Switch checked={autoSync} onChange={setAutoSync} label="自动同步" />
|
||||
</Space>
|
||||
<Space justify="between" style={{ width: 300 }}>
|
||||
<Text>显示封面图</Text>
|
||||
<Switch checked={showCover} onChange={setShowCover} label="显示封面图" />
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="尺寸"
|
||||
desc="sm 用于行内紧凑列表,md 用于设置表单。"
|
||||
code={`<Switch size="sm" checked={on} onChange={setOn} label="紧凑模式" />
|
||||
<Switch size="md" checked={on} onChange={setOn} label="紧凑模式" />`}
|
||||
>
|
||||
<Space gap="lg">
|
||||
<Switch size="sm" checked={compact} onChange={() => undefined} label="紧凑模式" />
|
||||
<Switch size="md" checked={compact} onChange={() => undefined} label="紧凑模式" />
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="禁用"
|
||||
desc="禁用时保留当前状态的可读性,只降透明度并改光标。"
|
||||
code={`<Switch checked onChange={() => {}} disabled label="不可修改" />`}
|
||||
>
|
||||
<Space gap="lg">
|
||||
<Switch checked onChange={() => undefined} disabled label="不可修改(开)" />
|
||||
<Switch checked={false} onChange={() => undefined} disabled label="不可修改(关)" />
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'checked', type: 'boolean', required: true, desc: '受控状态' },
|
||||
{ name: 'onChange', type: '(checked: boolean) => void', required: true, desc: '切换回调' },
|
||||
{ name: 'size', type: "'sm' | 'md'", defaultValue: "'md'", desc: '32×18 / 40×22' },
|
||||
{ name: 'disabled', type: 'boolean', defaultValue: 'false', desc: '禁用' },
|
||||
{ name: 'label', type: 'string', desc: '无障碍名称:旁边没有文字时必填' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Table, Tag, Text } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
const COLUMNS = [
|
||||
{ key: 'name', title: '分区' },
|
||||
{ key: 'kind', title: '类型', width: 100 },
|
||||
{ key: 'count', title: '条目数', width: 100, align: 'right' as const },
|
||||
{ key: 'status', title: '状态', width: 120 },
|
||||
]
|
||||
|
||||
const ROWS = [
|
||||
{
|
||||
id: 'recommend',
|
||||
name: '常用推荐',
|
||||
kind: <Text size="sm">apps</Text>,
|
||||
count: 18,
|
||||
status: <Tag tone="success">已上线</Tag>,
|
||||
},
|
||||
{
|
||||
id: 'movie',
|
||||
name: '推荐电影',
|
||||
kind: <Text size="sm">posters</Text>,
|
||||
count: 12,
|
||||
status: <Tag tone="success">已上线</Tag>,
|
||||
},
|
||||
{
|
||||
id: 'ces',
|
||||
name: 'CES 现场报道',
|
||||
kind: <Text size="sm">articles</Text>,
|
||||
count: 12,
|
||||
status: <Tag tone="warning">草稿</Tag>,
|
||||
},
|
||||
]
|
||||
|
||||
export function Tables() {
|
||||
return (
|
||||
<Section
|
||||
id="table"
|
||||
title="表格"
|
||||
api="Table"
|
||||
desc="只做展示:列定义 + 行数据 + 空态。排序、筛选、分页由页面组合,组件不内置交互。"
|
||||
>
|
||||
<Demo
|
||||
title="基础表格"
|
||||
desc="列宽、对齐方式按列配置;单元格内容可以是任意 ReactNode(这里用了 Tag 与 Text)。"
|
||||
layout="column"
|
||||
code={`const columns = [
|
||||
{ key: 'name', title: '分区' },
|
||||
{ key: 'count', title: '条目数', width: 100, align: 'right' },
|
||||
]
|
||||
|
||||
<Table columns={columns} rows={rows} rowKey="id" />`}
|
||||
>
|
||||
<Table columns={COLUMNS} rows={ROWS} rowKey="id" />
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="空态"
|
||||
desc="空数据时渲染默认文案;也可以传 emptyText 换一句更具体的说明。"
|
||||
layout="column"
|
||||
code={`<Table columns={columns} rows={[]} emptyText="还没有分区" />`}
|
||||
>
|
||||
<Table columns={COLUMNS} rows={[]} emptyText="还没有分区,先建一个吧。" />
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'columns', type: 'TableColumn[]', required: true, desc: '{ key, title, width?, align? }' },
|
||||
{ name: 'rows', type: 'Record<string, ReactNode>[]', required: true, desc: '行数据,键对应列的 key' },
|
||||
{ name: 'rowKey', type: 'string', defaultValue: "'id'", desc: '行的唯一键字段' },
|
||||
{ name: 'emptyText', type: 'ReactNode', defaultValue: "'暂无数据'", desc: '空态文案' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from 'react'
|
||||
import { Text, Tabs } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
const ITEMS = [
|
||||
{ key: 'detail', label: '详情' },
|
||||
{ key: 'comments', label: '评论 6' },
|
||||
{ key: 'history', label: '历史(暂不可用)', disabled: true },
|
||||
]
|
||||
|
||||
export function TabsSection() {
|
||||
const [tab, setTab] = useState('detail')
|
||||
const [pillTab, setPillTab] = useState('all')
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="tabs"
|
||||
title="标签页"
|
||||
api="Tabs"
|
||||
desc="在几块内容之间切换。只负责标签栏,面板由页面渲染;键盘 ← → 移动、Home/End 跳首尾。"
|
||||
>
|
||||
<Demo
|
||||
title="下划线(默认)"
|
||||
desc="适合详情页、设置页这类页面级分区;选中项用 2px 主色下划线标记。"
|
||||
layout="column"
|
||||
code={`const [tab, setTab] = useState('detail')
|
||||
|
||||
<Tabs items={ITEMS} activeKey={tab} onChange={setTab} />
|
||||
<div role="tabpanel" style={{ paddingTop: 12 }}>{tab} 面板</div>`}
|
||||
>
|
||||
<Tabs items={ITEMS} activeKey={tab} onChange={setTab} />
|
||||
<Text size="sm" tone="muted">
|
||||
当前面板:{tab}
|
||||
</Text>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="胶囊"
|
||||
desc="pill 变体放在浅底容器里,适合卡片内的次级切换。"
|
||||
layout="column"
|
||||
code={`<Tabs variant="pill" items={items} activeKey={pillTab} onChange={setPillTab} />`}
|
||||
>
|
||||
<Tabs
|
||||
variant="pill"
|
||||
items={[
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'published', label: '已发布' },
|
||||
{ key: 'draft', label: '草稿' },
|
||||
]}
|
||||
activeKey={pillTab}
|
||||
onChange={setPillTab}
|
||||
/>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'items', type: 'TabItem[]', required: true, desc: '{ key, label, disabled? }' },
|
||||
{ name: 'activeKey', type: 'string', required: true, desc: '当前标签' },
|
||||
{ name: 'onChange', type: '(key: string) => void', required: true, desc: '切换回调' },
|
||||
{ name: 'variant', type: "'line' | 'pill'", defaultValue: "'line'", desc: '下划线 / 胶囊' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Button, Space, toast } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
export function Toasts() {
|
||||
return (
|
||||
<Section
|
||||
id="toast"
|
||||
title="轻提示"
|
||||
api="toast / Toaster"
|
||||
desc="操作反馈,几秒后自动消失。命令式调用:应用根节点挂一次 <Toaster />,任意位置 toast('已保存')。页面内常驻提示用 Notice。"
|
||||
>
|
||||
<Demo
|
||||
title="三种语气"
|
||||
desc="success / error 用对应图标与颜色;info 是中性提示。默认 2.4 秒后消失。"
|
||||
code={`toast('已复制到剪贴板')
|
||||
toast.success('已保存')
|
||||
toast.error('保存失败:内容库被占用')`}
|
||||
>
|
||||
<Space gap="md" wrap>
|
||||
<Button variant="secondary" onClick={() => toast('已复制到剪贴板')}>
|
||||
info
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => toast.success('已保存')}>
|
||||
success
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => toast.error('保存失败:内容库被占用')}>
|
||||
error
|
||||
</Button>
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="自定义时长"
|
||||
desc="duration 毫秒;返回的 id 可以配合 dismissToast 手动关闭。"
|
||||
code={`const id = toast.success('稍后会消失', { duration: 1000 })
|
||||
dismissToast(id)`}
|
||||
>
|
||||
<Space gap="md" wrap>
|
||||
<Button variant="ghost" onClick={() => toast('这条只显示 1 秒', { duration: 1000 })}>
|
||||
1 秒后消失
|
||||
</Button>
|
||||
<Button variant="ghost" onClick={() => toast.success('固定 5 秒', { duration: 5000 })}>
|
||||
5 秒后消失
|
||||
</Button>
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'toast(message, options?)', type: '(string, { tone?, duration? }) => number', desc: '弹一条提示,返回 id' },
|
||||
{ name: 'toast.success / .error', type: '(message, options?) => number', desc: '语气快捷方法' },
|
||||
{ name: 'dismissToast(id)', type: '(id: number) => void', desc: '手动关闭' },
|
||||
{ name: '<Toaster />', type: 'ReactNode', desc: '渲染容器,整个应用只挂一次' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Button, IconButton, Icon, Popover, Space, Text, Tooltip } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
export function Tooltips() {
|
||||
return (
|
||||
<Section
|
||||
id="tooltip"
|
||||
title="工具提示"
|
||||
api="Tooltip / Popover"
|
||||
desc="Tooltip 是 hover / 聚焦时出现的一行文字;Popover 是点击展开的小面板。两者都只做轻量内容,复杂交互请用 Drawer / Modal。"
|
||||
>
|
||||
<Demo
|
||||
title="文字提示"
|
||||
desc="纯 CSS 定位(top / bottom),触发元素建议是可聚焦的按钮或链接;键盘聚焦时同样出现。"
|
||||
code={`<Tooltip content="重新扫描项目">
|
||||
<IconButton label="刷新" icon={<Icon name="refresh_3_line" />} />
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip content="出现在下方" placement="bottom">
|
||||
<Button variant="secondary">下方提示</Button>
|
||||
</Tooltip>`}
|
||||
>
|
||||
<Space gap="lg">
|
||||
<Tooltip content="重新扫描项目">
|
||||
<IconButton label="刷新" icon={<Icon name="refresh_3_line" />} variant="soft" />
|
||||
</Tooltip>
|
||||
<Tooltip content="出现在下方" placement="bottom">
|
||||
<Button variant="secondary">下方提示</Button>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="气泡卡片"
|
||||
desc="点击触发器展开,点外部或 Esc 关闭;面板里可以放说明、链接或简单表单。"
|
||||
code={`<Popover
|
||||
content={<Text size="sm">改动会立即写入 data/stash.db,5 秒内推送前端。</Text>}
|
||||
>
|
||||
<Button variant="ghost">说明</Button>
|
||||
</Popover>`}
|
||||
>
|
||||
<Space gap="lg">
|
||||
<Popover
|
||||
content={
|
||||
<Text size="sm" tone="sub">
|
||||
改动会立即写入 data/stash.db,服务端 5 秒内通过 SSE 推送前端。
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Button variant="ghost">说明</Button>
|
||||
</Popover>
|
||||
<Popover
|
||||
placement="bottom-end"
|
||||
content={
|
||||
<Space direction="column" gap="sm" align="start">
|
||||
<Text size="sm" strong>
|
||||
快捷操作
|
||||
</Text>
|
||||
<Button size="sm" variant="ghost">
|
||||
重新扫描
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost">
|
||||
打开内容库
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Button variant="secondary">更多操作</Button>
|
||||
</Popover>
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'content', type: 'ReactNode', required: true, desc: '提示内容' },
|
||||
{ name: 'placement', type: "'top' | 'bottom'", defaultValue: "'top'", desc: '出现方向' },
|
||||
{ name: 'children', type: 'ReactNode', required: true, desc: '触发元素(建议可聚焦)' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'content', type: 'ReactNode', required: true, desc: '面板内容' },
|
||||
{ name: 'children', type: 'ReactNode', required: true, desc: '触发器' },
|
||||
{ name: 'placement', type: "'bottom-start' | 'bottom-end'", defaultValue: "'bottom-start'", desc: '对齐方式' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Paragraph, Space, Text, Title } from '../../src'
|
||||
import { Demo } from '../components/Demo'
|
||||
import { PropsTable } from '../components/PropsTable'
|
||||
import { Section } from '../components/Section'
|
||||
|
||||
export function Typographies() {
|
||||
return (
|
||||
<Section
|
||||
id="typography"
|
||||
title="排版"
|
||||
api="Title / Text / Paragraph"
|
||||
desc="把字号、字重、文字层级收进三个组件;页面不再各写 font-size。字号阶梯来自参考站实测:13 / 14 / 16 / 18 / 24,正文(Paragraph)默认 16/24。"
|
||||
>
|
||||
<Demo
|
||||
title="标题层级"
|
||||
desc="level 决定字号并输出对应的 h1–h4;要换语义标签时传 as。组件不带外边距,间距交给 Space。"
|
||||
layout="column"
|
||||
code={`<Title level={1}>页面标题</Title>
|
||||
<Title level={2}>区块标题</Title>
|
||||
<Title level={3}>卡片标题</Title>
|
||||
<Title level={4}>小节标题</Title>`}
|
||||
>
|
||||
<Title level={1}>页面标题 24/32</Title>
|
||||
<Title level={2}>区块标题 18/26</Title>
|
||||
<Title level={3}>卡片标题 16/24</Title>
|
||||
<Title level={4}>小节标题 14/22</Title>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="文字层级与尺寸"
|
||||
desc="tone 控制颜色层级(main → secondary → sub → muted),size 控制字号;strong 是主题的 500 字重,truncate 做单行省略。"
|
||||
layout="column"
|
||||
code={`<Text tone="main">主文字</Text>
|
||||
<Text tone="secondary">次级文字</Text>
|
||||
<Text tone="sub">说明文字</Text>
|
||||
<Text tone="muted">占位文字</Text>
|
||||
|
||||
<Text size="xs">12</Text>
|
||||
<Text size="sm">13</Text>
|
||||
<Text size="md">14</Text>
|
||||
<Text size="base">16</Text>
|
||||
<Text size="lg">18</Text>`}
|
||||
>
|
||||
<Space direction="column" gap="sm" align="start">
|
||||
<Text tone="main">主文字:标题、正文重点</Text>
|
||||
<Text tone="secondary">次级文字:默认段落</Text>
|
||||
<Text tone="sub">说明文字:摘要、描述</Text>
|
||||
<Text tone="muted">占位文字:时间、元信息</Text>
|
||||
</Space>
|
||||
|
||||
<Space gap="lg" wrap>
|
||||
<Text size="xs">12 角标</Text>
|
||||
<Text size="sm">13 标签</Text>
|
||||
<Text size="md">14 控件</Text>
|
||||
<Text size="base">16 正文</Text>
|
||||
<Text size="lg">18 标题</Text>
|
||||
</Space>
|
||||
|
||||
<Space gap="lg" wrap>
|
||||
<Text strong>500 加粗</Text>
|
||||
<Text truncate style={{ maxWidth: 180 }}>
|
||||
单行截断:这一行文字太长时会在末尾显示省略号,不会换行
|
||||
</Text>
|
||||
</Space>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="段落与行数截断"
|
||||
desc="文章摘要常用 2–3 行截断;lines 用 -webkit-line-clamp 实现,不破坏文本可选中。"
|
||||
layout="column"
|
||||
code={`<Paragraph>正文段落,默认 16/24、次级颜色。</Paragraph>
|
||||
<Paragraph lines={2}>两行后省略……</Paragraph>`}
|
||||
>
|
||||
<Paragraph>
|
||||
组件只负责排版:字号、行高、颜色都来自令牌。段落之间不要用 <code>margin</code>,
|
||||
放进 <code>Space</code> 里控制间距,这样换页面时节奏是一致的。
|
||||
</Paragraph>
|
||||
<Paragraph lines={2}>
|
||||
这一段的文字比较长,用来演示两行截断:用 -webkit-line-clamp 做视觉截断,
|
||||
文本本身仍然完整保留在 DOM 里,复制、搜索、屏幕阅读器都能拿到全文,只是超出两行后不再显示。
|
||||
</Paragraph>
|
||||
</Demo>
|
||||
|
||||
<Demo
|
||||
title="字重"
|
||||
desc="库内置 400 / 500 / 600 / 700 四档;默认 500,参考站的标题是 600,用 weight='semibold'。"
|
||||
layout="column"
|
||||
code={`<Title level={3} weight="medium">默认 500</Title>
|
||||
<Title level={3} weight="semibold">600(参考站标题)</Title>
|
||||
<Title level={3} weight="strong">700(强调)</Title>`}
|
||||
>
|
||||
<Title level={3} weight="medium">
|
||||
默认 500
|
||||
</Title>
|
||||
<Title level={3} weight="semibold">
|
||||
600(参考站标题)
|
||||
</Title>
|
||||
<Title level={3} weight="strong">
|
||||
700(强调)
|
||||
</Title>
|
||||
</Demo>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'level', type: '1 | 2 | 3 | 4', defaultValue: '2', desc: '字号档位,默认输出 h1–h4' },
|
||||
{ name: 'as', type: "'h1' | 'h2' | 'h3' | 'h4' | 'div'", desc: '换语义标签' },
|
||||
{ name: '…rest', type: 'HTMLAttributes', desc: '透传原生属性' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'tone', type: "'main' | 'secondary' | 'sub' | 'muted'", defaultValue: "'main'", desc: '文字层级' },
|
||||
{ name: 'size', type: "'xs' | 'sm' | 'md' | 'base' | 'lg'", defaultValue: "'md'", desc: '12 / 13 / 14 / 16 / 18' },
|
||||
{ name: 'strong', type: 'boolean', defaultValue: 'false', desc: '500 字重' },
|
||||
{ name: 'truncate', type: 'boolean', defaultValue: 'false', desc: '单行省略' },
|
||||
{ name: 'as', type: "'span' | 'p' | 'div'", defaultValue: "'span'", desc: '语义标签' },
|
||||
]}
|
||||
/>
|
||||
|
||||
<PropsTable
|
||||
rows={[
|
||||
{ name: 'tone', type: "'main' | 'secondary' | 'sub'", defaultValue: "'sub'", desc: '文字层级' },
|
||||
{ name: 'lines', type: 'number', desc: '最多显示几行,超出省略' },
|
||||
]}
|
||||
/>
|
||||
</Section>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user