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
2 changes: 2 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
@import "tailwindcss";
@import "../styles/breakpoints.css";
@import "../styles/containers.css";

:root,
.light {
Expand Down
61 changes: 61 additions & 0 deletions src/components/layout/ChartsArea.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(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 (
<div ref={ref} className="container-charts-area charts-responsive p-4">
{charts.map((chart) => (
<div
key={chart.key}
className="chart-wrapper relative rounded-xl border border-border bg-background p-4"
>
<h3 className="text-sm font-semibold text-muted-foreground mb-2">
{chart.title}
</h3>
{chart.content}
</div>
))}

{charts.length === 0 && (
<div className="flex items-center justify-center col-span-full py-12 text-sm text-muted-foreground">
No charts configured
</div>
)}
</div>
);
}
71 changes: 71 additions & 0 deletions src/components/layout/DataGrid.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"use client";

interface Column<T> {
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<T extends { id: string }> {
columns: Column<T>[];
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<T extends { id: string }>({
columns,
rows,
}: DataGridProps<T>) {
return (
<div className="container-data-grid data-grid-responsive border border-border rounded-xl bg-background overflow-hidden">
<div className="w-full overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b border-border bg-muted/50">
{columns.map((col) => (
<th
key={col.key}
className={`data-grid-cell-${col.secondary ? "secondary" : "primary"} text-left text-xs font-semibold text-muted-foreground uppercase tracking-wider px-4 py-2`}
>
{col.header}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.id}
className="data-grid-row border-b border-border hover:bg-accent/30 transition-colors"
>
{columns.map((col) => (
<td
key={col.key}
className={`data-grid-cell-${col.secondary ? "secondary" : "primary"} px-4`}
>
{col.render(row)}
</td>
))}
</tr>
))}
</tbody>
</table>

{rows.length === 0 && (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
No data available
</div>
)}
</div>
</div>
);
}
34 changes: 34 additions & 0 deletions src/components/layout/MapPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="container-map-panel rounded-xl border border-border bg-background overflow-hidden">
{/* Controls toolbar — CSS container queries switch direction */}
{controls && (
<div className="map-controls flex items-center p-2 gap-2 border-b border-border">
{controls}
</div>
)}

{/* Main content area */}
<div className="w-full" style={{ minHeight: 300 }}>
{children}
</div>
</div>
);
}
61 changes: 61 additions & 0 deletions src/components/layout/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>(null);
const { width, containerState } = useContainerSize(ref, {
compactMax: 400,
expandedMin: 800,
});

return (
<aside
ref={ref}
className="container-sidebar border-r border-border bg-background shrink-0 sidebar-responsive"
data-container-state={containerState}
style={{ minWidth: 0 }}
>
{/* Navigation items — labels are hidden by CSS container queries in compact mode */}
<nav className="flex flex-col gap-1 p-2">
{items.map((item) => (
<a
key={item.href}
href={item.href}
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-accent transition-colors text-sm font-medium text-foreground no-underline sidebar-item"
>
<span className="sidebar-icon shrink-0">{item.icon}</span>
<span className="sidebar-label truncate">{item.label}</span>
</a>
))}
</nav>

{/* Container size indicator (development aid only) */}
{process.env.NODE_ENV === "development" && (
<div className="mt-auto pt-4 border-t border-border px-2">
<span className="text-xs text-muted-foreground">
{width}px · {containerState}
</span>
</div>
)}
</aside>
);
}
130 changes: 130 additions & 0 deletions src/hooks/useContainerSize.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLElement | null> | 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<ContainerSize>({
width: 0,
height: 0,
containerState: "compact",
});

const timerRef = useRef<ReturnType<typeof setTimeout>>(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<HTMLElement>(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;
}
Loading
Loading