Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ the primitive, not N components.
| Full-bleed page hero (centered) | `<PageHero theme title subtitle>` | `components/layout/PageHero.tsx` | a one-off `<section>` with a bespoke `<h1>` type string |
| Page-section vertical rhythm / tinted band | `<Section density tone>` | `components/layout/Section.tsx` | hardcoded `py-16 sm:py-20 …` |
| Mobile bottom tab bar (`<lg`) | `<BottomNav items ariaLabel more>` | `components/layout/BottomNav.tsx` | a `fixed bottom-0 … flex` `<nav>` + tab loop |
| Edge overlay (right sheet / bottom sheet) | `<Drawer isOpen onClose side ariaLabel>` | `components/ui/Drawer.tsx` | a hand-rolled `fixed inset-0` portal + backdrop + focus-trap |
| Centered dialog | `<Modal isOpen onClose title>` | `components/ui/Modal.tsx` | a bespoke centered overlay |
| Active/inactive nav item classes | `navLinkClass(shape, active)` / `NAV_STATE` | `lib/design/nav.ts` | inline `text-action` / `ring-action/20` ladders |

Rules:
Expand All @@ -171,11 +173,12 @@ Rules:
`<Card>`/`<Panel>` both resolve to it. `rounded-lg`, missing border, or
`shadow-xs` on a static card is drift — fix it, don't copy it.

Deliberately NOT unified (yet): the right-edge **drawer/sheet** overlay
(MessageSidebar, MobileMenu, DashboardMobileNav sheet) — a shared `<Drawer>`
with focus-trap + scroll-lock is the next chrome primitive; until it lands,
match MobileMenu's implementation (scrim `bg-black/40 backdrop-blur-xs`, focus
trap via `useFocusTrap`, body scroll-lock) and never `bg-opacity-*`.
Every edge overlay goes through `<Drawer>` (right sheet or `side="bottom"`) —
it owns the portal, `useFocusTrap` (Escape / initial focus / Tab cycle / focus
restore), body scroll-lock, and the `bg-black/40 backdrop-blur-xs` scrim. Pass
panel size via `className`, viewport gating via `rootClassName` (`xl:hidden` /
`lg:hidden`). Never hand-roll a `fixed inset-0` overlay and never `bg-opacity-*`
(use `bg-black/NN`). Centered dialogs use `<Modal>`, its sibling.

### Mobile-first = ACTION-first, not stats-first

Expand Down
44 changes: 11 additions & 33 deletions src/components/dashboard/DashboardMobileNav.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
'use client'

