feat(layout): add configurable application layout

This commit is contained in:
Maofeng
2026-09-30 01:30:47 +08:00
parent 8b880a19e2
commit ffda0b9099
10 changed files with 665 additions and 0 deletions
+19
View File
@@ -186,6 +186,23 @@
"vitest": "^4", "vitest": "^4",
}, },
}, },
"packages/layout": {
"name": "@workspace/layout",
"version": "0.0.0",
"dependencies": {
"@workspace/ui": "workspace:*",
"react": "^19.2.6",
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"jsdom": "^30.0.1",
"react-dom": "^19.2.6",
"typescript": "~6",
"vitest": "^4.1.10",
},
},
"packages/lexical": { "packages/lexical": {
"name": "@workspace/lexical", "name": "@workspace/lexical",
"version": "0.0.0", "version": "0.0.0",
@@ -1135,6 +1152,8 @@
"@workspace/i18n": ["@workspace/i18n@workspace:packages/i18n"], "@workspace/i18n": ["@workspace/i18n@workspace:packages/i18n"],
"@workspace/layout": ["@workspace/layout@workspace:packages/layout"],
"@workspace/lexical": ["@workspace/lexical@workspace:packages/lexical"], "@workspace/lexical": ["@workspace/lexical@workspace:packages/lexical"],
"@workspace/media": ["@workspace/media@workspace:packages/media"], "@workspace/media": ["@workspace/media@workspace:packages/media"],
+27
View File
@@ -0,0 +1,27 @@
{
"name": "@workspace/layout",
"version": "0.0.0",
"type": "module",
"private": true,
"scripts": {
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@workspace/ui": "workspace:*",
"react": "^19.2.6"
},
"devDependencies": {
"@testing-library/react": "^16.3.2",
"@types/react": "^19",
"@types/react-dom": "^19",
"jsdom": "^30.0.1",
"react-dom": "^19.2.6",
"typescript": "~6",
"vitest": "^4.1.10"
},
"exports": {
".": "./src/index.ts",
"./globals.css": "./src/styles/globals.css"
}
}
+144
View File
@@ -0,0 +1,144 @@
import * as React from "react"
import { cn } from "@workspace/ui/lib/utils"
import type { LayoutConfig } from "./config"
import { createLayoutGrid } from "./grid"
import { LayoutConfigContext, useLayoutConfigValue } from "./layout-context"
export type Slot<TComponent extends React.ComponentType = React.ComponentType> =
React.ReactNode | TComponent
export type HeaderComponent = React.ComponentType
export type RailComponent = React.ComponentType
export type SidebarComponent = React.ComponentType
export type AsideComponent = React.ComponentType
export type FooterComponent = React.ComponentType
export interface AppLayoutProps extends Omit<
React.ComponentProps<"div">,
"children"
> {
aside?: Slot<AsideComponent>
children: React.ReactNode
defaultLayout?: Partial<LayoutConfig>
footer?: Slot<FooterComponent>
header?: Slot<HeaderComponent>
layout?: Partial<LayoutConfig>
onLayoutChange?: (layout: LayoutConfig) => void
rail?: Slot<RailComponent>
sidebar?: Slot<SidebarComponent>
}
export function AppLayout({
aside,
children,
className,
defaultLayout,
footer,
header,
layout: controlledLayout,
onLayoutChange,
rail,
sidebar,
style,
...props
}: AppLayoutProps) {
const context = useLayoutConfigValue({
defaultLayout,
layout: controlledLayout,
onLayoutChange,
})
const { layout } = context
const hasRail = isPresentSlot(rail)
const hasSidebar =
isPresentSlot(sidebar) && (!layout.sidebarCollapsible || layout.sidebarOpen)
const hasAside =
isPresentSlot(aside) && layout.asideEnabled && layout.asideOpen
const hasHeader = isPresentSlot(header)
const hasFooter = isPresentSlot(footer)
const grid = createLayoutGrid({
hasAside,
hasFooter,
hasHeader,
hasRail,
hasSidebar,
layout,
})
return (
<LayoutConfigContext.Provider value={context}>
<div
{...props}
data-slot="app-layout"
data-aside={hasAside ? "open" : "closed"}
data-footer-start={grid.footerStart}
data-header-start={grid.headerStart}
data-sidebar={hasSidebar ? "open" : "closed"}
className={cn("grid min-h-dvh w-full min-w-0", className)}
style={{
...style,
gridTemplateAreas: grid.areas,
gridTemplateColumns: grid.columns,
gridTemplateRows: grid.rows,
}}
>
{hasHeader && (
<LayoutRegion area="header">{renderSlot(header)}</LayoutRegion>
)}
{hasRail && <LayoutRegion area="rail">{renderSlot(rail)}</LayoutRegion>}
{hasSidebar && (
<LayoutRegion area="sidebar">{renderSlot(sidebar)}</LayoutRegion>
)}
<main
data-slot="app-layout-content"
className="min-h-0 min-w-0 overflow-auto"
style={{ gridArea: "content" }}
>
{children}
</main>
{hasAside && (
<LayoutRegion area="aside">{renderSlot(aside)}</LayoutRegion>
)}
{hasFooter && (
<LayoutRegion area="footer">{renderSlot(footer)}</LayoutRegion>
)}
</div>
</LayoutConfigContext.Provider>
)
}
function LayoutRegion({
area,
children,
}: {
area: "header" | "rail" | "sidebar" | "aside" | "footer"
children: React.ReactNode
}) {
return (
<div
data-slot={`app-layout-${area}`}
className="min-h-0 min-w-0"
style={{ gridArea: area }}
>
{children}
</div>
)
}
function isPresentSlot<TComponent extends React.ComponentType>(
slot?: Slot<TComponent>
): boolean {
return (
typeof slot === "function" ||
(slot !== undefined && React.Children.toArray(slot).length > 0)
)
}
function renderSlot<TComponent extends React.ComponentType>(
slot?: Slot<TComponent>
): React.ReactNode {
if (typeof slot === "function") {
return React.createElement(slot as React.ComponentType)
}
return slot as React.ReactNode
}
+27
View File
@@ -0,0 +1,27 @@
export type HeaderStart = "rail" | "sidebar" | "content"
export type FooterStart = Exclude<HeaderStart, "rail">
export interface LayoutConfig {
asideEnabled: boolean
asideOpen: boolean
footerStart: FooterStart
headerStart: HeaderStart
sidebarCollapsible: boolean
sidebarOpen: boolean
}
export const defaultLayoutConfig = Object.freeze({
asideEnabled: true,
asideOpen: false,
footerStart: "content",
headerStart: "rail",
sidebarCollapsible: true,
sidebarOpen: true,
} satisfies LayoutConfig)
export function createLayoutConfig(
layout?: Partial<LayoutConfig>
): LayoutConfig {
return { ...defaultLayoutConfig, ...layout }
}
+122
View File
@@ -0,0 +1,122 @@
import type { FooterStart, HeaderStart, LayoutConfig } from "./config"
export type LayoutArea =
| "header"
| "rail"
| "sidebar"
| "content"
| "aside"
| "footer"
interface LayoutGridOptions {
hasAside: boolean
hasFooter: boolean
hasHeader: boolean
hasRail: boolean
hasSidebar: boolean
layout: LayoutConfig
}
export interface LayoutGrid {
areas: string
columns: string
footerStart: FooterStart
headerStart: HeaderStart
rows: string
}
export function createLayoutGrid({
hasAside,
hasFooter,
hasHeader,
hasRail,
hasSidebar,
layout,
}: LayoutGridOptions): LayoutGrid {
const columns: LayoutArea[] = [
...(hasRail ? (["rail"] as const) : []),
...(hasSidebar ? (["sidebar"] as const) : []),
"content",
...(hasAside ? (["aside"] as const) : []),
]
const contentIndex = columns.indexOf("content")
const headerStart = resolveHeaderStart(layout.headerStart, {
hasRail,
hasSidebar,
})
const footerStart = resolveFooterStart(layout.footerStart, {
hasRail,
hasSidebar,
})
const areaRows: LayoutArea[][] = []
const rowSizes: string[] = []
if (hasHeader) {
const startIndex = columns.indexOf(headerStart)
areaRows.push(
columns.map((area, index) => (index < startIndex ? area : "header"))
)
rowSizes.push("auto")
}
areaRows.push(columns)
rowSizes.push("minmax(0, 1fr)")
if (hasFooter) {
const startIndex = columns.indexOf(footerStart)
areaRows.push(
columns.map((area, index) => {
if (area === "aside") return area
return index < startIndex || index > contentIndex ? area : "footer"
})
)
rowSizes.push("auto")
}
return {
areas: areaRows.map((row) => `"${row.join(" ")}"`).join(" "),
columns: columns.map(getColumnSize).join(" "),
footerStart,
headerStart,
rows: rowSizes.join(" "),
}
}
function resolveHeaderStart(
start: HeaderStart,
availability: { hasRail: boolean; hasSidebar: boolean }
): HeaderStart {
if (start === "rail") {
if (availability.hasRail) return "rail"
if (availability.hasSidebar) return "sidebar"
return "content"
}
if (start === "sidebar" && availability.hasRail && availability.hasSidebar) {
return "sidebar"
}
return "content"
}
function resolveFooterStart(
start: FooterStart,
availability: { hasRail: boolean; hasSidebar: boolean }
): FooterStart {
return start === "sidebar" && availability.hasRail && availability.hasSidebar
? "sidebar"
: "content"
}
function getColumnSize(area: LayoutArea): string {
switch (area) {
case "rail":
return "var(--layout-rail-width, 4rem)"
case "sidebar":
return "var(--layout-sidebar-width, 16rem)"
case "aside":
return "var(--layout-aside-width, 20rem)"
default:
return "minmax(0, 1fr)"
}
}
+22
View File
@@ -0,0 +1,22 @@
export {
AppLayout,
type AppLayoutProps,
type AsideComponent,
type FooterComponent,
type HeaderComponent,
type RailComponent,
type SidebarComponent,
type Slot,
} from "./app-layout"
export {
createLayoutConfig,
defaultLayoutConfig,
type FooterStart,
type HeaderStart,
type LayoutConfig,
} from "./config"
export {
type LayoutConfigContextValue,
type LayoutConfigUpdate,
useLayoutConfig,
} from "./layout-context"
+104
View File
@@ -0,0 +1,104 @@
import * as React from "react"
import { createLayoutConfig, type LayoutConfig } from "./config"
export type LayoutConfigUpdate =
| Partial<LayoutConfig>
| ((layout: LayoutConfig) => Partial<LayoutConfig>)
export interface LayoutConfigContextValue {
layout: LayoutConfig
setAsideOpen: (open: boolean) => void
setSidebarOpen: (open: boolean) => void
toggleAside: VoidFunction
toggleSidebar: VoidFunction
updateLayout: (update: LayoutConfigUpdate) => void
}
export const LayoutConfigContext =
React.createContext<LayoutConfigContextValue | null>(null)
LayoutConfigContext.displayName = "LayoutConfigContext"
export interface UseLayoutConfigOptions {
defaultLayout?: Partial<LayoutConfig>
layout?: Partial<LayoutConfig>
onLayoutChange?: (layout: LayoutConfig) => void
}
/** Creates the value exposed by AppLayout and useLayoutConfig. */
export function useLayoutConfigValue({
defaultLayout,
layout: controlledLayout,
onLayoutChange,
}: UseLayoutConfigOptions): LayoutConfigContextValue {
const controlled = controlledLayout !== undefined
const [uncontrolledLayout, setUncontrolledLayout] = React.useState(() =>
createLayoutConfig(defaultLayout)
)
const layout = React.useMemo(
() =>
controlled ? createLayoutConfig(controlledLayout) : uncontrolledLayout,
[controlled, controlledLayout, uncontrolledLayout]
)
const layoutRef = React.useRef(layout)
layoutRef.current = layout
const updateLayout = React.useCallback(
(update: LayoutConfigUpdate) => {
const current = layoutRef.current
const patch = typeof update === "function" ? update(current) : update
const next = createLayoutConfig({ ...current, ...patch })
layoutRef.current = next
if (!controlled) setUncontrolledLayout(next)
onLayoutChange?.(next)
},
[controlled, onLayoutChange]
)
const setAsideOpen = React.useCallback(
(open: boolean) => updateLayout({ asideOpen: open }),
[updateLayout]
)
const setSidebarOpen = React.useCallback(
(open: boolean) => updateLayout({ sidebarOpen: open }),
[updateLayout]
)
const toggleAside = React.useCallback(
() => updateLayout((current) => ({ asideOpen: !current.asideOpen })),
[updateLayout]
)
const toggleSidebar = React.useCallback(
() => updateLayout((current) => ({ sidebarOpen: !current.sidebarOpen })),
[updateLayout]
)
return React.useMemo(
() => ({
layout,
setAsideOpen,
setSidebarOpen,
toggleAside,
toggleSidebar,
updateLayout,
}),
[
layout,
setAsideOpen,
setSidebarOpen,
toggleAside,
toggleSidebar,
updateLayout,
]
)
}
/** Reads and updates the nearest AppLayout configuration. */
export function useLayoutConfig(): LayoutConfigContextValue {
const context = React.useContext(LayoutConfigContext)
if (!context) {
throw new Error("useLayoutConfig must be used within an AppLayout")
}
return context
}
+180
View File
@@ -0,0 +1,180 @@
// @vitest-environment jsdom
import * as React from "react"
import { cleanup, fireEvent, render, screen } from "@testing-library/react"
import {
AppLayout,
createLayoutConfig,
defaultLayoutConfig,
useLayoutConfig,
} from "@workspace/layout"
import { afterEach, describe, expect, it, vi } from "vitest"
afterEach(cleanup)
function LayoutControls() {
const { layout, toggleAside, toggleSidebar } = useLayoutConfig()
return (
<div>
<output aria-label="layout-state">
{layout.sidebarOpen ? "sidebar-open" : "sidebar-closed"}|
{layout.asideOpen ? "aside-open" : "aside-closed"}
</output>
<button type="button" onClick={toggleSidebar}>
Toggle sidebar
</button>
<button type="button" onClick={toggleAside}>
Toggle aside
</button>
</div>
)
}
function Header() {
return <span>Header component</span>
}
function PreferencesProbe() {
const { layout } = useLayoutConfig()
return (
<output aria-label="preferences">
{String(layout.sidebarOpen)}|{String(layout.asideOpen)}
</output>
)
}
describe("layout config", () => {
it("creates complete configurations from partial input", () => {
expect(createLayoutConfig({ asideOpen: true })).toEqual({
...defaultLayoutConfig,
asideOpen: true,
})
})
})
describe("AppLayout", () => {
it("renders component and node slots in a generated grid", () => {
render(
<AppLayout
header={Header}
rail={<span>Rail node</span>}
sidebar={<span>Sidebar node</span>}
aside={<span>Aside node</span>}
footer={<span>Footer node</span>}
layout={{
asideOpen: true,
footerStart: "sidebar",
headerStart: "sidebar",
}}
>
Content
</AppLayout>
)
const root = document.querySelector<HTMLElement>('[data-slot="app-layout"]')
expect(root?.style.gridTemplateAreas).toBe(
'"rail header header header" "rail sidebar content aside" "rail footer footer aside"'
)
expect(root?.dataset.headerStart).toBe("sidebar")
expect(root?.dataset.footerStart).toBe("sidebar")
expect(screen.getByText("Header component")).toBeTruthy()
expect(screen.getByText("Rail node")).toBeTruthy()
expect(screen.getByText("Sidebar node")).toBeTruthy()
expect(screen.getByText("Aside node")).toBeTruthy()
expect(screen.getByText("Footer node")).toBeTruthy()
})
it("moves sidebar starts inward when the rail is absent", () => {
render(
<AppLayout
header={<span>Header</span>}
sidebar={<span>Sidebar</span>}
aside={<span>Aside</span>}
footer={<span>Footer</span>}
layout={{
asideOpen: true,
footerStart: "sidebar",
headerStart: "sidebar",
}}
>
Content
</AppLayout>
)
const root = document.querySelector<HTMLElement>('[data-slot="app-layout"]')
expect(root?.style.gridTemplateAreas).toBe(
'"sidebar header header" "sidebar content aside" "sidebar footer aside"'
)
expect(root?.dataset.headerStart).toBe("content")
expect(root?.dataset.footerStart).toBe("content")
})
it("applies visibility capabilities without changing stored preferences", () => {
render(
<AppLayout
sidebar={<span>Sidebar</span>}
aside={<span>Aside</span>}
layout={{
asideEnabled: false,
asideOpen: true,
sidebarCollapsible: false,
sidebarOpen: false,
}}
>
<PreferencesProbe />
</AppLayout>
)
expect(screen.getByText("Sidebar")).toBeTruthy()
expect(screen.queryByText("Aside")).toBeNull()
expect(screen.getByLabelText("preferences").textContent).toBe("false|true")
})
it("manages uncontrolled sidebar and aside state through the hook", () => {
render(
<AppLayout sidebar={<span>Sidebar</span>} aside={<span>Aside</span>}>
<LayoutControls />
</AppLayout>
)
expect(screen.getByText("Sidebar")).toBeTruthy()
expect(screen.queryByText("Aside")).toBeNull()
fireEvent.click(screen.getByRole("button", { name: "Toggle sidebar" }))
fireEvent.click(screen.getByRole("button", { name: "Toggle aside" }))
expect(screen.queryByText("Sidebar")).toBeNull()
expect(screen.getByText("Aside")).toBeTruthy()
expect(screen.getByLabelText("layout-state").textContent).toBe(
"sidebar-closed|aside-open"
)
})
it("reports controlled changes without mutating controlled state", () => {
const onLayoutChange = vi.fn()
render(
<AppLayout
aside={<span>Aside</span>}
layout={{ asideOpen: false }}
onLayoutChange={onLayoutChange}
>
<LayoutControls />
</AppLayout>
)
fireEvent.click(screen.getByRole("button", { name: "Toggle aside" }))
expect(onLayoutChange).toHaveBeenCalledWith({
...defaultLayoutConfig,
asideOpen: true,
})
expect(screen.queryByText("Aside")).toBeNull()
expect(screen.getByLabelText("layout-state").textContent).toContain(
"aside-closed"
)
})
})
+1
View File
@@ -0,0 +1 @@
@source "../";
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"paths": {
"@workspace/layout": ["./src/index.ts"],
"@workspace/ui/*": ["../ui/src/*"]
}
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}