feat(sidebar): desktop collapse mode 220px → 56px per handoff prototype

Handoff prototype показывает «‹ Свернуть» button внизу sidebar — toggle
desktop rail в icon-only 56px. Persists в localStorage ord-sidebar-collapsed.

Sidebar component:
  - State collapsed (localStorage 'ord-sidebar-collapsed' = '1')
  - <aside width> transitions между w-[220px] и w-[56px] (200ms ease)

SidebarContent (DRY с MobileSidebar) получил props:
  - collapsed?: boolean (default false)
  - onToggleCollapsed?: () => void (undefined hides button — mobile drawer)

Когда collapsed:
  - Logo: 'ORDINIS' → 'O' (single char, centered)
  - Section labels (text-cap headers) — hidden
  - Nav links: icon-only, centered, icon size 18px (vs 15px expanded);
    title attribute с label для hover tooltip
  - Badges (records count, pending) — hidden (нет места)
  - User block: avatar only, name/email hidden; title attribute с full name
  - Toggle button bottom: '‹ Свернуть' → '›' (icon-only)

Mobile (MobileSidebar Sheet) НЕ получает collapse — там Sheet даёт full
nav text всегда, close через × header.

A11y: aria-label / title для всех collapsed buttons. Click handler
sets localStorage + state — survives F5.

Tests: 116 pass. TS strict clean.
This commit is contained in:
Zimin A.N.
2026-05-11 17:13:06 +03:00
parent 889207f629
commit 69a86b238c
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { Link, useLocation } from '@tanstack/react-router'
import { useTranslation } from 'react-i18next'
import {
@@ -113,9 +114,37 @@ export function Sidebar() {
}))
.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 (
<aside className="hidden lg:flex w-[220px] shrink-0 flex-col border-r border-line bg-surface">
<SidebarContent sections={visibleSections} auth={auth} />
<aside
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>
)
}
@@ -128,22 +157,35 @@ function SidebarContent({
sections,
auth,
onNavigate,
collapsed = false,
onToggleCollapsed,
}: {
sections: NavSection[]
auth: ReturnType<typeof useAuth>
/** Called после клика на nav item — для close drawer в mobile mode. */
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 (
<>
{/* 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
to="/"
className="font-display text-base tracking-wider text-navy hover:opacity-80 transition-opacity"
onClick={onNavigate}
title="ORDINIS"
>
ORDINIS
{collapsed ? 'O' : 'ORDINIS'}
</Link>
</div>
@@ -151,11 +193,11 @@ function SidebarContent({
<nav className="flex-1 overflow-y-auto py-3 px-2 flex flex-col gap-4">
{sections.map((section, idx) => (
<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>
)}
{section.items.map((item) => (
<SidebarLink key={item.to} {...item} onClick={onNavigate} />
<SidebarLink key={item.to} {...item} onClick={onNavigate} collapsed={collapsed} />
))}
</div>
))}
@@ -163,8 +205,19 @@ function SidebarContent({
{/* User block — sticky bottom */}
{auth.isAuthenticated && auth.user?.profile && (
<div className="border-t border-line px-3 py-3 flex items-center gap-2 shrink-0">
<div className="size-8 rounded-full bg-accent text-on-accent inline-flex items-center justify-center text-cell font-semibold">
<div
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(
auth.user.profile.preferred_username ||
auth.user.profile.name ||
@@ -173,21 +226,45 @@ function SidebarContent({
.charAt(0)
.toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<div className="text-cell font-medium text-ink truncate">
{String(
auth.user.profile.preferred_username ||
auth.user.profile.name ||
auth.user.profile.email ||
'user',
{!collapsed && (
<div className="min-w-0 flex-1">
<div className="text-cell font-medium text-ink truncate">
{String(
auth.user.profile.preferred_username ||
auth.user.profile.name ||
auth.user.profile.email ||
'user',
)}
</div>
{auth.user.profile.email && (
<div className="text-cell text-mute truncate">{String(auth.user.profile.email)}</div>
)}
</div>
{auth.user.profile.email && (
<div className="text-cell text-mute truncate">{String(auth.user.profile.email)}</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 active =
to === '/'
@@ -282,27 +366,33 @@ function SidebarLink({ to, label, icon: Icon, badge, onClick }: NavItem & { onCl
<Link
to={to}
onClick={onClick}
title={collapsed ? (typeof label === 'string' ? label : undefined) : undefined}
className={cn(
// 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
? 'bg-accent-bg text-accent font-medium'
: 'text-ink-2 hover:text-ink hover:bg-surface-2',
)}
>
<Icon size={15} strokeWidth={1.75} className="shrink-0" />
<span className="flex-1 truncate">{label}</span>
{badge !== undefined && (
<span
className={cn(
'inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-sm text-mono',
active
? 'bg-accent text-on-accent'
: 'bg-surface-2 text-ink-2 group-hover:bg-line',
<Icon size={collapsed ? 18 : 15} strokeWidth={1.75} className="shrink-0" />
{!collapsed && (
<>
<span className="flex-1 truncate">{label}</span>
{badge !== undefined && (
<span
className={cn(
'inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-sm text-mono',
active
? 'bg-accent text-on-accent'
: 'bg-surface-2 text-ink-2 group-hover:bg-line',
)}
>
{badge}
</span>
)}
>
{badge}
</span>
</>
)}
</Link>
)