feat(mobile): Sidebar drawer <1024px + Modal/Sheet fullscreen <880px
Per design_handoff_ordinis_mdm/README.md responsive table (line 464-465):
- ≤1024px: Sidebar becomes overlay drawer (hamburger в TopBar)
- ≤880px: Drawers и modals fullscreen (100vw × 100dvh, no border-radius)
Implementation:
src/components/layout/Sidebar.tsx
- Extracted SidebarContent (logo + nav + user block) для DRY
- Desktop <Sidebar /> wraps SidebarContent в hidden lg:flex aside
- New <MobileSidebar open onClose /> — Sheet с тем же содержимым,
260px wide, slides from left
- SidebarLink принимает onClick → drawer auto-close при navigate
src/components/layout/TopBar.tsx
- Optional onMenuClick prop — рендерит hamburger button (Menu icon
из lucide-react) с lg:hidden
- VersionBadge скрыт <md (768px) — diagnostic info, не critical UX
src/routes/__root.tsx
- useState mobileNavOpen, передаём в MobileSidebar + TopBar
src/ui/components/dialog.tsx
- SIZE_CLASSES получили prefix (arbitrary Tailwind v4
breakpoint per handoff exact 880px). На mobile <880px modal становится
fullscreen (inset-0 w-full h-full no rounded), ≥880px — centered с
translate + max-w из SIZE_CLASSES.
src/ui/components/sheet.tsx
- size variants: sm:max-w-* → min-[880px]:max-w-*. На mobile <880px
sheet берёт w-full (fullscreen drawer per handoff).
src/components/record/RecordDrawer.tsx
- max-width переключатель 520/880 теперь применяется только ≥880px.
На mobile drawer всегда fullscreen.
src/ui/components/language-switch.tsx
- Display fallback: opt.short → opt.label → uppercase id.
Previously показывал id ('ru-RU', 'en-US') когда short не был задан —
теперь использует label ('RU', 'EN') как fallback.
Browser-verified в preview viewport 375×812:
- Hamburger ☰ visible, click открывает Sheet с nav items
- Backdrop fade + slide-from-left animation
- TopBar fits в 375px (RU/EN compact, Sign in visible)
- LoadingBlock + Alert text-wrap correctly
This commit is contained in:
@@ -15,6 +15,7 @@ import {
|
||||
import { useAuth } from 'react-oidc-context'
|
||||
import { useCanMutate } from '@/auth/useCanMutate'
|
||||
import { useDictionaries, useMyDrafts } from '@/api/queries'
|
||||
import { Sheet, SheetContent } from '@/ui'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
@@ -114,24 +115,47 @@ export function Sidebar() {
|
||||
|
||||
return (
|
||||
<aside className="hidden lg:flex w-[220px] shrink-0 flex-col border-r border-line bg-surface">
|
||||
<SidebarContent sections={visibleSections} auth={auth} />
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* SidebarContent — внутренности (logo + nav + user). Используется в desktop
|
||||
* rail (Sidebar) И в mobile drawer (MobileSidebar) — DRY.
|
||||
*/
|
||||
function SidebarContent({
|
||||
sections,
|
||||
auth,
|
||||
onNavigate,
|
||||
}: {
|
||||
sections: NavSection[]
|
||||
auth: ReturnType<typeof useAuth>
|
||||
/** Called после клика на nav item — для close drawer в mobile mode. */
|
||||
onNavigate?: () => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{/* Logo block */}
|
||||
<div className="h-14 flex items-center px-5 border-b border-line">
|
||||
<Link to="/" className="font-display text-base tracking-wider text-navy hover:opacity-80 transition-opacity">
|
||||
<div className="h-14 flex items-center px-5 border-b border-line shrink-0">
|
||||
<Link
|
||||
to="/"
|
||||
className="font-display text-base tracking-wider text-navy hover:opacity-80 transition-opacity"
|
||||
onClick={onNavigate}
|
||||
>
|
||||
ORDINIS
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Nav sections */}
|
||||
<nav className="flex-1 overflow-y-auto py-3 px-2 flex flex-col gap-4">
|
||||
{visibleSections.map((section, idx) => (
|
||||
{sections.map((section, idx) => (
|
||||
<div key={idx} className="flex flex-col gap-0.5">
|
||||
{section.label && (
|
||||
<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) => (
|
||||
<SidebarLink key={item.to} {...item} />
|
||||
<SidebarLink key={item.to} {...item} onClick={onNavigate} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
@@ -139,7 +163,7 @@ export function Sidebar() {
|
||||
|
||||
{/* User block — sticky bottom */}
|
||||
{auth.isAuthenticated && auth.user?.profile && (
|
||||
<div className="border-t border-line px-3 py-3 flex items-center gap-2">
|
||||
<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">
|
||||
{String(
|
||||
auth.user.profile.preferred_username ||
|
||||
@@ -164,11 +188,90 @@ export function Sidebar() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarLink({ to, label, icon: Icon, badge }: NavItem) {
|
||||
/**
|
||||
* MobileSidebar — Sheet с тем же содержимым что desktop Sidebar. Открывается
|
||||
* через hamburger button в TopBar (<1024px), закрывается на: backdrop click,
|
||||
* Escape, или клик на любой nav item (auto-close, привычная mobile UX).
|
||||
*
|
||||
* <p>Hooks logic дублируется (auth, dictsCount etc.) — нужно для filter
|
||||
* auth-required sections. Можно вынести в useSidebarSections() hook
|
||||
* следующим refactor.
|
||||
*/
|
||||
export function MobileSidebar({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const { t } = useTranslation()
|
||||
const auth = useAuth()
|
||||
const canMutate = useCanMutate()
|
||||
const dictsQuery = useDictionaries()
|
||||
const myDrafts = useMyDrafts(0, 1)
|
||||
|
||||
const dictsCount = dictsQuery.data?.length ?? 0
|
||||
const myDraftsCount = myDrafts.data?.totalElements ?? 0
|
||||
const reviewsCount = 0
|
||||
|
||||
const sections: NavSection[] = [
|
||||
{
|
||||
items: [
|
||||
{ to: '/', label: t('nav.home'), icon: Home },
|
||||
{
|
||||
to: '/dictionaries',
|
||||
label: t('nav.dictionaries'),
|
||||
icon: Database,
|
||||
badge: dictsCount > 0 ? dictsCount : undefined,
|
||||
},
|
||||
{ to: '/search', label: t('nav.search'), icon: Search },
|
||||
{ to: '/graph', label: t('nav.graph'), icon: GitBranch },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('nav.section.workflow'),
|
||||
items: [
|
||||
{
|
||||
to: '/my-drafts',
|
||||
label: t('nav.myDrafts'),
|
||||
icon: FileText,
|
||||
badge: myDraftsCount > 0 ? myDraftsCount : undefined,
|
||||
authRequired: true,
|
||||
},
|
||||
{
|
||||
to: '/reviews',
|
||||
label: t('nav.reviews'),
|
||||
icon: ClipboardList,
|
||||
badge: reviewsCount > 0 ? reviewsCount : undefined,
|
||||
authRequired: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('nav.section.admin'),
|
||||
items: [
|
||||
{ to: '/audit', label: t('nav.audit'), icon: ScrollText, authRequired: true },
|
||||
{ to: '/outbox', label: t('nav.outbox'), icon: Inbox, authRequired: true },
|
||||
{ to: '/webhooks', label: t('nav.webhooks'), icon: Webhook, authRequired: true },
|
||||
],
|
||||
},
|
||||
]
|
||||
const visibleSections = sections
|
||||
.map((s) => ({ ...s, items: s.items.filter((i) => !i.authRequired || canMutate) }))
|
||||
.filter((s) => s.items.length > 0)
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<SheetContent
|
||||
side="left"
|
||||
size="md"
|
||||
className="!w-[260px] !max-w-[260px] !p-0 !gap-0 flex flex-col bg-surface"
|
||||
>
|
||||
<SidebarContent sections={visibleSections} auth={auth} onNavigate={onClose} />
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarLink({ to, label, icon: Icon, badge, onClick }: NavItem & { onClick?: () => void }) {
|
||||
const location = useLocation()
|
||||
const active =
|
||||
to === '/'
|
||||
@@ -178,6 +281,7 @@ function SidebarLink({ to, label, icon: Icon, badge }: NavItem) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
onClick={onClick}
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user