Skip to content

Commit 2d49cf6

Browse files
feat[frontend](sidebar): add collapse toggle for icon-only mode (#2517)
1 parent d42ce27 commit 2d49cf6

1 file changed

Lines changed: 151 additions & 40 deletions

File tree

frontend/src/shared/layouts/DashboardLayout/Sidebar.tsx

Lines changed: 151 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
LifeBuoy,
2424
Mail,
2525
Palette,
26+
PanelLeftClose,
27+
PanelLeftOpen,
2628
Plug,
2729
Radar,
2830
ScrollText,
@@ -41,6 +43,7 @@ import {
4143
} from 'lucide-react'
4244
import { useTranslation } from 'react-i18next'
4345
import { cn } from '@/shared/lib/utils'
46+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/components/ui/tooltip'
4447
import { useAuth } from '@/features/auth'
4548
import { useBilling } from '@/features/billing'
4649
import { useSupportTenant } from '@/shared/lib/current-tenant'
@@ -177,6 +180,7 @@ const settingsItems: {
177180

178181
const SECTIONS_KEY = 'utmstack-sidebar-sections-closed'
179182
const GROUPS_KEY = 'utmstack-sidebar-groups-closed'
183+
const COLLAPSED_KEY = 'utmstack-sidebar-collapsed'
180184

181185
function loadSet(key: string): Set<string> {
182186
if (typeof window === 'undefined') return new Set()
@@ -190,6 +194,36 @@ function loadSet(key: string): Set<string> {
190194
}
191195
}
192196

197+
function loadCollapsed(): boolean {
198+
if (typeof window === 'undefined') return false
199+
return localStorage.getItem(COLLAPSED_KEY) === '1'
200+
}
201+
202+
function useCollapsed(): [boolean, () => void] {
203+
const [collapsed, setCollapsed] = useState<boolean>(loadCollapsed)
204+
useEffect(() => {
205+
localStorage.setItem(COLLAPSED_KEY, collapsed ? '1' : '0')
206+
}, [collapsed])
207+
return [collapsed, () => setCollapsed((v) => !v)]
208+
}
209+
210+
function CollapseToggle({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) {
211+
const Icon = collapsed ? PanelLeftOpen : PanelLeftClose
212+
return (
213+
<button
214+
type="button"
215+
onClick={onToggle}
216+
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
217+
className={cn(
218+
'flex items-center rounded-lg p-2 text-sidebar-foreground/60 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground',
219+
collapsed ? 'justify-center' : 'ml-auto'
220+
)}
221+
>
222+
<Icon size={16} strokeWidth={1.75} />
223+
</button>
224+
)
225+
}
226+
193227
export function Sidebar() {
194228
const { t } = useTranslation()
195229
const { isAdmin, isPlatformAdmin } = useAuth()
@@ -201,6 +235,7 @@ export function Sidebar() {
201235
const actingAsPlatform = isPlatformAdmin && supportTenant === null
202236
const [closedSections, setClosedSections] = useState<Set<string>>(() => loadSet(SECTIONS_KEY))
203237
const [closedGroups, setClosedGroups] = useState<Set<string>>(() => loadSet(GROUPS_KEY))
238+
const [collapsed, toggleCollapsed] = useCollapsed()
204239
const { pathname } = useLocation()
205240

206241
useEffect(() => {
@@ -233,12 +268,20 @@ export function Sidebar() {
233268

234269
// Drill-down: when navigating under /settings the sidebar swaps to a sub-nav.
235270
if (pathname.startsWith('/settings')) {
236-
return <SettingsSidebar isPathActive={isPathActive} />
271+
return <SettingsSidebar isPathActive={isPathActive} collapsed={collapsed} onToggleCollapsed={toggleCollapsed} />
237272
}
238273

239274
return (
240-
<aside className="flex h-full w-60 shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground">
241-
<nav className="flex-1 overflow-y-auto overflow-x-hidden p-2">
275+
<aside
276+
className={cn(
277+
'flex h-full shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground transition-[width] duration-150',
278+
collapsed ? 'w-14' : 'w-60'
279+
)}
280+
>
281+
<div className="flex p-2">
282+
<CollapseToggle collapsed={collapsed} onToggle={toggleCollapsed} />
283+
</div>
284+
<nav className="flex-1 overflow-y-auto overflow-x-hidden p-2 pt-0">
242285
{sections
243286
.filter((section) => !section.adminOnly || isAdmin)
244287
.map((section, idx) => {
@@ -248,7 +291,7 @@ export function Sidebar() {
248291
key={section.id}
249292
className={cn(idx > 0 && 'mt-2 pt-2 border-t border-sidebar-border/60')}
250293
>
251-
{section.label && (
294+
{section.label && !collapsed && (
252295
<button
253296
onClick={() => toggleSection(section.id)}
254297
className="mb-1 flex w-full items-center justify-between px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-sidebar-foreground/50 hover:text-sidebar-foreground"
@@ -260,7 +303,7 @@ export function Sidebar() {
260303
/>
261304
</button>
262305
)}
263-
{!isClosed && (
306+
{(collapsed || !isClosed) && (
264307
<div className="space-y-0.5">
265308
{section.items
266309
.filter(
@@ -276,12 +319,15 @@ export function Sidebar() {
276319
onToggle={() => toggleGroup(item.id)}
277320
isPathActive={isPathActive}
278321
isBaseActive={isBaseActive}
322+
collapsed={collapsed}
323+
onExpandRequest={toggleCollapsed}
279324
/>
280325
) : (
281326
<SidebarLeaf
282327
key={item.to}
283328
item={item}
284329
active={item.matchPrefix ? isBaseActive(item.to) : isPathActive(item.to)}
330+
collapsed={collapsed}
285331
/>
286332
)
287333
)}
@@ -299,28 +345,40 @@ function SidebarLeaf({
299345
item,
300346
active,
301347
nested,
348+
collapsed,
302349
}: {
303350
item: { to: string; label: string; icon: LucideIcon }
304351
active: boolean
305352
nested?: boolean
353+
collapsed?: boolean
306354
}) {
307355
const { t } = useTranslation()
308356
const Icon = item.icon
309-
return (
357+
const label = t(item.label)
358+
const link = (
310359
<Link
311360
to={item.to}
312361
className={cn(
313-
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
362+
'flex items-center rounded-lg text-sm transition-colors',
363+
collapsed ? 'justify-center px-2 py-2' : 'gap-3 px-3 py-2',
314364
active
315365
? 'bg-primary/15 text-primary'
316366
: 'text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground',
317-
nested && 'pl-7'
367+
!collapsed && nested && 'pl-7'
318368
)}
369+
aria-label={collapsed ? label : undefined}
319370
>
320371
<Icon size={16} strokeWidth={1.75} className="shrink-0" />
321-
<span className="whitespace-nowrap">{t(item.label)}</span>
372+
{!collapsed && <span className="whitespace-nowrap">{label}</span>}
322373
</Link>
323374
)
375+
if (!collapsed) return link
376+
return (
377+
<Tooltip>
378+
<TooltipTrigger asChild>{link}</TooltipTrigger>
379+
<TooltipContent side="right">{label}</TooltipContent>
380+
</Tooltip>
381+
)
324382
}
325383

326384
function SidebarGroup({
@@ -329,16 +387,45 @@ function SidebarGroup({
329387
onToggle,
330388
isPathActive,
331389
isBaseActive,
390+
collapsed,
391+
onExpandRequest,
332392
}: {
333393
item: GroupItem
334394
open: boolean
335395
onToggle: () => void
336396
isPathActive: (to: string) => boolean
337397
isBaseActive: (base: string) => boolean
398+
collapsed?: boolean
399+
onExpandRequest?: () => void
338400
}) {
339401
const { t } = useTranslation()
340402
const Icon = item.icon
341403
const baseActive = isBaseActive(item.basePath)
404+
const label = t(item.label)
405+
406+
// Collapsed: no sub-menu room, so clicking a group just re-opens the sidebar.
407+
if (collapsed) {
408+
const btn = (
409+
<button
410+
onClick={onExpandRequest}
411+
aria-label={label}
412+
className={cn(
413+
'flex w-full items-center justify-center rounded-lg px-2 py-2 text-sm transition-colors',
414+
baseActive
415+
? 'bg-primary/10 text-primary'
416+
: 'text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground'
417+
)}
418+
>
419+
<Icon size={16} strokeWidth={1.75} className="shrink-0" />
420+
</button>
421+
)
422+
return (
423+
<Tooltip>
424+
<TooltipTrigger asChild>{btn}</TooltipTrigger>
425+
<TooltipContent side="right">{label}</TooltipContent>
426+
</Tooltip>
427+
)
428+
}
342429

343430
return (
344431
<div>
@@ -352,7 +439,7 @@ function SidebarGroup({
352439
)}
353440
>
354441
<Icon size={16} strokeWidth={1.75} className="shrink-0" />
355-
<span className="flex-1 whitespace-nowrap text-left">{t(item.label)}</span>
442+
<span className="flex-1 whitespace-nowrap text-left">{label}</span>
356443
<ChevronDown
357444
size={14}
358445
className={cn(
@@ -377,7 +464,15 @@ function SidebarGroup({
377464
)
378465
}
379466

380-
function SettingsSidebar({ isPathActive }: { isPathActive: (to: string) => boolean }) {
467+
function SettingsSidebar({
468+
isPathActive,
469+
collapsed,
470+
onToggleCollapsed,
471+
}: {
472+
isPathActive: (to: string) => boolean
473+
collapsed: boolean
474+
onToggleCollapsed: () => void
475+
}) {
381476
const { t } = useTranslation()
382477
const { isAdmin, isPlatformAdmin } = useAuth()
383478
const { license } = useBilling()
@@ -387,39 +482,55 @@ function SettingsSidebar({ isPathActive }: { isPathActive: (to: string) => boole
387482
if (item.tenantOnly) return isAdmin && !isPlatformAdmin && license?.mssp === true
388483
return true
389484
})
485+
const backLabel = t('nav.back')
390486
return (
391-
<aside className="flex h-full w-60 shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground">
392-
<div className="border-b border-sidebar-border p-2">
393-
<Link
394-
to="/home"
395-
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
396-
>
397-
<ArrowLeft size={16} strokeWidth={1.75} className="shrink-0" />
398-
<span>{t('nav.back')}</span>
399-
</Link>
487+
<aside
488+
className={cn(
489+
'flex h-full shrink-0 flex-col border-r border-sidebar-border bg-sidebar text-sidebar-foreground transition-[width] duration-150',
490+
collapsed ? 'w-14' : 'w-60'
491+
)}
492+
>
493+
<div className="flex border-b border-sidebar-border p-2">
494+
{collapsed ? (
495+
<Tooltip>
496+
<TooltipTrigger asChild>
497+
<Link
498+
to="/home"
499+
aria-label={backLabel}
500+
className="flex flex-1 items-center justify-center rounded-lg px-2 py-2 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
501+
>
502+
<ArrowLeft size={16} strokeWidth={1.75} className="shrink-0" />
503+
</Link>
504+
</TooltipTrigger>
505+
<TooltipContent side="right">{backLabel}</TooltipContent>
506+
</Tooltip>
507+
) : (
508+
<Link
509+
to="/home"
510+
className="flex flex-1 items-center gap-2 rounded-lg px-3 py-2 text-sm text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-foreground"
511+
>
512+
<ArrowLeft size={16} strokeWidth={1.75} className="shrink-0" />
513+
<span>{backLabel}</span>
514+
</Link>
515+
)}
400516
</div>
401-
<div className="px-3 pt-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-sidebar-foreground/50">
402-
{t('settings.title')}
517+
<div className="flex p-2 pb-0">
518+
<CollapseToggle collapsed={collapsed} onToggle={onToggleCollapsed} />
403519
</div>
520+
{!collapsed && (
521+
<div className="px-3 pt-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-sidebar-foreground/50">
522+
{t('settings.title')}
523+
</div>
524+
)}
404525
<nav className="flex-1 space-y-0.5 overflow-y-auto overflow-x-hidden p-2 pt-1">
405-
{items.map((item) => {
406-
const Icon = item.icon
407-
return (
408-
<Link
409-
key={item.to}
410-
to={item.to}
411-
className={cn(
412-
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors',
413-
isPathActive(item.to)
414-
? 'bg-primary/15 text-primary'
415-
: 'text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-foreground'
416-
)}
417-
>
418-
<Icon size={16} strokeWidth={1.75} className="shrink-0" />
419-
<span className="whitespace-nowrap">{t(item.label)}</span>
420-
</Link>
421-
)
422-
})}
526+
{items.map((item) => (
527+
<SidebarLeaf
528+
key={item.to}
529+
item={{ to: item.to, label: item.label, icon: item.icon }}
530+
active={isPathActive(item.to)}
531+
collapsed={collapsed}
532+
/>
533+
))}
423534
</nav>
424535
</aside>
425536
)

0 commit comments

Comments
 (0)