forked from baron/baron-sso
사이드바 접기 기능 추가
This commit is contained in:
@@ -127,6 +127,22 @@ describe("admin AppLayout", () => {
|
|||||||
expect(worksmobileIcon.querySelector('path[fill="white"]')).toBeNull();
|
expect(worksmobileIcon.querySelector('path[fill="white"]')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("toggles the sidebar and persists the collapsed state", async () => {
|
||||||
|
renderLayout();
|
||||||
|
|
||||||
|
const collapseButton = await screen.findByRole("button", {
|
||||||
|
name: "사이드바 접기",
|
||||||
|
});
|
||||||
|
fireEvent.click(collapseButton);
|
||||||
|
|
||||||
|
expect(window.localStorage.getItem("baron_shell_sidebar_collapsed")).toBe(
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "사이드바 펼치기" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("opens profile menu, navigates, toggles theme/session, and logs out", async () => {
|
it("opens profile menu, navigates, toggles theme/session, and logs out", async () => {
|
||||||
renderLayout();
|
renderLayout();
|
||||||
|
|
||||||
|
|||||||
@@ -26,11 +26,13 @@ import {
|
|||||||
buildShellProfileSummary,
|
buildShellProfileSummary,
|
||||||
buildShellSessionStatus,
|
buildShellSessionStatus,
|
||||||
readShellSessionExpiryEnabled,
|
readShellSessionExpiryEnabled,
|
||||||
|
readShellSidebarCollapsed,
|
||||||
readShellTheme,
|
readShellTheme,
|
||||||
type ShellSidebarNavItem,
|
type ShellSidebarNavItem,
|
||||||
type ShellTranslator,
|
type ShellTranslator,
|
||||||
shellLayoutClasses,
|
shellLayoutClasses,
|
||||||
writeShellSessionExpiryEnabled,
|
writeShellSessionExpiryEnabled,
|
||||||
|
writeShellSidebarCollapsed,
|
||||||
} from "../../../../common/shell";
|
} from "../../../../common/shell";
|
||||||
import { canAccessWorksmobile } from "../../features/tenants/routes/worksmobileAccess";
|
import { canAccessWorksmobile } from "../../features/tenants/routes/worksmobileAccess";
|
||||||
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
|
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
|
||||||
@@ -165,6 +167,9 @@ function AppLayout() {
|
|||||||
const isDevelopmentRuntime = import.meta.env.MODE === "development";
|
const isDevelopmentRuntime = import.meta.env.MODE === "development";
|
||||||
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
|
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
|
||||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||||
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() =>
|
||||||
|
readShellSidebarCollapsed(false),
|
||||||
|
);
|
||||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
|
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
|
||||||
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
|
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
|
||||||
);
|
);
|
||||||
@@ -508,10 +513,18 @@ function AppLayout() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const handleSidebarToggle = () => {
|
||||||
|
setIsSidebarCollapsed((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
writeShellSidebarCollapsed(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
const sidebarNavContent = (
|
const sidebarNavContent = (
|
||||||
<div className={shellLayoutClasses.navList}>
|
<div className={shellLayoutClasses.navList}>
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const { labelKey, labelFallback, to, icon: Icon, isExternal } = item;
|
const { labelKey, labelFallback, to, icon: Icon, isExternal } = item;
|
||||||
|
const label = t(labelKey, labelFallback);
|
||||||
|
|
||||||
if (isExternal) {
|
if (isExternal) {
|
||||||
return (
|
return (
|
||||||
@@ -522,11 +535,18 @@ function AppLayout() {
|
|||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className={[
|
className={[
|
||||||
shellLayoutClasses.navItemBase,
|
shellLayoutClasses.navItemBase,
|
||||||
|
isSidebarCollapsed
|
||||||
|
? shellLayoutClasses.navItemBaseCollapsed
|
||||||
|
: "",
|
||||||
shellLayoutClasses.navItemIdle,
|
shellLayoutClasses.navItemIdle,
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
>
|
>
|
||||||
<Icon size={18} />
|
<Icon size={18} />
|
||||||
<span>{t(labelKey, labelFallback)}</span>
|
<span className={isSidebarCollapsed ? "sr-only" : ""}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -539,6 +559,9 @@ function AppLayout() {
|
|||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
[
|
[
|
||||||
shellLayoutClasses.navItemBase,
|
shellLayoutClasses.navItemBase,
|
||||||
|
isSidebarCollapsed
|
||||||
|
? shellLayoutClasses.navItemBaseCollapsed
|
||||||
|
: "",
|
||||||
item.isActive !== undefined
|
item.isActive !== undefined
|
||||||
? item.isActive
|
? item.isActive
|
||||||
? shellLayoutClasses.navItemActive
|
? shellLayoutClasses.navItemActive
|
||||||
@@ -548,9 +571,11 @@ function AppLayout() {
|
|||||||
: shellLayoutClasses.navItemIdle,
|
: shellLayoutClasses.navItemIdle,
|
||||||
].join(" ")
|
].join(" ")
|
||||||
}
|
}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
>
|
>
|
||||||
<Icon size={18} />
|
<Icon size={18} />
|
||||||
<span>{t(labelKey, labelFallback)}</span>
|
<span className={isSidebarCollapsed ? "sr-only" : ""}>{label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -561,10 +586,17 @@ function AppLayout() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className={shellLayoutClasses.logoutButton}
|
className={
|
||||||
|
isSidebarCollapsed
|
||||||
|
? shellLayoutClasses.logoutButtonCollapsed
|
||||||
|
: shellLayoutClasses.logoutButton
|
||||||
|
}
|
||||||
|
title={t("ui.shell.nav.logout", "Logout")}
|
||||||
>
|
>
|
||||||
<LogOut size={18} />
|
<LogOut size={18} />
|
||||||
<span>{t("ui.shell.nav.logout", "Logout")}</span>
|
<span className={isSidebarCollapsed ? "sr-only" : ""}>
|
||||||
|
{t("ui.shell.nav.logout", "Logout")}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -578,13 +610,23 @@ function AppLayout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={shellLayoutClasses.root}>
|
<div
|
||||||
|
className={
|
||||||
|
isSidebarCollapsed
|
||||||
|
? shellLayoutClasses.rootCollapsed
|
||||||
|
: shellLayoutClasses.root
|
||||||
|
}
|
||||||
|
>
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
brandLabel={t("ui.admin.brand", "Baron 로그인")}
|
brandLabel={t("ui.admin.brand", "Baron 로그인")}
|
||||||
brandTitle={t("ui.admin.title", "Admin Control")}
|
brandTitle={t("ui.admin.title", "Admin Control")}
|
||||||
brandIcon={<ShieldHalf size={20} />}
|
brandIcon={<ShieldHalf size={20} />}
|
||||||
navContent={sidebarNavContent}
|
navContent={sidebarNavContent}
|
||||||
footerContent={sidebarFooterContent}
|
footerContent={sidebarFooterContent}
|
||||||
|
collapsed={isSidebarCollapsed}
|
||||||
|
onToggleCollapsed={handleSidebarToggle}
|
||||||
|
collapseLabel={t("ui.shell.sidebar.collapse", "사이드바 접기")}
|
||||||
|
expandLabel={t("ui.shell.sidebar.expand", "사이드바 펼치기")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={shellLayoutClasses.contentWide}>
|
<div className={shellLayoutClasses.contentWide}>
|
||||||
|
|||||||
@@ -1541,6 +1541,10 @@ unknown_name = "Unknown User"
|
|||||||
logout = "Logout"
|
logout = "Logout"
|
||||||
profile = "My Profile"
|
profile = "My Profile"
|
||||||
|
|
||||||
|
[ui.shell.sidebar]
|
||||||
|
collapse = "Collapse sidebar"
|
||||||
|
expand = "Expand sidebar"
|
||||||
|
|
||||||
[ui.shell.role]
|
[ui.shell.role]
|
||||||
rp_admin = "Service Administrator (RP Admin)"
|
rp_admin = "Service Administrator (RP Admin)"
|
||||||
super_admin = "System Administrator (Super Admin)"
|
super_admin = "System Administrator (Super Admin)"
|
||||||
|
|||||||
@@ -1544,6 +1544,10 @@ unknown_name = "Unknown User"
|
|||||||
logout = "Logout"
|
logout = "Logout"
|
||||||
profile = "내 정보"
|
profile = "내 정보"
|
||||||
|
|
||||||
|
[ui.shell.sidebar]
|
||||||
|
collapse = "사이드바 접기"
|
||||||
|
expand = "사이드바 펼치기"
|
||||||
|
|
||||||
[ui.shell.role]
|
[ui.shell.role]
|
||||||
rp_admin = "서비스 관리자 (RP Admin)"
|
rp_admin = "서비스 관리자 (RP Admin)"
|
||||||
super_admin = "시스템 관리자 (Super Admin)"
|
super_admin = "시스템 관리자 (Super Admin)"
|
||||||
|
|||||||
@@ -1513,6 +1513,10 @@ unknown_name = ""
|
|||||||
logout = ""
|
logout = ""
|
||||||
profile = ""
|
profile = ""
|
||||||
|
|
||||||
|
[ui.shell.sidebar]
|
||||||
|
collapse = ""
|
||||||
|
expand = ""
|
||||||
|
|
||||||
[ui.shell.role]
|
[ui.shell.role]
|
||||||
rp_admin = ""
|
rp_admin = ""
|
||||||
super_admin = ""
|
super_admin = ""
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Menu, SquareMenu } from "lucide-react";
|
||||||
import type { ComponentType, ReactNode } from "react";
|
import type { ComponentType, ReactNode } from "react";
|
||||||
import { shellLayoutClasses } from "./layout";
|
import { shellLayoutClasses } from "./layout";
|
||||||
|
|
||||||
@@ -14,9 +15,13 @@ export type ShellSidebarNavItem = {
|
|||||||
type ShellSidebarProps = {
|
type ShellSidebarProps = {
|
||||||
brandLabel: string;
|
brandLabel: string;
|
||||||
brandTitle: string;
|
brandTitle: string;
|
||||||
brandIcon: ReactNode;
|
brandIcon?: ReactNode;
|
||||||
navContent: ReactNode;
|
navContent: ReactNode;
|
||||||
footerContent: ReactNode;
|
footerContent: ReactNode;
|
||||||
|
collapsed?: boolean;
|
||||||
|
onToggleCollapsed?: () => void;
|
||||||
|
collapseLabel?: string;
|
||||||
|
expandLabel?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function AppSidebar({
|
export function AppSidebar({
|
||||||
@@ -25,14 +30,57 @@ export function AppSidebar({
|
|||||||
brandIcon,
|
brandIcon,
|
||||||
navContent,
|
navContent,
|
||||||
footerContent,
|
footerContent,
|
||||||
|
collapsed = false,
|
||||||
|
onToggleCollapsed,
|
||||||
|
collapseLabel = "Collapse sidebar",
|
||||||
|
expandLabel = "Expand sidebar",
|
||||||
}: ShellSidebarProps) {
|
}: ShellSidebarProps) {
|
||||||
return (
|
return (
|
||||||
<aside className={shellLayoutClasses.aside}>
|
<aside
|
||||||
<div>
|
className={
|
||||||
<div className={shellLayoutClasses.brandSection}>
|
collapsed ? shellLayoutClasses.asideCollapsed : shellLayoutClasses.aside
|
||||||
<div className={shellLayoutClasses.brandWrap}>
|
}
|
||||||
<div className={shellLayoutClasses.brandIcon}>{brandIcon}</div>
|
>
|
||||||
<div>
|
<div>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
collapsed
|
||||||
|
? shellLayoutClasses.brandSectionCollapsed
|
||||||
|
: shellLayoutClasses.brandSection
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
collapsed
|
||||||
|
? shellLayoutClasses.brandWrapCollapsed
|
||||||
|
: shellLayoutClasses.brandWrap
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{onToggleCollapsed ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleCollapsed}
|
||||||
|
className="grid h-11 w-11 place-items-center rounded-xl border border-border bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)] transition hover:bg-primary/20"
|
||||||
|
aria-label={collapsed ? expandLabel : collapseLabel}
|
||||||
|
title={collapsed ? expandLabel : collapseLabel}
|
||||||
|
>
|
||||||
|
<span className="sr-only">
|
||||||
|
{collapsed ? expandLabel : collapseLabel}
|
||||||
|
</span>
|
||||||
|
{collapsed ? <Menu size={20} /> : <SquareMenu size={20} />}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
collapsed
|
||||||
|
? shellLayoutClasses.brandIconCollapsed
|
||||||
|
: shellLayoutClasses.brandIcon
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{brandIcon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={collapsed ? "hidden" : "block"}>
|
||||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||||
{brandLabel}
|
{brandLabel}
|
||||||
</p>
|
</p>
|
||||||
@@ -40,7 +88,15 @@ export function AppSidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav className={shellLayoutClasses.navWrap}>{navContent}</nav>
|
<nav
|
||||||
|
className={
|
||||||
|
collapsed
|
||||||
|
? shellLayoutClasses.navWrapCollapsed
|
||||||
|
: shellLayoutClasses.navWrap
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{navContent}
|
||||||
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>{footerContent}</div>
|
<div>{footerContent}</div>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ type ShellProfileSummaryParams = {
|
|||||||
|
|
||||||
export const SHELL_THEME_STORAGE_KEY = "admin_theme";
|
export const SHELL_THEME_STORAGE_KEY = "admin_theme";
|
||||||
export const SHELL_SESSION_EXPIRY_STORAGE_KEY = SESSION_EXPIRY_STORAGE_KEY;
|
export const SHELL_SESSION_EXPIRY_STORAGE_KEY = SESSION_EXPIRY_STORAGE_KEY;
|
||||||
|
export const SHELL_SIDEBAR_COLLAPSED_STORAGE_KEY =
|
||||||
|
"baron_shell_sidebar_collapsed";
|
||||||
export type { ShellSidebarNavItem } from "./AppSidebar";
|
export type { ShellSidebarNavItem } from "./AppSidebar";
|
||||||
export { AppSidebar } from "./AppSidebar";
|
export { AppSidebar } from "./AppSidebar";
|
||||||
export { shellLayoutClasses } from "./layout";
|
export { shellLayoutClasses } from "./layout";
|
||||||
@@ -52,6 +54,25 @@ export function writeShellSessionExpiryEnabled(isEnabled: boolean) {
|
|||||||
writeSessionExpiryEnabled(isEnabled);
|
writeSessionExpiryEnabled(isEnabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readShellSidebarCollapsed(defaultCollapsed = false) {
|
||||||
|
const stored = window.localStorage.getItem(
|
||||||
|
SHELL_SIDEBAR_COLLAPSED_STORAGE_KEY,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (stored === null) {
|
||||||
|
return defaultCollapsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return stored === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeShellSidebarCollapsed(isCollapsed: boolean) {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
SHELL_SIDEBAR_COLLAPSED_STORAGE_KEY,
|
||||||
|
String(isCollapsed),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function buildShellProfileSummary({
|
export function buildShellProfileSummary({
|
||||||
profileName,
|
profileName,
|
||||||
profileEmail,
|
profileEmail,
|
||||||
|
|||||||
@@ -1,22 +1,34 @@
|
|||||||
export const shellLayoutClasses = {
|
export const shellLayoutClasses = {
|
||||||
root: "grid min-h-screen grid-cols-[240px,minmax(0,1fr)] bg-background text-foreground",
|
root: "grid min-h-screen grid-cols-[240px,minmax(0,1fr)] bg-background text-foreground",
|
||||||
|
rootCollapsed:
|
||||||
|
"grid min-h-screen grid-cols-[80px,minmax(0,1fr)] bg-background text-foreground",
|
||||||
aside:
|
aside:
|
||||||
"sticky top-0 flex h-screen flex-col justify-between border-r border-border bg-card backdrop-blur",
|
"sticky top-0 flex h-screen flex-col justify-between border-r border-border bg-card backdrop-blur",
|
||||||
|
asideCollapsed:
|
||||||
|
"sticky top-0 flex h-screen flex-col justify-between border-r border-border bg-card backdrop-blur",
|
||||||
asideStatic:
|
asideStatic:
|
||||||
"sticky top-0 h-screen border-r border-border bg-card backdrop-blur",
|
"sticky top-0 h-screen border-r border-border bg-card backdrop-blur",
|
||||||
brandSection:
|
brandSection:
|
||||||
"flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6",
|
"flex items-center justify-between px-5 py-4 md:block md:space-y-6 md:py-6",
|
||||||
|
brandSectionCollapsed:
|
||||||
|
"flex items-center justify-between px-3 py-4 md:block md:space-y-4 md:px-2 md:py-6",
|
||||||
brandWrap: "flex items-center gap-3 md:flex-col md:items-start",
|
brandWrap: "flex items-center gap-3 md:flex-col md:items-start",
|
||||||
|
brandWrapCollapsed: "flex items-center gap-3 md:flex-col md:items-center",
|
||||||
brandIcon:
|
brandIcon:
|
||||||
"grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]",
|
"grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]",
|
||||||
|
brandIconCollapsed:
|
||||||
|
"grid h-11 w-11 place-items-center rounded-xl bg-primary/15 text-primary shadow-[0_12px_30px_rgba(54,211,153,0.22)]",
|
||||||
scopeBadge:
|
scopeBadge:
|
||||||
"hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2",
|
"hidden rounded-full border border-border px-3 py-2 text-xs text-muted-foreground md:inline-flex md:items-center md:gap-2",
|
||||||
navWrap: "px-2 pb-4 md:px-3 md:pb-8",
|
navWrap: "px-2 pb-4 md:px-3 md:pb-8",
|
||||||
|
navWrapCollapsed: "px-2 pb-4 md:px-2 md:pb-8",
|
||||||
navMeta:
|
navMeta:
|
||||||
"flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start",
|
"flex flex-wrap gap-2 px-3 pb-4 text-[11px] text-muted-foreground md:flex-col md:items-start",
|
||||||
navList: "flex flex-col gap-1",
|
navList: "flex flex-col gap-1",
|
||||||
navItemBase:
|
navItemBase:
|
||||||
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
"flex items-center gap-3 rounded-xl px-3 py-3 text-sm transition",
|
||||||
|
navItemBaseCollapsed:
|
||||||
|
"flex items-center justify-center gap-0 rounded-xl px-3 py-3 text-sm transition",
|
||||||
navItemActive:
|
navItemActive:
|
||||||
"bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]",
|
"bg-primary/10 text-primary shadow-[0_12px_40px_rgba(54,211,153,0.18)]",
|
||||||
navItemIdle: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
navItemIdle: "text-muted-foreground hover:bg-muted/10 hover:text-foreground",
|
||||||
@@ -24,6 +36,8 @@ export const shellLayoutClasses = {
|
|||||||
"hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block",
|
"hidden space-y-2 px-5 pb-6 pt-2 text-xs text-[var(--color-muted)] md:block",
|
||||||
logoutButton:
|
logoutButton:
|
||||||
"flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive",
|
"flex w-full items-center gap-3 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive",
|
||||||
|
logoutButtonCollapsed:
|
||||||
|
"flex w-full items-center justify-center gap-0 rounded-xl px-3 py-3 text-sm text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive",
|
||||||
header:
|
header:
|
||||||
"sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur",
|
"sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur",
|
||||||
headerElevated:
|
headerElevated:
|
||||||
@@ -31,8 +45,11 @@ export const shellLayoutClasses = {
|
|||||||
headerInner: "flex items-center justify-between px-5 py-4 md:px-8",
|
headerInner: "flex items-center justify-between px-5 py-4 md:px-8",
|
||||||
headerTitleWrap: "flex flex-col gap-1",
|
headerTitleWrap: "flex flex-col gap-1",
|
||||||
headerActions: "flex items-center gap-2 text-sm",
|
headerActions: "flex items-center gap-2 text-sm",
|
||||||
|
headerActionsCollapsed: "flex items-center gap-2 text-sm",
|
||||||
actionButton:
|
actionButton:
|
||||||
"inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20",
|
"inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20",
|
||||||
|
sidebarToggleButton:
|
||||||
|
"inline-flex items-center gap-2 rounded-full border border-border px-3 py-2 text-muted-foreground transition hover:bg-muted/20",
|
||||||
sessionBadge:
|
sessionBadge:
|
||||||
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
|
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
|
||||||
profileInitial:
|
profileInitial:
|
||||||
|
|||||||
@@ -116,6 +116,24 @@ describe("devfront AppLayout", () => {
|
|||||||
expect(document.documentElement.classList.contains("light")).toBe(true);
|
expect(document.documentElement.classList.contains("light")).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("toggles the sidebar and persists the collapsed state", async () => {
|
||||||
|
const container = await renderLayout();
|
||||||
|
|
||||||
|
const collapseButton = container.querySelector(
|
||||||
|
'button[aria-label="Collapse sidebar"]',
|
||||||
|
) as HTMLButtonElement;
|
||||||
|
await act(async () => {
|
||||||
|
collapseButton.click();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(window.localStorage.getItem("baron_shell_sidebar_collapsed")).toBe(
|
||||||
|
"true",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
container.querySelector('button[aria-label="Expand sidebar"]'),
|
||||||
|
).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("toggles profile menu, navigates to profile, toggles theme, and logs out", async () => {
|
it("toggles profile menu, navigates to profile, toggles theme, and logs out", async () => {
|
||||||
const container = await renderLayout();
|
const container = await renderLayout();
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ import {
|
|||||||
buildShellProfileSummary,
|
buildShellProfileSummary,
|
||||||
buildShellSessionStatus,
|
buildShellSessionStatus,
|
||||||
readShellSessionExpiryEnabled,
|
readShellSessionExpiryEnabled,
|
||||||
|
readShellSidebarCollapsed,
|
||||||
readShellTheme,
|
readShellTheme,
|
||||||
type ShellSidebarNavItem,
|
type ShellSidebarNavItem,
|
||||||
type ShellTranslator,
|
type ShellTranslator,
|
||||||
shellLayoutClasses,
|
shellLayoutClasses,
|
||||||
writeShellSessionExpiryEnabled,
|
writeShellSessionExpiryEnabled,
|
||||||
|
writeShellSidebarCollapsed,
|
||||||
} from "../../../../common/shell";
|
} from "../../../../common/shell";
|
||||||
import { fetchMe } from "../../features/auth/authApi";
|
import { fetchMe } from "../../features/auth/authApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
@@ -118,6 +120,9 @@ function AppLayout() {
|
|||||||
const isDevelopmentRuntime = import.meta.env.MODE === "development";
|
const isDevelopmentRuntime = import.meta.env.MODE === "development";
|
||||||
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
|
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
|
||||||
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
|
||||||
|
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() =>
|
||||||
|
readShellSidebarCollapsed(false),
|
||||||
|
);
|
||||||
const [, setDevelopmentRenderRevision] = useState(0);
|
const [, setDevelopmentRenderRevision] = useState(0);
|
||||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
|
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
|
||||||
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
|
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
|
||||||
@@ -352,9 +357,19 @@ function AppLayout() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
const handleSidebarToggle = () => {
|
||||||
|
setIsSidebarCollapsed((prev) => {
|
||||||
|
const next = !prev;
|
||||||
|
writeShellSidebarCollapsed(next);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
const sidebarNavContent = (
|
const sidebarNavContent = (
|
||||||
<div className={shellLayoutClasses.navList}>
|
<div className={shellLayoutClasses.navList}>
|
||||||
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
|
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => {
|
||||||
|
const label = t(labelKey, labelFallback);
|
||||||
|
|
||||||
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={to}
|
key={to}
|
||||||
to={to}
|
to={to}
|
||||||
@@ -362,16 +377,22 @@ function AppLayout() {
|
|||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
[
|
[
|
||||||
shellLayoutClasses.navItemBase,
|
shellLayoutClasses.navItemBase,
|
||||||
|
isSidebarCollapsed
|
||||||
|
? shellLayoutClasses.navItemBaseCollapsed
|
||||||
|
: "",
|
||||||
isActive
|
isActive
|
||||||
? shellLayoutClasses.navItemActive
|
? shellLayoutClasses.navItemActive
|
||||||
: shellLayoutClasses.navItemIdle,
|
: shellLayoutClasses.navItemIdle,
|
||||||
].join(" ")
|
].join(" ")
|
||||||
}
|
}
|
||||||
|
title={label}
|
||||||
|
aria-label={label}
|
||||||
>
|
>
|
||||||
<Icon size={18} />
|
<Icon size={18} />
|
||||||
<span>{t(labelKey, labelFallback)}</span>
|
<span className={isSidebarCollapsed ? "sr-only" : ""}>{label}</span>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const sidebarFooterContent = (
|
const sidebarFooterContent = (
|
||||||
@@ -379,22 +400,39 @@ function AppLayout() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className={shellLayoutClasses.logoutButton}
|
className={
|
||||||
|
isSidebarCollapsed
|
||||||
|
? shellLayoutClasses.logoutButtonCollapsed
|
||||||
|
: shellLayoutClasses.logoutButton
|
||||||
|
}
|
||||||
|
title={t("ui.shell.nav.logout", "Logout")}
|
||||||
>
|
>
|
||||||
<LogOut size={18} />
|
<LogOut size={18} />
|
||||||
<span>{t("ui.shell.nav.logout", "Logout")}</span>
|
<span className={isSidebarCollapsed ? "sr-only" : ""}>
|
||||||
|
{t("ui.shell.nav.logout", "Logout")}
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={shellLayoutClasses.root}>
|
<div
|
||||||
|
className={
|
||||||
|
isSidebarCollapsed
|
||||||
|
? shellLayoutClasses.rootCollapsed
|
||||||
|
: shellLayoutClasses.root
|
||||||
|
}
|
||||||
|
>
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
brandLabel={t("ui.dev.brand", "Baron Sign In")}
|
brandLabel={t("ui.dev.brand", "Baron Sign In")}
|
||||||
brandTitle={t("ui.dev.console_title", "Developer Console")}
|
brandTitle={t("ui.dev.console_title", "Developer Console")}
|
||||||
brandIcon={<ShieldHalf size={20} />}
|
brandIcon={<ShieldHalf size={20} />}
|
||||||
navContent={sidebarNavContent}
|
navContent={sidebarNavContent}
|
||||||
footerContent={sidebarFooterContent}
|
footerContent={sidebarFooterContent}
|
||||||
|
collapsed={isSidebarCollapsed}
|
||||||
|
onToggleCollapsed={handleSidebarToggle}
|
||||||
|
collapseLabel={t("ui.shell.sidebar.collapse", "사이드바 접기")}
|
||||||
|
expandLabel={t("ui.shell.sidebar.expand", "사이드바 펼치기")}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={shellLayoutClasses.content}>
|
<div className={shellLayoutClasses.content}>
|
||||||
|
|||||||
@@ -1361,6 +1361,10 @@ unknown_name = "Unknown User"
|
|||||||
logout = "Logout"
|
logout = "Logout"
|
||||||
profile = "My Profile"
|
profile = "My Profile"
|
||||||
|
|
||||||
|
[ui.shell.sidebar]
|
||||||
|
collapse = "Collapse sidebar"
|
||||||
|
expand = "Expand sidebar"
|
||||||
|
|
||||||
[ui.shell.role]
|
[ui.shell.role]
|
||||||
rp_admin = "Service Administrator (RP Admin)"
|
rp_admin = "Service Administrator (RP Admin)"
|
||||||
super_admin = "System Administrator (Super Admin)"
|
super_admin = "System Administrator (Super Admin)"
|
||||||
|
|||||||
@@ -1361,6 +1361,10 @@ unknown_name = "Unknown User"
|
|||||||
logout = "Logout"
|
logout = "Logout"
|
||||||
profile = "내 정보"
|
profile = "내 정보"
|
||||||
|
|
||||||
|
[ui.shell.sidebar]
|
||||||
|
collapse = "사이드바 접기"
|
||||||
|
expand = "사이드바 펼치기"
|
||||||
|
|
||||||
[ui.shell.role]
|
[ui.shell.role]
|
||||||
rp_admin = "서비스 관리자 (RP Admin)"
|
rp_admin = "서비스 관리자 (RP Admin)"
|
||||||
super_admin = "시스템 관리자 (Super Admin)"
|
super_admin = "시스템 관리자 (Super Admin)"
|
||||||
|
|||||||
@@ -1417,6 +1417,10 @@ unknown_name = ""
|
|||||||
logout = ""
|
logout = ""
|
||||||
profile = ""
|
profile = ""
|
||||||
|
|
||||||
|
[ui.shell.sidebar]
|
||||||
|
collapse = ""
|
||||||
|
expand = ""
|
||||||
|
|
||||||
[ui.shell.role]
|
[ui.shell.role]
|
||||||
rp_admin = ""
|
rp_admin = ""
|
||||||
super_admin = ""
|
super_admin = ""
|
||||||
|
|||||||
@@ -2601,6 +2601,10 @@ title_remote = "Sign-in Approved"
|
|||||||
logout = "Logout"
|
logout = "Logout"
|
||||||
profile = "My Profile"
|
profile = "My Profile"
|
||||||
|
|
||||||
|
[ui.shell.sidebar]
|
||||||
|
collapse = "Collapse sidebar"
|
||||||
|
expand = "Expand sidebar"
|
||||||
|
|
||||||
[ui.shell.profile]
|
[ui.shell.profile]
|
||||||
menu_aria = "Open account menu"
|
menu_aria = "Open account menu"
|
||||||
menu_title = "Account"
|
menu_title = "Account"
|
||||||
|
|||||||
@@ -3026,6 +3026,10 @@ title_remote = "로그인 승인 완료"
|
|||||||
logout = "로그아웃"
|
logout = "로그아웃"
|
||||||
profile = "내 정보"
|
profile = "내 정보"
|
||||||
|
|
||||||
|
[ui.shell.sidebar]
|
||||||
|
collapse = "사이드바 접기"
|
||||||
|
expand = "사이드바 펼치기"
|
||||||
|
|
||||||
[ui.shell.profile]
|
[ui.shell.profile]
|
||||||
menu_aria = "계정 메뉴 열기"
|
menu_aria = "계정 메뉴 열기"
|
||||||
menu_title = "계정"
|
menu_title = "계정"
|
||||||
|
|||||||
@@ -2904,6 +2904,10 @@ title_remote = ""
|
|||||||
logout = ""
|
logout = ""
|
||||||
profile = ""
|
profile = ""
|
||||||
|
|
||||||
|
[ui.shell.sidebar]
|
||||||
|
collapse = ""
|
||||||
|
expand = ""
|
||||||
|
|
||||||
[ui.shell.profile]
|
[ui.shell.profile]
|
||||||
menu_aria = ""
|
menu_aria = ""
|
||||||
menu_title = ""
|
menu_title = ""
|
||||||
|
|||||||
Reference in New Issue
Block a user