feat(layout): add configurable application layout
This commit is contained in:
@@ -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"],
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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)"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
@source "../";
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user