import { useEffect, useMemo, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { Link, usePathname } from '@/i18n/navigation'
import { Button } from '@/components/ui/button'
import { BottomNav } from '@/components/layout/BottomNav'
import { Drawer } from '@/components/ui/Drawer'
import { navLinkClass } from '@/lib/design/nav'
import { useFocusTrap } from '@/hooks/useFocusTrap'
import {
DASHBOARD_CATEGORIES,
getAllDashboardCards,
Expand Down Expand Up @@ -53,20 +52,6 @@ export function DashboardMobileNav({
[role, isStaff, isSuperAdmin, isTechnician],
)
const [moreOpen, setMoreOpen] = useState(false)
const [mounted, setMounted] = useState(false)
const sheetRef = useFocusTrap<HTMLDivElement>(moreOpen, () => setMoreOpen(false))

useEffect(() => setMounted(true), [])

// Lock body scroll while the sheet is open.
useEffect(() => {
if (!moreOpen) return
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = prev
}
}, [moreOpen])

// Close the sheet on navigation.
useEffect(() => {
Expand Down Expand Up @@ -101,19 +86,15 @@ export function DashboardMobileNav({
more={{ label: 'Mehr', ariaLabel: 'Mehr anzeigen', onClick: () => setMoreOpen(true), expanded: moreOpen }}
/>

{/* "Mehr" bottom sheet */}
{mounted && moreOpen && createPortal(
<div className="fixed inset-0 z-50 lg:hidden" role="dialog" aria-modal="true" aria-label="Dashboard-Menü">
<Button
variant="ghost"
aria-label="Schliessen"
onClick={() => setMoreOpen(false)}
className="absolute inset-0 h-full w-full rounded-none bg-black/40 p-0 backdrop-blur-xs hover:bg-black/40"
/>
<div
ref={sheetRef}
className="absolute inset-x-0 bottom-0 max-h-[80vh] overflow-y-auto rounded-t-2xl border-t border-subtle bg-surface-base p-4 pb-[max(1rem,env(safe-area-inset-bottom))] shadow-card"
>
{/* "Mehr" bottom sheet — Drawer supplies the portal, focus-trap and scroll-lock. */}
<Drawer
isOpen={moreOpen}
onClose={() => setMoreOpen(false)}
side="bottom"
ariaLabel="Dashboard-Menü"
rootClassName="lg:hidden"
className="overflow-y-auto p-4 pb-[max(1rem,env(safe-area-inset-bottom))] shadow-card"
>
<div className="mb-3 flex items-center justify-between">
<span className="text-sm font-semibold text-text-primary">Alle Bereiche</span>
<Button
Expand Down Expand Up @@ -152,10 +133,7 @@ export function DashboardMobileNav({
</div>
))}
</div>
</div>
</div>,
document.body,
)}
</Drawer>
</>
)
}
71 changes: 12 additions & 59 deletions src/components/layout/MobileMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
'use client'

import { useEffect, useState } from 'react'
import { useState } from 'react'
import { Link } from '@/i18n/navigation'
import { useFocusTrap } from '@/hooks/useFocusTrap'
import { useRouter } from 'next/navigation'
import { X, ChevronDown, ExternalLink, ArrowRight } from 'lucide-react'
import { createPortal } from 'react-dom'
import { useTranslations } from 'next-intl'
import { Drawer } from '@/components/ui/Drawer'
import { NavigationItem } from '@/config/navigation'
import { ORG } from '@/config/org'
import { Button } from '@/components/ui/button'
Expand Down Expand Up @@ -47,27 +46,9 @@ export function MobileMenu({
if (!key) return null
try { return tBadge(key as never) } catch { return key }
}
// Escape-to-close, initial focus, focus restore (to the hamburger trigger)
// and the Tab trap all live in the shared hook; attach its ref to the panel.
const menuPanelRef = useFocusTrap<HTMLDivElement>(isOpen, onClose)
// Portal, backdrop, focus-trap (Escape / initial focus / Tab cycle / focus
// restore) and body scroll-lock all live in <Drawer>.
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
const [mounted, setMounted] = useState(false)

useEffect(() => {
// Defer setState to avoid synchronous update during effect
const frame = requestAnimationFrame(() => setMounted(true))
return () => cancelAnimationFrame(frame)
}, [])

// Lock body scroll while the menu is open.
useEffect(() => {
if (!isOpen) return
const prevOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = prevOverflow
}
}, [isOpen])

const handleNavigation = (href: string) => {
if (href === '#') return
Expand All @@ -82,44 +63,18 @@ export function MobileMenu({
setOpenDropdown(openDropdown === itemName ? null : itemName)
}

if (!mounted || !isOpen) return null

// Separate primary nav from action items
const primaryItems = navigationItems.filter(item => !item.highlight)
const actionItems = navigationItems.filter(item => item.highlight)

return createPortal(
<div
className="fixed inset-0 z-100 xl:hidden"
role="dialog"
aria-modal="true"
aria-label="Mobile Navigation"
return (
<Drawer
isOpen={isOpen}
onClose={onClose}
side="right"
ariaLabel="Mobile Navigation"
rootClassName="xl:hidden"
>
{/* Backdrop */}
<div
className={cn(
"fixed inset-0 bg-black/40 backdrop-blur-xs",
"transition-opacity duration-300",
isOpen ? "opacity-100" : "opacity-0"
)}
onClick={onClose}
aria-hidden="true"
/>

{/* Menu Panel */}
<div
ref={menuPanelRef}
tabIndex={-1}
className={cn(
"fixed inset-y-0 right-0 z-101 w-full sm:max-w-md",
// Border-only separation matches the rest of the design system —
// the translucent backdrop already provides the plane lift.
"bg-surface-base border-l border",
"flex flex-col",
"transition-transform duration-300 ease-out",
isOpen ? "translate-x-0" : "translate-x-full"
)}
>
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-subtle dark:border-white/6">
<Button type="button" variant="ghost" onClick={onClose} className="cursor-pointer bg-transparent border-none p-0 h-auto">
Expand Down Expand Up @@ -392,9 +347,7 @@ export function MobileMenu({
</div>
)}
</div>
</div>
</div>,
document.body
</Drawer>
)
}

Expand Down
9 changes: 3 additions & 6 deletions src/components/messaging/MessageSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { formatDateShort, formatTime } from '@/lib/date-formats'
import Heading from '@/components/ui/Heading'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Drawer } from '@/components/ui/Drawer'

interface Conversation {
id: string
Expand Down Expand Up @@ -130,11 +131,8 @@ export function MessageSidebar({ isOpen, onClose, initialConversationId }: Messa
conv.title?.toLowerCase().includes(searchQuery.toLowerCase())
)

if (!isOpen) return null

return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex">
<div className="ml-auto w-full max-w-md bg-surface-base h-full flex flex-col">
<Drawer isOpen={isOpen} onClose={onClose} side="right" ariaLabel={t('title')}>
{/* Header */}
<div className="p-4 border-b border-strong flex items-center justify-between">
<Heading level={2} className="text-lg font-semibold">{t('title')}</Heading>
Expand Down Expand Up @@ -295,8 +293,7 @@ export function MessageSidebar({ isOpen, onClose, initialConversationId }: Messa
</div>
)}
</div>
</div>
</div>
</Drawer>
)
}

Expand Down
107 changes: 107 additions & 0 deletions src/components/ui/Drawer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
'use client'

/**
* Drawer — the edge-anchored overlay primitive (the sibling of <Modal>, which
* is the centered dialog). The SSOT for right-side sheets (mobile menu, message
* sidebar) and bottom sheets (dashboard "Mehr").
*
* Before this primitive, three overlays hand-rolled the same shell and drifted:
* MobileMenu (portal + focus-trap + scroll-lock, the good reference),
* DashboardMobileNav's sheet (same, duplicated), and MessageSidebar (NO portal,
* NO focus-trap, NO scroll-lock, backdrop that didn't close, deprecated
* `bg-opacity-50`). Drawer gives all three: a portal (so it escapes transformed
* ancestors like the smart-hiding header), the shared `useFocusTrap`
* (initial focus + Tab cycle + Escape + focus restore), body scroll-lock, and a
* click-to-close backdrop.
*
* Caller owns the panel *contents* (header, body) and the open state. The panel
* width/height is a sensible default per side; override via `className`. Use
* `rootClassName` for viewport gating (e.g. `xl:hidden`, `lg:hidden`).
*/

import { useEffect, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { useFocusTrap } from '@/hooks/useFocusTrap'
import { cn } from '@/lib/utils'

type DrawerSide = 'right' | 'bottom'

interface DrawerProps {
isOpen: boolean
onClose: () => void
/** Which edge the panel is anchored to. Default 'right'. */
side?: DrawerSide
/** Accessible name for the dialog (required — it's `aria-label`). */
ariaLabel: string
/** Classes for the sliding panel (width / max-height / padding overrides). */
className?: string
/** Classes for the root portal layer — e.g. `xl:hidden` / `lg:hidden`. */
rootClassName?: string
children: ReactNode
}

// Panel shape per side. `bg-surface-base` + border matches the design system
// (border-only separation; the translucent backdrop supplies the plane lift).
const SIDE_PANEL: Record<DrawerSide, string> = {
right: 'inset-y-0 right-0 w-full sm:max-w-md border-l border',
bottom: 'inset-x-0 bottom-0 max-h-[85vh] rounded-t-2xl border-t border-subtle',
}

export function Drawer({
isOpen,
onClose,
side = 'right',
ariaLabel,
className,
rootClassName,
children,
}: DrawerProps) {
// Escape-to-close, initial focus, Tab trap and focus restore all live in the
// shared hook; attach its ref to the panel.
const panelRef = useFocusTrap<HTMLDivElement>(isOpen, onClose)
// Portals need the document — mount after first client render.
const [mounted, setMounted] = useState(false)
useEffect(() => {
const frame = requestAnimationFrame(() => setMounted(true))
return () => cancelAnimationFrame(frame)
}, [])

// Lock body scroll while open.
useEffect(() => {
if (!isOpen) return
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => {
document.body.style.overflow = prev
}
}, [isOpen])

if (!mounted || !isOpen) return null

return createPortal(
<div
className={cn('fixed inset-0 z-100', rootClassName)}
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
>
<div
className="fixed inset-0 bg-black/40 backdrop-blur-xs"
onClick={onClose}
aria-hidden="true"
/>
<div
ref={panelRef}
tabIndex={-1}
className={cn(
'fixed z-101 flex flex-col bg-surface-base focus:outline-hidden',
SIDE_PANEL[side],
className,
)}
>
{children}
</div>
</div>,
document.body,
)
}