feat(layout): add configurable application layout
This commit is contained in:
@@ -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"
|
||||
)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user