/** * Shell * Provides shell state management, context, provider, placement, and singleton manager * Platform-agnostic shell system for UI layout control */ import React, { createContext, useContext, useState, useCallback, useEffect } from 'react'; import { XStack, YStack, Text, Button } from 'tamagui'; import { getIcon } from './IconMapper.jsx'; // ============================================================================ // Shell Context // ============================================================================ const ShellContext = createContext(null); // ============================================================================ // Shell Manager (Singleton) // ============================================================================ /** * Shell Manager * Singleton that maintains shell state and provides global access for handlers * Accessible via Shell.Manager for global access */ class ShellManager { constructor() { this._state = { leftSideWidth: 0, rightSideWidth: 0, headerHeight: 0, footerHeight: 0 }; this._setters = { setLeftSideWidth: null, setRightSideWidth: null, setHeaderHeight: null, setFooterHeight: null }; this._listeners = new Set(); } /** * Initialize shell manager (called by ShellProvider) * @param {Object} setters - State setter functions from ShellProvider */ _init(setters) { this._setters = setters; } /** * Update internal state (called by ShellProvider) */ _updateState(state) { this._state = { ...state }; this._notifyListeners(); } /** * Notify all listeners of state changes */ _notifyListeners() { this._listeners.forEach(listener => { try { listener({ ...this._state }); } catch (error) { console.warn('[ShellManager] Listener error:', error); } }); } /** * Get current state * @returns {Object} Current shell state */ getState() { return { ...this._state }; } /** * Get left side width * @returns {number} */ getLeftSideWidth() { return this._state.leftSideWidth; } /** * Get right side width * @returns {number} */ getRightSideWidth() { return this._state.rightSideWidth; } /** * Set left side width * @param {number} width */ setLeftSideWidth(width) { if (this._setters.setLeftSideWidth) { this._setters.setLeftSideWidth(width); } else { console.warn('[ShellManager] setLeftSideWidth not initialized'); } } /** * Set right side width * @param {number} width */ setRightSideWidth(width) { if (this._setters.setRightSideWidth) { this._setters.setRightSideWidth(width); } else { console.warn('[ShellManager] setRightSideWidth not initialized'); } } /** * Toggle left side * @param {number} [targetWidth=250] - Target width when opening */ toggleLeftSide(targetWidth = 250) { const currentWidth = this._state.leftSideWidth; this.setLeftSideWidth(currentWidth === 0 ? targetWidth : 0); } /** * Toggle right side * @param {number} [targetWidth=250] - Target width when opening */ toggleRightSide(targetWidth = 250) { const currentWidth = this._state.rightSideWidth; this.setRightSideWidth(currentWidth === 0 ? targetWidth : 0); } /** * Subscribe to state changes * @param {Function} listener - Callback function * @returns {Function} Unsubscribe function */ subscribe(listener) { this._listeners.add(listener); return () => { this._listeners.delete(listener); }; } } // Create singleton instance const shellManager = new ShellManager(); // ============================================================================ // Toast Manager (Singleton) // ============================================================================ /** * Toast Manager * Singleton that maintains toast state and provides global access for handlers * Accessible via Shell.ToastManager for global access */ class ToastManager { constructor() { this._toasts = []; this._setToasts = null; this._maxToasts = 5; this._defaultDuration = 5000; this._timeouts = new Map(); // Map of toast ID to timeout ID this._pausedTimes = new Map(); // Map of toast ID to remaining time when paused } /** * Initialize toast manager (called by ShellProvider) * @param {Function} setToasts - State setter function from ShellProvider */ _init(setToasts) { this._setToasts = setToasts; } /** * Generate unique toast ID * @returns {string} */ _generateId() { return `toast-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; } /** * Show a toast notification * @param {string} title - Toast title * @param {string} [message] - Toast message * @param {Object} [options] - Toast options * @param {string} [options.type='info'] - Toast type: 'info' | 'success' | 'warning' | 'error' * @param {number} [options.duration] - Auto-dismiss duration in ms (default: 5000) * @returns {string} Toast ID */ show(title, message = '', options = {}) { const { type = 'info', duration = this._defaultDuration } = options; const startTime = Date.now(); const toast = { id: this._generateId(), title, message, type, duration, timestamp: startTime, startTime: startTime // Store start time for pause/resume calculations }; if (!this._setToasts) { console.warn('[ToastManager] Toast setter not initialized'); return toast.id; } this._setToasts(prev => { const updated = [toast, ...prev]; // Limit max toasts return updated.slice(0, this._maxToasts); }); // Auto-dismiss after duration if (duration > 0) { const timeoutId = setTimeout(() => { this.hide(toast.id); this._timeouts.delete(toast.id); }, duration); this._timeouts.set(toast.id, timeoutId); } return toast.id; } /** * Hide a toast by ID * @param {string} id - Toast ID */ hide(id) { if (!this._setToasts) { console.warn('[ToastManager] Toast setter not initialized'); return; } // Clear timeout if exists const timeoutId = this._timeouts.get(id); if (timeoutId) { clearTimeout(timeoutId); this._timeouts.delete(id); } this._pausedTimes.delete(id); this._setToasts(prev => prev.filter(toast => toast.id !== id)); } /** * Pause auto-dismiss timeout for a toast (e.g., when hovered) * @param {string} id - Toast ID */ pauseTimeout(id) { const timeoutId = this._timeouts.get(id); if (!timeoutId) return; // No active timeout if (!this._setToasts) return; // Get the toast to calculate remaining time let toast = null; this._setToasts(prev => { toast = prev.find(t => t.id === id); return prev; }); if (!toast || !toast.duration || !toast.startTime) return; // Calculate elapsed time and remaining time const elapsed = Date.now() - toast.startTime; const remaining = toast.duration - elapsed; if (remaining <= 0) { // Time already expired, hide immediately this.hide(id); return; } // Clear the current timeout clearTimeout(timeoutId); this._timeouts.delete(id); // Store remaining time this._pausedTimes.set(id, { remaining }); } /** * Resume auto-dismiss timeout for a toast (e.g., when hover ends) * @param {string} id - Toast ID */ resumeTimeout(id) { const pausedData = this._pausedTimes.get(id); if (!pausedData) return; // Wasn't paused if (!this._setToasts) return; // Get the toast to verify it still exists let toast = null; this._setToasts(prev => { toast = prev.find(t => t.id === id); return prev; }); if (!toast || !toast.duration) return; // Use the remaining time from when it was paused const remaining = pausedData.remaining; if (remaining <= 0) { // Time already expired, hide immediately this.hide(id); return; } // Set new timeout with remaining time const timeoutId = setTimeout(() => { this.hide(id); this._timeouts.delete(id); }, remaining); this._timeouts.set(id, timeoutId); this._pausedTimes.delete(id); } /** * Clear all toasts */ clear() { if (!this._setToasts) { console.warn('[ToastManager] Toast setter not initialized'); return; } // Clear all timeouts this._timeouts.forEach(timeoutId => clearTimeout(timeoutId)); this._timeouts.clear(); this._pausedTimes.clear(); this._setToasts([]); } /** * Get all active toasts * @returns {Array} */ getToasts() { return [...this._toasts]; } } // Create singleton instance const toastManager = new ToastManager(); // ============================================================================ // Shell Provider // ============================================================================ /** * Shell Provider Component * Provides shell state and control functions to child components * * @param {Object} props * @param {React.ReactNode} props.children * @param {number} [props.initialLeftWidth=0] * @param {number} [props.initialRightWidth=0] * @param {number} [props.initialHeaderHeight=0] * @param {number} [props.initialFooterHeight=0] */ export function ShellProvider({ children, initialLeftWidth = 0, initialRightWidth = 0, initialHeaderHeight = 0, initialFooterHeight = 0 }) { const [leftSideWidth, setLeftSideWidth] = useState(initialLeftWidth); const [rightSideWidth, setRightSideWidth] = useState(initialRightWidth); const [headerHeight, setHeaderHeight] = useState(initialHeaderHeight); const [footerHeight, setFooterHeight] = useState(initialFooterHeight); // Toast state const [toasts, setToasts] = useState([]); // Initialize shell manager with setters useEffect(() => { shellManager._init({ setLeftSideWidth, setRightSideWidth, setHeaderHeight, setFooterHeight }); }, []); // Initialize toast manager with setter useEffect(() => { toastManager._init(setToasts); }, []); // Update shell manager state when it changes useEffect(() => { shellManager._updateState({ leftSideWidth, rightSideWidth, headerHeight, footerHeight }); }, [leftSideWidth, rightSideWidth, headerHeight, footerHeight]); // Toggle functions for convenience const toggleLeftSide = useCallback((targetWidth = 250) => { setLeftSideWidth(prev => prev === 0 ? targetWidth : 0); }, []); const toggleRightSide = useCallback((targetWidth = 250) => { setRightSideWidth(prev => prev === 0 ? targetWidth : 0); }, []); // Toast functions const showToast = useCallback((title, message = '', options = {}) => { return toastManager.show(title, message, options); }, []); const hideToast = useCallback((id) => { toastManager.hide(id); }, []); const clearToasts = useCallback(() => { toastManager.clear(); }, []); const pauseToast = useCallback((id) => { toastManager.pauseTimeout(id); }, []); const resumeToast = useCallback((id) => { toastManager.resumeTimeout(id); }, []); const value = { leftSideWidth, rightSideWidth, headerHeight, footerHeight, setLeftSideWidth, setRightSideWidth, setHeaderHeight, setFooterHeight, toggleLeftSide, toggleRightSide, toast: { show: showToast, hide: hideToast, clear: clearToasts, pause: pauseToast, resume: resumeToast, toasts } }; return ( {children} ); } // ============================================================================ // Shell Hooks // ============================================================================ /** * Hook to access shell context * @returns {Object} Shell state and control functions */ export function useShell() { const context = useContext(ShellContext); if (!context) { throw new Error('useShell must be used within a ShellProvider'); } return context; } // ============================================================================ // Toast Components // ============================================================================ /** * Toast Component * Individual toast notification item */ function Toast({ toast, onClose, onPause, onResume }) { const [isHovered, setIsHovered] = useState(false); const [isExiting, setIsExiting] = useState(false); // Pause/resume timeout on hover useEffect(() => { if (isHovered) { onPause?.(toast.id); } else { onResume?.(toast.id); } }, [isHovered, toast.id, onPause, onResume]); const handleClose = () => { setIsExiting(true); setTimeout(() => { onClose(toast.id); }, 300); // Match animation duration }; // Type-specific styling const typeStyles = { info: { backgroundColor: '$blue3', borderColor: '$blue8', icon: 'info' }, success: { backgroundColor: '$green3', borderColor: '$green8', icon: 'success' }, warning: { backgroundColor: '$yellow3', borderColor: '$yellow8', icon: 'warning' }, error: { backgroundColor: '$red3', borderColor: '$red8', icon: 'error' } }; const style = typeStyles[toast.type] || typeStyles.info; const Icon = getIcon(style.icon); return ( setIsHovered(true)} onPointerLeave={() => setIsHovered(false)} animation="quick" opacity={isExiting ? 0 : 1} style={{ transition: 'opacity 0.3s ease, transform 0.3s ease', transform: isExiting ? 'translateX(400px)' : 'translateX(0)' }} role="status" aria-live="polite" > {Icon ? ( ) : null} {toast.title && ( {toast.title} )} {toast.message && ( {toast.message} )} ); } /** * ToastViewport Component * Container for toast notifications (fixed bottom-right) */ export function ToastViewport() { const { toast } = useShell(); if (!toast || !toast.toasts || toast.toasts.length === 0) { return null; } return ( {toast.toasts.map((toastItem) => ( ))} ); } // ============================================================================ // Shell Placement // ============================================================================ /** * ShellPlacement Component * Helper component for placing children in specific shell sections * * Usage: * Sidebar content * Header content * * @param {Object} props * @param {string} props.placement - 'leftSide' | 'rightSide' | 'header' | 'footer' | 'mainContent' * @param {React.ReactNode} props.children - Content to place */ export function ShellPlacement({ placement = 'mainContent', children }) { // Clone children and add placement prop return React.Children.map(children, (child) => { if (React.isValidElement(child)) { return React.cloneElement(child, { placement, shellPlacement: placement }); } return child; }); } // ============================================================================ // Exports // ============================================================================ // Attach static properties ShellProvider.Manager = shellManager; ShellProvider.ToastManager = toastManager; ShellProvider.Context = ShellContext; ShellProvider.Placement = ShellPlacement; export default ShellProvider; export { shellManager as ShellManager, toastManager as ToastManager }; export { ShellContext }; // ShellPlacement is already exported as a function declaration above