Muluhabt ERP modules

This commit is contained in:
Mulu Mehari
2026-08-25 00:11:39 +03:00
parent 5c2100e76d
commit 70171fa9d8
441 changed files with 68587 additions and 214 deletions

View File

@@ -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>
);

View File

@@ -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>
);
};

View File

@@ -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";

View File

@@ -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";