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:
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user