"use client" import React, { useState, useEffect, useRef } from 'react' import { motion, AnimatePresence, useReducedMotion } from 'framer-motion' import { usePreventScroll } from '@react-aria/overlays' import { cn } from '../../utils' import { useHeaderHeight } from '../../hooks/ui' import { useFocusTrap } from '../../hooks/ui/use-focus-trap' import { SlidingSidebarConfig, NavigationItem } from '../../types/navigation' import { Button } from '../ui/button' import { OVERLAY_BACKDROP_CLASS } from '../ui/drawer' export interface SlidingSidebarProps { config: SlidingSidebarConfig } export function SlidingSidebar({ config }: SlidingSidebarProps) { const [expandedItems, setExpandedItems] = useState>(new Set()) const [mounted, setMounted] = useState(false) const headerHeight = useHeaderHeight() const asideRef = useRef(null) const prefersReducedMotion = useReducedMotion() // Shared ref-counted scroll lock (react-aria) while the drawer is open. usePreventScroll({ isDisabled: !config.isOpen }) // Escape + initial focus + guarded restore. `contain: false`: the z-50 // header stays visible and clickable ABOVE the open drawer (see the z-tier // comment below), so this is a NON-modal dialog — Tab may leave freely. useFocusTrap(asideRef, config.isOpen, { onEscape: config.onClose, contain: false }) useEffect(() => { setMounted(true) }, []) const toggleExpanded = (itemId: string) => { const newExpanded = new Set(expandedItems) if (newExpanded.has(itemId)) { newExpanded.delete(itemId) } else { newExpanded.add(itemId) } setExpandedItems(newExpanded) } const renderMenuItem = (item: NavigationItem, level = 0): React.ReactNode => { // If custom element provided, render it if (item.element) { return
{item.element}
} const hasChildren = item.children && item.children.length > 0 const isExpanded = expandedItems.has(item.id) if (hasChildren) { const chevronIcon = ( ) return (
{isExpanded && (
{item.children!.map(child => renderMenuItem(child, level + 1))}
)}
) } // Leaf node - either link or button if (item.href) { // For Next.js apps, onClick handler should handle navigation return ( ) } return ( ) } if (!mounted) { return null } return ( <> {/* Overlay — admin-drawer tier (overlay z-[45] / panel z-[46]): ABOVE the footer (lowered to z-[44]) so the open drawer covers it, but BEHIND the header (z-[50]) so the header stays visible on top (the panel has a header spacer below). Kept under z-50 so it also stays beneath modals/ dialogs (z-50+). See the z-index hierarchy in ODS_TOKEN_RULES.md. */} {config.isOpen && ( config.onClose()} /> )} {/* Sliding Sidebar */} {/* Header spacer - dynamic height */}
{/* Navigation */} {/* Footer - bottom padding clears the iOS home indicator */} {config.footer && (
{config.footer}
)} ) }