Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 21 additions & 5 deletions frontend/components/Layouts/DefaultLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
"use client";
import Header from "@/components/Header";
import Sidebar from "@/components/Sidebar";
import Sidebar, { type SidebarDisclosureState } from "@/components/Sidebar";
import type { Metadata } from "next";
import type React from "react";
import { useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";

const WORKSPACE_SHELL_METRICS_CLASS =
"[--workspace-header-offset:9.75rem] [--workspace-sidebar-width:15.5rem] sm:[--workspace-header-offset:8.75rem] lg:[--workspace-header-offset:4.5rem]";
const PUBLIC_SHELL_METRICS_CLASS = "[--workspace-header-offset:5rem]";
const DESKTOP_SIDEBAR_QUERY = "(min-width: 1024px)";

export default function DefaultLayout({
children,
Expand All @@ -18,9 +19,24 @@ export default function DefaultLayout({
metadata?: Metadata;
variant?: "workspace" | "public";
}) {
const [sidebarOpen, setSidebarOpen] = useState(true);
// No GoF pattern applies; this layout tracks simple responsive disclosure state.
const [sidebarState, setSidebarState] = useState<SidebarDisclosureState>("responsive");
const [isDesktopSidebarViewport, setIsDesktopSidebarViewport] = useState(false);
const sidebarSwitcherRef = useRef<HTMLButtonElement>(null);
const isWorkspaceShell = variant === "workspace";
const sidebarOpen =
sidebarState === "open" || (sidebarState === "responsive" && isDesktopSidebarViewport);
const setSidebarOpen = (open: boolean) => setSidebarState(open ? "open" : "closed");

useEffect(() => {
const desktopQuery = window.matchMedia(DESKTOP_SIDEBAR_QUERY);
const syncDesktopViewport = () => setIsDesktopSidebarViewport(desktopQuery.matches);

syncDesktopViewport();
desktopQuery.addEventListener("change", syncDesktopViewport);

return () => desktopQuery.removeEventListener("change", syncDesktopViewport);
}, []);

return (
<main
Expand All @@ -40,14 +56,14 @@ export default function DefaultLayout({
<div className="pt-[var(--workspace-header-offset)]">
{isWorkspaceShell && (
<Sidebar
sidebarOpen={sidebarOpen}
sidebarState={sidebarState}
setSidebarOpen={setSidebarOpen}
exceptionRef={sidebarSwitcherRef}
/>
)}
<div
className={`min-h-[calc(100vh_-_var(--workspace-header-offset))] min-w-0 transition-[margin] duration-200 ${
isWorkspaceShell && sidebarOpen ? "lg:ml-[var(--workspace-sidebar-width)]" : ""
isWorkspaceShell && sidebarState !== "closed" ? "lg:ml-[var(--workspace-sidebar-width)]" : ""
}`}
>
<div
Expand Down
16 changes: 13 additions & 3 deletions frontend/components/Sidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,20 @@ import Link from "next/link";
import type React from "react";

interface SidebarProps {
sidebarOpen: boolean;
sidebarState: SidebarDisclosureState;
setSidebarOpen: (arg: boolean) => void;
exceptionRef?: React.RefObject<HTMLElement>;
}

export type SidebarDisclosureState = "closed" | "open" | "responsive";

// No GoF pattern applies; this is a finite responsive disclosure state.
const sidebarTransformClass = {
closed: "-translate-x-full",
open: "translate-x-0",
responsive: "-translate-x-full lg:translate-x-0",
} as const satisfies Record<SidebarDisclosureState, string>;

interface SidebarHoverCardProps {
description: string;
eyebrow: string;
Expand Down Expand Up @@ -59,11 +68,12 @@ const SidebarHoverCard = ({
);
};

const Sidebar = ({ sidebarOpen, setSidebarOpen, exceptionRef }: SidebarProps) => {
const Sidebar = ({ sidebarState, setSidebarOpen, exceptionRef }: SidebarProps) => {
const { currentContext, isLoading, roleConfig } = useWorkspace();
const contextNote = isLoading
? "Tutor is resolving the latest context note for this role."
: currentContext.note;
const sidebarOpen = sidebarState === "open";

return (
<>
Expand All @@ -79,7 +89,7 @@ const Sidebar = ({ sidebarOpen, setSidebarOpen, exceptionRef }: SidebarProps) =>
<aside
id="sidebar"
className={`fixed left-0 top-[var(--workspace-header-offset)] z-40 flex h-[calc(100vh_-_var(--workspace-header-offset))] w-[var(--workspace-sidebar-width)] flex-col border-r border-stone-200 bg-stone-50/95 px-3 py-3 shadow-sm transition-transform duration-200 dark:border-slate-800 dark:bg-slate-950/90 ${
sidebarOpen ? "translate-x-0" : "-translate-x-full"
sidebarTransformClass[sidebarState]
}`}
>
<div className="rounded-lg border border-stone-200 bg-white/90 p-2.5 shadow-sm dark:border-slate-700 dark:bg-slate-900/80">
Expand Down
80 changes: 80 additions & 0 deletions frontend/e2e/route-shells.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,28 @@ const expectNoHorizontalOverflow = async (page: Page) => {
.toBeLessThanOrEqual(1);
};

const expectSidebarOffCanvas = async (page: Page) => {
await expect
.poll(async () =>
page.locator("#sidebar").evaluate((sidebar) => Math.round(sidebar.getBoundingClientRect().right)),
)
.toBeLessThanOrEqual(1);
};

const expectSidebarOnCanvas = async (page: Page) => {
await expect
.poll(async () =>
page.locator("#sidebar").evaluate((sidebar) => Math.round(sidebar.getBoundingClientRect().right)),
)
.toBeGreaterThan(200);
};

const expectSidebarFixed = async (page: Page) => {
await expect
.poll(async () => page.locator("#sidebar").evaluate((sidebar) => window.getComputedStyle(sidebar).position))
.toBe("fixed");
};

const setWorkspaceRole = async (page: Page, role: string) => {
await page.addInitScript((workspaceRole) => {
window.localStorage.setItem("tutor.workspace.role", JSON.stringify(workspaceRole));
Expand Down Expand Up @@ -164,4 +186,62 @@ test.describe("workspace shell reflow", () => {
});
}
}

for (const viewport of WORKSPACE_REFLOW_VIEWPORTS) {
test(`workspace sidebar starts closed and toggles on ${viewport.name}`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await setWorkspaceRole(page, "admin");

await page.goto("/configuration/cases");

await expect(page.locator("#main-content")).toBeVisible();
await expect(page.locator('button[aria-label="Close sidebar"]')).toHaveCount(0);
await expect(page.locator('button[aria-controls="sidebar"]')).toHaveAttribute(
"aria-expanded",
"false",
);
await expectSidebarOffCanvas(page);
await expectNoHorizontalOverflow(page);

await page.locator('button[aria-controls="sidebar"]').click();
await expect(page.locator('button[aria-label="Close sidebar"]')).toBeVisible();
await expect(page.locator('button[aria-controls="sidebar"]')).toHaveAttribute(
"aria-expanded",
"true",
);
await expectSidebarOnCanvas(page);

await page.mouse.click(viewport.width - 16, Math.min(320, viewport.height - 16));
await expect(page.locator('button[aria-label="Close sidebar"]')).toHaveCount(0);
await expect(page.locator('button[aria-controls="sidebar"]')).toHaveAttribute(
"aria-expanded",
"false",
);
await expectSidebarOffCanvas(page);
await expectNoHorizontalOverflow(page);
});
}

test("workspace sidebar is visible by default on desktop and still toggles", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await setWorkspaceRole(page, "admin");

await page.goto("/configuration/cases");

await expect(page.locator("#main-content")).toBeVisible();
await expect(page.locator('button[aria-controls="sidebar"]')).toHaveAttribute("aria-expanded", "true");
await expectSidebarOnCanvas(page);
await expectSidebarFixed(page);
await expectNoHorizontalOverflow(page);

await page.locator('button[aria-controls="sidebar"]').click();
await expect(page.locator('button[aria-controls="sidebar"]')).toHaveAttribute("aria-expanded", "false");
await expectSidebarOffCanvas(page);
await expectNoHorizontalOverflow(page);

await page.locator('button[aria-controls="sidebar"]').click();
await expect(page.locator('button[aria-controls="sidebar"]')).toHaveAttribute("aria-expanded", "true");
await expectSidebarOnCanvas(page);
await expectNoHorizontalOverflow(page);
});
});
Loading