/**
* 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