688 lines
17 KiB
JavaScript
688 lines
17 KiB
JavaScript
/**
|
||
* 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 (
|
||
<ShellContext.Provider value={value}>
|
||
{children}
|
||
</ShellContext.Provider>
|
||
);
|
||
}
|
||
|
||
// ============================================================================
|
||
// 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 (
|
||
<XStack
|
||
backgroundColor={style.backgroundColor}
|
||
borderWidth={1}
|
||
borderColor={style.borderColor}
|
||
borderRadius="$4"
|
||
padding="$3"
|
||
minWidth={300}
|
||
maxWidth={400}
|
||
shadowColor="$shadowColor"
|
||
shadowOffset={{ width: 0, height: 2 }}
|
||
shadowOpacity={0.1}
|
||
shadowRadius={8}
|
||
elevation={4}
|
||
gap="$2"
|
||
onPointerEnter={() => 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 ? (
|
||
<XStack alignItems="center" justifyContent="center" width={20} height={20} flexShrink={0}>
|
||
<Icon size="md" color="$textSecondary" />
|
||
</XStack>
|
||
) : null}
|
||
<YStack flex={1} gap="$1">
|
||
{toast.title && (
|
||
<Text fontWeight="600" fontSize="$4" color="$color">
|
||
{toast.title}
|
||
</Text>
|
||
)}
|
||
{toast.message && (
|
||
<Text fontSize="$3" color="$textSecondary">
|
||
{toast.message}
|
||
</Text>
|
||
)}
|
||
</YStack>
|
||
<Button
|
||
size="$2"
|
||
circular
|
||
chromeless
|
||
onPress={handleClose}
|
||
aria-label="Close toast"
|
||
>
|
||
{(() => {
|
||
const CloseIcon = getIcon('close');
|
||
return CloseIcon ? <CloseIcon size="sm" color="$textSecondary" /> : <Text color="$textSecondary">×</Text>;
|
||
})()}
|
||
</Button>
|
||
</XStack>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<YStack
|
||
position="fixed"
|
||
bottom="$4"
|
||
right="$4"
|
||
gap="$2"
|
||
zIndex={10000}
|
||
pointerEvents="none"
|
||
maxWidth="calc(100vw - 32px)"
|
||
>
|
||
{toast.toasts.map((toastItem) => (
|
||
<XStack
|
||
key={toastItem.id}
|
||
pointerEvents="auto"
|
||
>
|
||
<Toast
|
||
toast={toastItem}
|
||
onClose={toast.hide}
|
||
onPause={toast.pause}
|
||
onResume={toast.resume}
|
||
/>
|
||
</XStack>
|
||
))}
|
||
</YStack>
|
||
);
|
||
}
|
||
|
||
// ============================================================================
|
||
// Shell Placement
|
||
// ============================================================================
|
||
|
||
/**
|
||
* ShellPlacement Component
|
||
* Helper component for placing children in specific shell sections
|
||
*
|
||
* Usage:
|
||
* <ShellPlacement placement="leftSide">Sidebar content</ShellPlacement>
|
||
* <ShellPlacement placement="header">Header content</ShellPlacement>
|
||
*
|
||
* @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
|
||
|