1
0
forked from baron/baron-sso

사이드바 접기 기능 추가

This commit is contained in:
2026-06-04 15:56:33 +09:00
parent f6c7cb3b22
commit 1596342d03
16 changed files with 277 additions and 33 deletions

View File

@@ -127,6 +127,22 @@ describe("admin AppLayout", () => {
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 () => {
renderLayout();

View File

@@ -26,11 +26,13 @@ import {
buildShellProfileSummary,
buildShellSessionStatus,
readShellSessionExpiryEnabled,
readShellSidebarCollapsed,
readShellTheme,
type ShellSidebarNavItem,
type ShellTranslator,
shellLayoutClasses,
writeShellSessionExpiryEnabled,
writeShellSidebarCollapsed,
} from "../../../../common/shell";
import { canAccessWorksmobile } from "../../features/tenants/routes/worksmobileAccess";
import { buildAuthenticatedOrgChartUrl } from "../../features/users/orgChartPicker";
@@ -165,6 +167,9 @@ function AppLayout() {
const isDevelopmentRuntime = import.meta.env.MODE === "development";
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
const [isProfileOpen, setIsProfileOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() =>
readShellSidebarCollapsed(false),
);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
);
@@ -508,10 +513,18 @@ function AppLayout() {
return next;
});
};
const handleSidebarToggle = () => {
setIsSidebarCollapsed((prev) => {
const next = !prev;
writeShellSidebarCollapsed(next);
return next;
});
};
const sidebarNavContent = (
<div className={shellLayoutClasses.navList}>
{navItems.map((item) => {
const { labelKey, labelFallback, to, icon: Icon, isExternal } = item;
const label = t(labelKey, labelFallback);
if (isExternal) {
return (
@@ -522,11 +535,18 @@ function AppLayout() {
rel="noopener noreferrer"
className={[
shellLayoutClasses.navItemBase,
isSidebarCollapsed
? shellLayoutClasses.navItemBaseCollapsed
: "",
shellLayoutClasses.navItemIdle,
].join(" ")}
title={label}
aria-label={label}
>
<Icon size={18} />
<span>{t(labelKey, labelFallback)}</span>
<span className={isSidebarCollapsed ? "sr-only" : ""}>
{label}
</span>
</a>
);
}
@@ -539,6 +559,9 @@ function AppLayout() {
className={({ isActive }) =>
[
shellLayoutClasses.navItemBase,
isSidebarCollapsed
? shellLayoutClasses.navItemBaseCollapsed
: "",
item.isActive !== undefined
? item.isActive
? shellLayoutClasses.navItemActive
@@ -548,9 +571,11 @@ function AppLayout() {
: shellLayoutClasses.navItemIdle,
].join(" ")
}
title={label}
aria-label={label}
>
<Icon size={18} />
<span>{t(labelKey, labelFallback)}</span>
<span className={isSidebarCollapsed ? "sr-only" : ""}>{label}</span>
</NavLink>
);
})}
@@ -561,10 +586,17 @@ function AppLayout() {
<button
type="button"
onClick={handleLogout}
className={shellLayoutClasses.logoutButton}
className={
isSidebarCollapsed
? shellLayoutClasses.logoutButtonCollapsed
: shellLayoutClasses.logoutButton
}
title={t("ui.shell.nav.logout", "Logout")}
>
<LogOut size={18} />
<span>{t("ui.shell.nav.logout", "Logout")}</span>
<span className={isSidebarCollapsed ? "sr-only" : ""}>
{t("ui.shell.nav.logout", "Logout")}
</span>
</button>
</div>
);
@@ -578,13 +610,23 @@ function AppLayout() {
}
return (
<div className={shellLayoutClasses.root}>
<div
className={
isSidebarCollapsed
? shellLayoutClasses.rootCollapsed
: shellLayoutClasses.root
}
>
<AppSidebar
brandLabel={t("ui.admin.brand", "Baron 로그인")}
brandTitle={t("ui.admin.title", "Admin Control")}
brandIcon={<ShieldHalf size={20} />}
navContent={sidebarNavContent}
footerContent={sidebarFooterContent}
collapsed={isSidebarCollapsed}
onToggleCollapsed={handleSidebarToggle}
collapseLabel={t("ui.shell.sidebar.collapse", "사이드바 접기")}
expandLabel={t("ui.shell.sidebar.expand", "사이드바 펼치기")}
/>
<div className={shellLayoutClasses.contentWide}>

View File

@@ -1541,6 +1541,10 @@ unknown_name = "Unknown User"
logout = "Logout"
profile = "My Profile"
[ui.shell.sidebar]
collapse = "Collapse sidebar"
expand = "Expand sidebar"
[ui.shell.role]
rp_admin = "Service Administrator (RP Admin)"
super_admin = "System Administrator (Super Admin)"

View File

@@ -1544,6 +1544,10 @@ unknown_name = "Unknown User"
logout = "Logout"
profile = "내 정보"
[ui.shell.sidebar]
collapse = "사이드바 접기"
expand = "사이드바 펼치기"
[ui.shell.role]
rp_admin = "서비스 관리자 (RP Admin)"
super_admin = "시스템 관리자 (Super Admin)"

View File

@@ -1513,6 +1513,10 @@ unknown_name = ""
logout = ""
profile = ""
[ui.shell.sidebar]
collapse = ""
expand = ""
[ui.shell.role]
rp_admin = ""
super_admin = ""

View File

@@ -1,3 +1,4 @@
import { Menu, SquareMenu } from "lucide-react";
import type { ComponentType, ReactNode } from "react";
import { shellLayoutClasses } from "./layout";
@@ -14,9 +15,13 @@ export type ShellSidebarNavItem = {
type ShellSidebarProps = {
brandLabel: string;
brandTitle: string;
brandIcon: ReactNode;
brandIcon?: ReactNode;
navContent: ReactNode;
footerContent: ReactNode;
collapsed?: boolean;
onToggleCollapsed?: () => void;
collapseLabel?: string;
expandLabel?: string;
};
export function AppSidebar({
@@ -25,14 +30,57 @@ export function AppSidebar({
brandIcon,
navContent,
footerContent,
collapsed = false,
onToggleCollapsed,
collapseLabel = "Collapse sidebar",
expandLabel = "Expand sidebar",
}: ShellSidebarProps) {
return (
<aside className={shellLayoutClasses.aside}>
<aside
className={
collapsed ? shellLayoutClasses.asideCollapsed : shellLayoutClasses.aside
}
>
<div>
<div className={shellLayoutClasses.brandSection}>
<div className={shellLayoutClasses.brandWrap}>
<div className={shellLayoutClasses.brandIcon}>{brandIcon}</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">
{brandLabel}
</p>
@@ -40,7 +88,15 @@ export function AppSidebar({
</div>
</div>
</div>
<nav className={shellLayoutClasses.navWrap}>{navContent}</nav>
<nav
className={
collapsed
? shellLayoutClasses.navWrapCollapsed
: shellLayoutClasses.navWrap
}
>
{navContent}
</nav>
</div>
<div>{footerContent}</div>

View File

@@ -27,6 +27,8 @@ type ShellProfileSummaryParams = {
export const SHELL_THEME_STORAGE_KEY = "admin_theme";
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 { AppSidebar } from "./AppSidebar";
export { shellLayoutClasses } from "./layout";
@@ -52,6 +54,25 @@ export function writeShellSessionExpiryEnabled(isEnabled: boolean) {
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({
profileName,
profileEmail,

View File

@@ -1,22 +1,34 @@
export const shellLayoutClasses = {
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:
"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:
"sticky top-0 h-screen border-r border-border bg-card backdrop-blur",
brandSection:
"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",
brandWrapCollapsed: "flex items-center gap-3 md:flex-col md:items-center",
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)]",
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:
"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",
navWrapCollapsed: "px-2 pb-4 md:px-2 md:pb-8",
navMeta:
"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",
navItemBase:
"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:
"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",
@@ -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",
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",
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:
"sticky top-0 z-20 border-b border-border bg-background/90 backdrop-blur",
headerElevated:
@@ -31,8 +45,11 @@ export const shellLayoutClasses = {
headerInner: "flex items-center justify-between px-5 py-4 md:px-8",
headerTitleWrap: "flex flex-col gap-1",
headerActions: "flex items-center gap-2 text-sm",
headerActionsCollapsed: "flex items-center gap-2 text-sm",
actionButton:
"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:
"hidden rounded-full border px-3 py-2 text-xs font-medium md:inline-flex",
profileInitial:

View File

@@ -116,6 +116,24 @@ describe("devfront AppLayout", () => {
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 () => {
const container = await renderLayout();

View File

@@ -19,11 +19,13 @@ import {
buildShellProfileSummary,
buildShellSessionStatus,
readShellSessionExpiryEnabled,
readShellSidebarCollapsed,
readShellTheme,
type ShellSidebarNavItem,
type ShellTranslator,
shellLayoutClasses,
writeShellSessionExpiryEnabled,
writeShellSidebarCollapsed,
} from "../../../../common/shell";
import { fetchMe } from "../../features/auth/authApi";
import { t } from "../../lib/i18n";
@@ -118,6 +120,9 @@ function AppLayout() {
const isDevelopmentRuntime = import.meta.env.MODE === "development";
const [theme, setTheme] = useState<"light" | "dark">(readShellTheme);
const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false);
const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(() =>
readShellSidebarCollapsed(false),
);
const [, setDevelopmentRenderRevision] = useState(0);
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(() =>
readShellSessionExpiryEnabled(!isDevelopmentRuntime),
@@ -352,26 +357,42 @@ function AppLayout() {
return next;
});
};
const handleSidebarToggle = () => {
setIsSidebarCollapsed((prev) => {
const next = !prev;
writeShellSidebarCollapsed(next);
return next;
});
};
const sidebarNavContent = (
<div className={shellLayoutClasses.navList}>
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => (
<NavLink
key={to}
to={to}
end={to === "/"}
className={({ isActive }) =>
[
shellLayoutClasses.navItemBase,
isActive
? shellLayoutClasses.navItemActive
: shellLayoutClasses.navItemIdle,
].join(" ")
}
>
<Icon size={18} />
<span>{t(labelKey, labelFallback)}</span>
</NavLink>
))}
{navItems.map(({ labelKey, labelFallback, to, icon: Icon }) => {
const label = t(labelKey, labelFallback);
return (
<NavLink
key={to}
to={to}
end={to === "/"}
className={({ isActive }) =>
[
shellLayoutClasses.navItemBase,
isSidebarCollapsed
? shellLayoutClasses.navItemBaseCollapsed
: "",
isActive
? shellLayoutClasses.navItemActive
: shellLayoutClasses.navItemIdle,
].join(" ")
}
title={label}
aria-label={label}
>
<Icon size={18} />
<span className={isSidebarCollapsed ? "sr-only" : ""}>{label}</span>
</NavLink>
);
})}
</div>
);
const sidebarFooterContent = (
@@ -379,22 +400,39 @@ function AppLayout() {
<button
type="button"
onClick={handleLogout}
className={shellLayoutClasses.logoutButton}
className={
isSidebarCollapsed
? shellLayoutClasses.logoutButtonCollapsed
: shellLayoutClasses.logoutButton
}
title={t("ui.shell.nav.logout", "Logout")}
>
<LogOut size={18} />
<span>{t("ui.shell.nav.logout", "Logout")}</span>
<span className={isSidebarCollapsed ? "sr-only" : ""}>
{t("ui.shell.nav.logout", "Logout")}
</span>
</button>
</div>
);
return (
<div className={shellLayoutClasses.root}>
<div
className={
isSidebarCollapsed
? shellLayoutClasses.rootCollapsed
: shellLayoutClasses.root
}
>
<AppSidebar
brandLabel={t("ui.dev.brand", "Baron Sign In")}
brandTitle={t("ui.dev.console_title", "Developer Console")}
brandIcon={<ShieldHalf size={20} />}
navContent={sidebarNavContent}
footerContent={sidebarFooterContent}
collapsed={isSidebarCollapsed}
onToggleCollapsed={handleSidebarToggle}
collapseLabel={t("ui.shell.sidebar.collapse", "사이드바 접기")}
expandLabel={t("ui.shell.sidebar.expand", "사이드바 펼치기")}
/>
<div className={shellLayoutClasses.content}>

View File

@@ -1361,6 +1361,10 @@ unknown_name = "Unknown User"
logout = "Logout"
profile = "My Profile"
[ui.shell.sidebar]
collapse = "Collapse sidebar"
expand = "Expand sidebar"
[ui.shell.role]
rp_admin = "Service Administrator (RP Admin)"
super_admin = "System Administrator (Super Admin)"

View File

@@ -1361,6 +1361,10 @@ unknown_name = "Unknown User"
logout = "Logout"
profile = "내 정보"
[ui.shell.sidebar]
collapse = "사이드바 접기"
expand = "사이드바 펼치기"
[ui.shell.role]
rp_admin = "서비스 관리자 (RP Admin)"
super_admin = "시스템 관리자 (Super Admin)"

View File

@@ -1417,6 +1417,10 @@ unknown_name = ""
logout = ""
profile = ""
[ui.shell.sidebar]
collapse = ""
expand = ""
[ui.shell.role]
rp_admin = ""
super_admin = ""

View File

@@ -2601,6 +2601,10 @@ title_remote = "Sign-in Approved"
logout = "Logout"
profile = "My Profile"
[ui.shell.sidebar]
collapse = "Collapse sidebar"
expand = "Expand sidebar"
[ui.shell.profile]
menu_aria = "Open account menu"
menu_title = "Account"

View File

@@ -3026,6 +3026,10 @@ title_remote = "로그인 승인 완료"
logout = "로그아웃"
profile = "내 정보"
[ui.shell.sidebar]
collapse = "사이드바 접기"
expand = "사이드바 펼치기"
[ui.shell.profile]
menu_aria = "계정 메뉴 열기"
menu_title = "계정"

View File

@@ -2904,6 +2904,10 @@ title_remote = ""
logout = ""
profile = ""
[ui.shell.sidebar]
collapse = ""
expand = ""
[ui.shell.profile]
menu_aria = ""
menu_title = ""