页头与页身之间的一条真实占位带(搜索 / 筛选 / 操作),宽度跟随容器, contained 时与侧栏 + 内容同宽,height=screen 下不参与内容区滚动。 用它替代「搜索框 fixed + contentOffset 让位」的写法。 - Layout.tsx / Layout.module.css:新增 toolbar prop 与样式 - 展示页 #layout 增加「页头下的工具条」示例 - COMPONENTS.md 补契约、与 contentOffset 的分工说明、changelog
286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
import { useState } from 'react'
|
||
import { Card, Grid, Input, 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>
|
||
|
||
<Demo
|
||
title="页头下的工具条"
|
||
desc="toolbar 是真实占位的一层:宽度跟随容器,坐在页头与页身之间;整屏模式下它不参与内容区滚动,搜索框不用再 fixed + 手算 offset。"
|
||
layout="column"
|
||
code={`<Layout
|
||
header={<Header />}
|
||
toolbar={<Search />}
|
||
sider={<Nav />}
|
||
height="screen"
|
||
scrollable
|
||
>
|
||
<Content />
|
||
</Layout>`}
|
||
>
|
||
<div className="layout-frame" style={{ height: 320, overflow: 'hidden' }}>
|
||
<Layout
|
||
header={<DemoHeader />}
|
||
toolbar={
|
||
<div className="layout-toolbar">
|
||
<Input size="lg" icon="search_3_line" placeholder="搜索本地项目…" readOnly />
|
||
</div>
|
||
}
|
||
sider={<DemoSider compact />}
|
||
headerHeight={56}
|
||
height="screen"
|
||
scrollable
|
||
hideScrollbar
|
||
style={{ height: 320 }}
|
||
>
|
||
<div className="layout-content">
|
||
{Array.from({ length: 6 }, (_, 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: 'toolbar', 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>
|
||
)
|
||
}
|