Merge branch 'feat/sidebar-collapse' into 'main'
feat(sidebar): desktop collapse 220px → 56px per handoff See merge request 2-6/2-6-4/terravault/ordinis!68
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { Link, useLocation } from '@tanstack/react-router'
|
import { Link, useLocation } from '@tanstack/react-router'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import {
|
import {
|
||||||
@@ -113,9 +114,37 @@ export function Sidebar() {
|
|||||||
}))
|
}))
|
||||||
.filter((s) => s.items.length > 0)
|
.filter((s) => s.items.length > 0)
|
||||||
|
|
||||||
|
// Sidebar collapsed mode — per handoff prototype 'Свернуть' button bottom-left.
|
||||||
|
// Persists в localStorage. Collapsed = icon-only 56px rail, expanded = 220px.
|
||||||
|
const [collapsed, setCollapsed] = useState<boolean>(() => {
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
return window.localStorage.getItem('ord-sidebar-collapsed') === '1'
|
||||||
|
})
|
||||||
|
const toggleCollapsed = () => {
|
||||||
|
setCollapsed((prev) => {
|
||||||
|
const next = !prev
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem('ord-sidebar-collapsed', next ? '1' : '0')
|
||||||
|
} catch {
|
||||||
|
// localStorage может быть disabled
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className="hidden lg:flex w-[220px] shrink-0 flex-col border-r border-line bg-surface">
|
<aside
|
||||||
<SidebarContent sections={visibleSections} auth={auth} />
|
className={cn(
|
||||||
|
'hidden lg:flex shrink-0 flex-col border-r border-line bg-surface transition-[width] duration-200',
|
||||||
|
collapsed ? 'w-[56px]' : 'w-[220px]',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SidebarContent
|
||||||
|
sections={visibleSections}
|
||||||
|
auth={auth}
|
||||||
|
collapsed={collapsed}
|
||||||
|
onToggleCollapsed={toggleCollapsed}
|
||||||
|
/>
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -128,22 +157,35 @@ function SidebarContent({
|
|||||||
sections,
|
sections,
|
||||||
auth,
|
auth,
|
||||||
onNavigate,
|
onNavigate,
|
||||||
|
collapsed = false,
|
||||||
|
onToggleCollapsed,
|
||||||
}: {
|
}: {
|
||||||
sections: NavSection[]
|
sections: NavSection[]
|
||||||
auth: ReturnType<typeof useAuth>
|
auth: ReturnType<typeof useAuth>
|
||||||
/** Called после клика на nav item — для close drawer в mobile mode. */
|
/** Called после клика на nav item — для close drawer в mobile mode. */
|
||||||
onNavigate?: () => void
|
onNavigate?: () => void
|
||||||
|
/** Desktop collapse state — affects labels visibility и width. */
|
||||||
|
collapsed?: boolean
|
||||||
|
/** Toggle button handler. When undefined — collapse button hidden (mobile). */
|
||||||
|
onToggleCollapsed?: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Logo block */}
|
{/* Logo block */}
|
||||||
<div className="h-14 flex items-center px-5 border-b border-line shrink-0">
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-14 flex items-center border-b border-line shrink-0',
|
||||||
|
collapsed ? 'justify-center px-2' : 'px-5',
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className="font-display text-base tracking-wider text-navy hover:opacity-80 transition-opacity"
|
className="font-display text-base tracking-wider text-navy hover:opacity-80 transition-opacity"
|
||||||
onClick={onNavigate}
|
onClick={onNavigate}
|
||||||
|
title="ORDINIS"
|
||||||
>
|
>
|
||||||
ORDINIS
|
{collapsed ? 'O' : 'ORDINIS'}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -151,11 +193,11 @@ function SidebarContent({
|
|||||||
<nav className="flex-1 overflow-y-auto py-3 px-2 flex flex-col gap-4">
|
<nav className="flex-1 overflow-y-auto py-3 px-2 flex flex-col gap-4">
|
||||||
{sections.map((section, idx) => (
|
{sections.map((section, idx) => (
|
||||||
<div key={idx} className="flex flex-col gap-0.5">
|
<div key={idx} className="flex flex-col gap-0.5">
|
||||||
{section.label && (
|
{section.label && !collapsed && (
|
||||||
<div className="text-cap px-3 py-1 text-mute">{section.label}</div>
|
<div className="text-cap px-3 py-1 text-mute">{section.label}</div>
|
||||||
)}
|
)}
|
||||||
{section.items.map((item) => (
|
{section.items.map((item) => (
|
||||||
<SidebarLink key={item.to} {...item} onClick={onNavigate} />
|
<SidebarLink key={item.to} {...item} onClick={onNavigate} collapsed={collapsed} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -163,8 +205,19 @@ function SidebarContent({
|
|||||||
|
|
||||||
{/* User block — sticky bottom */}
|
{/* User block — sticky bottom */}
|
||||||
{auth.isAuthenticated && auth.user?.profile && (
|
{auth.isAuthenticated && auth.user?.profile && (
|
||||||
<div className="border-t border-line px-3 py-3 flex items-center gap-2 shrink-0">
|
<div
|
||||||
<div className="size-8 rounded-full bg-accent text-on-accent inline-flex items-center justify-center text-cell font-semibold">
|
className={cn(
|
||||||
|
'border-t border-line py-3 flex items-center gap-2 shrink-0',
|
||||||
|
collapsed ? 'px-2 justify-center' : 'px-3',
|
||||||
|
)}
|
||||||
|
title={String(
|
||||||
|
auth.user.profile.preferred_username ||
|
||||||
|
auth.user.profile.name ||
|
||||||
|
auth.user.profile.email ||
|
||||||
|
'user',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="size-8 rounded-full bg-accent text-on-accent inline-flex items-center justify-center text-cell font-semibold shrink-0">
|
||||||
{String(
|
{String(
|
||||||
auth.user.profile.preferred_username ||
|
auth.user.profile.preferred_username ||
|
||||||
auth.user.profile.name ||
|
auth.user.profile.name ||
|
||||||
@@ -173,21 +226,45 @@ function SidebarContent({
|
|||||||
.charAt(0)
|
.charAt(0)
|
||||||
.toUpperCase()}
|
.toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
{!collapsed && (
|
||||||
<div className="text-cell font-medium text-ink truncate">
|
<div className="min-w-0 flex-1">
|
||||||
{String(
|
<div className="text-cell font-medium text-ink truncate">
|
||||||
auth.user.profile.preferred_username ||
|
{String(
|
||||||
auth.user.profile.name ||
|
auth.user.profile.preferred_username ||
|
||||||
auth.user.profile.email ||
|
auth.user.profile.name ||
|
||||||
'user',
|
auth.user.profile.email ||
|
||||||
|
'user',
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{auth.user.profile.email && (
|
||||||
|
<div className="text-cell text-mute truncate">{String(auth.user.profile.email)}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{auth.user.profile.email && (
|
)}
|
||||||
<div className="text-cell text-mute truncate">{String(auth.user.profile.email)}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Collapse toggle — desktop only (mobile uses Sheet close ×) */}
|
||||||
|
{onToggleCollapsed && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onToggleCollapsed}
|
||||||
|
aria-label={collapsed
|
||||||
|
? t('sidebar.expand', { defaultValue: 'Развернуть' })
|
||||||
|
: t('sidebar.collapse', { defaultValue: 'Свернуть' })
|
||||||
|
}
|
||||||
|
title={collapsed ? t('sidebar.expand', { defaultValue: 'Развернуть' }) : t('sidebar.collapse', { defaultValue: 'Свернуть' })}
|
||||||
|
className={cn(
|
||||||
|
'border-t border-line shrink-0 h-8 flex items-center gap-2 text-cap text-mute hover:text-ink hover:bg-surface-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||||
|
collapsed ? 'justify-center' : 'px-4',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span aria-hidden>{collapsed ? '›' : '‹'}</span>
|
||||||
|
{!collapsed && (
|
||||||
|
<span>{t('sidebar.collapse', { defaultValue: 'Свернуть' })}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -271,7 +348,14 @@ export function MobileSidebar({ open, onClose }: { open: boolean; onClose: () =>
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarLink({ to, label, icon: Icon, badge, onClick }: NavItem & { onClick?: () => void }) {
|
function SidebarLink({
|
||||||
|
to,
|
||||||
|
label,
|
||||||
|
icon: Icon,
|
||||||
|
badge,
|
||||||
|
onClick,
|
||||||
|
collapsed = false,
|
||||||
|
}: NavItem & { onClick?: () => void; collapsed?: boolean }) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const active =
|
const active =
|
||||||
to === '/'
|
to === '/'
|
||||||
@@ -282,27 +366,33 @@ function SidebarLink({ to, label, icon: Icon, badge, onClick }: NavItem & { onCl
|
|||||||
<Link
|
<Link
|
||||||
to={to}
|
to={to}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
title={collapsed ? (typeof label === 'string' ? label : undefined) : undefined}
|
||||||
className={cn(
|
className={cn(
|
||||||
// text-body = 13px workhorse per handoff (см. styles.css @utility).
|
// text-body = 13px workhorse per handoff (см. styles.css @utility).
|
||||||
'group flex items-center gap-2.5 px-3 py-1.5 rounded-md text-body transition-colors',
|
'group flex items-center rounded-md text-body transition-colors',
|
||||||
|
collapsed ? 'justify-center h-9 mx-1' : 'gap-2.5 px-3 py-1.5',
|
||||||
active
|
active
|
||||||
? 'bg-accent-bg text-accent font-medium'
|
? 'bg-accent-bg text-accent font-medium'
|
||||||
: 'text-ink-2 hover:text-ink hover:bg-surface-2',
|
: 'text-ink-2 hover:text-ink hover:bg-surface-2',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Icon size={15} strokeWidth={1.75} className="shrink-0" />
|
<Icon size={collapsed ? 18 : 15} strokeWidth={1.75} className="shrink-0" />
|
||||||
<span className="flex-1 truncate">{label}</span>
|
{!collapsed && (
|
||||||
{badge !== undefined && (
|
<>
|
||||||
<span
|
<span className="flex-1 truncate">{label}</span>
|
||||||
className={cn(
|
{badge !== undefined && (
|
||||||
'inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-sm text-mono',
|
<span
|
||||||
active
|
className={cn(
|
||||||
? 'bg-accent text-on-accent'
|
'inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-sm text-mono',
|
||||||
: 'bg-surface-2 text-ink-2 group-hover:bg-line',
|
active
|
||||||
|
? 'bg-accent text-on-accent'
|
||||||
|
: 'bg-surface-2 text-ink-2 group-hover:bg-line',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{badge}
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
>
|
</>
|
||||||
{badge}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user