Files
CNWei 93bb5cbb28 feat(ui): Layout 新增 toolbar 插槽
页头与页身之间的一条真实占位带(搜索 / 筛选 / 操作),宽度跟随容器,
contained 时与侧栏 + 内容同宽,height=screen 下不参与内容区滚动。
用它替代「搜索框 fixed + contentOffset 让位」的写法。

- Layout.tsx / Layout.module.css:新增 toolbar prop 与样式
- 展示页 #layout 增加「页头下的工具条」示例
- COMPONENTS.md 补契约、与 contentOffset 的分工说明、changelog
2026-11-01 15:15:14 +08:00

286 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}