From ffda0b9099cf9fe53d68b296fe1e79b23c3e431d Mon Sep 17 00:00:00 2001 From: Maofeng Date: Wed, 30 Sep 2026 01:30:47 +0800 Subject: [PATCH] feat(layout): add configurable application layout --- bun.lock | 19 +++ packages/layout/package.json | 27 ++++ packages/layout/src/app-layout.tsx | 144 ++++++++++++++++++++ packages/layout/src/config.ts | 27 ++++ packages/layout/src/grid.ts | 122 +++++++++++++++++ packages/layout/src/index.ts | 22 +++ packages/layout/src/layout-context.tsx | 104 ++++++++++++++ packages/layout/src/layout.test.tsx | 180 +++++++++++++++++++++++++ packages/layout/src/styles/globals.css | 1 + packages/layout/tsconfig.json | 19 +++ 10 files changed, 665 insertions(+) create mode 100644 packages/layout/package.json create mode 100644 packages/layout/src/app-layout.tsx create mode 100644 packages/layout/src/config.ts create mode 100644 packages/layout/src/grid.ts create mode 100644 packages/layout/src/index.ts create mode 100644 packages/layout/src/layout-context.tsx create mode 100644 packages/layout/src/layout.test.tsx create mode 100644 packages/layout/src/styles/globals.css create mode 100644 packages/layout/tsconfig.json diff --git a/bun.lock b/bun.lock index da43ada..3ce9de3 100644 --- a/bun.lock +++ b/bun.lock @@ -186,6 +186,23 @@ "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": { "name": "@workspace/lexical", "version": "0.0.0", @@ -1135,6 +1152,8 @@ "@workspace/i18n": ["@workspace/i18n@workspace:packages/i18n"], + "@workspace/layout": ["@workspace/layout@workspace:packages/layout"], + "@workspace/lexical": ["@workspace/lexical@workspace:packages/lexical"], "@workspace/media": ["@workspace/media@workspace:packages/media"], diff --git a/packages/layout/package.json b/packages/layout/package.json new file mode 100644 index 0000000..5d53535 --- /dev/null +++ b/packages/layout/package.json @@ -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" + } +} \ No newline at end of file diff --git a/packages/layout/src/app-layout.tsx b/packages/layout/src/app-layout.tsx new file mode 100644 index 0000000..d866d77 --- /dev/null +++ b/packages/layout/src/app-layout.tsx @@ -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 = + 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 + children: React.ReactNode + defaultLayout?: Partial + footer?: Slot + header?: Slot + layout?: Partial + onLayoutChange?: (layout: LayoutConfig) => void + rail?: Slot + sidebar?: Slot +} + +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 ( + +
+ {hasHeader && ( + {renderSlot(header)} + )} + {hasRail && {renderSlot(rail)}} + {hasSidebar && ( + {renderSlot(sidebar)} + )} +
+ {children} +
+ {hasAside && ( + {renderSlot(aside)} + )} + {hasFooter && ( + {renderSlot(footer)} + )} +
+
+ ) +} + +function LayoutRegion({ + area, + children, +}: { + area: "header" | "rail" | "sidebar" | "aside" | "footer" + children: React.ReactNode +}) { + return ( +
+ {children} +
+ ) +} + +function isPresentSlot( + slot?: Slot +): boolean { + return ( + typeof slot === "function" || + (slot !== undefined && React.Children.toArray(slot).length > 0) + ) +} + +function renderSlot( + slot?: Slot +): React.ReactNode { + if (typeof slot === "function") { + return React.createElement(slot as React.ComponentType) + } + return slot as React.ReactNode +} diff --git a/packages/layout/src/config.ts b/packages/layout/src/config.ts new file mode 100644 index 0000000..fc30201 --- /dev/null +++ b/packages/layout/src/config.ts @@ -0,0 +1,27 @@ +export type HeaderStart = "rail" | "sidebar" | "content" + +export type FooterStart = Exclude + +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 { + return { ...defaultLayoutConfig, ...layout } +} diff --git a/packages/layout/src/grid.ts b/packages/layout/src/grid.ts new file mode 100644 index 0000000..868a4ed --- /dev/null +++ b/packages/layout/src/grid.ts @@ -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)" + } +} diff --git a/packages/layout/src/index.ts b/packages/layout/src/index.ts new file mode 100644 index 0000000..f6f4587 --- /dev/null +++ b/packages/layout/src/index.ts @@ -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" diff --git a/packages/layout/src/layout-context.tsx b/packages/layout/src/layout-context.tsx new file mode 100644 index 0000000..ed39355 --- /dev/null +++ b/packages/layout/src/layout-context.tsx @@ -0,0 +1,104 @@ +import * as React from "react" + +import { createLayoutConfig, type LayoutConfig } from "./config" + +export type LayoutConfigUpdate = + | Partial + | ((layout: LayoutConfig) => Partial) + +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(null) + +LayoutConfigContext.displayName = "LayoutConfigContext" + +export interface UseLayoutConfigOptions { + defaultLayout?: Partial + layout?: Partial + 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 +} diff --git a/packages/layout/src/layout.test.tsx b/packages/layout/src/layout.test.tsx new file mode 100644 index 0000000..d29cd14 --- /dev/null +++ b/packages/layout/src/layout.test.tsx @@ -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 ( +
+ + {layout.sidebarOpen ? "sidebar-open" : "sidebar-closed"}| + {layout.asideOpen ? "aside-open" : "aside-closed"} + + + +
+ ) +} + +function Header() { + return Header component +} + +function PreferencesProbe() { + const { layout } = useLayoutConfig() + return ( + + {String(layout.sidebarOpen)}|{String(layout.asideOpen)} + + ) +} + +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( + Rail node} + sidebar={Sidebar node} + aside={Aside node} + footer={Footer node} + layout={{ + asideOpen: true, + footerStart: "sidebar", + headerStart: "sidebar", + }} + > + Content + + ) + + const root = document.querySelector('[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( + Header} + sidebar={Sidebar} + aside={Aside} + footer={Footer} + layout={{ + asideOpen: true, + footerStart: "sidebar", + headerStart: "sidebar", + }} + > + Content + + ) + + const root = document.querySelector('[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( + Sidebar} + aside={Aside} + layout={{ + asideEnabled: false, + asideOpen: true, + sidebarCollapsible: false, + sidebarOpen: false, + }} + > + + + ) + + 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( + Sidebar} aside={Aside}> + + + ) + + 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( + Aside} + layout={{ asideOpen: false }} + onLayoutChange={onLayoutChange} + > + + + ) + + 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" + ) + }) +}) diff --git a/packages/layout/src/styles/globals.css b/packages/layout/src/styles/globals.css new file mode 100644 index 0000000..f3275b1 --- /dev/null +++ b/packages/layout/src/styles/globals.css @@ -0,0 +1 @@ +@source "../"; diff --git a/packages/layout/tsconfig.json b/packages/layout/tsconfig.json new file mode 100644 index 0000000..b623068 --- /dev/null +++ b/packages/layout/tsconfig.json @@ -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"] +} \ No newline at end of file