feat(layout): add configurable application layout
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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