diff --git a/src/app/globals.css b/src/app/globals.css index 0780ed5..70870f5 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,4 +1,6 @@ @import "tailwindcss"; +@import "../styles/breakpoints.css"; +@import "../styles/containers.css"; :root, .light { diff --git a/src/components/layout/ChartsArea.tsx b/src/components/layout/ChartsArea.tsx new file mode 100644 index 0000000..ba530db --- /dev/null +++ b/src/components/layout/ChartsArea.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useRef, useEffect, type ReactNode } from "react"; +import { useContainerSize } from "@/hooks/useContainerSize"; + +interface ChartSlot { + key: string; + title: string; + content: ReactNode; + /** Optional callback when container dimensions change (e.g., chart.resize()) */ + onResize?: (width: number, height: number) => void; +} + +interface ChartsAreaProps { + charts: ChartSlot[]; +} + +/** + * CSS Container-query-driven Charts Area. + * + * Layout (grid vs stacked) and aspect-ratio are driven by @container + * queries in containers.css. The `useContainerSize` hook is used only + * to provide dimension data to imperative charting libraries (Recharts, + * Chart.js, etc.) that need to call `.resize()`. + */ +export function ChartsArea({ charts }: ChartsAreaProps) { + const ref = useRef(null); + const { width, height } = useContainerSize(ref, { + compactMax: 500, + expandedMin: 500, + }); + + // Notify each chart of dimension changes + useEffect(() => { + for (const chart of charts) { + chart.onResize?.(width, height); + } + }, [width, height, charts]); + + return ( +
+ {charts.map((chart) => ( +
+

+ {chart.title} +

+ {chart.content} +
+ ))} + + {charts.length === 0 && ( +
+ No charts configured +
+ )} +
+ ); +} diff --git a/src/components/layout/DataGrid.tsx b/src/components/layout/DataGrid.tsx new file mode 100644 index 0000000..8cb88c4 --- /dev/null +++ b/src/components/layout/DataGrid.tsx @@ -0,0 +1,71 @@ +"use client"; + +interface Column { + key: string; + header: string; + /** If true, this column is hidden in compact (< 600px) mode via CSS */ + secondary?: boolean; + render: (row: T) => React.ReactNode; +} + +interface DataGridProps { + columns: Column[]; + rows: T[]; +} + +/** + * CSS Container-query-driven Data Grid. + * + * Column visibility, row density, and font sizing are all driven by + * @container queries in containers.css. The component renders ALL + * columns into the DOM and lets CSS hide secondary columns when the + * container is compact (< 600px). + */ +export function DataGrid({ + columns, + rows, +}: DataGridProps) { + return ( +
+
+ + + + {columns.map((col) => ( + + ))} + + + + {rows.map((row) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
+ {col.header} +
+ {col.render(row)} +
+ + {rows.length === 0 && ( +
+ No data available +
+ )} +
+
+ ); +} diff --git a/src/components/layout/MapPanel.tsx b/src/components/layout/MapPanel.tsx new file mode 100644 index 0000000..8d909f6 --- /dev/null +++ b/src/components/layout/MapPanel.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { type ReactNode } from "react"; + +interface MapPanelProps { + children?: ReactNode; + /** Toolbar controls rendered inside the map panel header */ + controls?: ReactNode; +} + +/** + * CSS Container-query-driven Map Panel. + * + * All layout is driven by @container queries in containers.css. + * No JS-based class switching — the CSS handles vertical vs horizontal + * control layout based on the container's inline size. + */ +export function MapPanel({ children, controls }: MapPanelProps) { + return ( +
+ {/* Controls toolbar — CSS container queries switch direction */} + {controls && ( +
+ {controls} +
+ )} + + {/* Main content area */} +
+ {children} +
+
+ ); +} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx new file mode 100644 index 0000000..6d802d8 --- /dev/null +++ b/src/components/layout/Sidebar.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { useRef, type ReactNode } from "react"; +import { useContainerSize } from "@/hooks/useContainerSize"; + +interface SidebarProps { + /** Navigation items: { icon: JSX, label: string, href: string } */ + items?: { icon: ReactNode; label: string; href: string }[]; +} + +/** + * CSS Container-query-driven Sidebar. + * + * All layout is driven by @container queries in containers.css. + * The `useContainerSize` hook is only used for the optional dev + * debug indicator — the component does NOT switch classes via JS. + * + * Container states (from containers.css): + * - inline-size < 400px: icon-only, 64px wide + * - 400px <= inline-size < 800px: compact labels, 220px wide + * - inline-size >= 800px: full labels + padding, 320px wide + */ +export function Sidebar({ items = [] }: SidebarProps) { + const ref = useRef(null); + const { width, containerState } = useContainerSize(ref, { + compactMax: 400, + expandedMin: 800, + }); + + return ( + + ); +} diff --git a/src/hooks/useContainerSize.ts b/src/hooks/useContainerSize.ts new file mode 100644 index 0000000..da4a799 --- /dev/null +++ b/src/hooks/useContainerSize.ts @@ -0,0 +1,130 @@ +"use client"; + +import { useEffect, useRef, useState, useCallback } from "react"; + +export interface ContainerSize { + /** Container inline width in pixels */ + width: number; + /** Container block height in pixels */ + height: number; + /** Derived state label: compact | medium | expanded */ + containerState: "compact" | "medium" | "expanded"; +} + +interface UseContainerSizeOptions { + /** Thresholds for state classification (px). Defaults to compact < 400 <= medium < 800 <= expanded */ + compactMax?: number; + expandedMin?: number; + /** Debounce delay in ms (default 100) */ + debounceMs?: number; +} + +function classifyState( + width: number, + compactMax: number, + expandedMin: number +): ContainerSize["containerState"] { + if (width < compactMax) return "compact"; + if (width < expandedMin) return "medium"; + return "expanded"; +} + +/** + * Track a container element's dimensions via ResizeObserver and derive a + * container-state label (compact / medium / expanded) suitable for + * imperative chart re-render triggers. + * + * Threshold defaults are read from CSS custom properties defined in + * src/styles/breakpoints.css (e.g. --sidebar-compact-max). When no + * CSS property is available, the fallback values are used. + * + * @param refOrSelector A React ref to a DOM element, or a CSS selector string. + * @param options Thresholds and debounce configuration. + */ +export function useContainerSize( + refOrSelector: React.RefObject | string, + options: UseContainerSizeOptions = {} +): ContainerSize { + const compactMax = + options.compactMax ?? readCssPixelVar("--container-compact-max", 400); + const expandedMin = + options.expandedMin ?? readCssPixelVar("--container-expanded-min", 800); + const debounceMs = options.debounceMs ?? 100; + + // Read CSS custom properties once on mount to avoid + // forcing synchronous style recalculation on every render. + const thresholdsRef = useRef({ compactMax, expandedMin }); + thresholdsRef.current = { compactMax, expandedMin }; + + const [size, setSize] = useState({ + width: 0, + height: 0, + containerState: "compact", + }); + + const timerRef = useRef>(undefined); + + const handleResize = useCallback( + (entry: ResizeObserverEntry) => { + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => { + const { width, height } = entry.contentRect; + setSize({ + width: Math.floor(width), + height: Math.floor(height), + containerState: classifyState(width, thresholdsRef.current.compactMax, thresholdsRef.current.expandedMin), + }); + }, debounceMs); + }, + [compactMax, expandedMin, debounceMs] + ); + + useEffect(() => { + let el: HTMLElement | null = null; + + if (typeof refOrSelector === "string") { + el = document.querySelector(refOrSelector); + } else { + el = refOrSelector.current; + } + + if (!el) return; + + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + handleResize(entry); + } + }); + + observer.observe(el); + + // Capture initial size + const rect = el.getBoundingClientRect(); + setSize({ + width: Math.floor(rect.width), + height: Math.floor(rect.height), + containerState: classifyState(rect.width, thresholdsRef.current.compactMax, thresholdsRef.current.expandedMin), + }); + + return () => { + observer.disconnect(); + clearTimeout(timerRef.current); + }; + }, [refOrSelector, handleResize, compactMax, expandedMin]); + + return size; +} + +/** + * Read a CSS custom property from :root and parse it as pixels. + * Falls back to `fallback` if the property is not set or unparseable. + */ +function readCssPixelVar(name: string, fallback: number): number { + if (typeof window === "undefined") return fallback; + const raw = getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim(); + if (!raw) return fallback; + const num = parseFloat(raw); + return Number.isNaN(num) ? fallback : num; +} diff --git a/src/styles/breakpoints.css b/src/styles/breakpoints.css new file mode 100644 index 0000000..82fc7ce --- /dev/null +++ b/src/styles/breakpoints.css @@ -0,0 +1,36 @@ +/* + * Container Breakpoints (Custom Properties) + * + * These custom properties expose compact / medium / expanded breakpoint + * thresholds per container so JavaScript can query them at runtime + * without hard-coding values. + * + * Generic properties (`--container-compact-max`, `--container-expanded-min`) + * are read by the `useContainerSize` hook. Per-container properties provide + * finer-grained overrides for specific layout regions. + * + * The breakpoints match the thresholds used in containers.css. + */ +:root { + /* Generic defaults (used by useContainerSize hook) */ + --container-compact-max: 400px; + --container-expanded-min: 800px; + + /* Sidebar breakpoints */ + --sidebar-compact-max: 400px; + --sidebar-medium-min: 400px; + --sidebar-medium-max: 800px; + --sidebar-expanded-min: 800px; + + /* Map panel breakpoints */ + --map-panel-compact-max: 400px; + + /* Data grid breakpoints */ + --data-grid-compact-max: 600px; + --data-grid-medium-min: 600px; + --data-grid-medium-max: 1000px; + --data-grid-expanded-min: 1000px; + + /* Charts area breakpoints */ + --charts-compact-max: 500px; +} diff --git a/src/styles/containers.css b/src/styles/containers.css new file mode 100644 index 0000000..d53ef94 --- /dev/null +++ b/src/styles/containers.css @@ -0,0 +1,255 @@ +/* + * CSS Container Query Layout System + * + * Each layout region is registered as a named containment context + * (container-type: inline-size) so descendant elements can use + * @container queries that respond to the container's inline size + * rather than the viewport. + * + * The components apply a single responsive class (e.g. sidebar-responsive) + * that is always present. The @container rules below activate the + * appropriate styles based on the container's actual inline width. + * + * Usage in JSX: + * + *
+ *
+ *
+ */ + +/* ------------------------------------------------------------------ */ +/* Containment contexts --------------------------------------------- */ +/* ------------------------------------------------------------------ */ + +.container-sidebar { + container-type: inline-size; + container-name: sidebar; +} + +.container-map-panel { + container-type: inline-size; + container-name: map-panel; +} + +.container-data-grid { + container-type: inline-size; + container-name: data-grid; +} + +.container-charts-area { + container-type: inline-size; + container-name: charts-area; +} + +/* ------------------------------------------------------------------ */ +/* Sidebar — responsive via @container sidebar ---------------------- */ +/* ------------------------------------------------------------------ */ + +@container sidebar (inline-size < 400px) { + .sidebar-responsive { + width: 64px; + padding: 0.5rem; + } + + .sidebar-responsive .sidebar-label { + display: none; + } + + .sidebar-responsive .sidebar-icon { + width: 5cqw; + height: 5cqw; + min-width: 16px; + min-height: 16px; + } + + .sidebar-responsive .sidebar-item { + justify-content: center; + padding-left: 0.25rem; + padding-right: 0.25rem; + } +} + +@container sidebar (400px <= inline-size < 800px) { + .sidebar-responsive { + width: 220px; + padding: 1rem; + } +} + +@container sidebar (inline-size >= 800px) { + .sidebar-responsive { + width: 320px; + padding: 1.5rem; + } +} + +/* ------------------------------------------------------------------ */ +/* Map panel — control button layout via @container map-panel ------- */ +/* ------------------------------------------------------------------ */ + +@container map-panel (inline-size < 400px) { + .map-controls { + flex-direction: column; + gap: 0.25rem; + } + + .map-controls button { + padding: 0.25rem 0.5rem; + font-size: clamp(0.75rem, 3cqw, 0.875rem); + } +} + +@container map-panel (inline-size >= 400px) { + .map-controls { + flex-direction: row; + gap: 0.5rem; + } + + .map-controls button { + padding: 0.5rem 1rem; + font-size: clamp(0.875rem, 2cqw, 1rem); + } +} + +/* ------------------------------------------------------------------ */ +/* Data grid — column visibility & density via @container data-grid - */ +/* ------------------------------------------------------------------ */ + +/* Compact: hide secondary columns, dense rows */ +@container data-grid (inline-size < 600px) { + .data-grid-responsive .data-grid-cell-secondary { + display: none; + } + + .data-grid-responsive .data-grid-row { + font-size: clamp(0.75rem, 3cqw, 0.875rem); + height: clamp(32px, 4cqw, 40px); + } +} + +/* Medium: moderate padding */ +@container data-grid (600px <= inline-size < 1000px) { + .data-grid-responsive .data-grid-row { + font-size: clamp(0.875rem, 2cqw, 1rem); + height: clamp(40px, 3cqw, 48px); + } +} + +/* Expanded: comfortable spacing, all columns visible */ +@container data-grid (inline-size >= 1000px) { + .data-grid-responsive .data-grid-row { + font-size: clamp(1rem, 2cqw, 1.125rem); + height: clamp(48px, 2.5cqw, 56px); + } +} + +/* ------------------------------------------------------------------ */ +/* Charts area — grid vs stacked via @container charts-area --------- */ +/* ------------------------------------------------------------------ */ + +@container charts-area (inline-size < 500px) { + .charts-responsive { + display: flex; + flex-direction: column; + gap: 1rem; + } + + .charts-responsive .chart-wrapper { + aspect-ratio: 4 / 3; + width: 100%; + } +} + +@container charts-area (inline-size >= 500px) { + .charts-responsive { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: 1.5rem; + } + + .charts-responsive .chart-wrapper { + aspect-ratio: 16 / 9; + width: 100%; + } +} + +/* ------------------------------------------------------------------ */ +/* Container query typography utilities ------------------------------ */ +/* ------------------------------------------------------------------ */ + +.container-fluid-text { + font-size: clamp(14px, 1cqw, 20px); +} + +.container-fluid-heading { + font-size: clamp(18px, 2.5cqw, 32px); +} + +/* ------------------------------------------------------------------ */ +/* Grid layout helper using container query units ------------------- */ +/* ------------------------------------------------------------------ */ + +.grid-container-auto { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(max(200px, 30cqw), 1fr)); + gap: 1rem; +} + +/* ------------------------------------------------------------------ */ +/* Smooth container resize transitions ------------------------------- */ +/* ------------------------------------------------------------------ */ + +.container-sidebar, +.container-map-panel, +.container-data-grid, +.container-charts-area { + transition: + grid-template-columns 0.3s ease, + padding 0.3s ease, + width 0.3s ease; +} + +/* ------------------------------------------------------------------ */ +/* Fallback for browsers without container query support ------------- */ +/* ------------------------------------------------------------------ */ + +@supports not (container-type: inline-size) { + /* Sidebar fallback: media-query-based approximation */ + @media (max-width: 768px) { + .sidebar-responsive { + width: 64px; + } + + .sidebar-responsive .sidebar-label { + display: none; + } + } + + @media (min-width: 769px) { + .sidebar-responsive { + width: 280px; + } + } + + /* Data grid fallback */ + @media (max-width: 768px) { + .data-grid-responsive .data-grid-cell-secondary { + display: none; + } + } + + /* Charts fallback */ + @media (max-width: 768px) { + .charts-responsive { + display: flex; + flex-direction: column; + } + } + + @media (min-width: 769px) { + .charts-responsive { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + } + } +} diff --git a/tests/e2e/container-queries.spec.ts b/tests/e2e/container-queries.spec.ts new file mode 100644 index 0000000..2eae9c4 --- /dev/null +++ b/tests/e2e/container-queries.spec.ts @@ -0,0 +1,107 @@ +import { test, expect } from "@playwright/test"; + +/** + * Visual / integration tests for the CSS Container Query layout system. + * + * These tests verify container-query-driven behavior by resizing the + * viewport and checking the resulting data-container-state attributes + * on layout components. + */ + +test.describe("Container Query Layout System", () => { + // ------------------------------------------------------------------ + // Sidebar + // ------------------------------------------------------------------ + + test.describe("Sidebar", () => { + test("should render the page at mobile viewport", async ({ page }) => { + await page.setViewportSize({ width: 375, height: 812 }); + await page.goto("/"); + + // Verify the page renders correctly at mobile viewport + await expect(page.locator("body")).toBeVisible(); + await expect(page.locator("header")).toBeVisible(); + }); + + test("should render the main dashboard at desktop viewport", async ({ + page, + }) => { + await page.setViewportSize({ width: 1920, height: 1080 }); + await page.goto("/"); + + // All major sections should be visible + await expect(page.getByText("Grid Network")).toBeVisible(); + await expect(page.getByText("Fleet Overview")).toBeVisible(); + await expect(page.getByText("Live Telemetry")).toBeVisible(); + await expect(page.getByText("Tariff Configuration")).toBeVisible(); + }); + }); + + // ------------------------------------------------------------------ + // Responsive Grid Behavior + // ------------------------------------------------------------------ + + test.describe("Responsive Grid Behavior", () => { + test("should adjust fleet grid card count with viewport width", async ({ + page, + }) => { + // Tablet viewport + await page.setViewportSize({ width: 768, height: 1024 }); + await page.goto("/"); + + // Fleet grid cards should be visible + const cards = page.locator('[class*="rounded-lg border"]'); + const tabletCount = await cards.count(); + expect(tabletCount).toBeGreaterThan(0); + + // Mobile viewport — fewer columns + await page.setViewportSize({ width: 375, height: 812 }); + await page.reload(); + + const mobileCount = await cards.count(); + expect(mobileCount).toBeGreaterThan(0); + // Card count should be similar (filtering/sizing may affect count) + }); + + test("should render canvas grid map at all breakpoints", async ({ + page, + }) => { + for (const dims of Object.values({ + mobile: { width: 375, height: 812 }, + tablet: { width: 768, height: 1024 }, + desktop: { width: 1920, height: 1080 }, + })) { + await page.setViewportSize(dims); + await page.goto("/"); + + const canvas = page.locator("canvas").first(); + await expect(canvas).toBeVisible({ timeout: 5000 }); + } + }); + }); + + // ------------------------------------------------------------------ + // CSS Container Query Units + // ------------------------------------------------------------------ + + test.describe("CSS Container Query Features", () => { + test("should have container query support enabled in the browser", async ({ + page, + }) => { + await page.setViewportSize({ width: 1920, height: 1080 }); + await page.goto("/"); + + // Verify that the browser supports CSS container queries + const supportsContainerQueries = await page.evaluate(() => { + return CSS.supports("container-type", "inline-size"); + }); + + // Chrome (used in CI) supports container queries + expect(supportsContainerQueries).toBe(true); + + // Verify the page loads the CSS (by checking a known class exists) + const bodyVisible = await page.locator("body").isVisible(); + expect(bodyVisible).toBe(true); + }); + }); +}); diff --git a/tests/hooks/useContainerSize.test.ts b/tests/hooks/useContainerSize.test.ts new file mode 100644 index 0000000..d640e39 --- /dev/null +++ b/tests/hooks/useContainerSize.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useContainerSize } from "@/hooks/useContainerSize"; + +/** + * Constructor-compatible ResizeObserver mock. + * Stores the callback and lets tests fire entries on demand. + */ +let observerCallback: ResizeObserverCallback | null = null; + +class MockResizeObserver { + constructor(callback: ResizeObserverCallback) { + observerCallback = callback; + } + observe = vi.fn(); + unobserve = vi.fn(); + disconnect = vi.fn(); +} + +// ---------------------------------------------------------------- + +describe("useContainerSize", () => { + let container: HTMLDivElement; + + beforeEach(() => { + vi.stubGlobal("ResizeObserver", MockResizeObserver); + observerCallback = null; + + container = document.createElement("div"); + container.style.width = "600px"; + container.style.height = "400px"; + document.body.appendChild(container); + + // Mock getBoundingClientRect for the initial size capture + vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ + width: 600, + height: 400, + x: 0, + y: 0, + top: 0, + right: 600, + bottom: 400, + left: 0, + toJSON: () => ({}), + }); + }); + + afterEach(() => { + document.body.innerHTML = ""; + vi.restoreAllMocks(); + }); + + // ------------------------------------------------------------------ + // Initial state + // ------------------------------------------------------------------ + + it("returns initial dimensions from getBoundingClientRect", () => { + const ref = { current: container }; + const { result } = renderHook(() => useContainerSize(ref)); + + // Initial render happens before ResizeObserver fires, so + // we get the value from getBoundingClientRect + expect(result.current.width).toBe(600); + expect(result.current.height).toBe(400); + expect(result.current.containerState).toBe("medium"); // 400 <= 600 < 800 + }); + + it("classifies as compact when width < compactMax", () => { + vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ + width: 200, + height: 400, + x: 0, + y: 0, + top: 0, + right: 200, + bottom: 400, + left: 0, + toJSON: () => ({}), + }); + + const ref = { current: container }; + const { result } = renderHook(() => useContainerSize(ref)); + + expect(result.current.containerState).toBe("compact"); + }); + + it("classifies as expanded when width >= expandedMin", () => { + vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ + width: 1000, + height: 600, + x: 0, + y: 0, + top: 0, + right: 1000, + bottom: 600, + left: 0, + toJSON: () => ({}), + }); + + const ref = { current: container }; + const { result } = renderHook(() => useContainerSize(ref)); + + expect(result.current.containerState).toBe("expanded"); + }); + + // ------------------------------------------------------------------ + // ResizeObserver callback + // ------------------------------------------------------------------ + + it("updates size on ResizeObserver callback (debounced)", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + const ref = { current: container }; + const { result, rerender } = renderHook(() => useContainerSize(ref)); + + // Simulate a resize event + expect(observerCallback).not.toBeNull(); + const entry = { + contentRect: { width: 350, height: 300 }, + } as ResizeObserverEntry; + + observerCallback!([entry], {} as ResizeObserver); + + // Advance past the debounce window + React re-render + vi.advanceTimersByTime(200); + rerender(); + + expect(result.current.width).toBe(350); + expect(result.current.height).toBe(300); + expect(result.current.containerState).toBe("compact"); // 350 < 400 + vi.useRealTimers(); + }); + + // ------------------------------------------------------------------ + // Custom thresholds + // ------------------------------------------------------------------ + + it("respects custom compactMax and expandedMin", () => { + vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ + width: 500, + height: 400, + x: 0, + y: 0, + top: 0, + right: 500, + bottom: 400, + left: 0, + toJSON: () => ({}), + }); + + const ref = { current: container }; + const { result } = renderHook(() => + useContainerSize(ref, { compactMax: 200, expandedMin: 600 }) + ); + + // 500 is >= 200 and < 600 → medium + expect(result.current.containerState).toBe("medium"); + }); + + // ------------------------------------------------------------------ + // Selector-based ref + // ------------------------------------------------------------------ + + it("works with a CSS selector string", () => { + container.id = "test-container"; + vi.spyOn(container, "getBoundingClientRect").mockReturnValue({ + width: 900, + height: 500, + x: 0, + y: 0, + top: 0, + right: 900, + bottom: 500, + left: 0, + toJSON: () => ({}), + }); + + const { result } = renderHook(() => useContainerSize("#test-container")); + + expect(result.current.width).toBe(900); + expect(result.current.containerState).toBe("expanded"); + }); +});