{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "split-layout-context", "type": "registry:block", "title": "Split Layout Context", "description": "SplitLayoutGlass Context", "dependencies": [], "registryDependencies": [ "variants" ], "files": [ { "path": "components/glass/composite/split-layout-context.tsx", "type": "registry:component", "content": "/* eslint-disable react-refresh/only-export-components */\n/**\n * SplitLayoutGlass Context\n *\n * Provides state management for compound SplitLayoutGlass component.\n * Handles selection state, mobile detection, and toggle functionality.\n *\n * @module split-layout-context\n */\n\nimport {\n createContext,\n useContext,\n useState,\n useCallback,\n useMemo,\n useEffect,\n type FC,\n type ReactNode,\n} from 'react';\n\n// ========================================\n// TYPES\n// ========================================\n\nimport type { IntensityType } from '@/lib/variants/glass-card-variants';\n\nexport type Breakpoint = 'sm' | 'md' | 'lg' | 'xl' | '2xl';\nexport type MobileMode = 'stack' | 'accordion' | 'drawer';\n\n/**\n * Context value for SplitLayoutGlass compound components\n *\n * API is designed to be consistent with shadcn/ui Sidebar patterns.\n */\nexport interface SplitLayoutContextValue {\n // === STATE ===\n /** Currently selected key for master-detail pattern */\n selectedKey: string | null;\n /** Set selected key */\n setSelectedKey: (key: string | null) => void;\n\n /** Sidebar open state (desktop) */\n isOpen: boolean;\n /** Set sidebar open state */\n setIsOpen: (open: boolean) => void;\n\n /** Mobile drawer/accordion open state */\n isMobileOpen: boolean;\n /** Set mobile open state */\n setMobileOpen: (open: boolean) => void;\n\n // === SHADCN ALIASES ===\n /** Sidebar state: \"expanded\" | \"collapsed\" (shadcn pattern) */\n state: 'expanded' | 'collapsed';\n /** Alias for isOpen (shadcn naming) */\n open: boolean;\n /** Alias for setIsOpen (shadcn naming) */\n setOpen: (open: boolean) => void;\n /** Alias for isMobileOpen (shadcn naming) */\n openMobile: boolean;\n /** Alias for setMobileOpen (shadcn naming) */\n setOpenMobile: (open: boolean) => void;\n /** Alias for toggle (shadcn naming) */\n toggleSidebar: () => void;\n\n // === RESPONSIVE ===\n /** Current viewport is below breakpoint */\n isMobile: boolean;\n\n // === CONFIG ===\n /** Breakpoint for desktop layout */\n breakpoint: Breakpoint;\n /** Mobile layout mode */\n mobileMode: MobileMode;\n /** Glass intensity for panels */\n intensity: IntensityType;\n /** Sticky offset from viewport top */\n stickyOffset: number;\n\n // === ACTIONS ===\n /** Toggle sidebar (desktop) or drawer (mobile) */\n toggle: () => void;\n}\n\n// ========================================\n// CONTEXT\n// ========================================\n\nconst SplitLayoutContext = createContext(null);\n\n/**\n * Hook to access SplitLayout context\n *\n * @throws Error if used outside of SplitLayoutGlass.Provider\n *\n * @example\n * ```tsx\n * function YearCard({ year }) {\n * const { selectedKey, setSelectedKey, isMobile } = useSplitLayout();\n * const isSelected = selectedKey === year.id;\n *\n * return (\n * setSelectedKey(year.id)}\n * className={cn('p-3', isSelected && 'bg-primary/10')}\n * >\n * {year.year}\n * \n * );\n * }\n * ```\n */\nexport function useSplitLayout(): SplitLayoutContextValue {\n const context = useContext(SplitLayoutContext);\n if (!context) {\n throw new Error(\n 'useSplitLayout must be used within SplitLayoutGlass.Provider. ' +\n 'Wrap your component tree with .'\n );\n }\n return context;\n}\n\n/**\n * Optional hook that returns null if outside provider (doesn't throw)\n */\nexport function useSplitLayoutOptional(): SplitLayoutContextValue | null {\n return useContext(SplitLayoutContext);\n}\n\n// ========================================\n// BREAKPOINT MAP\n// ========================================\n\nconst BREAKPOINT_VALUES: Record = {\n sm: 640,\n md: 768,\n lg: 1024,\n xl: 1440,\n '2xl': 1536,\n};\n\n// ========================================\n// PROVIDER\n// ========================================\n\n/**\n * Props for SplitLayoutGlass.Provider\n */\nexport interface SplitLayoutProviderProps {\n children: ReactNode;\n\n // === SELECTION STATE ===\n /** Controlled selected key */\n selectedKey?: string | null;\n /** Callback when selected key changes */\n onSelectedKeyChange?: (key: string | null) => void;\n /** Default selected key (uncontrolled) */\n defaultSelectedKey?: string | null;\n\n // === OPEN STATE ===\n /** Controlled open state (desktop) */\n open?: boolean;\n /** Callback when open state changes */\n onOpenChange?: (open: boolean) => void;\n /** Default open state */\n defaultOpen?: boolean;\n\n // === CONFIG ===\n /** Breakpoint for mobile/desktop switch @default \"md\" */\n breakpoint?: Breakpoint;\n /** Mobile layout mode @default \"stack\" */\n mobileMode?: MobileMode;\n /** Glass intensity for panels @default \"medium\" */\n intensity?: IntensityType;\n /** Sticky offset from viewport top @default 24 */\n stickyOffset?: number;\n\n // === PERSISTENCE ===\n /** URL param name for selectedKey persistence */\n urlParamName?: string;\n\n // === KEYBOARD ===\n /** Keyboard shortcut key for toggle (Cmd/Ctrl + key) @default \"b\" */\n keyboardShortcut?: string | false;\n}\n\n/**\n * Provider component for SplitLayoutGlass compound components\n *\n * @example\n * ```tsx\n * console.log('Selected:', key)}\n * >\n * \n * ...\n * \n * \n * ```\n */\nexport const SplitLayoutProvider: FC = ({\n children,\n selectedKey: controlledSelectedKey,\n onSelectedKeyChange,\n defaultSelectedKey = null,\n open: controlledOpen,\n onOpenChange,\n defaultOpen = true,\n breakpoint = 'md',\n mobileMode = 'stack',\n intensity = 'medium',\n stickyOffset = 24,\n urlParamName,\n keyboardShortcut = 'b',\n}) => {\n // === SELECTION STATE (controlled/uncontrolled) ===\n const [internalSelectedKey, setInternalSelectedKey] = useState(() => {\n // Try to read from URL if urlParamName is provided\n if (urlParamName && typeof window !== 'undefined') {\n const params = new URLSearchParams(window.location.search);\n const urlValue = params.get(urlParamName);\n if (urlValue) return urlValue;\n }\n return defaultSelectedKey;\n });\n\n const isSelectedKeyControlled = controlledSelectedKey !== undefined;\n const selectedKey = isSelectedKeyControlled ? controlledSelectedKey : internalSelectedKey;\n\n const setSelectedKey = useCallback(\n (key: string | null) => {\n if (!isSelectedKeyControlled) {\n setInternalSelectedKey(key);\n }\n onSelectedKeyChange?.(key);\n\n // Update URL if urlParamName is provided\n if (urlParamName && typeof window !== 'undefined') {\n const url = new URL(window.location.href);\n if (key) {\n url.searchParams.set(urlParamName, key);\n } else {\n url.searchParams.delete(urlParamName);\n }\n window.history.replaceState({}, '', url.toString());\n }\n },\n [isSelectedKeyControlled, onSelectedKeyChange, urlParamName]\n );\n\n // === OPEN STATE (controlled/uncontrolled) ===\n const [internalOpen, setInternalOpen] = useState(defaultOpen);\n const isOpenControlled = controlledOpen !== undefined;\n const isOpen = isOpenControlled ? controlledOpen : internalOpen;\n\n const setIsOpen = useCallback(\n (open: boolean) => {\n if (!isOpenControlled) {\n setInternalOpen(open);\n }\n onOpenChange?.(open);\n },\n [isOpenControlled, onOpenChange]\n );\n\n // === MOBILE OPEN STATE ===\n const [isMobileOpen, setMobileOpen] = useState(false);\n\n // === RESPONSIVE DETECTION ===\n const [isMobile, setIsMobile] = useState(() => {\n if (typeof window === 'undefined') return false;\n return window.innerWidth < BREAKPOINT_VALUES[breakpoint];\n });\n\n useEffect(() => {\n if (typeof window === 'undefined') return;\n\n const checkMobile = () => {\n setIsMobile(window.innerWidth < BREAKPOINT_VALUES[breakpoint]);\n };\n\n // Check on mount\n checkMobile();\n\n // Listen for resize\n window.addEventListener('resize', checkMobile);\n return () => window.removeEventListener('resize', checkMobile);\n }, [breakpoint]);\n\n // === TOGGLE ACTION ===\n const toggle = useCallback(() => {\n if (isMobile) {\n setMobileOpen((prev) => !prev);\n } else {\n setIsOpen(!isOpen);\n }\n }, [isMobile, isOpen, setIsOpen]);\n\n // === KEYBOARD NAVIGATION ===\n useEffect(() => {\n if (!keyboardShortcut) return;\n\n const handleKeyDown = (e: KeyboardEvent) => {\n // Escape - close mobile drawer/accordion\n if (e.key === 'Escape' && isMobile && isMobileOpen) {\n e.preventDefault();\n setMobileOpen(false);\n return;\n }\n\n // Cmd/Ctrl + key - toggle sidebar\n if (e.key === keyboardShortcut && (e.metaKey || e.ctrlKey)) {\n e.preventDefault();\n toggle();\n }\n };\n\n document.addEventListener('keydown', handleKeyDown);\n return () => document.removeEventListener('keydown', handleKeyDown);\n }, [keyboardShortcut, isMobile, isMobileOpen, toggle]);\n\n // === CONTEXT VALUE ===\n const value = useMemo(\n () => ({\n // Original API\n selectedKey,\n setSelectedKey,\n isOpen,\n setIsOpen,\n isMobileOpen,\n setMobileOpen,\n isMobile,\n breakpoint,\n mobileMode,\n intensity,\n stickyOffset,\n toggle,\n\n // shadcn aliases\n state: isOpen ? 'expanded' : 'collapsed',\n open: isOpen,\n setOpen: setIsOpen,\n openMobile: isMobileOpen,\n setOpenMobile: setMobileOpen,\n toggleSidebar: toggle,\n }),\n [\n selectedKey,\n setSelectedKey,\n isOpen,\n setIsOpen,\n isMobileOpen,\n isMobile,\n breakpoint,\n mobileMode,\n intensity,\n stickyOffset,\n toggle,\n ]\n );\n\n return {children};\n};\n\nSplitLayoutProvider.displayName = 'SplitLayoutGlass.Provider';\n" } ], "categories": [ "composite" ] }