Initial commit: bface library, build fixes, and refreshed docs
- Externalize all @tamagui/* and tamagui subpaths so dist no longer vendors Tamagui. - Emit TypeScript declarations with vite-plugin-dts; fix package exports types for ui/*. - Align initEnv with profiles: displayName, brandLogo, api.baseURL, themeColor, uiShell. - Stabilize tests with Node localStorage file; env tests pass. - Update README and component docs for services, menus, API client, and development.
This commit is contained in:
@@ -0,0 +1,687 @@
|
||||
/**
|
||||
* 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={18} />
|
||||
</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="$color" opacity={0.9}>
|
||||
{toast.message}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
<Button
|
||||
size="$2"
|
||||
circular
|
||||
chromeless
|
||||
onPress={handleClose}
|
||||
aria-label="Close toast"
|
||||
>
|
||||
{(() => {
|
||||
const CloseIcon = getIcon('close');
|
||||
return CloseIcon ? <CloseIcon size={16} /> : <Text>×</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
|
||||
|
||||
Reference in New Issue
Block a user