mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 19:00:55 +00:00
Muluhabt ERP modules
This commit is contained in:
@@ -4,11 +4,17 @@ import {
|
||||
ChevronDown,
|
||||
Languages,
|
||||
LogOut,
|
||||
Menu,
|
||||
Moon,
|
||||
Sun,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Sidebar, { SidebarItem } from "./Sidebar";
|
||||
import Sidebar, { SidebarItem, useSidebarMode } from "./Sidebar";
|
||||
|
||||
export interface LanguageOption {
|
||||
code: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface DashboardLayoutProps {
|
||||
title?: string;
|
||||
@@ -22,6 +28,38 @@ export interface DashboardLayoutProps {
|
||||
userInitials?: string;
|
||||
onLogout?: () => void;
|
||||
children: ReactNode;
|
||||
|
||||
// ---- additive; every default below reproduces the previous behaviour ----
|
||||
|
||||
/**
|
||||
* Where the reader is, shown at the left of the top bar. When given it
|
||||
* REPLACES the app title there — the title already sits in the sidebar, and
|
||||
* printing it twice per screen spends the one spot that could say something.
|
||||
*/
|
||||
breadcrumb?: ReactNode;
|
||||
/**
|
||||
* Offer these languages behind the globe. Omitted, the globe stays the inert
|
||||
* button it has always been for the apps that never wired one up.
|
||||
*/
|
||||
languages?: LanguageOption[];
|
||||
language?: string;
|
||||
onLanguageChange?: (code: string) => void;
|
||||
/**
|
||||
* The bell. Default `true` for compatibility, but an app with no
|
||||
* notification source should pass `false`: a red dot that never clears is a
|
||||
* lie, and readers learn to ignore it.
|
||||
*/
|
||||
showNotifications?: boolean;
|
||||
/** Target of the user menu's Profile link. */
|
||||
profileHref?: string;
|
||||
/** Hide Profile entirely where the app has no profile screen. */
|
||||
showProfileLink?: boolean;
|
||||
/** Collapse to a rail, then a drawer, as the viewport narrows. */
|
||||
responsiveSidebar?: boolean;
|
||||
/** localStorage key for which sidebar groups the reader left open. */
|
||||
sidebarPersistKey?: string;
|
||||
/** Extra controls next to the breadcrumb — a search trigger, typically. */
|
||||
headerLeft?: ReactNode;
|
||||
}
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
@@ -51,6 +89,16 @@ const DashboardLayout = ({
|
||||
userInitials,
|
||||
onLogout,
|
||||
children,
|
||||
breadcrumb,
|
||||
languages,
|
||||
language,
|
||||
onLanguageChange,
|
||||
showNotifications = true,
|
||||
profileHref = "#profile",
|
||||
showProfileLink = true,
|
||||
responsiveSidebar = false,
|
||||
sidebarPersistKey,
|
||||
headerLeft,
|
||||
}: DashboardLayoutProps) => {
|
||||
const initials =
|
||||
userInitials ??
|
||||
@@ -80,6 +128,11 @@ const DashboardLayout = ({
|
||||
|
||||
const [isUserMenuOpen, setIsUserMenuOpen] = useState(false);
|
||||
const userMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [isLangMenuOpen, setIsLangMenuOpen] = useState(false);
|
||||
const langMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
|
||||
const sidebarMode = useSidebarMode(responsiveSidebar);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isUserMenuOpen) return;
|
||||
@@ -105,6 +158,27 @@ const DashboardLayout = ({
|
||||
};
|
||||
}, [isUserMenuOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLangMenuOpen) return;
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (
|
||||
langMenuRef.current &&
|
||||
!langMenuRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsLangMenuOpen(false);
|
||||
}
|
||||
};
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") setIsLangMenuOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [isLangMenuOpen]);
|
||||
|
||||
const themeToggleButton = enableThemeToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
@@ -122,6 +196,53 @@ const DashboardLayout = ({
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const languageControl =
|
||||
languages && languages.length > 0 ? (
|
||||
<div ref={langMenuRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Change language"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isLangMenuOpen}
|
||||
data-testid="language-button"
|
||||
onClick={() => setIsLangMenuOpen((open) => !open)}
|
||||
className={iconButtonClass}
|
||||
>
|
||||
<Languages className="h-5 w-5" />
|
||||
</button>
|
||||
{isLangMenuOpen ? (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute right-0 top-full z-50 mt-2 w-40 overflow-hidden rounded-xl border border-border bg-card py-1 shadow-lg"
|
||||
>
|
||||
{languages.map((option) => (
|
||||
<button
|
||||
key={option.code}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid={`language-option-${option.code}`}
|
||||
onClick={() => {
|
||||
setIsLangMenuOpen(false);
|
||||
onLanguageChange?.(option.code);
|
||||
}}
|
||||
className={`flex w-full items-center px-4 py-2 text-sm transition hover:bg-accent hover:text-accent-foreground ${
|
||||
option.code === language
|
||||
? "font-semibold text-[#10B981]"
|
||||
: "text-card-foreground"
|
||||
}`}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" aria-label="Change language" className={iconButtonClass}>
|
||||
<Languages className="h-5 w-5" />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar
|
||||
@@ -130,37 +251,58 @@ const DashboardLayout = ({
|
||||
activeHref={activeHref}
|
||||
onNavigate={onNavigate}
|
||||
headerExtra={themeToggleButton}
|
||||
responsive={responsiveSidebar}
|
||||
persistKey={sidebarPersistKey}
|
||||
mobileOpen={drawerOpen}
|
||||
onMobileOpenChange={setDrawerOpen}
|
||||
/>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<header className="flex h-16 items-center justify-between border-b border-border bg-background px-6 text-foreground">
|
||||
<div className="text-base font-medium">{title}</div>
|
||||
{/* `min-w-0`: a flex child will not shrink below its content's intrinsic
|
||||
width, so one wide table used to stretch this column and scroll the
|
||||
whole PAGE sideways, header included. With it, each wide table
|
||||
scrolls inside its own container. */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex h-16 items-center justify-between gap-3 border-b border-border bg-background px-6 text-foreground">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
{responsiveSidebar && sidebarMode === "drawer" ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open navigation"
|
||||
data-testid="nav-hamburger"
|
||||
onClick={() => setDrawerOpen(true)}
|
||||
className="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-border bg-card text-foreground transition hover:bg-accent"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
) : null}
|
||||
<div className="min-w-0 truncate text-base font-medium">
|
||||
{breadcrumb ?? title}
|
||||
</div>
|
||||
{headerLeft}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Change language"
|
||||
className={iconButtonClass}
|
||||
>
|
||||
<Languages className="h-5 w-5" />
|
||||
</button>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{languageControl}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Notifications"
|
||||
className={`${iconButtonClass} relative`}
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white dark:ring-slate-900" />
|
||||
</button>
|
||||
{showNotifications ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Notifications"
|
||||
className={`${iconButtonClass} relative`}
|
||||
>
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="absolute right-2 top-2 h-2 w-2 rounded-full bg-red-500 ring-2 ring-white dark:ring-slate-900" />
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div ref={userMenuRef} className="relative ml-1">
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isUserMenuOpen}
|
||||
data-testid="user-menu-button"
|
||||
onClick={() => setIsUserMenuOpen((open) => !open)}
|
||||
className="flex items-center gap-2 rounded-xl border border-transparent px-2 py-1.5 transition hover:border-[#10B981]/20 hover:bg-accent aria-expanded:border-[#10B981]/30 aria-expanded:bg-accent"
|
||||
>
|
||||
className="flex items-center gap-2 rounded-xl border border-transparent px-2 py-1.5 transition hover:border-[#10B981]/20 hover:bg-accent aria-expanded:border-[#10B981]/30 aria-expanded:bg-accent"
|
||||
>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-[#10B981] text-xs font-semibold text-white">
|
||||
{initials}
|
||||
</div>
|
||||
@@ -168,8 +310,9 @@ const DashboardLayout = ({
|
||||
{userName}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-4 w-4 text-muted-foreground transition ${isUserMenuOpen ? "rotate-180 text-[#10B981]" : ""
|
||||
}`}
|
||||
className={`h-4 w-4 text-muted-foreground transition ${
|
||||
isUserMenuOpen ? "rotate-180 text-[#10B981]" : ""
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -188,18 +331,28 @@ const DashboardLayout = ({
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<a
|
||||
href="#profile"
|
||||
role="menuitem"
|
||||
onClick={() => setIsUserMenuOpen(false)}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-card-foreground transition hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
Profile
|
||||
</a>
|
||||
{showProfileLink ? (
|
||||
<a
|
||||
href={profileHref}
|
||||
role="menuitem"
|
||||
data-testid="user-menu-profile"
|
||||
onClick={(event) => {
|
||||
setIsUserMenuOpen(false);
|
||||
if (onNavigate && profileHref.startsWith("/")) {
|
||||
event.preventDefault();
|
||||
onNavigate(profileHref);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-card-foreground transition hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
Profile
|
||||
</a>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
data-testid="user-menu-logout"
|
||||
onClick={() => {
|
||||
setIsUserMenuOpen(false);
|
||||
onLogout?.();
|
||||
@@ -217,7 +370,9 @@ const DashboardLayout = ({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-auto bg-background ">{children}</main>
|
||||
<main className="min-w-0 flex-1 overflow-auto bg-background">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
import { type MouseEvent, type ReactNode, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import clsx from "clsx";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
export interface SidebarItem {
|
||||
/** Stable identity. Falls back to `href`, then `label`, for older callers. */
|
||||
id?: string;
|
||||
label: string;
|
||||
href: string;
|
||||
/**
|
||||
* Optional: a group header carries no route. It renders as a `<button>` that
|
||||
* only toggles, so it can never navigate to a screen the reader is gated out
|
||||
* of.
|
||||
*/
|
||||
href?: string;
|
||||
icon?: ReactNode;
|
||||
/** Rendered at the right of the row — a count, a dot, anything small. */
|
||||
badge?: ReactNode;
|
||||
/** Emitted as `data-testid` on the row. */
|
||||
testId?: string;
|
||||
children?: SidebarItem[];
|
||||
}
|
||||
|
||||
@@ -15,8 +33,90 @@ export interface SidebarProps {
|
||||
activeHref?: string;
|
||||
onNavigate?: (href: string) => void;
|
||||
headerExtra?: ReactNode;
|
||||
/**
|
||||
* Opt in to the collapsing rail. Default `false`, so every existing consumer
|
||||
* keeps the fixed 256px column it has today.
|
||||
*/
|
||||
responsive?: boolean;
|
||||
/** localStorage key for which groups the reader left open. */
|
||||
persistKey?: string;
|
||||
/** Drawer state, below `DRAWER_MAX`. Owned by the caller so the header's
|
||||
* hamburger and the drawer agree. */
|
||||
mobileOpen?: boolean;
|
||||
onMobileOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/** Full labelled sidebar at or above this width. */
|
||||
const FULL_MIN = 1280;
|
||||
/** Below this the sidebar leaves the flow entirely and opens as a drawer. */
|
||||
const DRAWER_MAX = 900;
|
||||
|
||||
export type SidebarMode = "full" | "rail" | "drawer";
|
||||
|
||||
/**
|
||||
* Which of the three layouts applies at the current width.
|
||||
*
|
||||
* Exported because the header needs it too: the hamburger exists only in
|
||||
* `drawer` mode, and rendering one that opens nothing is worse than none.
|
||||
*/
|
||||
export const useSidebarMode = (responsive = false): SidebarMode => {
|
||||
const read = useCallback((): SidebarMode => {
|
||||
if (!responsive || typeof window === "undefined") return "full";
|
||||
const width = window.innerWidth;
|
||||
if (width >= FULL_MIN) return "full";
|
||||
if (width >= DRAWER_MAX) return "rail";
|
||||
return "drawer";
|
||||
}, [responsive]);
|
||||
|
||||
const [mode, setMode] = useState<SidebarMode>(read);
|
||||
|
||||
useEffect(() => {
|
||||
if (!responsive) {
|
||||
setMode("full");
|
||||
return;
|
||||
}
|
||||
const onResize = () => setMode(read());
|
||||
onResize();
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [responsive, read]);
|
||||
|
||||
return mode;
|
||||
};
|
||||
|
||||
const keyOf = (item: SidebarItem) => item.id ?? item.href ?? item.label;
|
||||
|
||||
/**
|
||||
* How well `href` matches `path`, or -1 when it does not.
|
||||
*
|
||||
* Whole-segment boundaries, and "/" matches only itself. The score is the
|
||||
* matched length so the LONGEST match can win: `/leave/approvals` used to light
|
||||
* up both "My leave" and "Leave approvals", because each row tested its own
|
||||
* prefix in isolation and both passed.
|
||||
*/
|
||||
const matchLength = (href: string, path: string) => {
|
||||
const target = href.toLowerCase().replace(/\/+$/, "");
|
||||
const current = path.toLowerCase().replace(/\/+$/, "") || "/";
|
||||
if (target === "") return current === "/" ? 1 : -1;
|
||||
if (current === target) return target.length;
|
||||
if (current.startsWith(`${target}/`)) return target.length;
|
||||
return -1;
|
||||
};
|
||||
|
||||
const readStored = (persistKey?: string): Record<string, boolean> => {
|
||||
if (!persistKey || typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = window.localStorage.getItem(persistKey);
|
||||
return raw ? (JSON.parse(raw) as Record<string, boolean>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
/** Only the `true` entries of an auto-expand map. See the merge below. */
|
||||
const openOnly = (map: Record<string, boolean>) =>
|
||||
Object.fromEntries(Object.entries(map).filter(([, open]) => open));
|
||||
|
||||
/**
|
||||
* Brand palette
|
||||
* #10B981 — dominant dark green → brand mark, active state, hover text
|
||||
@@ -29,157 +129,365 @@ const Sidebar = ({
|
||||
activeHref,
|
||||
onNavigate,
|
||||
headerExtra,
|
||||
responsive = false,
|
||||
persistKey,
|
||||
mobileOpen = false,
|
||||
onMobileOpenChange,
|
||||
}: SidebarProps) => {
|
||||
const activePath = activeHref?.toLowerCase() ?? "";
|
||||
const mode = useSidebarMode(responsive);
|
||||
const activePath = activeHref ?? "";
|
||||
|
||||
/** The single best-matching href in the whole tree — at most one row is active. */
|
||||
const activeItemHref = useMemo(() => {
|
||||
let best: string | undefined;
|
||||
let bestScore = 0;
|
||||
const consider = (href?: string) => {
|
||||
if (!href) return;
|
||||
const score = matchLength(href, activePath);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = href;
|
||||
}
|
||||
};
|
||||
for (const item of items) {
|
||||
consider(item.href);
|
||||
item.children?.forEach((child) => consider(child.href));
|
||||
}
|
||||
return best;
|
||||
}, [activePath, items]);
|
||||
|
||||
const defaultExpanded = useMemo(
|
||||
() =>
|
||||
items.reduce<Record<string, boolean>>((acc, item) => {
|
||||
if (item.children?.length) {
|
||||
acc[item.href] =
|
||||
activePath === item.href.toLowerCase() ||
|
||||
activePath.startsWith(`${item.href.toLowerCase()}/`) ||
|
||||
item.children.some((child) =>
|
||||
activePath.startsWith(child.href.toLowerCase()),
|
||||
);
|
||||
acc[keyOf(item)] = item.children.some(
|
||||
(child) => child.href && child.href === activeItemHref,
|
||||
);
|
||||
}
|
||||
return acc;
|
||||
}, {}),
|
||||
[activePath, items],
|
||||
[activeItemHref, items],
|
||||
);
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>(defaultExpanded);
|
||||
|
||||
const [expanded, setExpanded] = useState<Record<string, boolean>>(() => ({
|
||||
...readStored(persistKey),
|
||||
...openOnly(defaultExpanded),
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
setExpanded((current) => ({ ...defaultExpanded, ...current }));
|
||||
// Merge only the `true` entries. `defaultExpanded` says `false` for every
|
||||
// group that does not hold the current page, and those falses are not a
|
||||
// statement about those groups — spreading the map whole silently closed
|
||||
// whatever the reader had left open.
|
||||
setExpanded((current) => ({ ...current, ...openOnly(defaultExpanded) }));
|
||||
}, [defaultExpanded]);
|
||||
|
||||
const navigateTo = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
|
||||
useEffect(() => {
|
||||
if (!persistKey || typeof window === "undefined") return;
|
||||
try {
|
||||
window.localStorage.setItem(persistKey, JSON.stringify(expanded));
|
||||
} catch {
|
||||
/* a full or blocked store must not take the nav down */
|
||||
}
|
||||
}, [expanded, persistKey]);
|
||||
|
||||
const [flyout, setFlyout] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (mode !== "rail") setFlyout(null);
|
||||
}, [mode]);
|
||||
|
||||
const closeDrawer = useCallback(
|
||||
() => onMobileOpenChange?.(false),
|
||||
[onMobileOpenChange],
|
||||
);
|
||||
|
||||
// A drawer that survives the route change hides the page it just opened.
|
||||
useEffect(() => {
|
||||
if (mode === "drawer" && mobileOpen) closeDrawer();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activePath]);
|
||||
|
||||
// Widening past the drawer breakpoint leaves an overlay with no way back.
|
||||
useEffect(() => {
|
||||
if (mode !== "drawer" && mobileOpen) closeDrawer();
|
||||
}, [mode, mobileOpen, closeDrawer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!(mode === "drawer" && mobileOpen)) return;
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") closeDrawer();
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [mode, mobileOpen, closeDrawer]);
|
||||
|
||||
const navigateTo = (
|
||||
event: MouseEvent<HTMLAnchorElement>,
|
||||
href: string,
|
||||
) => {
|
||||
if (onNavigate) {
|
||||
event.preventDefault();
|
||||
onNavigate(href);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="flex w-64 flex-col gap-1 border-r border-sidebar-border bg-sidebar px-3 py-5 ">
|
||||
{title ? (
|
||||
<div className="flex items-center justify-between gap-2 px-3 pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-sidebar-primary text-sm font-bold text-white">
|
||||
{title.charAt(0)}
|
||||
</div>
|
||||
<div className="text-base font-semibold text-slate-800 dark:text-slate-100">
|
||||
{title}
|
||||
</div>
|
||||
</div>
|
||||
{headerExtra}
|
||||
</div>
|
||||
) : null}
|
||||
const toggle = (key: string) =>
|
||||
setExpanded((current) => ({ ...current, [key]: !current[key] }));
|
||||
|
||||
<nav className="flex flex-col gap-1">
|
||||
{items.map((item) => {
|
||||
const hasChildren = Boolean(item.children?.length);
|
||||
const itemHref = item.href.toLowerCase();
|
||||
const childActive =
|
||||
item.children?.some((child) =>
|
||||
activePath.startsWith(child.href.toLowerCase()),
|
||||
) ?? false;
|
||||
const isCurrentItem = hasChildren
|
||||
? activePath === itemHref
|
||||
: activePath === itemHref || activePath.startsWith(`${itemHref}/`);
|
||||
const isSectionActive = childActive && !isCurrentItem;
|
||||
const renderBadge = (badge: ReactNode, active: boolean) =>
|
||||
badge === undefined || badge === null || badge === false ? null : (
|
||||
<span
|
||||
className={clsx(
|
||||
"ml-auto inline-flex min-w-5 shrink-0 items-center justify-center rounded-full px-1.5 py-0.5 text-xs font-semibold",
|
||||
active
|
||||
? "bg-white/20 text-white"
|
||||
: "bg-[#10B981]/15 text-[#0f766e] dark:bg-emerald-400/20 dark:text-emerald-200",
|
||||
)}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={item.href} className="flex flex-col gap-1">
|
||||
<div
|
||||
className={clsx(
|
||||
"group flex items-center gap-2 rounded-md transition",
|
||||
isCurrentItem
|
||||
? "bg-sidebar-primary text-sidebar-primary-foreground shadow-sm"
|
||||
: isSectionActive
|
||||
? "bg-sidebar-accent/70 text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<a
|
||||
href={item.href}
|
||||
onClick={(event) => navigateTo(event, item.href)}
|
||||
aria-current={isCurrentItem ? "page" : undefined}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-sm font-medium"
|
||||
>
|
||||
{item.icon ? (
|
||||
<span
|
||||
className={clsx(
|
||||
"flex h-5 w-5 items-center justify-center [&_svg]:h-5 [&_svg]:w-5",
|
||||
isCurrentItem
|
||||
? "text-white"
|
||||
: isSectionActive
|
||||
? "text-[#10B981] dark:text-emerald-300"
|
||||
: "text-slate-500 group-hover:text-[#10B981] dark:text-slate-400 dark:group-hover:text-white",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
) : null}
|
||||
<span className="truncate">{item.label}</span>
|
||||
</a>
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Toggle ${item.label}`}
|
||||
aria-expanded={expanded[item.href] ?? false}
|
||||
onClick={() =>
|
||||
setExpanded((current) => ({
|
||||
...current,
|
||||
[item.href]: !current[item.href],
|
||||
}))
|
||||
}
|
||||
className={clsx(
|
||||
"mr-2 inline-flex h-8 w-8 items-center justify-center rounded-md transition",
|
||||
isCurrentItem
|
||||
? "text-white/90 hover:bg-white/10"
|
||||
: isSectionActive
|
||||
? "text-[#10B981] hover:bg-sidebar-accent/80 dark:text-emerald-300"
|
||||
: "text-slate-500 hover:bg-sidebar-accent/60 dark:text-slate-400",
|
||||
)}
|
||||
>
|
||||
<ChevronDown
|
||||
className={clsx(
|
||||
"h-4 w-4 transition-transform",
|
||||
expanded[item.href] ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
const isRail = mode === "rail";
|
||||
|
||||
{hasChildren && expanded[item.href] ? (
|
||||
<div className="ml-4 flex flex-col gap-1 border-l border-sidebar-border/60 pl-3">
|
||||
{item.children!.map((child) => {
|
||||
const childActiveHref = activePath === child.href.toLowerCase();
|
||||
const renderChildren = (item: SidebarItem, inFlyout = false) => (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex flex-col gap-1",
|
||||
inFlyout
|
||||
? ""
|
||||
: "ml-4 border-l border-sidebar-border/60 pl-3",
|
||||
)}
|
||||
>
|
||||
{item.children!.map((child) => {
|
||||
const childActive = Boolean(child.href) && child.href === activeItemHref;
|
||||
return (
|
||||
<a
|
||||
key={keyOf(child)}
|
||||
href={child.href}
|
||||
data-testid={child.testId}
|
||||
onClick={(event) => navigateTo(event, child.href!)}
|
||||
aria-current={childActive ? "page" : undefined}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 rounded-md px-3 py-2 text-sm transition",
|
||||
childActive
|
||||
? "bg-sidebar-primary text-sidebar-primary-foreground shadow-sm"
|
||||
: "text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{child.label}</span>
|
||||
{renderBadge(child.badge, childActive)}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<a
|
||||
key={child.href}
|
||||
href={child.href}
|
||||
onClick={(event) => navigateTo(event, child.href)}
|
||||
aria-current={childActiveHref ? "page" : undefined}
|
||||
className={clsx(
|
||||
"rounded-md px-3 py-2 text-sm transition",
|
||||
childActiveHref
|
||||
? "bg-sidebar-primary text-sidebar-primary-foreground shadow-sm"
|
||||
: "text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground",
|
||||
)}
|
||||
>
|
||||
{child.label}
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
const renderItem = (item: SidebarItem) => {
|
||||
const key = keyOf(item);
|
||||
const hasChildren = Boolean(item.children?.length);
|
||||
const isCurrentItem = Boolean(item.href) && item.href === activeItemHref;
|
||||
const isSectionActive =
|
||||
hasChildren &&
|
||||
(item.children?.some(
|
||||
(child) => child.href && child.href === activeItemHref,
|
||||
) ??
|
||||
false);
|
||||
const open = expanded[key] ?? false;
|
||||
|
||||
const rowClass = clsx(
|
||||
"group flex items-center gap-2 rounded-md transition",
|
||||
isCurrentItem
|
||||
? "bg-sidebar-primary text-sidebar-primary-foreground shadow-sm"
|
||||
: isSectionActive
|
||||
? "bg-sidebar-accent/70 text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground hover:bg-sidebar-accent/60 hover:text-sidebar-accent-foreground",
|
||||
);
|
||||
|
||||
const iconSpan = item.icon ? (
|
||||
<span
|
||||
className={clsx(
|
||||
"flex h-5 w-5 shrink-0 items-center justify-center [&_svg]:h-5 [&_svg]:w-5",
|
||||
isCurrentItem
|
||||
? "text-white"
|
||||
: isSectionActive
|
||||
? "text-[#10B981] dark:text-emerald-300"
|
||||
: "text-slate-500 group-hover:text-[#10B981] dark:text-slate-400 dark:group-hover:text-white",
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
// ---- rail: icons only, groups open a flyout -------------------------
|
||||
if (isRail) {
|
||||
const flyoutOpen = flyout === key;
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="relative"
|
||||
onMouseEnter={() => hasChildren && setFlyout(key)}
|
||||
onMouseLeave={() => hasChildren && setFlyout(null)}
|
||||
>
|
||||
{item.href ? (
|
||||
<a
|
||||
href={item.href}
|
||||
data-testid={item.testId}
|
||||
title={item.label}
|
||||
aria-label={item.label}
|
||||
onClick={(event) => navigateTo(event, item.href!)}
|
||||
aria-current={isCurrentItem ? "page" : undefined}
|
||||
className={clsx(rowClass, "relative justify-center px-2 py-2.5")}
|
||||
>
|
||||
{iconSpan ?? <span className="text-sm">{item.label[0]}</span>}
|
||||
{item.badge ? (
|
||||
<span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-[#10B981]" />
|
||||
) : null}
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={item.testId}
|
||||
title={item.label}
|
||||
aria-label={item.label}
|
||||
aria-expanded={flyoutOpen}
|
||||
onClick={() => setFlyout(flyoutOpen ? null : key)}
|
||||
className={clsx(rowClass, "relative w-full justify-center px-2 py-2.5")}
|
||||
>
|
||||
{iconSpan ?? <span className="text-sm">{item.label[0]}</span>}
|
||||
{item.badge ? (
|
||||
<span className="absolute right-1 top-1 h-2 w-2 rounded-full bg-[#10B981]" />
|
||||
) : null}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasChildren && flyoutOpen ? (
|
||||
<div className="absolute left-full top-0 z-50 ml-1 w-56 rounded-xl border border-sidebar-border bg-sidebar p-2 shadow-lg">
|
||||
<div className="px-2 pb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
||||
{item.label}
|
||||
</div>
|
||||
{renderChildren(item, true)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- full / drawer: labelled rows -----------------------------------
|
||||
return (
|
||||
<div key={key} className="flex flex-col gap-1">
|
||||
<div className={rowClass}>
|
||||
{item.href ? (
|
||||
<a
|
||||
href={item.href}
|
||||
data-testid={item.testId}
|
||||
onClick={(event) => navigateTo(event, item.href!)}
|
||||
aria-current={isCurrentItem ? "page" : undefined}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-sm font-medium"
|
||||
>
|
||||
{iconSpan}
|
||||
<span className="truncate">{item.label}</span>
|
||||
{renderBadge(item.badge, isCurrentItem)}
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={item.testId}
|
||||
aria-expanded={open}
|
||||
onClick={() => toggle(key)}
|
||||
className="flex min-w-0 flex-1 items-center gap-3 px-3 py-2.5 text-left text-sm font-medium"
|
||||
>
|
||||
{iconSpan}
|
||||
<span className="truncate">{item.label}</span>
|
||||
{renderBadge(item.badge, isCurrentItem)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Toggle ${item.label}`}
|
||||
aria-expanded={open}
|
||||
onClick={() => toggle(key)}
|
||||
className={clsx(
|
||||
"mr-2 inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md transition",
|
||||
isCurrentItem
|
||||
? "text-white/90 hover:bg-white/10"
|
||||
: isSectionActive
|
||||
? "text-[#10B981] hover:bg-sidebar-accent/80 dark:text-emerald-300"
|
||||
: "text-slate-500 hover:bg-sidebar-accent/60 dark:text-slate-400",
|
||||
)}
|
||||
>
|
||||
<ChevronDown
|
||||
className={clsx(
|
||||
"h-4 w-4 transition-transform",
|
||||
open ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{hasChildren && open ? renderChildren(item) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const header = title ? (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex items-center gap-2 pb-4",
|
||||
isRail ? "justify-center px-0" : "justify-between px-3",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-sidebar-primary text-sm font-bold text-white">
|
||||
{title.charAt(0)}
|
||||
</div>
|
||||
{!isRail ? (
|
||||
<div className="truncate text-base font-semibold text-slate-800 dark:text-slate-100">
|
||||
{title}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!isRail ? headerExtra : null}
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
// `sticky top-0 h-screen` + `overflow-y-auto`: the column scrolls itself.
|
||||
// Without it a tall nav (HR's 25 entries run to ~1,180px) pushed the last
|
||||
// rows below the fold and scrolled the whole page to reach them.
|
||||
const asideClass = clsx(
|
||||
"flex shrink-0 flex-col gap-1 border-r border-sidebar-border bg-sidebar py-5",
|
||||
"sticky top-0 h-screen overflow-y-auto",
|
||||
isRail ? "w-16 px-2" : "w-64 px-3",
|
||||
);
|
||||
|
||||
if (mode === "drawer") {
|
||||
if (!mobileOpen) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close navigation"
|
||||
onClick={closeDrawer}
|
||||
className="absolute inset-0 bg-black/40"
|
||||
/>
|
||||
<aside
|
||||
className={clsx(asideClass, "relative z-10 w-64 px-3 shadow-xl")}
|
||||
data-testid="sidebar-drawer"
|
||||
>
|
||||
{header}
|
||||
<nav className="flex flex-col gap-1">{items.map(renderItem)}</nav>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={asideClass} data-testid="sidebar">
|
||||
{header}
|
||||
<nav className="flex flex-col gap-1">{items.map(renderItem)}</nav>
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
export { default as Sidebar } from "./Sidebar";
|
||||
export { default as Sidebar, useSidebarMode } from "./Sidebar";
|
||||
export { default as DashboardLayout } from "./DashboardLayout";
|
||||
export type { SidebarProps, SidebarItem } from "./Sidebar";
|
||||
export type { DashboardLayoutProps } from "./DashboardLayout";
|
||||
export type { SidebarProps, SidebarItem, SidebarMode } from "./Sidebar";
|
||||
export type {
|
||||
DashboardLayoutProps,
|
||||
LanguageOption,
|
||||
} from "./DashboardLayout";
|
||||
|
||||
@@ -51,12 +51,18 @@ export type {
|
||||
export { Badge } from "./components/badge";
|
||||
// export type { BadgeProps } from "./components/badge";
|
||||
|
||||
export { Sidebar, DashboardLayout } from "./components/Layout";
|
||||
export {
|
||||
Sidebar,
|
||||
DashboardLayout,
|
||||
useSidebarMode,
|
||||
} from "./components/Layout";
|
||||
|
||||
export type {
|
||||
SidebarProps,
|
||||
SidebarItem,
|
||||
SidebarMode,
|
||||
DashboardLayoutProps,
|
||||
LanguageOption,
|
||||
} from "./components/Layout";
|
||||
|
||||
export * from "./components/button";
|
||||
|
||||
Reference in New Issue
Block a user