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:
Amer Agovic
2026-04-18 10:43:52 -05:00
commit 94a9f32969
87 changed files with 19750 additions and 0 deletions
+532
View File
@@ -0,0 +1,532 @@
/**
* App Component
* Main application component that manages all contexts and app state
* Provides extensible structure for multiple context managers (Theme, Auth, etc.)
*/
import React, { createContext, useContext, useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { TamaguiProvider, Theme, createTamagui, YStack } from 'tamagui';
import { registerServiceWorker, clearPWACache } from '../platform/sw-register.js';
import { getProvider } from '../platform/storage.js';
import * as apiClient from '../platform/api.js';
import * as storageModuleRef from '../platform/storage.js';
import * as menuRef from '../platform/menu.js';
import * as envModuleRef from '../platform/env.js';
import { getConfig, setConfig, CONFIG_KEYS, createLogger, startTrace, isDevelopment } from '../platform/env.js';
import { EmptyShell, LandingShell, DashboardShell, AppInfo, Router } from './components/index.js';
import { LoginPage } from '../security/pages/LoginPage.jsx';
import { getStyleTheme, DEFAULT_STYLE_THEME, normalizeStyleThemeName } from './styles/index.js';
import { THEME_MODE_CONFIG_KEY, THEME_NAME_CONFIG_KEY, THEME_MODES, themeManager } from './theme-controller.js';
import { securityService, useSecurityState } from '../security/runtime/security-service.js';
// ============================================================================
// Theme Manager
// ============================================================================
// Import platform-agnostic theme detection
import { getSystemThemeMode, subscribeToSystemThemeMode } from '../platform/compat.js';
// ============================================================================
// App Context
// ============================================================================
/**
* App Context
* Consolidated context providing access to all app managers (theme, etc.)
*/
const AppContext = createContext(null);
const appLogger = createLogger('App');
function resolveShellComponent(shellName = 'EmptyShell') {
const key = String(shellName ?? 'EmptyShell').trim().toLowerCase();
switch (key) {
case 'landingshell':
return LandingShell;
// Same layout as LandingShell: TopBar in shell header with primary/secondary/personal menus.
case 'topbarshell':
return LandingShell;
case 'dashboardshell':
return DashboardShell;
case 'emptyshell':
default:
return EmptyShell;
}
}
// ============================================================================
// App Component
// ============================================================================
function App({ onInit, renderBootScreen = null, showInitialBootSplash = false, initialProfile = null }) {
// App state
const [swStatus, setSwStatus] = useState('Checking...');
const [storageBackend, setStorageBackend] = useState('localStorage');
const [appName, setAppName] = useState(initialProfile?.id ?? '');
const [menuItems, setMenuItems] = useState([]);
const [initialized, setInitialized] = useState(false);
const [ShellComponent, setShellComponent] = useState(() => resolveShellComponent(initialProfile?.ui_shell ?? 'EmptyShell'));
const [bootResult, setBootResult] = useState(null);
const [bootModeOverride, setBootModeOverride] = useState(null);
// Theme state
const [themeMode, setThemeModeState] = useState(THEME_MODES.SYSTEM);
const [systemScheme, setSystemScheme] = useState(getSystemThemeMode());
const [styleThemeName, setStyleThemeName] = useState(DEFAULT_STYLE_THEME);
const securityState = useSecurityState();
// Initialize theme manager
useEffect(() => {
themeManager.init(setThemeModeState, setSystemScheme, setStyleThemeName);
}, []);
// Get style theme configuration
const styleTheme = useMemo(() => getStyleTheme(styleThemeName), [styleThemeName]);
// Create Tamagui config from style theme
const tamaguiConfig = useMemo(() => {
return createTamagui(styleTheme);
}, [styleTheme]);
// Calculate active theme (light/dark variant)
const activeTheme = themeMode === THEME_MODES.SYSTEM ? systemScheme : themeMode;
// Update theme manager state
useEffect(() => {
themeManager.updateState(themeMode, systemScheme, activeTheme, styleThemeName);
}, [themeMode, systemScheme, activeTheme, styleThemeName]);
// Load theme preferences from storage on mount
useEffect(() => {
async function loadThemePreferences() {
try {
// Load theme mode (light/dark/system)
const savedMode = await getConfig(THEME_MODE_CONFIG_KEY, null);
if (savedMode && Object.values(THEME_MODES).includes(savedMode)) {
setThemeModeState(savedMode);
}
// Load style theme (material/minimal/colorful)
const savedStyleTheme = await getConfig(THEME_NAME_CONFIG_KEY, null);
if (savedStyleTheme) {
setStyleThemeName(normalizeStyleThemeName(savedStyleTheme));
}
} catch (error) {
console.warn('[App] Failed to load theme preferences:', error);
}
}
loadThemePreferences();
}, []);
// Listen for system theme changes using platform-agnostic compat layer
useEffect(() => {
const unsubscribe = subscribeToSystemThemeMode((scheme) => {
setSystemScheme(scheme);
});
return unsubscribe;
}, []);
// Save theme mode preference to storage when it changes
useEffect(() => {
async function saveThemePreference() {
try {
await setConfig(THEME_MODE_CONFIG_KEY, themeMode);
} catch (error) {
console.warn('[App] Failed to save theme preference:', error);
}
}
if (themeMode !== THEME_MODES.SYSTEM || systemScheme) {
saveThemePreference();
}
}, [themeMode]);
// Save style theme preference to storage when it changes
useEffect(() => {
async function saveStyleThemePreference() {
try {
await setConfig(THEME_NAME_CONFIG_KEY, styleThemeName);
} catch (error) {
console.warn('[App] Failed to save style theme preference:', error);
}
}
saveStyleThemePreference();
}, [styleThemeName]);
/**
* Get platform services for module injection
* Returns services with renamed keys and includes env
*/
async function getPlatformServices() {
// Get UI Router (for route declarations - Router will collect on mount)
let ui_router = null;
try {
if (Router && Router.publishRoutes) {
ui_router = {
publishRoutes: Router.publishRoutes
};
}
} catch (error) {
console.warn('[App] Failed to import Router module:', error);
}
// Get API router from service worker (if available)
let api_router = null;
if (typeof window !== 'undefined' && 'serviceWorker' in navigator) {
// API Router is in service worker, modules will register via message passing
// or we expose a registration function
api_router = {
register: (path, handler) => {
// Register endpoint in SW router
// Note: For now, we'll need to handle this differently since
// functions can't be serialized. Modules should register routes
// in their routes.js files which get loaded into the SW.
console.log(`[API Router] Register endpoint: ${path}`);
// TODO: Implement proper SW router registration
}
};
}
return {
api_client: apiClient.api, // Renamed from api
storage: storageModuleRef,
api_router,
ui_router,
menu: menuRef,
env: envModuleRef // New service
};
}
const initializeApp = useCallback(async () => {
const initTrace = startTrace('App', 'initializeApp');
appLogger.log('Initializing PWA...');
try {
// Do not clear `initialized` here: it caused a blank / half-ready shell flash while re-running init.
setBootModeOverride(null);
// Get platform services
const servicesTrace = startTrace('App', 'getPlatformServices');
const services = await getPlatformServices();
servicesTrace.end();
const initialSecurityConfig = initialProfile?.security || {};
const securityTrace = startTrace('Security', 'init', { provider: initialSecurityConfig.provider ?? 'basic' });
await securityService.init(initialSecurityConfig);
securityTrace.end({ enabled: initialSecurityConfig.enabled === true });
securityService.installAPIClient(services.api_client);
// Call onInit callback if provided (handles profile, env, modules, SW)
let selectedProfile = null;
if (onInit) {
const onInitTrace = startTrace('App', 'onInit');
selectedProfile = await onInit(services, { initialProfile });
onInitTrace.end({ profile: selectedProfile?.id ?? initialProfile?.id ?? 'default' });
}
const selectedSecurityConfig = selectedProfile?.security || initialSecurityConfig;
if (JSON.stringify(selectedSecurityConfig) !== JSON.stringify(initialSecurityConfig)) {
const selectedSecurityTrace = startTrace('Security', 're-init from selected profile', { provider: selectedSecurityConfig.provider ?? 'basic' });
await securityService.init(selectedSecurityConfig);
selectedSecurityTrace.end({ enabled: selectedSecurityConfig.enabled === true });
}
if (selectedProfile?.__boot) {
setBootResult(selectedProfile.__boot);
}
if (services.menu?.restoreMenuPreferences) {
await services.menu.restoreMenuPreferences();
}
// Use services to set up app state
const name = await services.env.getConfig(CONFIG_KEYS.APP_NAME, 'default');
setAppName(name);
const backend = await services.env.getConfig(CONFIG_KEYS.STORAGE_BACKEND, 'localStorage');
setStorageBackend(backend);
// Load UI shell from config
const shellName = await services.env.getConfig(CONFIG_KEYS.UI_SHELL, 'EmptyShell');
const Shell = resolveShellComponent(shellName);
setShellComponent(() => Shell);
appLogger.log(`Using shell: ${shellName}`);
// Get menu items from primary menu
if (services.menu) {
// Query primary menu (returns root + all nested items)
const allPrimaryItems = services.menu.queryMenuItems('/primary');
appLogger.debug('Primary menu items found:', allPrimaryItems);
// Filter out the root "Primary" item, keep only actual menu items
const items = allPrimaryItems.filter(item => item.path !== '/primary');
appLogger.debug('Filtered menu items (excluding root):', items);
setMenuItems(items);
} else {
appLogger.warn('Menu service not available');
}
// Update service worker status if available
const devHost = await getConfig(CONFIG_KEYS.DEV_HOST, isDevelopment());
if ('serviceWorker' in navigator) {
try {
if (devHost) {
const registration = await navigator.serviceWorker.getRegistration();
setSwStatus(registration ? 'Active' : 'Development Disabled');
if (registration) {
registration.update();
}
} else {
const registration = await navigator.serviceWorker.ready;
setSwStatus('Active');
}
} catch (error) {
setSwStatus('Not Supported');
}
} else {
setSwStatus('Not Supported');
}
setInitialized(true);
initTrace.end({
shell: shellName,
bootMode: selectedProfile?.__boot?.uiMode ?? 'runtime'
});
} catch (error) {
initTrace.fail(error);
appLogger.error('Failed to initialize app:', error);
setInitialized(true);
}
}, [initialProfile, onInit]);
// Initialize app
useEffect(() => {
initializeApp();
}, [initializeApp]);
// Consolidated app context value
const appContextValue = {
theme: {
themeMode,
activeTheme,
systemScheme,
styleThemeName,
styleTheme,
setThemeMode: async (mode) => {
if (!Object.values(THEME_MODES).includes(mode)) {
console.warn('[App] Invalid theme mode:', mode);
return;
}
setThemeModeState(mode);
},
setStyleTheme: async (themeName) => {
setStyleThemeName(normalizeStyleThemeName(themeName));
},
toggleTheme: () => {
const nextMode =
themeMode === THEME_MODES.LIGHT ? THEME_MODES.DARK :
themeMode === THEME_MODES.DARK ? THEME_MODES.SYSTEM :
THEME_MODES.LIGHT;
setThemeModeState(nextMode);
},
THEME_MODES
},
security: {
...securityState,
login: (credentials) => securityService.login(credentials),
logout: (options) => securityService.logout(options),
refreshSession: () => securityService.refreshSession(),
injectAuthHeaders: (headers) => securityService.injectAuthHeaders(headers),
userRequired: (options) => securityService.userRequired(options),
userPermitted: (rights, resourcePath, options) => securityService.userPermitted(rights, resourcePath, options),
isPermitted: (rights, resourcePath, options) => securityService.isPermitted(rights, resourcePath, options),
registerResource: (resource) => securityService.registerResource(resource),
updateAccountProfile: (patch) => securityService.updateAccountProfile(patch),
changePassword: (passwordInput) => securityService.changePassword(passwordInput),
listUsers: () => securityService.listUsers(),
listRoles: () => securityService.listRoles(),
listRealms: () => securityService.listRealms(),
listResources: () => securityService.listResources(),
listPermits: () => securityService.listPermits()
},
system: {
locale: envModuleRef.getLocaleSync(),
getLocale: (altValue) => envModuleRef.getLocale(altValue),
setLocale: (locale) => envModuleRef.setLocale(locale)
}
// Future managers can be added here: auth, etc.
};
const effectiveBootMode = bootModeOverride ?? bootResult?.uiMode ?? 'runtime';
const shouldRenderBootScreen = effectiveBootMode !== 'runtime' || (!initialized && showInitialBootSplash);
const shouldHoldDuringInit = !initialized && !showInitialBootSplash;
const shouldRenderLoginGate = initialized && !shouldRenderBootScreen && securityState.enabled && securityState.requireLogin && !securityState.isAuthenticated;
let bootScreenContent = null;
let appContent = null;
if (shouldRenderBootScreen) {
if (typeof renderBootScreen === 'function') {
bootScreenContent = renderBootScreen({
mode: initialized ? effectiveBootMode : 'splash',
bootResult,
continueToRuntime: () => setBootModeOverride('runtime'),
completeSetup: async () => {
if (bootResult?.runtimeStorage?.markSetupComplete) {
await bootResult.runtimeStorage.markSetupComplete();
}
await initializeApp();
},
completeModuleSetup: async (moduleId) => {
if (bootResult?.runtimeStorage?.markModuleSetupComplete) {
await bootResult.runtimeStorage.markModuleSetupComplete(moduleId);
}
await initializeApp();
},
retryBoot: async () => {
await initializeApp();
}
});
}
}
if (shouldRenderLoginGate) {
appContent = <LoginPage />;
} else if (!shouldRenderBootScreen && !shouldHoldDuringInit) {
appContent = (
<Router initialPath="/home">
{/* Declarative route registration (commented out - routes now registered programmatically via modules)
<Router.Endpoint path="/home" component={HomePage} />
<Router.Group path="/dashboard">
<Router.Endpoint path="/" component={DashboardPage} />
<Router.Endpoint path="/analytics" component={AnalyticsPage} />
<Router.Endpoint path="/reports" component={ReportsPage} />
</Router.Group>
*/}
<ShellComponent>
<Router.SelectedComponent placement="mainContent" />
</ShellComponent>
</Router>
);
} else if (shouldHoldDuringInit) {
appContent = (
<YStack width="100%" minHeight="100vh" backgroundColor="$background">
<ShellComponent>
<YStack flex={1} minHeight="100vh" backgroundColor="$background" />
</ShellComponent>
</YStack>
);
}
return (
<AppContext.Provider value={appContextValue}>
<TamaguiProvider config={tamaguiConfig} defaultTheme={activeTheme}>
<Theme name={activeTheme}>
{shouldRenderBootScreen ? (
bootScreenContent
) : appContent}
</Theme>
</TamaguiProvider>
</AppContext.Provider>
);
}
// ============================================================================
// App Hooks
// ============================================================================
/**
* useApp Hook
* Access all app managers (theme, etc.) within React components
*
* @returns {{
* theme: {
* themeMode: 'light' | 'dark' | 'system',
* activeTheme: 'light' | 'dark',
* systemScheme: 'light' | 'dark',
* styleThemeName: string,
* styleTheme: object,
* setThemeMode: (mode: string) => Promise<void>,
* setStyleTheme: (themeName: string) => Promise<void>,
* toggleTheme: () => void,
* THEME_MODES: object
* },
* }}
*/
function useApp() {
const context = useContext(AppContext);
if (!context) {
throw new Error('useApp must be used within App component');
}
return context;
}
/**
* useTheme Hook (Backward Compatibility)
* Access theme state and controls within React components
* @deprecated Use useApp().theme instead
*
* @returns {{
* themeMode: 'light' | 'dark' | 'system',
* activeTheme: 'light' | 'dark',
* systemScheme: 'light' | 'dark',
* styleThemeName: string,
* styleTheme: object,
* setThemeMode: (mode: string) => Promise<void>,
* setStyleTheme: (themeName: string) => Promise<void>,
* toggleTheme: () => void,
* THEME_MODES: object
* }}
*/
function useTheme() {
const app = useApp();
return app.theme;
}
// ============================================================================
// App Static Properties (Global Access)
// ============================================================================
// Attach static properties to App
App.ThemeManager = themeManager;
App.useApp = useApp;
App.useTheme = useTheme; // Backward compatibility
App.THEME_MODES = THEME_MODES;
// ============================================================================
// Export and Initialize
// ============================================================================
// Export App as both default and named for flexibility
export default App;
export { App };
export { useApp, useTheme, THEME_MODES, themeManager as ThemeManager };
// Expose PWA utilities globally for console access
if (typeof window !== 'undefined') {
window.clearPWACache = clearPWACache;
window.__PWA_UTILS__ = {
clearPWACache,
clearAllCaches: async () => {
const { clearAllCaches } = await import('../platform/sw-register.js');
return clearAllCaches();
},
unregisterAllServiceWorkers: async () => {
const { unregisterAllServiceWorkers } = await import('../platform/sw-register.js');
return unregisterAllServiceWorkers();
},
clearAllStorage: async () => {
const { clearAllStorage } = await import('../platform/sw-register.js');
return clearAllStorage();
}
};
console.log('💡 PWA Utilities available:');
console.log(' - clearPWACache() - Clear everything (caches, SW, storage)');
console.log(' - __PWA_UTILS__.clearAllCaches() - Clear caches only');
console.log(' - __PWA_UTILS__.unregisterAllServiceWorkers() - Unregister SW only');
console.log(' - __PWA_UTILS__.clearAllStorage() - Clear storage only');
}
// Note: App initialization is handled by the consuming project (e.g., app-react.jsx)
+72
View File
@@ -0,0 +1,72 @@
/**
* AppInfo Component
* Displays application information and status
*/
import React, { useState, useEffect } from 'react';
import { YStack, XStack, Text, Heading } from 'tamagui';
import { getConfig, CONFIG_KEYS } from '../../platform/env.js';
/**
* AppInfo Component
*
* @param {Object} props
* @param {string} props.appName - Application name
* @param {string} props.swStatus - Service worker status
* @param {string} props.storageBackend - Storage backend name
* @param {Array} props.menuItems - Menu items to display
* @param {boolean} props.initialized - Whether app is initialized
*/
export function AppInfo({ appName, swStatus, storageBackend, menuItems = [], initialized = false }) {
const [displayName, setDisplayName] = useState('PWA Template');
useEffect(() => {
async function loadConfig() {
const name = await getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, 'PWA Template');
setDisplayName(name);
}
loadConfig();
}, []);
return (
<YStack padding="$4" gap="$4" maxWidth={800} margin="0 auto">
<Heading size="$8">
{displayName}
</Heading>
<XStack gap="$2">
<Text>App:</Text>
<Text fontWeight="bold">{appName || 'Loading...'}</Text>
</XStack>
<XStack gap="$2">
<Text>Service Worker:</Text>
<Text fontWeight="bold">{swStatus}</Text>
</XStack>
<XStack gap="$2">
<Text>Storage Backend:</Text>
<Text fontWeight="bold">{storageBackend}</Text>
</XStack>
{initialized && menuItems.length > 0 && (
<YStack marginTop="$4" gap="$2">
<Text fontWeight="bold">Menu Items:</Text>
{menuItems.map((item) => (
<XStack key={item.id} gap="$2">
<Text> {item.label}</Text>
{item.icon && <Text>({item.icon})</Text>}
</XStack>
))}
</YStack>
)}
<YStack marginTop="$4">
<Text>Ready for development!</Text>
</YStack>
</YStack>
);
}
export default AppInfo;
+29
View File
@@ -0,0 +1,29 @@
/**
* DashboardShell - Dashboard shell
* Derived from EmptyShell, designed for dashboard/application pages
*/
import React from 'react';
import EmptyShell from './EmptyShell.jsx';
import { SideBar } from './SideBar.jsx';
/**
* DashboardShell Component
* Shell component for dashboard pages with SideBar in left side
*
* @param {Object} props - Component props
* @param {React.ReactNode} props.children - Content to render inside shell
*/
export function DashboardShell(props) {
return (
<EmptyShell {...props} initialLeftWidth={250}>
<SideBar placement="leftSide">
{/* Menu items will be placed here via placement */}
</SideBar>
{props.children}
</EmptyShell>
);
}
export default DashboardShell;
+27
View File
@@ -0,0 +1,27 @@
import React from 'react';
import { SidePanelShell } from './SidePanelShell.jsx';
export function DetView({
open = false,
onClose = null,
title = 'Detail View',
toolbar = [],
footerActions = [],
width = 420,
children = null
}) {
return (
<SidePanelShell
open={open}
onClose={onClose}
title={title}
toolbar={toolbar}
footerActions={footerActions}
width={width}
>
{children}
</SidePanelShell>
);
}
export default DetView;
+421
View File
@@ -0,0 +1,421 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Button, Input, Paragraph, ScrollView, Separator, Spinner, Text, XStack, YStack } from 'tamagui';
import { getIcon } from './IconMapper.jsx';
import { normalizeColumnsArray } from './grid/utils.js';
const EMPTY_COLUMNS = [];
const EMPTY_ACTIONS = [];
const DEFAULT_SEARCH_CONFIG = { enabled: true, placeholder: 'Search records...' };
const EMPTY_SUMMARY_DEFINITIONS = [];
function getColumnJustify(align) {
if (align === 'right') {
return 'flex-end';
}
if (align === 'center') {
return 'center';
}
return 'flex-start';
}
function normalizeSummaryValue(value) {
if (typeof value === 'number') {
return Number.isInteger(value) ? String(value) : value.toFixed(2);
}
if (value && typeof value === 'object') {
return Object.entries(value)
.map(([key, count]) => `${key}: ${count}`)
.join(' · ');
}
return String(value ?? '');
}
function SummaryCards({ summary }) {
if (!summary?.items?.length) {
return null;
}
return (
<XStack gap="$3" flexWrap="wrap">
{summary.items.map((item) => (
<YStack
key={item.id || item.label}
minWidth={140}
padding="$3"
borderWidth={1}
borderColor="$borderColor"
borderRadius="$4"
backgroundColor="$accentSurface"
gap="$1"
>
<Text fontSize="$3" color="$color" opacity={0.7}>
{item.label}
</Text>
<Text fontSize="$7" fontWeight="700" color="$accentColor">
{normalizeSummaryValue(item.value)}
</Text>
</YStack>
))}
</XStack>
);
}
function HeaderCell({ column, orderBy, order, onSort }) {
const sortable = column.sortable !== false;
const isActive = orderBy === column.id;
const arrow = isActive ? (order === 'asc' ? '↑' : '↓') : '';
const justifyContent = getColumnJustify(column.align);
return (
<XStack
flex={column.flex || 1}
flexBasis={0}
minWidth={column.minWidth || 120}
alignItems="center"
justifyContent={justifyContent}
>
<Button
chromeless
disabled={!sortable}
onPress={() => sortable && onSort(column.id)}
padding={0}
justifyContent={justifyContent}
width="100%"
>
<Text fontSize="$4" fontWeight="700" color="$color" textAlign={column.align || 'left'} width="100%">
{column.label}{arrow ? ` ${arrow}` : ''}
</Text>
</Button>
</XStack>
);
}
function RowCell({ column, record }) {
const value = record?.[column.id];
const content = column.render ? column.render(value, record, column) : (value ?? '');
const justifyContent = getColumnJustify(column.align);
return (
<XStack
flex={column.flex || 1}
flexBasis={0}
minWidth={column.minWidth || 120}
justifyContent={justifyContent}
alignItems="center"
>
<XStack width="100%" justifyContent={justifyContent} alignItems="center">
{React.isValidElement(content) ? content : (
<Text color="$color" numberOfLines={1} textAlign={column.align || 'left'} width="100%">
{String(content)}
</Text>
)}
</XStack>
</XStack>
);
}
export function DirView({
dataModel,
columns = EMPTY_COLUMNS,
title = 'Directory',
toolbarActions = EMPTY_ACTIONS,
toolbarItems = undefined,
actions = undefined,
topLeftContent = null,
topRightContent = null,
bodyHeaderContent = null,
bodyFooterContent = null,
searchConfig = DEFAULT_SEARCH_CONFIG,
searchValue = undefined,
onSearchChange = null,
initialSearchValue = '',
summaryDefinitions = EMPTY_SUMMARY_DEFINITIONS,
pageSize = 10,
bodyMaxHeight = 480,
onRowClick = null,
onRowPress = null,
onRefresh = null
}) {
const [dataVersion, setDataVersion] = useState(0);
const [records, setRecords] = useState([]);
const [summary, setSummary] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [internalSearchTerm, setInternalSearchTerm] = useState(initialSearchValue);
const resolvedColumns = useMemo(() => normalizeColumnsArray(columns), [columns]);
const effectiveToolbarItems = actions ?? toolbarItems ?? toolbarActions;
const effectiveSearchTerm = searchValue ?? internalSearchTerm;
const effectiveRowPress = onRowPress ?? onRowClick;
const [orderBy, setOrderBy] = useState(resolvedColumns.find((column) => column.sortable !== false)?.id || '');
const [order, setOrder] = useState('asc');
const [currentPage, setCurrentPage] = useState(1);
const [totalRecords, setTotalRecords] = useState(0);
const resolvedSummaryDefinitions = summaryDefinitions || EMPTY_SUMMARY_DEFINITIONS;
const RefreshIcon = getIcon('refresh');
const FirstPageIcon = getIcon('first-page');
const PreviousPageIcon = getIcon('chevron-left');
const NextPageIcon = getIcon('chevron-right');
const LastPageIcon = getIcon('last-page');
useEffect(() => {
if (!dataModel?.subscribe) {
return undefined;
}
return dataModel.subscribe(() => {
setDataVersion((value) => value + 1);
});
}, [dataModel]);
const totalPages = useMemo(() => Math.max(1, Math.ceil(totalRecords / pageSize)), [totalRecords, pageSize]);
useEffect(() => {
let cancelled = false;
async function loadData() {
if (!dataModel) {
return;
}
setLoading(true);
setError('');
try {
const query = {
page: currentPage,
pageSize,
search: effectiveSearchTerm,
orderBy,
order
};
const [recordResult, summaryResult] = await Promise.all([
dataModel.queryRecords(query),
dataModel.querySummary(query, resolvedSummaryDefinitions)
]);
if (!cancelled) {
setRecords(recordResult.records || []);
setTotalRecords(recordResult.totalRecords || 0);
setSummary(summaryResult || null);
}
} catch (loadError) {
if (!cancelled) {
setError(loadError.message || 'Failed to load records');
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
}
loadData();
return () => {
cancelled = true;
};
}, [currentPage, dataModel, dataVersion, effectiveSearchTerm, order, orderBy, pageSize, resolvedSummaryDefinitions]);
useEffect(() => {
const nextOrderBy = resolvedColumns.find((column) => column.sortable !== false)?.id || '';
setOrderBy((current) => {
if (current && resolvedColumns.some((column) => column.id === current)) {
return current;
}
return nextOrderBy;
});
}, [resolvedColumns]);
const handleRefresh = async () => {
if (onRefresh) {
await onRefresh();
}
setDataVersion((value) => value + 1);
};
const updateSearchTerm = (value) => {
if (searchValue === undefined) {
setInternalSearchTerm(value);
}
onSearchChange?.(value);
searchConfig?.onChange?.(value);
setCurrentPage(1);
};
const renderToolbarButton = (action, index) => {
if (React.isValidElement(action)) {
return React.cloneElement(action, { key: action.key || `toolbar-${index}` });
}
if (action?.kind === 'text') {
return (
<Text key={action?.key || action?.text || index} color="$color" opacity={0.7}>
{action?.text}
</Text>
);
}
if (action?.kind === 'node') {
return <React.Fragment key={action?.key || index}>{action?.node}</React.Fragment>;
}
const IconComponent = action?.icon ? getIcon(action.icon) : null;
return (
<Button
key={action?.id || action?.label || index}
size="$3"
theme={action?.theme}
chromeless={action?.chromeless}
disabled={loading || action?.disabled}
icon={IconComponent ? <IconComponent size={16} /> : undefined}
onPress={action?.onPress}
>
{action?.label}
</Button>
);
};
return (
<YStack gap="$4" width="100%">
<XStack justifyContent="space-between" alignItems="center" gap="$4" flexWrap="wrap">
<XStack alignItems="center" gap="$3" flex={1} minWidth={240} flexWrap="wrap">
<Text fontSize="$8" fontWeight="800" color="$accentColor">
{title}
</Text>
{topLeftContent}
</XStack>
<XStack alignItems="center" justifyContent="flex-end" gap="$2" flexWrap="wrap">
{searchConfig?.enabled ? (
<Input
width={260}
placeholder={searchConfig.placeholder || 'Search records...'}
value={effectiveSearchTerm}
onChangeText={updateSearchTerm}
/>
) : null}
{effectiveToolbarItems.map(renderToolbarButton)}
<Button
size="$3"
chromeless
circular
aria-label="Refresh directory"
icon={RefreshIcon ? <RefreshIcon size={16} /> : undefined}
onPress={handleRefresh}
disabled={loading}
/>
{topRightContent}
</XStack>
</XStack>
{error ? (
<YStack padding="$3" borderRadius="$4" backgroundColor="#fef2f2" borderWidth={1} borderColor="#fecaca">
<Text color="#b91c1c">{error}</Text>
</YStack>
) : null}
<SummaryCards summary={summary} />
{bodyHeaderContent}
<YStack borderWidth={1} borderColor="$borderColor" borderRadius="$5" overflow="hidden" backgroundColor="$background">
<XStack padding="$3" gap="$3" backgroundColor="$accentSurface" borderBottomWidth={1} borderBottomColor="$borderColor">
{resolvedColumns.map((column) => (
<HeaderCell
key={column.id}
column={column}
orderBy={orderBy}
order={order}
onSort={(columnId) => {
const nextOrder = orderBy === columnId && order === 'asc' ? 'desc' : 'asc';
setOrderBy(columnId);
setOrder(nextOrder);
setCurrentPage(1);
}}
/>
))}
</XStack>
<ScrollView maxHeight={bodyMaxHeight}>
<YStack>
{loading ? (
<XStack justifyContent="center" padding="$6">
<Spinner size="large" color="$accentColor" />
</XStack>
) : records.length === 0 ? (
<YStack padding="$6" alignItems="center">
<Paragraph color="$color" opacity={0.7}>
No records found.
</Paragraph>
</YStack>
) : (
records.map((record, index) => (
<YStack key={record?.[dataModel?.getIdField?.() || 'id'] || index}>
<XStack
padding="$3"
gap="$3"
alignItems="center"
hoverStyle={{ backgroundColor: '$accentSurface' }}
pressStyle={{ backgroundColor: '$accentSurface' }}
cursor={effectiveRowPress ? 'pointer' : undefined}
onPress={() => effectiveRowPress?.(record)}
>
{resolvedColumns.map((column) => (
<RowCell key={column.id} column={column} record={record} />
))}
</XStack>
{index < records.length - 1 ? <Separator /> : null}
</YStack>
))
)}
</YStack>
</ScrollView>
</YStack>
{bodyFooterContent}
<XStack justifyContent="space-between" alignItems="center" gap="$3" flexWrap="wrap">
<Text color="$color" opacity={0.7}>
Rows: {totalRecords}
</Text>
<XStack alignItems="center" gap="$2" flexWrap="wrap">
<Button
size="$3"
chromeless
aria-label="First page"
icon={FirstPageIcon ? <FirstPageIcon size={16} /> : undefined}
onPress={() => setCurrentPage(1)}
disabled={currentPage === 1 || loading}
/>
<Button
size="$3"
chromeless
aria-label="Previous page"
icon={PreviousPageIcon ? <PreviousPageIcon size={16} /> : undefined}
onPress={() => setCurrentPage((value) => Math.max(1, value - 1))}
disabled={currentPage === 1 || loading}
/>
<Text color="$color" opacity={0.75}>
Page {currentPage} of {totalPages}
</Text>
<Button
size="$3"
chromeless
aria-label="Next page"
icon={NextPageIcon ? <NextPageIcon size={16} /> : undefined}
onPress={() => setCurrentPage((value) => Math.min(totalPages, value + 1))}
disabled={currentPage >= totalPages || loading}
/>
<Button
size="$3"
chromeless
aria-label="Last page"
icon={LastPageIcon ? <LastPageIcon size={16} /> : undefined}
onPress={() => setCurrentPage(totalPages)}
disabled={currentPage >= totalPages || loading}
/>
</XStack>
</XStack>
</YStack>
);
}
export default DirView;
+292
View File
@@ -0,0 +1,292 @@
/**
* EmptyShell - Base shell component
* Base component for all UI shells with sectioned layout
* Platform-agnostic using Tamagui components
*/
import React, { useMemo } from 'react';
import { XStack, YStack, useMedia } from 'tamagui';
import { View } from '@tamagui/core';
import { ShellProvider, useShell, ToastViewport } from './Shell.jsx';
// Section components for children placement
const SectionContext = React.createContext(null);
/**
* EmptyShell Component
* Provides a responsive three-section layout: LeftSide, MiddleSide, RightSide
* MiddleSide contains: Header, MainContent, Footer
*
* Desktop Layout (gtSm, > 801px):
* ┌─────────────────────────────────────┐
* │ LeftSide │ MiddleSide │ RightSide │
* │ │ ┌────────┐ │ │
* │ │ │ Header │ │ │
* │ │ ├────────┤ │ │
* │ │ │ Main │ │ │
* │ │ │Content │ │ │
* │ │ ├────────┤ │ │
* │ │ │ Footer │ │ │
* │ │ └────────┘ │ │
* └─────────────────────────────────────┘
*
* Mobile Layout (sm and below, ≤ 801px):
* ┌─────────────────────┐
* │ Header │
* ├─────────────────────┤
* │ LeftSide (TopBar) │
* ├─────────────────────┤
* │ MainContent │
* ├─────────────────────┤
* │ RightSide │
* ├─────────────────────┤
* │ Footer │
* └─────────────────────┘
*
* @param {Object} props - Component props
* @param {React.ReactNode} props.children - Content to render (defaults to MainContent)
* @param {number} props.initialLeftWidth - Initial left side width (default: 0, desktop only)
* @param {number} props.initialRightWidth - Initial right side width (default: 0, desktop only)
* @param {number} props.initialHeaderHeight - Initial header height (default: 0)
* @param {number} props.initialFooterHeight - Initial footer height (default: 0)
*/
function EmptyShellInner({ children }) {
const shell = useShell();
const media = useMedia();
const isMobile = !media.gtSm; // Below 801px (sm breakpoint)
// Organize children by placement
const organizedChildren = useMemo(() => {
const sections = {
leftSide: [],
rightSide: [],
header: [],
footer: [],
mainContent: []
};
React.Children.forEach(children, (child) => {
if (!React.isValidElement(child)) {
sections.mainContent.push(child);
return;
}
const placement = child.props?.placement || child.props?.shellPlacement || 'mainContent';
switch (placement) {
case 'leftSide':
case 'left':
sections.leftSide.push(child);
break;
case 'rightSide':
case 'right':
sections.rightSide.push(child);
break;
case 'header':
sections.header.push(child);
break;
case 'footer':
sections.footer.push(child);
break;
case 'mainContent':
case 'main':
default:
sections.mainContent.push(child);
break;
}
});
return sections;
}, [children]);
// Mobile layout: Vertical stack
if (isMobile) {
return (
<>
<YStack width="100%" height="100vh" overflow="hidden">
{/* Header */}
{organizedChildren.header.length > 0 && (
<View
height={shell.headerHeight > 0 ? shell.headerHeight : 'auto'}
width="100%"
overflow="visible"
style={{
transition: 'height 0.3s ease',
flexShrink: 0,
position: 'relative',
zIndex: 100
}}
>
{organizedChildren.header}
</View>
)}
{/* Left Side - Becomes top bar on mobile */}
{organizedChildren.leftSide.length > 0 && (
<View
width="100%"
overflow="hidden"
style={{
flexShrink: 0
}}
>
{organizedChildren.leftSide}
</View>
)}
{/* Main Content */}
<View
flex={1}
width="100%"
overflow="auto"
style={{ flexShrink: 1 }}
>
{organizedChildren.mainContent}
</View>
{/* Right Side */}
{organizedChildren.rightSide.length > 0 && (
<View
width="100%"
overflow="hidden"
style={{
flexShrink: 0
}}
>
{organizedChildren.rightSide}
</View>
)}
{/* Footer */}
{organizedChildren.footer.length > 0 && (
<View
height={shell.footerHeight > 0 ? shell.footerHeight : 'auto'}
width="100%"
overflow="hidden"
style={{
transition: 'height 0.3s ease',
flexShrink: 0
}}
>
{organizedChildren.footer}
</View>
)}
</YStack>
<ToastViewport />
</>
);
}
// Desktop layout: Horizontal stack (original layout)
// Calculate middle section width (100% minus side widths)
const middleWidth = `calc(100% - ${shell.leftSideWidth}px - ${shell.rightSideWidth}px)`;
return (
<>
<XStack width="100%" height="100vh" overflow="hidden">
{/* Left Side */}
<View
width={shell.leftSideWidth}
overflow="hidden"
style={{
transition: 'width 0.3s ease',
flexShrink: 0
}}
>
{organizedChildren.leftSide}
</View>
{/* Middle Section */}
<YStack
width={middleWidth}
height="100%"
overflow="hidden"
style={{ flexShrink: 0 }}
>
{/* Header */}
<View
height={shell.headerHeight}
width="100%"
overflow="visible"
style={{
transition: 'height 0.3s ease',
flexShrink: 0,
position: 'relative',
zIndex: 100
}}
>
{organizedChildren.header}
</View>
{/* Main Content */}
<View
flex={1}
width="100%"
overflow="auto"
style={{ flexShrink: 1 }}
>
{organizedChildren.mainContent}
</View>
{/* Footer */}
<View
height={shell.footerHeight}
width="100%"
overflow="hidden"
style={{
transition: 'height 0.3s ease',
flexShrink: 0
}}
>
{organizedChildren.footer}
</View>
</YStack>
{/* Right Side */}
<View
width={shell.rightSideWidth}
overflow="hidden"
style={{
transition: 'width 0.3s ease',
flexShrink: 0
}}
>
{organizedChildren.rightSide}
</View>
</XStack>
<ToastViewport />
</>
);
}
/**
* EmptyShell - Wrapper with ShellProvider
* Pure layout component - handles section placement and dimensions only
*
* @param {Object} props - Component props
* @param {React.ReactNode} props.children - Content to render (defaults to MainContent)
* @param {number} props.initialLeftWidth - Initial left side width (default: 0)
* @param {number} props.initialRightWidth - Initial right side width (default: 0)
* @param {number} props.initialHeaderHeight - Initial header height (default: 0)
* @param {number} props.initialFooterHeight - Initial footer height (default: 0)
*/
export function EmptyShell({
children,
initialLeftWidth = 0,
initialRightWidth = 0,
initialHeaderHeight = 0,
initialFooterHeight = 0
}) {
return (
<ShellProvider
initialLeftWidth={initialLeftWidth}
initialRightWidth={initialRightWidth}
initialHeaderHeight={initialHeaderHeight}
initialFooterHeight={initialFooterHeight}
>
<EmptyShellInner children={children} />
</ShellProvider>
);
}
export default EmptyShell;
+283
View File
@@ -0,0 +1,283 @@
import React from 'react';
import { Adapt, Button, Input, Label, Paragraph, Select, Separator, Sheet, Text, TextArea, XStack, YStack } from 'tamagui';
import { Check, ChevronDown, ChevronUp } from '@tamagui/lucide-icons';
import { pickFile } from '../../platform/compat.js';
function FieldShell({ label, helperText, error, children }) {
return (
<YStack gap="$2" width="100%">
{label ? (
<Label color="$color" fontWeight="600">
{label}
</Label>
) : null}
{children}
{error || helperText ? (
<Paragraph color={error ? '#dc2626' : '$color'} opacity={error ? 1 : 0.7} fontSize="$3">
{error || helperText}
</Paragraph>
) : null}
</YStack>
);
}
function SelectField({ label, value, options = [], placeholder, onValueChange, error, helperText, disabled = false }) {
return (
<FieldShell label={label} error={error} helperText={helperText}>
<Select value={value ?? ''} onValueChange={onValueChange} disabled={disabled}>
<Select.Trigger iconAfter={ChevronDown}>
<Select.Value placeholder={placeholder || label || 'Select'} />
</Select.Trigger>
<Adapt when="sm" platform="touch">
<Sheet modal dismissOnSnapToBottom snapPoints={[55]}>
<Sheet.Frame>
<Sheet.ScrollView>
<Adapt.Contents />
</Sheet.ScrollView>
</Sheet.Frame>
<Sheet.Overlay />
</Sheet>
</Adapt>
<Select.Content zIndex={200000}>
<Select.ScrollUpButton alignItems="center" justifyContent="center" position="relative" width="100%" height="$3">
<YStack zIndex={10}>
<ChevronUp size={18} />
</YStack>
</Select.ScrollUpButton>
<Select.Viewport minWidth={220}>
<Select.Group>
{options.map((option, index) => (
<Select.Item key={option.value} index={index} value={String(option.value)}>
<Select.ItemText>{option.label}</Select.ItemText>
<Select.ItemIndicator marginLeft="auto">
<Check size={16} />
</Select.ItemIndicator>
</Select.Item>
))}
</Select.Group>
</Select.Viewport>
<Select.ScrollDownButton alignItems="center" justifyContent="center" position="relative" width="100%" height="$3">
<YStack zIndex={10}>
<ChevronDown size={18} />
</YStack>
</Select.ScrollDownButton>
</Select.Content>
</Select>
</FieldShell>
);
}
async function handleFilePick(fieldId, onChange, props = {}) {
const selection = await pickFile({
accept: props.accept || '*',
readAs: props.readAs || null
});
if (!selection?.file) {
return;
}
onChange?.(fieldId, selection.file, selection);
}
export function FormField({
id,
type = 'text',
label,
placeholder,
required = false,
disabled = false,
readOnly = false,
options = [],
value,
onChange,
error,
helperText,
children,
...props
}) {
const fieldValue = value ?? (type === 'multiselect' ? [] : type === 'checkbox' ? false : '');
if (type === 'divider') {
return <Separator />;
}
if (type === 'title') {
return (
<YStack gap="$1">
<Text fontSize="$7" fontWeight="700" color="$accentColor">
{label}
</Text>
{helperText ? (
<Paragraph color="$color" opacity={0.7}>
{helperText}
</Paragraph>
) : null}
</YStack>
);
}
if (type === 'custom') {
return children || null;
}
if (type === 'select') {
return (
<SelectField
label={label}
value={fieldValue === '' ? '' : String(fieldValue)}
options={options}
placeholder={placeholder}
onValueChange={(nextValue) => onChange?.(id, nextValue)}
error={error}
helperText={helperText}
disabled={disabled}
/>
);
}
if (type === 'multiselect') {
const selectedValues = Array.isArray(fieldValue) ? fieldValue.map(String) : [];
return (
<FieldShell label={label} error={error} helperText={helperText}>
<XStack flexWrap="wrap" gap="$2">
{options.map((option) => {
const selected = selectedValues.includes(String(option.value));
return (
<Button
key={option.value}
size="$3"
theme={selected ? 'active' : undefined}
backgroundColor={selected ? '$accentColor' : '$background'}
color={selected ? 'white' : '$color'}
borderWidth={1}
borderColor={selected ? '$accentColor' : '$borderColor'}
disabled={disabled}
onPress={() => {
const nextValues = selected
? selectedValues.filter((item) => item !== String(option.value))
: [...selectedValues, String(option.value)];
onChange?.(id, nextValues);
}}
>
{option.label}
</Button>
);
})}
</XStack>
</FieldShell>
);
}
if (type === 'checkbox') {
return (
<FieldShell label={label} error={error} helperText={helperText}>
<Button
size="$3"
alignSelf="flex-start"
backgroundColor={fieldValue ? '$accentColor' : '$background'}
color={fieldValue ? 'white' : '$color'}
borderWidth={1}
borderColor={fieldValue ? '$accentColor' : '$borderColor'}
disabled={disabled}
onPress={() => onChange?.(id, !fieldValue)}
>
{fieldValue ? 'Enabled' : 'Disabled'}
</Button>
</FieldShell>
);
}
if (type === 'radio') {
return (
<FieldShell label={label} error={error} helperText={helperText}>
<XStack gap="$2" flexWrap="wrap">
{options.map((option) => {
const selected = String(fieldValue) === String(option.value);
return (
<Button
key={option.value}
size="$3"
backgroundColor={selected ? '$accentColor' : '$background'}
color={selected ? 'white' : '$color'}
borderWidth={1}
borderColor={selected ? '$accentColor' : '$borderColor'}
disabled={disabled}
onPress={() => onChange?.(id, option.value)}
>
{option.label}
</Button>
);
})}
</XStack>
</FieldShell>
);
}
if (type === 'file') {
return (
<FieldShell label={label} error={error} helperText={helperText}>
<XStack alignItems="center" gap="$3" flexWrap="wrap">
<Button disabled={disabled} onPress={() => handleFilePick(id, onChange, props)}>
{fieldValue?.name ? 'Replace File' : 'Choose File'}
</Button>
<Text color="$color" opacity={0.75}>
{fieldValue?.name || 'No file selected'}
</Text>
</XStack>
</FieldShell>
);
}
if (type === 'textarea') {
return (
<FieldShell label={label} error={error} helperText={helperText}>
<TextArea
placeholder={placeholder}
value={String(fieldValue)}
onChangeText={(nextValue) => onChange?.(id, nextValue)}
disabled={disabled}
readOnly={readOnly}
minHeight={120}
/>
</FieldShell>
);
}
if (readOnly) {
return (
<FieldShell label={label} error={error} helperText={helperText}>
<YStack
padding="$3"
borderWidth={1}
borderColor="$borderColor"
borderRadius="$4"
backgroundColor="$accentSurface"
>
<Text>{String(fieldValue || '')}</Text>
</YStack>
</FieldShell>
);
}
return (
<FieldShell label={label} error={error} helperText={helperText}>
<Input
placeholder={placeholder}
value={String(fieldValue)}
onChangeText={(nextValue) => onChange?.(id, type === 'number' ? Number(nextValue) : nextValue)}
disabled={disabled}
required={required}
type={type === 'datetime' ? 'datetime-local' : type}
keyboardType={type === 'number' ? 'numeric' : undefined}
autoCapitalize={type === 'email' || type === 'password' ? 'none' : undefined}
/>
</FieldShell>
);
}
export default FormField;
+145
View File
@@ -0,0 +1,145 @@
import React, { useMemo } from 'react';
import { Button, XStack, YStack } from 'tamagui';
import { SidePanelShell } from './SidePanelShell.jsx';
import { FormField } from './FormField.jsx';
import { getIcon } from './IconMapper.jsx';
function defaultExpressionEvaluator(template, form) {
return template.replace(/\{\{(\w+)\}\}/g, (_match, fieldName) => form[fieldName] || '');
}
function renderAction(action, index, fallbackHandler) {
const IconComponent = action?.icon ? getIcon(action.icon) : null;
return {
id: action?.id || action?.label || `action-${index}`,
label: action?.label,
icon: action?.icon,
disabled: action?.disabled,
theme: action?.theme,
chromeless: action?.chromeless,
onPress: action?.onPress || fallbackHandler,
iconComponent: IconComponent
};
}
export function FormView({
open = false,
onClose = null,
title = 'Edit Record',
toolbar = [],
fields = [],
values = {},
onChange = () => {},
onSubmit = () => {},
onReset = () => {},
buttons = [],
loading = false,
errors = {},
children = null,
hideButtons = false,
width = 460
}) {
const processedFields = useMemo(() => {
return fields.map((field) => {
if (!field.expression) {
return field;
}
const expressionFunction = typeof field.expression === 'string'
? (form) => defaultExpressionEvaluator(field.expression, form)
: field.expression;
return {
...field,
expressionFunction,
readOnly: true
};
});
}, [fields]);
const computedValues = useMemo(() => {
return processedFields.reduce((accumulator, field) => {
if (field.expressionFunction) {
accumulator[field.id] = field.expressionFunction(values);
}
return accumulator;
}, {});
}, [processedFields, values]);
const mergedValues = {
...values,
...computedValues
};
const footerActions = useMemo(() => {
if (hideButtons) {
return [];
}
const sourceButtons = buttons.length > 0
? buttons
: [
{ label: 'Reset', chromeless: true, onPress: onReset },
{ label: 'Save', theme: 'active', onPress: onSubmit }
];
return sourceButtons.map((button, index) => renderAction(button, index, index === 0 ? onReset : onSubmit));
}, [buttons, hideButtons, onReset, onSubmit]);
const renderField = (field, index) => (
<FormField
key={field.id || `${field.type || 'field'}-${index}`}
{...field}
value={mergedValues[field.id]}
onChange={onChange}
error={errors[field.id]}
disabled={field.disabled || loading}
/>
);
const renderChildren = () => {
if (!children) {
return null;
}
return React.Children.map(children, (child) => {
if (React.isValidElement(child) && child.type === FormField) {
const fieldId = child.props.id;
return React.cloneElement(child, {
value: mergedValues[fieldId],
onChange,
error: errors[fieldId],
disabled: child.props.disabled || loading
});
}
return child;
});
};
return (
<SidePanelShell
open={open}
onClose={onClose}
title={title}
toolbar={toolbar}
footerActions={footerActions}
width={width}
>
<YStack gap="$4">
{processedFields.map(renderField)}
{children ? (
<YStack gap="$4">
{renderChildren()}
</YStack>
) : null}
{loading ? (
<XStack justifyContent="flex-end">
<Button disabled>Saving...</Button>
</XStack>
) : null}
</YStack>
</SidePanelShell>
);
}
export default FormView;
+49
View File
@@ -0,0 +1,49 @@
/**
* GeneralConfig - General settings configuration panel
* Simple panel component for settings
*/
import React from 'react';
import { Text } from 'tamagui';
import { SettingsPanel } from './SettingsPanel.jsx';
import { useGeneralSettingsViews } from '../runtime/general-settings.js';
/**
* GeneralConfig Component
* General settings panel
*/
export function GeneralConfig() {
const views = useGeneralSettingsViews();
const content = views.map((view) => {
const ViewComponent = view.component;
return {
id: view.id,
label: view.label,
icon: view.icon || 'settings',
persistenceKey: `settings.general.${view.id}`,
content: <ViewComponent />
};
});
return (
<SettingsPanel
icon="settings"
title="General Settings"
description="General system preferences and shared application controls live here."
defaultExpanded
persistenceKey="settings.general"
content={content}
contentStyle="list"
>
{views.length === 0 ? (
<Text fontSize="$4" color="$color" opacity={0.8}>
No general settings views are registered yet.
</Text>
) : null}
</SettingsPanel>
);
}
export default GeneralConfig;
+324
View File
@@ -0,0 +1,324 @@
/**
* IconMapper - Maps icon names to Lucide icons from @tamagui/lucide-icons
* Cross-platform compatible (web + React Native)
*/
import React from 'react';
import {
AlertCircle,
AlertTriangle,
ArrowDown,
ArrowLeft,
ArrowRight,
ArrowUp,
ArrowUpDown,
BarChart3,
Bell,
Bold,
Bookmark,
Book,
Calendar,
Camera,
Check,
CheckCircle,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
ChevronUp,
Clock,
Cloud,
CloudDownload,
CloudUpload,
Clipboard,
Code,
Copy,
Crop,
DollarSign,
Download,
Eye,
EyeOff,
File,
FileText,
Filter,
Folder,
FolderOpen,
Globe,
HardDrive,
Heart,
HelpCircle,
Home,
Image,
Info,
Italic,
LayoutDashboard,
Library,
Link,
Lock,
LogIn,
LogOut,
Mail,
Map,
MapPin,
Menu,
MessageCircle,
MessageSquare,
Minus,
MoreHorizontal,
MoreVertical,
Navigation,
Network,
Paperclip,
Pause,
Phone,
Play,
Plus,
Power,
Printer,
RefreshCw,
RotateCw,
Save,
Scissors,
Search,
Send,
Settings,
Share2,
Signal,
SkipBack,
SkipForward,
Square,
SquarePen,
Star,
SlidersHorizontal,
Sun,
Trash2,
TrendingUp,
Underline,
Unlock,
Upload,
User,
UserCircle,
Users,
Video,
Volume1,
Volume2,
VolumeX,
Wifi,
WifiOff,
X,
ZoomIn,
ZoomOut
} from '@tamagui/lucide-icons';
/**
* Icon name to Lucide icon component mapping
* Maps Material Design icon names to Lucide equivalents
*/
const iconMap = {
// Navigation & UI
'home': Home,
'settings': Settings,
'user': User,
'person': User,
'account': UserCircle,
'menu': Menu,
'hamburger': Menu,
'search': Search,
'bell': Bell,
'notifications': Bell,
'mail': Mail,
'email': Mail,
// Files & Folders
'file': File,
'folder': Folder,
'folder-open': FolderOpen,
// Actions
'edit': SquarePen,
'delete': Trash2,
'save': Save,
'close': X,
'x': X,
'check': Check,
'plus': Plus,
'minus': Minus,
// Arrows & Navigation
'arrow-right': ArrowRight,
'arrow-left': ArrowLeft,
'arrow-up': ArrowUp,
'arrow-down': ArrowDown,
'chevron-right': ChevronRight,
'chevron-down': ChevronDown,
'chevron-up': ChevronUp,
'chevron-left': ChevronLeft,
'chevrons-right': ChevronsRight,
'chevrons-left': ChevronsLeft,
'more-vert': MoreVertical,
'more-horiz': MoreHorizontal,
// Auth
'logout': LogOut,
'login': LogIn,
'lock': Lock,
'unlock': Unlock,
// Dashboard & Analytics
'dashboard': LayoutDashboard,
'chart': BarChart3,
'analytics': TrendingUp,
'money': DollarSign,
'group': Users,
'report': FileText,
// Status & Feedback
'info': Info,
'warning': AlertTriangle,
'error': AlertCircle,
'success': CheckCircle,
'help': HelpCircle,
// Visibility
'visibility': Eye,
'visibility-off': EyeOff,
// Media
'image': Image,
'photo': Camera,
'video': Video,
'play': Play,
'pause': Pause,
'stop': Square,
// Communication
'chat': MessageCircle,
'message': MessageSquare,
'comment': MessageSquare,
'send': Send,
'phone': Phone,
// Content
'copy': Copy,
'cut': Scissors,
'paste': Clipboard,
'link': Link,
'attach': Paperclip,
// UI Controls
'filter': Filter,
'sort': ArrowUpDown,
'refresh': RefreshCw,
'download': Download,
'upload': Upload,
'share': Share2,
'language': Globe,
'locale': Globe,
'tune': SlidersHorizontal,
'first-page': SkipBack,
'last-page': SkipForward,
// Favorites & Bookmarks
'favorite': Heart,
'favorite-border': Heart,
'star': Star,
'star-border': Star,
'bookmark': Bookmark,
// Time & Calendar
'calendar': Calendar,
'time': Clock,
// Location
'location': MapPin,
'location-on': MapPin,
'map': Map,
'navigation': Navigation,
// System
'power': Power,
'brightness': Sun,
'wifi': Wifi,
'wifi-off': WifiOff,
// Media Controls
'volume-up': Volume2,
'volume-down': Volume1,
'volume-off': VolumeX,
'mute': VolumeX,
// Editing
'zoom-in': ZoomIn,
'zoom-out': ZoomOut,
'crop': Crop,
'rotate': RotateCw,
// Formatting
'format-bold': Bold,
'format-italic': Italic,
'format-underline': Underline,
'code': Code,
// Files & Documents
'document': FileText,
'article': FileText,
'book': Book,
'library': Library,
// Cloud & Storage
'cloud': Cloud,
'cloud-upload': CloudUpload,
'cloud-download': CloudDownload,
'drive': HardDrive,
// Network
'network': Network,
'signal': Signal,
// Print
'print': Printer,
// Add more mappings as needed
};
/**
* Get Lucide icon component by name
* @param {string} iconName - Name of the icon (e.g., 'home', 'settings')
* @returns {React.Component|null} Lucide icon component or null
*/
export function getIcon(iconName) {
if (!iconName || typeof iconName !== 'string') {
return null;
}
const normalizedName = iconName.toLowerCase().trim();
return iconMap[normalizedName] || null;
}
/**
* IconMapper Component
* Renders a Lucide icon by name with Tamagui theme support
* @param {string} iconName - Name of the icon
* @param {number|string} size - Size of the icon (number or Tamagui token like '$4')
* @param {string} color - Color of the icon (CSS color or Tamagui token like '$color')
* @returns {React.ReactElement|null} Rendered icon or null
*/
export function IconMapper({ iconName, size = 24, color = 'currentColor', ...props }) {
const IconComponent = getIcon(iconName);
if (!IconComponent) {
// Fallback for emojis or unknown icons
if (iconName && (iconName.length <= 2 || /[\u{1F300}-\u{1F9FF}]/u.test(iconName))) {
return <span style={{ fontSize: typeof size === 'string' ? size : `${size}px`, color }}>{iconName}</span>;
}
return null;
}
// Convert size if it's a number to a reasonable default
const iconSize = typeof size === 'string' ? size : size;
return <IconComponent size={iconSize} color={color} {...props} />;
}
export default IconMapper;
+35
View File
@@ -0,0 +1,35 @@
import React from 'react';
import { Paragraph, Text, YStack } from 'tamagui';
import { useApp } from '../App.jsx';
import { SettingsPanel } from './SettingsPanel.jsx';
export function IdentityConfig() {
const { security } = useApp();
return (
<SettingsPanel
icon="lock"
title="Identity"
description="Identity provider status and login policy for the current application profile."
defaultExpanded={false}
persistenceKey="settings.identity"
>
<YStack gap="$2">
<Text fontWeight="700" color="$accentColor">
{security.enabled ? 'Identity Enabled' : 'Identity Disabled'}
</Text>
<Paragraph color="$color" opacity={0.78}>
Provider: {security.config.provider}
</Paragraph>
<Paragraph color="$color" opacity={0.78}>
Require login: {security.requireLogin ? 'yes' : 'no'}
</Paragraph>
<Paragraph color="$color" opacity={0.68}>
Identity is controlled by app profile configuration today. This panel reflects the active security policy and is the anchor point for future runtime controls.
</Paragraph>
</YStack>
</SettingsPanel>
);
}
export default IdentityConfig;
+31
View File
@@ -0,0 +1,31 @@
/**
* LandingShell - Landing page shell
* Derived from EmptyShell, designed for landing/home pages
* Profile `ui_shell` values `LandingShell`, `TopbarShell`, and `TopBarShell` all use this layout
* (TopBar in the shell header with primary menu; main route content below).
*/
import React from 'react';
import EmptyShell from './EmptyShell.jsx';
import { TopBar } from './TopBar.jsx';
/**
* LandingShell Component
* Shell component for landing pages with TopBar in header
*
* @param {Object} props - Component props
* @param {React.ReactNode} props.children - Content to render inside shell
*/
export function LandingShell(props) {
return (
<EmptyShell {...props} initialHeaderHeight={60}>
<TopBar placement="header">
{/* Menu items will be placed here via placement */}
</TopBar>
{props.children}
</EmptyShell>
);
}
export default LandingShell;
+609
View File
@@ -0,0 +1,609 @@
/**
* MenuItemButton Component
* Displays a menu item with icon, label, and optional group expansion
* Based on Tamagui's ListItem component
*/
import React, { useState, useRef, useEffect } from 'react';
import { Button, XStack, YStack, Text } from 'tamagui';
import { getIcon } from './IconMapper.jsx';
import {
getBounds,
addDocumentEventListener,
addWindowEventListener,
elementContains,
getPopupBounds,
getPopupPositionStyle
} from '../../platform/compat.js';
import { MenuItem, getMenuItemExpandedPreference, setMenuItemExpandedPreference } from '../../platform/menu.js';
import { securityService, useSecurityState } from '../../security/runtime/security-service.js';
/**
* MenuItemButton Component
*
* @param {Object} props
* @param {Object} props.menuItem - MenuItem instance from menu system
* @param {string} [props.orientation='horizontal'] - 'horizontal' | 'vertical' (layout direction)
* @param {string} [props.expand_mode] - 'popup' | 'below' (how groups expand). Defaults: horizontal='popup', vertical='below'
* @param {boolean} [props.selected=false] - Whether item is selected/active
* @param {boolean} [props.hovered=false] - Whether item is hovered
* @param {number|string} [props.width] - Width in device-independent units (default: 'auto' for horizontal, '100%' for vertical)
* @param {string} [props.size='$4'] - Size variant (Tamagui size token)
* @param {Function} [props.onClick] - Click handler (overrides menuItem.invoke if provided)
* @param {Function} [props.onExpand] - Expand/collapse handler for groups
* @param {boolean} [props.expanded] - Whether group is expanded (controlled)
* @param {boolean} [props.defaultExpanded=false] - Default expanded state (uncontrolled)
* @param {boolean} [props.collapsed=false] - Whether sidebar is collapsed (hides label, shows tooltip)
* @param {string} [props.displayStyle] - Override MenuItem style ('both', 'label_only', 'icon_only'). If not provided, uses menuItem.style
* @param {string|number} [props.padding] - Internal padding (default: '$2'). Can be a Tamagui token like '$2' or a number
* @param {Object} [props.style] - Additional styles
* @param {string} [props.testID] - Test identifier
*/
export function MenuItemButton({
menuItem,
orientation = 'horizontal',
expand_mode,
selected = false,
hovered = false,
width,
size = '$4',
onClick,
onExpand,
expanded: controlledExpanded,
defaultExpanded = false,
collapsed = false,
displayStyle,
padding = '$2',
style,
testID,
stateVersion,
...otherProps
}) {
if (!menuItem) {
console.warn('[MenuItemButton] menuItem is required');
return null;
}
// Validate that menuItem is a MenuItem instance
if (!(menuItem instanceof MenuItem)) {
console.error('[MenuItemButton] menuItem must be a MenuItem instance, but received:', {
type: typeof menuItem,
constructor: menuItem?.constructor?.name,
hasIsActionable: typeof menuItem?.isActionable === 'function',
hasExecute: typeof menuItem?.execute === 'function',
menuItem: menuItem,
stack: new Error().stack
});
// Log where this is being called from to help debug
console.error('[MenuItemButton] Call stack:', new Error().stack);
return null;
}
const securityState = useSecurityState();
const security = {
...securityState,
isPermitted: (rights, resourcePath, options = {}) => securityService.isPermitted(rights, resourcePath, options)
};
if (!menuItem.isRenderable(security)) {
return null;
}
// Determine expand_mode: default based on orientation, but allow override
// When collapsed, always use popup mode for groups (no room for inline expansion)
const effectiveExpandMode = collapsed
? 'popup'
: (expand_mode || (orientation === 'horizontal' ? 'popup' : 'below'));
// Handle expanded state (controlled or uncontrolled)
const [internalExpanded, setInternalExpanded] = useState(() => getMenuItemExpandedPreference(menuItem.path, defaultExpanded));
const [popupOpen, setPopupOpen] = useState(false);
const [popupPosition, setPopupPosition] = useState({ top: 0, left: 0, alignRight: false, alignRightSide: false, alignBottom: false });
const popupRef = useRef(null);
const buttonRef = useRef(null);
const isExpanded = controlledExpanded !== undefined ? controlledExpanded : internalExpanded;
const renderableSubItems = menuItem.items
? Array.from(menuItem.items.values()).filter((item) => item instanceof MenuItem && item.isRenderable(security))
: [];
const hasSubitems = renderableSubItems.length > 0;
useEffect(() => {
if (controlledExpanded !== undefined) {
return;
}
setInternalExpanded(getMenuItemExpandedPreference(menuItem.path, defaultExpanded));
}, [controlledExpanded, defaultExpanded, menuItem.path, stateVersion]);
// Close popup when clicking outside (popup mode only)
useEffect(() => {
if (effectiveExpandMode === 'popup' && popupOpen) {
const handleClickOutside = (event) => {
if (
popupRef.current &&
buttonRef.current &&
!elementContains(popupRef, event.target) &&
!elementContains(buttonRef, event.target)
) {
setPopupOpen(false);
}
};
// Use compatibility layer for document event listener
return addDocumentEventListener('mousedown', handleClickOutside);
}
}, [popupOpen, effectiveExpandMode]);
// Determine width
const itemWidth = width !== undefined
? width
: (orientation === 'horizontal' ? 'auto' : '100%');
// Calculate smart popup position using compatibility layer
const calculatePopupPosition = () => {
if (!buttonRef.current) return;
// Use compatibility layer for popup position calculation
const bounds = getPopupBounds(buttonRef, 200, 300, 8);
if (bounds) {
setPopupPosition(bounds);
}
};
// Handle toggle expand/collapse (for chevron and fallback)
const handleToggleExpand = (e) => {
e.stopPropagation();
if (effectiveExpandMode === 'popup') {
// Calculate popup position before opening
if (!popupOpen) {
calculatePopupPosition();
}
// Toggle popup
setPopupOpen(!popupOpen);
} else {
// Toggle inline expansion (below mode)
const newExpanded = !isExpanded;
if (controlledExpanded === undefined) {
setInternalExpanded(newExpanded);
}
if (menuItem.path) {
setMenuItemExpandedPreference(menuItem.path, newExpanded);
}
if (onExpand) {
onExpand(newExpanded, menuItem);
}
}
};
// Handle main item click (icon + label)
const handleMainClick = (e) => {
// If item is actionable (has invoke or invoke_type+invoke_target), execute it
if (menuItem.isActionable()) {
if (onClick) {
onClick(e, menuItem);
} else {
// Pass event source (button element) and event to execute
menuItem.execute(buttonRef.current, e);
}
// Close popup if open
if (popupOpen) {
setPopupOpen(false);
}
e.stopPropagation();
} else if (hasSubitems) {
// If not actionable but has subitems, fallback to toggle expand/collapse
handleToggleExpand(e);
} else if (onClick) {
// If no invoke and no subitems, just call onClick if provided
onClick(e, menuItem);
}
};
// Recalculate position when popup opens or window resizes
useEffect(() => {
if (popupOpen && effectiveExpandMode === 'popup' && buttonRef.current) {
// Initial calculation
calculatePopupPosition();
// Recalculate after popup renders to use actual dimensions
const timeoutId = setTimeout(() => {
if (popupRef.current && buttonRef.current) {
// Use compatibility layer for recalculation with actual dimensions
const popupBounds = getBounds(popupRef);
const popupWidth = popupBounds?.width || 200;
const popupHeight = popupBounds?.height || 300;
const bounds = getPopupBounds(buttonRef, popupWidth, popupHeight, 8);
if (bounds) {
setPopupPosition(bounds);
}
}
}, 0);
const handleResize = () => {
if (buttonRef.current) {
calculatePopupPosition();
}
};
// Use compatibility layer for window event listener
const removeResizeListener = addWindowEventListener('resize', handleResize);
return () => {
clearTimeout(timeoutId);
removeResizeListener();
};
}
}, [popupOpen, effectiveExpandMode]);
// Determine icon component
// Icon can be: string (icon name), React component, or null
let IconComponent = null;
if (menuItem.icon) {
if (typeof menuItem.icon === 'string') {
const iconStr = menuItem.icon.trim();
// Check if it's an emoji or special character (fallback for emojis)
if (iconStr.length <= 2 || /[\u{1F300}-\u{1F9FF}]/u.test(iconStr)) {
IconComponent = iconStr;
} else {
// Try to get icon from IconMapper
const Icon = getIcon(iconStr);
if (Icon) {
IconComponent = Icon;
} else {
// Fallback: don't render unknown icon names
IconComponent = null;
}
}
} else {
// Assume it's a React component
IconComponent = menuItem.icon;
}
}
// Determine background color based on state
const getBackgroundColor = () => {
if (selected) {
return '$accentBackground';
}
if (hovered) {
return '$backgroundPress';
}
return 'transparent';
};
const getIconColor = () => {
if (selected) {
return '$accentColor';
}
if (menuItem.style === 'icon_only') {
return '$accentColor';
}
return '$color';
};
const getLabelColor = () => {
if (selected) {
return '$accentColor';
}
return '$color';
};
const getArrowColor = () => {
if (selected) {
return '$accentColor';
}
return '$colorSecondary';
};
// Determine display style (both, label_only, icon_only)
// Use displayStyle prop if provided, otherwise fall back to menuItem.style
const effectiveDisplayStyle = displayStyle !== undefined ? displayStyle : (menuItem.style || 'both');
const showIcon = (effectiveDisplayStyle === 'both' || effectiveDisplayStyle === 'icon_only') && IconComponent;
// Hide label when collapsed (unless it's icon_only style which never shows label)
const showLabel = !collapsed && (effectiveDisplayStyle === 'both' || effectiveDisplayStyle === 'label_only') && menuItem.label;
// Lucide chevron icons for groups
// For popup mode in vertical orientation, show chevron right (>)
// For popup mode in horizontal orientation, show chevron down (down arrow)
// For below mode, show chevron right when collapsed, chevron down when expanded
const arrowIconName = effectiveExpandMode === 'popup'
? (orientation === 'vertical' ? 'chevron-right' : 'chevron-down')
: (isExpanded ? 'chevron-down' : 'chevron-right');
const ArrowIcon = getIcon(arrowIconName);
// Render based on orientation
if (orientation === 'horizontal') {
const horizontalContent = (
<XStack
position="relative"
width={itemWidth}
alignItems="center"
style={style}
testID={testID}
{...otherProps}
>
<XStack
ref={buttonRef}
width="100%"
alignItems="center"
backgroundColor={getBackgroundColor()}
borderWidth={selected ? 1 : 0}
borderColor={selected ? '$accentBorder' : 'transparent'}
borderRadius="$2"
padding={padding}
opacity={menuItem.is_active !== false ? 1 : 0.5}
>
{/* Icon + Label (clickable main area) */}
<XStack
flex={1}
alignItems="center"
cursor={menuItem.isActionable() || hasSubitems ? 'pointer' : 'default'}
hoverStyle={{
backgroundColor: hovered || selected ? getBackgroundColor() : '$backgroundHover'
}}
pressStyle={{
backgroundColor: selected ? '$accentHover' : '$backgroundPress'
}}
onPress={handleMainClick}
>
{/* Icon */}
{showIcon && (
<XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center">
{typeof IconComponent === 'string' ? (
// Emoji fallback
<Text fontSize={size} lineHeight={size}>{IconComponent}</Text>
) : IconComponent ? (
// Material Design icon component
<IconComponent size={typeof size === 'string' ? 24 : (size || 24)} color={getIconColor()} />
) : null}
</XStack>
)}
{/* Label */}
{showLabel && (
<Text flex={1} fontSize={size} fontWeight={selected ? 'bold' : 'normal'} color={getLabelColor()}>
{menuItem.label}
</Text>
)}
</XStack>
{/* Group arrow (right side for horizontal) - clickable chevron */}
{hasSubitems && ArrowIcon && (
<XStack
cursor="pointer"
alignItems="center"
justifyContent="center"
padding="$1"
hoverStyle={{
backgroundColor: '$backgroundHover'
}}
pressStyle={{
backgroundColor: selected ? '$accentHover' : '$backgroundPress'
}}
onPress={handleToggleExpand}
>
<ArrowIcon
size={16}
color={getArrowColor()}
style={{ marginLeft: 4, flexShrink: 0 }}
/>
</XStack>
)}
</XStack>
{/* Popup menu for popup mode (horizontal) */}
{hasSubitems && popupOpen && effectiveExpandMode === 'popup' && orientation === 'horizontal' && buttonRef.current && (
<YStack
ref={popupRef}
position="fixed"
backgroundColor="$background"
borderRadius="$3"
padding={0}
borderWidth={1}
borderColor="$borderColor"
shadowColor="$shadowColor"
shadowOffset={{ width: 0, height: 4 }}
shadowOpacity={0.2}
shadowRadius={8}
elevation={8}
zIndex={9999}
minWidth={200}
maxWidth={300}
maxHeight="calc(100vh - 100px)"
overflow="auto"
gap="$1"
style={getPopupPositionStyle(popupPosition, buttonRef.current)}
>
{renderableSubItems.map((subItem) => (
<MenuItemButton
key={subItem.id}
menuItem={subItem}
orientation="vertical"
size={size}
hovered={false}
selected={false}
collapsed={false}
onClick={(e, item) => {
// First call the menuItem's invoke if it exists
if (item && item instanceof MenuItem && item.isActionable()) {
item.execute(e.target, e);
}
// Then call parent's onClick if provided
if (onClick) {
onClick(e, item);
}
// Finally close the popup
setPopupOpen(false);
}}
onExpand={onExpand}
defaultExpanded={defaultExpanded}
/>
))}
</YStack>
)}
</XStack>
);
return horizontalContent;
} else {
// Vertical orientation
const verticalContent = (
<YStack
width={itemWidth}
style={{ ...style, position: 'relative' }}
testID={testID}
{...otherProps}
>
<XStack
ref={buttonRef}
alignItems="center"
width="100%"
title={collapsed && menuItem.label ? menuItem.label : undefined}
backgroundColor={getBackgroundColor()}
borderWidth={selected ? 1 : 0}
borderColor={selected ? '$accentBorder' : 'transparent'}
borderRadius="$2"
padding={padding}
opacity={menuItem.is_active !== false ? 1 : 0.5}
>
{/* Icon + Label (clickable main area) */}
<XStack
flex={1}
alignItems="center"
cursor={menuItem.isActionable() || hasSubitems ? 'pointer' : 'default'}
hoverStyle={{
backgroundColor: hovered || selected ? getBackgroundColor() : '$backgroundHover'
}}
pressStyle={{
backgroundColor: selected ? '$accentHover' : '$backgroundPress'
}}
onPress={handleMainClick}
>
{/* Icon */}
{showIcon && (
<XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center">
{typeof IconComponent === 'string' ? (
// Emoji fallback
<Text fontSize={size} lineHeight={size}>{IconComponent}</Text>
) : IconComponent ? (
// Material Design icon component
<IconComponent size={typeof size === 'string' ? 24 : (size || 24)} color={getIconColor()} />
) : null}
</XStack>
)}
{/* Label */}
{showLabel && (
<Text flex={1} fontSize={size} fontWeight={selected ? 'bold' : 'normal'} color={getLabelColor()}>
{menuItem.label}
</Text>
)}
</XStack>
{/* Group arrow (right side for vertical) - clickable chevron */}
{hasSubitems && ArrowIcon && (
<XStack
cursor="pointer"
alignItems="center"
justifyContent="center"
padding="$1"
hoverStyle={{
backgroundColor: '$backgroundHover'
}}
pressStyle={{
backgroundColor: selected ? '$accentHover' : '$backgroundPress'
}}
onPress={handleToggleExpand}
>
<ArrowIcon
size={16}
color={getArrowColor()}
style={{ marginLeft: 8, flexShrink: 0 }}
/>
</XStack>
)}
</XStack>
{/* Expanded subitems (below mode only) */}
{hasSubitems && isExpanded && effectiveExpandMode === 'below' && (
<YStack
marginLeft="$4"
marginTop="$2"
gap="$1"
pointerEvents="auto"
onPointerEnter={(e) => e.stopPropagation()}
onPointerLeave={(e) => e.stopPropagation()}
>
{renderableSubItems.map((subItem) => (
<MenuItemButton
key={subItem.id}
menuItem={subItem}
orientation="vertical"
size={size}
selected={false}
hovered={false}
collapsed={collapsed}
onClick={onClick}
onExpand={onExpand}
defaultExpanded={defaultExpanded}
/>
))}
</YStack>
)}
{/* Popup menu for popup mode in vertical orientation */}
{hasSubitems && popupOpen && effectiveExpandMode === 'popup' && orientation === 'vertical' && buttonRef.current && (
<YStack
ref={popupRef}
position="fixed"
backgroundColor="$background"
borderRadius="$3"
padding={0}
borderWidth={1}
borderColor="$borderColor"
shadowColor="$shadowColor"
shadowOffset={{ width: 0, height: 4 }}
shadowOpacity={0.2}
shadowRadius={8}
elevation={8}
zIndex={9999}
minWidth={200}
maxWidth={300}
maxHeight="calc(100vh - 100px)"
overflow="auto"
gap="$1"
style={getPopupPositionStyle(popupPosition, buttonRef.current)}
>
{renderableSubItems.map((subItem) => (
<MenuItemButton
key={subItem.id}
menuItem={subItem}
orientation="vertical"
size={size}
hovered={false}
selected={false}
onClick={(e, item) => {
// First call the menuItem's invoke if it exists
if (item && item instanceof MenuItem && item.isActionable()) {
item.execute(e.target, e);
}
// Then call parent's onClick if provided
if (onClick) {
onClick(e, item);
}
// Finally close the popup
setPopupOpen(false);
}}
onExpand={onExpand}
collapsed={false}
defaultExpanded={defaultExpanded}
/>
))}
</YStack>
)}
</YStack>
);
return verticalContent;
}
}
export default MenuItemButton;
+110
View File
@@ -0,0 +1,110 @@
/**
* Page - Base page component with header and body
* Provides a consistent layout structure for all pages
* Platform-agnostic using Tamagui components
*/
import React from 'react';
import { XStack, YStack, Text } from 'tamagui';
import { getIcon } from './IconMapper.jsx';
/**
* Page Component
* Base component for all pages with header and body sections
*
* @param {Object} props
* @param {React.ReactNode} props.children - Content to render in body
* @param {string} [props.icon] - Icon name for header (first in headerLeft)
* @param {string} [props.title] - Page title (second in headerLeft)
* @param {Array<React.ReactNode>} [props.headerLeft] - Components to render in headerLeft (after icon and title)
* @param {Array<React.ReactNode>} [props.headerMiddle] - Components to render in headerMiddle
* @param {Array<React.ReactNode>} [props.headerRight] - Components to render in headerRight
*/
export function Page({
children,
icon,
title,
headerLeft = [],
headerMiddle = [],
headerRight = []
}) {
// Build headerLeft array: icon, title, then custom components
const headerLeftItems = [];
if (icon) {
const IconComponent = getIcon(icon);
if (IconComponent) {
headerLeftItems.push(
<XStack key="page-icon" alignItems="center" justifyContent="center" marginRight="$2">
<IconComponent size={24} color="$accentColor" />
</XStack>
);
}
}
if (title) {
headerLeftItems.push(
<Text key="page-title" fontWeight="600" fontSize="$6" color="$accentColor">
{title}
</Text>
);
}
// Add custom headerLeft components
if (Array.isArray(headerLeft)) {
headerLeft.forEach((item, index) => {
if (React.isValidElement(item)) {
headerLeftItems.push(React.cloneElement(item, { key: `headerLeft-${index}` }));
}
});
}
return (
<YStack width="100%" height="100%" flex={1}>
{/* Header */}
<XStack
width="100%"
padding="$4"
borderBottomWidth={1}
borderBottomColor="$accentBorder"
backgroundColor="$accentSurface"
alignItems="center"
gap="$3"
minHeight={64}
>
{/* Header Left */}
<XStack alignItems="center" gap="$2" flexShrink={0}>
{headerLeftItems}
</XStack>
{/* Header Middle */}
<XStack flex={1} alignItems="center" justifyContent="center" gap="$2" minWidth={0}>
{Array.isArray(headerMiddle) && headerMiddle.map((item, index) => {
if (React.isValidElement(item)) {
return React.cloneElement(item, { key: `headerMiddle-${index}` });
}
return null;
})}
</XStack>
{/* Header Right */}
<XStack alignItems="center" justifyContent="flex-end" gap="$2" flexShrink={0}>
{Array.isArray(headerRight) && headerRight.map((item, index) => {
if (React.isValidElement(item)) {
return React.cloneElement(item, { key: `headerRight-${index}` });
}
return null;
})}
</XStack>
</XStack>
{/* Body */}
<YStack flex={1} width="100%" padding="$4" overflow="auto">
{children}
</YStack>
</YStack>
);
}
export default Page;
+198
View File
@@ -0,0 +1,198 @@
/**
* Panel - Panel component with header and body
* Provides a consistent layout structure for nested panels/sections within pages
* Platform-agnostic using Tamagui components
*/
import React from 'react';
import { XStack, YStack, Text } from 'tamagui';
import { getIcon } from './IconMapper.jsx';
/**
* Header size mapping function
* Maps headerSize (1-4) to icon size, title fontSize, padding, and border radius
* Size 1 = largest (Page size), Size 4 = smallest
*
* @param {number} headerSize - Header size (1-4)
* @returns {Object} Object with iconSize, titleFontSize, padding, borderRadius, minHeight
*/
function getHeaderSizeStyles(headerSize) {
const sizeMap = {
1: {
iconSize: 24,
titleFontSize: '$6',
padding: '$3',
borderRadius: '$4',
minHeight: 64
},
2: {
iconSize: 20,
titleFontSize: '$5',
padding: '$2',
borderRadius: '$3',
minHeight: 48
},
3: {
iconSize: 18,
titleFontSize: '$4',
padding: '$1.5',
borderRadius: '$2',
minHeight: 44
},
4: {
iconSize: 16,
titleFontSize: '$3',
padding: '$1',
borderRadius: '$2',
minHeight: 36
}
};
// Clamp size to valid range (1-4)
const clampedSize = Math.max(1, Math.min(4, Math.round(headerSize)));
return sizeMap[clampedSize] || sizeMap[2]; // Default to size 2 if invalid
}
/**
* Panel Component
* Panel component for nested sections within pages with header and body sections
*
* @param {Object} props
* @param {React.ReactNode} props.children - Content to render in body
* @param {string} [props.icon] - Icon name for header (first in headerLeft)
* @param {string} [props.title] - Panel title (second in headerLeft)
* @param {Array<React.ReactNode>} [props.headerLeft] - Components to render in headerLeft (after icon and title)
* @param {Array<React.ReactNode>} [props.headerMiddle] - Components to render in headerMiddle
* @param {Array<React.ReactNode>} [props.headerRight] - Components to render in headerRight
* @param {boolean} [props.border=true] - Whether to show border around panel
* @param {string|number} [props.width=null] - Panel width (null for content-sized)
* @param {string|number} [props.height=null] - Panel height (null for content-sized)
* @param {number} [props.headerSize=2] - Header size (1-4), where 1 is largest (Page size) and 4 is smallest. Defaults to 2 for tighter panels.
* @param {Object} [props.headerFront] - Style object for header foreground elements (icon and title). Can include color, opacity, etc. Spread into icon and title components.
* @param {Object} [props.headerBack] - Style object for header background. Can include backgroundColor, opacity, etc. Spread into header XStack.
*/
export function Panel({
children,
icon,
title,
headerLeft = [],
headerMiddle = [],
headerRight = [],
border = true,
width = null,
height = null,
headerSize = 2,
headerFront = null,
headerBack = null
}) {
// Get size-specific styles
const sizeStyles = getHeaderSizeStyles(headerSize);
// Set default headerBack if not provided
const effectiveHeaderBack = headerBack || { backgroundColor: '$backgroundHover' };
// Build headerLeft array: icon, title, then custom components
const headerLeftItems = [];
if (icon) {
const IconComponent = getIcon(icon);
if (IconComponent) {
headerLeftItems.push(
<XStack key="panel-icon" alignItems="center" justifyContent="center" marginRight="$2" {...(headerFront || {})}>
<IconComponent size={sizeStyles.iconSize} {...(headerFront || {})} />
</XStack>
);
}
}
if (title) {
headerLeftItems.push(
<Text
key="panel-title"
fontWeight="600"
fontSize={sizeStyles.titleFontSize}
color="$color"
{...(headerFront || {})}
>
{title}
</Text>
);
}
// Add custom headerLeft components
if (Array.isArray(headerLeft)) {
headerLeft.forEach((item, index) => {
if (React.isValidElement(item)) {
headerLeftItems.push(React.cloneElement(item, { key: `headerLeft-${index}` }));
}
});
}
// Build style object for container
const containerStyle = {};
if (width !== null) {
containerStyle.width = width;
}
if (height !== null) {
containerStyle.height = height;
}
return (
<YStack
{...(Object.keys(containerStyle).length > 0 ? containerStyle : {})}
{...(border ? {
borderWidth: 1,
borderColor: '$borderColor',
borderRadius: sizeStyles.borderRadius
} : {})}
backgroundColor="$background"
overflow="hidden"
>
{/* Header */}
<XStack
width="100%"
padding={sizeStyles.padding}
borderBottomWidth={border ? 1 : 0}
borderBottomColor="$borderColor"
backgroundColor="$background"
alignItems="center"
gap="$3"
minHeight={sizeStyles.minHeight}
{...effectiveHeaderBack}
>
{/* Header Left */}
<XStack alignItems="center" gap="$2" flexShrink={0}>
{headerLeftItems}
</XStack>
{/* Header Middle */}
<XStack flex={1} alignItems="center" justifyContent="center" gap="$2" minWidth={0}>
{Array.isArray(headerMiddle) && headerMiddle.map((item, index) => {
if (React.isValidElement(item)) {
return React.cloneElement(item, { key: `headerMiddle-${index}` });
}
return null;
})}
</XStack>
{/* Header Right */}
<XStack alignItems="center" justifyContent="flex-end" gap="$2" flexShrink={0}>
{Array.isArray(headerRight) && headerRight.map((item, index) => {
if (React.isValidElement(item)) {
return React.cloneElement(item, { key: `headerRight-${index}` });
}
return null;
})}
</XStack>
</XStack>
{/* Body */}
<YStack width="100%" padding={sizeStyles.padding} overflow="auto" {...(height !== null ? { flex: 1 } : {})}>
{children}
</YStack>
</YStack>
);
}
export default Panel;
+82
View File
@@ -0,0 +1,82 @@
import React, { useMemo } from 'react';
import { MenuItem } from '../../platform/menu.js';
import { securityService, useSecurityState } from '../../security/runtime/security-service.js';
import { MenuItemButton } from './MenuItemButton.jsx';
function createSecurityRenderContext(securityState) {
return {
...securityState,
isPermitted: (rights, resourcePath, options = {}) => securityService.isPermitted(rights, resourcePath, options)
};
}
export function PersonalMenuItem({
personalRoot = null,
orientation = 'horizontal',
expand_mode = 'popup',
width,
collapsed = false,
padding,
displayStyle,
testID,
stateVersion
}) {
const securityState = useSecurityState();
const security = useMemo(() => createSecurityRenderContext(securityState), [securityState]);
const resolvedMenuItem = useMemo(() => {
if (!securityState.enabled) {
return null;
}
if (!securityState.isAuthenticated) {
return new MenuItem({
id: 'login',
label: 'Login',
icon: 'login',
style: 'both',
invoke_type: 'page',
invoke_target: securityState.config?.login_route || '/login'
});
}
const visibleChildren = new Map();
if (personalRoot?.items instanceof Map) {
for (const [id, item] of personalRoot.items.entries()) {
if (item instanceof MenuItem && item.isRenderable(security)) {
visibleChildren.set(id, item);
}
}
}
return new MenuItem({
id: 'personal-smart',
label: 'Account',
icon: 'account',
style: 'both',
invoke_type: 'page',
invoke_target: '/account',
items: visibleChildren
});
}, [personalRoot, security, securityState.config?.login_route, securityState.enabled, securityState.isAuthenticated]);
if (!resolvedMenuItem) {
return null;
}
return (
<MenuItemButton
menuItem={resolvedMenuItem}
orientation={orientation}
expand_mode={expand_mode}
width={width}
collapsed={collapsed}
padding={padding}
displayStyle={displayStyle}
testID={testID}
stateVersion={stateVersion}
/>
);
}
export default PersonalMenuItem;
+190
View File
@@ -0,0 +1,190 @@
/**
* ProgressBar — determinate or indeterminate progress with optional chrome.
* Uses Tamagui stacks/tokens only (no raw CSS gradients) for consistent theming and RN compatibility.
*
* Slots (all optional): `header`, `footer` (e.g. wrap your own ScrollView for history), `leftSlot` / `rightSlot`
* for inline actions, `label` centered above the track in the main column.
*
* @typedef {'determinate' | 'indeterminate'} ProgressMode
* @typedef {'inline' | 'fixedTop'} ProgressVariant
*/
import React, { useEffect, useMemo, useState } from 'react';
import { Text, XStack, YStack } from 'tamagui';
import { scheduleInterval, scheduleTimeout } from '../../platform/compat.js';
function clamp01(n) {
const x = Number(n);
if (!Number.isFinite(x)) {
return 0;
}
return Math.max(0, Math.min(100, x));
}
function renderNode(node) {
if (node === null || node === undefined || node === false) {
return null;
}
return node;
}
function hasSlot(node) {
return node !== null && node !== undefined && node !== false;
}
export function ProgressBar({
mode = 'indeterminate',
value = 0,
active = false,
/** Delay before unmount after `active` becomes false (smooth fade-out). Inline only; `fixedTop` hides immediately to avoid layout jump (label removed) during opacity animation. */
retainAfterInactiveMs = 420,
label,
header,
footer,
leftSlot,
rightSlot,
variant = 'inline',
trackHeight = 4,
zIndex = 20000
}) {
const [offset, setOffset] = useState(-45);
const [mounted, setMounted] = useState(active);
/** `fixedTop` follows `active` only — no delayed unmount, so the track cannot re-anchor to the top when the parent clears `label` on the same tick as `active`. */
const useRetainAfterInactive = variant !== 'fixedTop';
const visible = useRetainAfterInactive ? mounted : active;
const determinateWidth = useMemo(() => `${clamp01(value)}%`, [value]);
useEffect(() => {
if (active) {
setMounted(true);
}
}, [active]);
useEffect(() => {
if (!visible || mode !== 'indeterminate') {
return undefined;
}
return scheduleInterval(() => {
setOffset((v) => (v >= 100 ? -45 : v + 4));
}, 80);
}, [visible, mode]);
useEffect(() => {
if (!useRetainAfterInactive || active || !mounted) {
return undefined;
}
return scheduleTimeout(() => {
setMounted(false);
}, retainAfterInactiveMs);
}, [active, mounted, retainAfterInactiveMs, useRetainAfterInactive]);
if (!visible) {
return null;
}
const track = (
<YStack
position="relative"
width="100%"
height={trackHeight}
borderRadius="$1"
backgroundColor="$borderColor"
opacity={0.55}
overflow="hidden"
>
{mode === 'determinate' ? (
<YStack
position="absolute"
top={0}
left={0}
height="100%"
width={determinateWidth}
borderRadius="$1"
backgroundColor="$accentColor"
/>
) : (
<YStack
position="absolute"
top={0}
height="100%"
width="42%"
borderRadius="$1"
backgroundColor="$accentColor"
left={`${offset}%`}
/>
)}
</YStack>
);
const centerBlock = (
<YStack flex={1} minWidth={0} alignItems="center" gap="$1">
{label !== null && label !== undefined && label !== '' ? (
typeof label === 'string' ? (
<Text fontSize="$2" color="$colorSecondary" numberOfLines={1} textAlign="center" width="100%">
{label}
</Text>
) : (
label
)
) : null}
{track}
</YStack>
);
const body = (
<YStack
width="100%"
pointerEvents="box-none"
opacity={useRetainAfterInactive ? (active ? 1 : 0) : 1}
animation={useRetainAfterInactive ? 'medium' : undefined}
gap="$2"
>
{hasSlot(header) ? <YStack width="100%">{renderNode(header)}</YStack> : null}
<XStack width="100%" alignItems="center" justifyContent="center" gap="$2" pointerEvents="box-none">
{hasSlot(leftSlot) ? (
<XStack flexShrink={0} alignItems="center" pointerEvents="auto">
{renderNode(leftSlot)}
</XStack>
) : null}
{centerBlock}
{hasSlot(rightSlot) ? (
<XStack flexShrink={0} alignItems="center" pointerEvents="auto">
{renderNode(rightSlot)}
</XStack>
) : null}
</XStack>
{hasSlot(footer) ? (
<YStack width="100%" flexShrink={0}>
{renderNode(footer)}
</YStack>
) : null}
</YStack>
);
if (variant === 'fixedTop') {
return (
<YStack
position="fixed"
top={0}
left={0}
right={0}
zIndex={zIndex}
paddingHorizontal="$1"
paddingTop="$1"
pointerEvents="box-none"
backgroundColor="transparent"
>
{body}
</YStack>
);
}
return <YStack width="100%" pointerEvents="box-none">{body}</YStack>;
}
export default ProgressBar;
+269
View File
@@ -0,0 +1,269 @@
# Shell components
When consuming **@reliancy/bface** from npm, import shells and helpers from `@reliancy/bface/ui/components` (for example `import { EmptyShell, useShell } from '@reliancy/bface/ui/components'`). This repository uses `@ui/*` TypeScript path aliases in source; published paths follow `package.json` `exports`.
Shell components provide a flexible, responsive, sectioned layout system for the application. All components are built with Tamagui for cross-platform compatibility (web + React Native).
## Architecture
### Responsive Layout Structure
#### Desktop Layout (gtSm, > 801px)
```
┌─────────────────────────────────────┐
│ LeftSide │ MiddleSide │ RightSide │
│ │ ┌────────┐ │ │
│ │ │ Header │ │ │
│ │ ├────────┤ │ │
│ │ │ Main │ │ │
│ │ │Content │ │ │
│ │ ├────────┤ │ │
│ │ │ Footer │ │ │
│ │ └────────┘ │ │
└─────────────────────────────────────┘
```
#### Mobile Layout (sm and below, ≤ 801px)
```
┌─────────────────────┐
│ Header │
├─────────────────────┤
│ LeftSide (TopBar) │ ← Hamburger menu bar
├─────────────────────┤
│ MainContent │
├─────────────────────┤
│ RightSide │
├─────────────────────┤
│ Footer │
└─────────────────────┘
```
### Default Dimensions
- **LeftSide**: 0px width (collapsed on desktop), full width on mobile
- **RightSide**: 0px width (collapsed on desktop), full width on mobile
- **MiddleSide**: 100% width (takes remaining space on desktop)
- **Header**: 0px height (collapsed)
- **Footer**: 0px height (collapsed)
- **MainContent**: 100% height (flex: 1)
## Usage
### Basic Usage
```jsx
import { EmptyShell } from '@ui/components';
function App() {
return (
<EmptyShell>
<div>This goes to MainContent by default</div>
</EmptyShell>
);
}
```
### Placing Children in Specific Sections
#### Method 1: Using ShellPlacement Component
```jsx
import { EmptyShell, ShellPlacement } from '@ui/components';
function App() {
return (
<EmptyShell>
<ShellPlacement placement="leftSide">
<Sidebar />
</ShellPlacement>
<ShellPlacement placement="header">
<Header />
</ShellPlacement>
<div>Main content (default placement)</div>
<ShellPlacement placement="footer">
<Footer />
</ShellPlacement>
</EmptyShell>
);
}
```
#### Method 2: Using placement prop directly
```jsx
import { EmptyShell } from '@ui/components';
function App() {
return (
<EmptyShell>
<Sidebar placement="leftSide" />
<Header placement="header" />
<MainContent /> {/* Defaults to mainContent */}
<Footer placement="footer" />
</EmptyShell>
);
}
```
### Programmatic Control
Use the `useShell` hook to control section dimensions:
```jsx
import { EmptyShell, useShell } from '@ui/components';
function SidebarToggle() {
const { toggleLeftSide, leftSideWidth } = useShell();
return (
<button onClick={() => toggleLeftSide(250)}>
{leftSideWidth === 0 ? 'Show' : 'Hide'} Sidebar
</button>
);
}
function Dashboard() {
return (
<EmptyShell>
<Sidebar placement="leftSide" />
<SidebarToggle />
<MainContent />
</EmptyShell>
);
}
```
### Available Control Functions
```jsx
const {
// Current dimensions
leftSideWidth,
rightSideWidth,
headerHeight,
footerHeight,
// Setter functions
setLeftSideWidth,
setRightSideWidth,
setHeaderHeight,
setFooterHeight,
// Convenience toggles
toggleLeftSide, // (targetWidth = 250) => void
toggleRightSide // (targetWidth = 250) => void
} = useShell();
```
### Example: DashboardShell with Toggleable Sidebar
```jsx
import { EmptyShell, useShell } from '@ui/components';
function DashboardShell({ children }) {
return (
<EmptyShell initialLeftWidth={250}>
<Sidebar placement="leftSide" />
<DashboardHeader placement="header" />
{children}
<DashboardFooter placement="footer" />
</EmptyShell>
);
}
function Sidebar() {
const { toggleLeftSide, leftSideWidth } = useShell();
return (
<div>
<button onClick={() => toggleLeftSide(250)}>
{leftSideWidth === 0 ? '☰' : '✕'}
</button>
{/* Sidebar content */}
</div>
);
}
```
## Placement Values
- `'leftSide'` or `'left'` - Left sidebar
- `'rightSide'` or `'right'` - Right sidebar
- `'header'` - Top header section
- `'footer'` - Bottom footer section
- `'mainContent'` or `'main'` - Main content area (default)
## Responsive Design
All shell components are responsive and automatically adapt to screen size using Tamagui's `useMedia()` hook:
- **Breakpoint**: Switches at `sm` (801px) using Tamagui's default breakpoints
- **Desktop (> 801px)**: Horizontal layout with sidebars
- **Mobile (≤ 801px)**: Vertical stack layout
### TopBar Component
The `TopBar` component automatically switches between wide and narrow layouts:
- **Desktop**: Full horizontal navigation bar with all menu items visible
- **Mobile**: Hamburger menu button + Sheet drawer for menu items
**Subcomponents:**
- `TopBar.Wide` - Desktop layout (horizontal menu items)
- `TopBar.Narrow` - Mobile layout (hamburger + Sheet)
**Usage:**
```jsx
import { TopBar } from '@ui/components';
// Automatically responsive
<TopBar>
{/* Custom content with placement */}
</TopBar>
```
### SideBar Component
The `SideBar` component automatically switches between wide and narrow layouts:
- **Desktop**: Fixed vertical sidebar with all menu items visible
- **Mobile**: Hamburger menu button + Sheet drawer for menu items
**Subcomponents:**
- `SideBar.Wide` - Desktop layout (vertical sidebar)
- `SideBar.Narrow` - Mobile layout (hamburger + Sheet)
**Usage:**
```jsx
import { SideBar } from '@ui/components';
// Automatically responsive
<SideBar>
{/* Custom content with placement */}
</SideBar>
```
### EmptyShell Responsive Behavior
`EmptyShell` automatically adapts its layout:
- **Desktop**: Horizontal `XStack` with LeftSide, MiddleSide, RightSide side-by-side
- **Mobile**: Vertical `YStack` with sections stacked: Header → LeftSide → MainContent → RightSide → Footer
On mobile, `LeftSide` becomes a full-width top bar (perfect for hamburger menus), and `RightSide` stacks below main content.
## Platform-Agnostic
All shell components use Tamagui components (`XStack`, `YStack`, `View`, `Sheet`, `useMedia`) making them work on:
- Web (React)
- iOS (React Native)
- Android (React Native)
- Other platforms supported by Tamagui
The responsive design uses Tamagui's built-in breakpoints and media queries, ensuring consistent behavior across platforms.
File diff suppressed because it is too large Load Diff
+359
View File
@@ -0,0 +1,359 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Button, Paragraph, Separator, Tabs, Text, XStack, YStack } from 'tamagui';
import { getIcon } from './IconMapper.jsx';
import { getConfig, setConfig } from '../../platform/env.js';
function normalizeToggleAlign(toggleAlign) {
return toggleAlign === 'left' ? 'left' : 'right';
}
function normalizeVariant(variant, styleVariant) {
return styleVariant || variant || 'accordion';
}
function renderHeaderIcon(icon, color = '$color') {
const IconComponent = getIcon(icon);
if (!IconComponent) {
return null;
}
return <IconComponent size={18} color={color} />;
}
function normalizeContentStyle(contentStyle) {
return contentStyle === 'tabs' ? 'tabs' : 'list';
}
function getContentNode(item) {
if (!item) {
return null;
}
if (typeof item.render === 'function') {
return item.render();
}
if (item.component) {
const Component = item.component;
return <Component />;
}
if (item.content !== undefined) {
return item.content;
}
if (item.children !== undefined) {
return item.children;
}
return null;
}
export function SettingsPanel({
children,
content = [],
contentStyle = 'list',
title,
icon,
description = '',
defaultExpanded = true,
expanded: expandedProp,
onExpandedChange,
variant,
styleVariant,
toggleAlign = 'right',
headerRight = null,
bodyPadding = '$3',
persistenceKey = null
}) {
const effectiveVariant = normalizeVariant(variant, styleVariant);
const effectiveToggleAlign = normalizeToggleAlign(toggleAlign);
const effectiveContentStyle = normalizeContentStyle(contentStyle);
const isControlled = typeof expandedProp === 'boolean';
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);
const [hasLoadedPreference, setHasLoadedPreference] = useState(!persistenceKey || isControlled);
const [selectedContentId, setSelectedContentId] = useState(content[0]?.id || null);
const [hasLoadedTabPreference, setHasLoadedTabPreference] = useState(!persistenceKey || effectiveContentStyle !== 'tabs');
const hasWrittenInitialPreference = useRef(false);
const hasWrittenInitialTabPreference = useRef(false);
const expanded = isControlled ? expandedProp : internalExpanded;
const configKey = persistenceKey ? `settings.panel.${persistenceKey}.expanded` : null;
const tabConfigKey = persistenceKey ? `settings.panel.${persistenceKey}.selected-tab` : null;
useEffect(() => {
if (content.length === 0) {
setSelectedContentId(null);
return;
}
const matchingItem = content.find((item) => item.id === selectedContentId);
if (!matchingItem) {
setSelectedContentId(content[0].id);
}
}, [content, selectedContentId]);
useEffect(() => {
let cancelled = false;
async function loadPreference() {
if (!configKey || isControlled) {
return;
}
const savedValue = await getConfig(configKey, null);
if (!cancelled && typeof savedValue === 'boolean') {
setInternalExpanded(savedValue);
}
if (!cancelled) {
setHasLoadedPreference(true);
}
}
loadPreference();
return () => {
cancelled = true;
};
}, [configKey, isControlled]);
useEffect(() => {
let cancelled = false;
async function loadTabPreference() {
if (!tabConfigKey || effectiveContentStyle !== 'tabs') {
return;
}
const savedValue = await getConfig(tabConfigKey, null);
if (!cancelled && typeof savedValue === 'string' && content.some((item) => item.id === savedValue)) {
setSelectedContentId(savedValue);
}
if (!cancelled) {
setHasLoadedTabPreference(true);
}
}
loadTabPreference();
return () => {
cancelled = true;
};
}, [content, effectiveContentStyle, tabConfigKey]);
useEffect(() => {
if (!configKey || isControlled || !hasLoadedPreference) {
return;
}
if (!hasWrittenInitialPreference.current) {
hasWrittenInitialPreference.current = true;
return;
}
setConfig(configKey, expanded).catch((error) => {
console.warn(`[SettingsPanel] Failed to persist expanded state for ${configKey}:`, error);
});
}, [configKey, expanded, hasLoadedPreference, isControlled]);
useEffect(() => {
if (!tabConfigKey || effectiveContentStyle !== 'tabs' || !hasLoadedTabPreference || !selectedContentId) {
return;
}
if (!hasWrittenInitialTabPreference.current) {
hasWrittenInitialTabPreference.current = true;
return;
}
setConfig(tabConfigKey, selectedContentId).catch((error) => {
console.warn(`[SettingsPanel] Failed to persist selected tab for ${tabConfigKey}:`, error);
});
}, [effectiveContentStyle, hasLoadedTabPreference, selectedContentId, tabConfigKey]);
const ToggleIcon = useMemo(() => {
return getIcon(expanded ? 'chevron-down' : 'chevron-right');
}, [expanded]);
const handleToggle = () => {
const nextValue = !expanded;
if (!isControlled) {
setInternalExpanded(nextValue);
}
onExpandedChange?.(nextValue);
};
const toggleButton = (
<Button
key="settings-toggle"
chromeless
circular
size="$3"
aria-label={expanded ? `Collapse ${title}` : `Expand ${title}`}
onPress={handleToggle}
icon={ToggleIcon ? <ToggleIcon size={16} /> : undefined}
/>
);
const headerContent = (
<XStack
alignItems="center"
gap="$3"
width="100%"
minHeight={effectiveVariant === 'panel' ? 42 : 40}
>
{effectiveToggleAlign === 'left' ? toggleButton : null}
{icon ? (
<XStack
width={24}
height={24}
alignItems="center"
justifyContent="center"
flexShrink={0}
>
{renderHeaderIcon(icon, effectiveVariant === 'panel' ? '$accentColor' : '$color')}
</XStack>
) : null}
<XStack flex={1} minWidth={0} gap="$2" alignItems="baseline" flexWrap="wrap">
<Text fontWeight="700" fontSize={effectiveVariant === 'panel' ? '$4' : '$5'} color="$color">
{title}
</Text>
{description ? (
<Paragraph size="$2" color="$color" opacity={0.72} flex={1} minWidth={160}>
{description}
</Paragraph>
) : null}
</XStack>
{headerRight}
{effectiveToggleAlign !== 'left' ? toggleButton : null}
</XStack>
);
const renderedBody = (
<>
{children}
{content.length > 0 ? (
effectiveContentStyle === 'tabs' ? (
<Tabs
value={selectedContentId || content[0]?.id}
onValueChange={setSelectedContentId}
orientation="horizontal"
flexDirection="column"
gap="$3"
>
<Tabs.List
disablePassBorderRadius
backgroundColor="$backgroundStrong"
borderRadius="$4"
padding="$1"
gap="$1"
flexWrap="wrap"
>
{content.map((item) => (
<Tabs.Tab
key={item.id}
value={item.id}
borderRadius="$3"
backgroundColor="$background"
hoverStyle={{ backgroundColor: '$backgroundHover' }}
pressStyle={{ backgroundColor: '$backgroundPress' }}
>
<XStack alignItems="center" gap="$2">
{item.icon ? renderHeaderIcon(item.icon, '$accentColor') : null}
<Text fontWeight="700" color="$color">
{item.label || item.title}
</Text>
</XStack>
</Tabs.Tab>
))}
</Tabs.List>
{content.map((item) => (
<Tabs.Content key={item.id} value={item.id}>
<YStack
borderWidth={1}
borderColor="$borderColor"
borderRadius="$4"
backgroundColor="$background"
padding="$3"
gap="$3"
>
{getContentNode(item)}
</YStack>
</Tabs.Content>
))}
</Tabs>
) : (
<YStack gap="$3">
{content.map((item) => (
<SettingsPanel
key={item.id}
icon={item.icon || 'settings'}
title={item.label || item.title || item.id}
description={item.description || ''}
variant="panel"
defaultExpanded={item.defaultExpanded ?? false}
toggleAlign="right"
persistenceKey={item.persistenceKey}
>
{getContentNode(item)}
</SettingsPanel>
))}
</YStack>
)
) : null}
</>
);
if (effectiveVariant === 'panel') {
return (
<YStack
borderWidth={1}
borderColor="$borderColor"
borderRadius="$4"
backgroundColor="$background"
overflow="hidden"
>
<XStack
paddingHorizontal="$3"
paddingVertical="$1.5"
alignItems="center"
backgroundColor="$backgroundHover"
borderBottomWidth={expanded ? 1 : 0}
borderBottomColor="$borderColor"
>
{headerContent}
</XStack>
{expanded ? (
<YStack padding={bodyPadding} gap="$3">
{renderedBody}
</YStack>
) : null}
</YStack>
);
}
return (
<YStack gap="$2" paddingVertical="$1">
<Button
chromeless
onPress={handleToggle}
backgroundColor="transparent"
borderWidth={0}
padding={0}
justifyContent="flex-start"
hoverStyle={{ opacity: 0.9, backgroundColor: 'transparent' }}
pressStyle={{ opacity: 0.75, backgroundColor: 'transparent' }}
>
<YStack gap="$2">
{headerContent}
</YStack>
</Button>
{expanded ? (
<YStack paddingLeft={effectiveToggleAlign === 'left' ? '$7' : icon ? '$7' : '$1'} paddingRight="$1" gap="$3">
{renderedBody}
</YStack>
) : null}
<Separator borderColor="$borderColor" />
</YStack>
);
}
export default SettingsPanel;
+687
View File
@@ -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
+495
View File
@@ -0,0 +1,495 @@
/**
* SideBar Component
* Vertical navigation bar with three sections: topSide, middleSide, bottomSide
* Platform-agnostic using Tamagui components
* Responsive: Uses Adapt to switch between Wide and Narrow variants
*/
import React, { useMemo, useState, useEffect } from 'react';
import { XStack, YStack, Text, Image, Sheet, Button, useMedia } from 'tamagui';
import { View } from '@tamagui/core';
import { getConfig, setConfig, CONFIG_KEYS } from '../../platform/env.js';
import { getRootItem, subscribeToMenuChanges, getMenuVersion, MenuItem } from '../../platform/menu.js';
import { MenuItemButton } from './MenuItemButton.jsx';
import { PersonalMenuItem } from './PersonalMenuItem.jsx';
import { getIcon } from './IconMapper.jsx';
import { useShell } from './Shell.jsx';
import { securityService, useSecurityState } from '../../security/runtime/security-service.js';
/**
* Hook to track menu changes and force re-render
*/
function useMenuVersion() {
const [version, setVersion] = useState(() => getMenuVersion());
useEffect(() => {
const unsubscribe = subscribeToMenuChanges((newVersion) => {
setVersion(newVersion);
});
return () => {
unsubscribe();
};
}, []);
return version;
}
/**
* Shared logic for organizing children and menu items
* Used by both Wide and Narrow variants
*/
function useSideBarContent(children) {
// Subscribe to menu changes to force re-render when items are registered
const menuVersion = useMenuVersion();
const securityState = useSecurityState();
return useMemo(() => {
const security = {
...securityState,
isPermitted: (rights, resourcePath, options = {}) => securityService.isPermitted(rights, resourcePath, options)
};
// Get menu items directly from menu.js (ground truth)
const primaryRoot = getRootItem('primary');
const secondaryRoot = getRootItem('secondary');
const personalRoot = getRootItem('personal');
const primaryMenuItems = primaryRoot ? Array.from(primaryRoot.items.values()).filter((item) => item.isRenderable(security)) : [];
const secondaryMenuItems = secondaryRoot ? Array.from(secondaryRoot.items.values()).filter((item) => item.isRenderable(security)) : [];
const sections = {
topSide: [],
middleSide: [],
bottomSide: []
};
// First, add primary menu items to middleSide (scrollable area)
primaryMenuItems.forEach((item) => {
// Validate that item is a MenuItem instance
if (!(item instanceof MenuItem)) {
console.error('[SideBar] Expected MenuItem instance but got:', {
type: typeof item,
constructor: item?.constructor?.name,
item: item,
stack: new Error().stack
});
return; // Skip invalid items
}
sections.middleSide.push(
<MenuItemButton
key={item.id || item.path}
menuItem={item}
orientation="vertical"
stateVersion={menuVersion}
/>
);
});
// Then, sift through children
React.Children.forEach(children, (child) => {
if (!React.isValidElement(child)) {
sections.topSide.push(child);
return;
}
const placement = child.props?.placement || child.props?.sideBarPlacement || 'topSide';
switch (placement) {
case 'topSide':
case 'top':
default:
sections.topSide.push(child);
break;
case 'middleSide':
case 'middle':
sections.middleSide.push(child);
break;
case 'bottomSide':
case 'bottom':
sections.bottomSide.push(child);
break;
}
});
// Add secondary menu items to bottomSide
secondaryMenuItems.forEach((item) => {
// Validate that item is a MenuItem instance
if (!(item instanceof MenuItem)) {
console.error('[SideBar] Expected MenuItem instance but got:', {
type: typeof item,
constructor: item?.constructor?.name,
item: item,
stack: new Error().stack
});
return; // Skip invalid items
}
sections.bottomSide.push(
<MenuItemButton
key={item.id || item.path}
menuItem={item}
orientation="vertical"
stateVersion={menuVersion}
/>
);
});
// Add personal menu item as last element in bottomSide
if (personalRoot && personalRoot instanceof MenuItem) {
sections.bottomSide.push(
<PersonalMenuItem
key="personal-menu"
personalRoot={personalRoot}
orientation="vertical"
expand_mode="popup"
stateVersion={menuVersion}
/>
);
}
return {
sections,
primaryMenuItems,
secondaryMenuItems,
personalRoot,
menuVersion
};
}, [children, menuVersion, securityState]); // Include menuVersion to re-compute when menu changes
}
/**
* SideBar.Wide - Desktop/tablet wide layout
* Fixed vertical sidebar with all menu items visible
* Supports collapse/expand functionality
*/
function SideBarWide({
children,
topSideHeight = 0,
bottomSideHeight = 0,
expandedWidth = 250,
collapsedWidth = 80
}) {
const [brandLogo, setBrandLogo] = useState(null);
const [appName, setAppName] = useState(null);
useEffect(() => {
async function loadConfig() {
const logo = await getConfig(CONFIG_KEYS.BRAND_LOGO, null);
const name = await getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, null);
setBrandLogo(logo);
setAppName(name);
}
loadConfig();
}, []);
const organizedChildren = useSideBarContent(children);
const shell = useShell();
// Collapse/expand state
const [isCollapsed, setIsCollapsed] = useState(false);
// Load collapsed state from storage on mount
useEffect(() => {
async function loadCollapsedState() {
try {
const saved = await getConfig('sidebar.collapsed', false);
if (saved === true) {
setIsCollapsed(true);
}
} catch (error) {
console.warn('[SideBar] Failed to load collapsed state:', error);
}
}
loadCollapsedState();
}, []);
// Update Shell leftSideWidth when collapsed state changes
useEffect(() => {
if (shell && shell.setLeftSideWidth) {
shell.setLeftSideWidth(isCollapsed ? collapsedWidth : expandedWidth);
}
}, [isCollapsed, collapsedWidth, expandedWidth, shell]);
// Toggle collapse/expand
const handleToggle = async () => {
const newState = !isCollapsed;
setIsCollapsed(newState);
try {
await setConfig('sidebar.collapsed', newState);
} catch (error) {
console.warn('[SideBar] Failed to save collapsed state:', error);
}
};
const currentWidth = isCollapsed ? collapsedWidth : expandedWidth;
return (
<YStack
width={currentWidth}
height="100%"
gap="$2"
padding="$2"
backgroundColor="$accentSurface"
borderRightWidth={1}
borderRightColor="$accentBorder"
animation="quick"
animateOnly={['width']}
>
{/* Top Side */}
{topSideHeight > 0 && (
<XStack
height={topSideHeight}
width="100%"
alignItems="center"
gap="$2"
style={{ flexShrink: 0 }}
>
{organizedChildren.sections.topSide}
</XStack>
)}
{/* Brand Logo, App Name, and Toggle Button */}
<XStack
width="100%"
alignItems="center"
gap="$2"
paddingVertical="$2"
style={{ flexShrink: 0 }}
>
{brandLogo && (
<Image
source={{ uri: brandLogo }}
width={32}
height={32}
resizeMode="contain"
/>
)}
{appName && !isCollapsed && (
<Text fontWeight="bold" fontSize="$4" flex={1} color="$accentColor">
{appName}
</Text>
)}
{/* Toggle Button (chevron) - matches MenuItemButton chevron style */}
<XStack
cursor="pointer"
alignItems="center"
justifyContent="center"
padding="$1"
hoverStyle={{
backgroundColor: '$accentBackground'
}}
pressStyle={{
backgroundColor: '$accentHover'
}}
onPress={handleToggle}
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{(() => {
const ChevronIcon = getIcon(isCollapsed ? 'chevrons-right' : 'chevrons-left');
if (!ChevronIcon) return null;
return (
<ChevronIcon
size={16}
color="$accentColor"
style={{ flexShrink: 0 }}
/>
);
})()}
</XStack>
</XStack>
{/* Middle Side - Primary menu items and other content */}
<YStack
flex={1}
width="100%"
gap="$2"
style={{ flexShrink: 1, overflow: 'auto' }}
>
{React.Children.map(organizedChildren.sections.middleSide, (child) => {
if (React.isValidElement(child) && (child.type === MenuItemButton || child.type === PersonalMenuItem)) {
return React.cloneElement(child, { collapsed: isCollapsed });
}
return child;
})}
</YStack>
{/* Bottom Side - Secondary and Personal menu items (always shown if has content) */}
{organizedChildren.sections.bottomSide.length > 0 && (
<YStack
width="100%"
gap="$2"
alignItems="flex-start"
justifyContent="flex-end"
paddingTop="$2"
style={{ flexShrink: 0 }}
>
{React.Children.map(organizedChildren.sections.bottomSide, (child) => {
if (React.isValidElement(child) && (child.type === MenuItemButton || child.type === PersonalMenuItem)) {
return React.cloneElement(child, { collapsed: isCollapsed });
}
return child;
})}
</YStack>
)}
</YStack>
);
}
/**
* SideBar.Narrow - Mobile narrow layout
* Hamburger menu button + Sheet for menu items
*/
function SideBarNarrow({ children }) {
const [brandLogo, setBrandLogo] = useState(null);
const [appName, setAppName] = useState(null);
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
async function loadConfig() {
const logo = await getConfig(CONFIG_KEYS.BRAND_LOGO, null);
const name = await getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, null);
setBrandLogo(logo);
setAppName(name);
}
loadConfig();
}, []);
const organizedChildren = useSideBarContent(children);
return (
<>
<XStack
width="100%"
height="100%"
alignItems="center"
gap="$2"
padding="$2"
backgroundColor="$accentSurface"
borderBottomWidth={1}
borderBottomColor="$accentBorder"
>
{/* Hamburger Menu Button */}
<Button
size="$3"
circular
icon={getIcon('menu')}
backgroundColor="$accentBackground"
color="$accentColor"
onPress={() => setMenuOpen(true)}
/>
{/* Brand Logo */}
{brandLogo && (
<Image
source={{ uri: brandLogo }}
width={32}
height={32}
resizeMode="contain"
/>
)}
{/* App Name - takes remaining space */}
{appName && (
<Text fontWeight="bold" fontSize="$4" flex={1} numberOfLines={1} color="$accentColor">
{appName}
</Text>
)}
{/* Personal Menu (only on mobile) - render with horizontal orientation, right-aligned */}
{organizedChildren.personalRoot && organizedChildren.personalRoot instanceof MenuItem && (
<XStack flexShrink={0}>
<PersonalMenuItem
key="personal-menu"
personalRoot={organizedChildren.personalRoot}
orientation="horizontal"
expand_mode="popup"
width="auto"
stateVersion={organizedChildren.menuVersion}
/>
</XStack>
)}
</XStack>
{/* Mobile Menu Sheet */}
<Sheet
modal
open={menuOpen}
onOpenChange={setMenuOpen}
snapPoints={[85]}
dismissOnSnapToBottom
>
<Sheet.Overlay />
<Sheet.Handle />
<Sheet.Frame padding="$4" gap="$2">
<YStack gap="$2" width="100%">
{/* Primary Menu Items */}
{organizedChildren.primaryMenuItems.map((item) => (
<MenuItemButton
key={item.id || item.path}
menuItem={item}
orientation="vertical"
stateVersion={organizedChildren.menuVersion}
onClick={(e, menuItem) => {
// Execute the menu item action if it's actionable
if (menuItem && menuItem.isActionable()) {
menuItem.execute(e.target, e);
}
// Close the sheet after clicking
setMenuOpen(false);
}}
/>
))}
{/* Secondary Menu Items */}
{organizedChildren.secondaryMenuItems.map((item) => (
<MenuItemButton
key={item.id || item.path}
menuItem={item}
orientation="vertical"
stateVersion={organizedChildren.menuVersion}
onClick={(e, menuItem) => {
// Execute the menu item action if it's actionable
if (menuItem && menuItem.isActionable()) {
menuItem.execute(e.target, e);
}
// Close the sheet after clicking
setMenuOpen(false);
}}
/>
))}
</YStack>
</Sheet.Frame>
</Sheet>
</>
);
}
/**
* SideBar Component
* Responsive vertical navigation bar that adapts to screen size
* Uses Adapt to switch between Wide and Narrow variants
*
* @param {Object} props - Component props
* @param {React.ReactNode} props.children - Content to render (defaults to topSide)
* @param {number} [props.topSideHeight=0] - Top side height (default: 0, wide only)
* @param {number} [props.bottomSideHeight=0] - Bottom side height (default: 0, wide only)
*/
export function SideBar(props) {
const media = useMedia();
const isNarrow = !media.gtSm; // Below 801px (sm breakpoint)
// Use conditional rendering based on screen size
if (isNarrow) {
return <SideBarNarrow {...props} />;
}
return <SideBarWide {...props} />;
}
// Export subcomponents
SideBar.Wide = SideBarWide;
SideBar.Narrow = SideBarNarrow;
export default SideBar;
+142
View File
@@ -0,0 +1,142 @@
import React, { useEffect, useState } from 'react';
import { Button, ScrollView, Text, XStack, YStack } from 'tamagui';
import { getIcon } from './IconMapper.jsx';
function ActionButton({ action }) {
const IconComponent = action?.icon ? getIcon(action.icon) : null;
return (
<Button
size="$3"
theme={action?.theme}
chromeless={action?.chromeless}
disabled={action?.disabled}
onPress={action?.onPress}
icon={IconComponent ? <IconComponent size={16} /> : undefined}
>
{action?.label}
</Button>
);
}
export function SidePanelShell({
open = false,
onClose = null,
title = 'Panel',
toolbar = [],
footerActions = [],
width = 420,
children = null
}) {
const [mounted, setMounted] = useState(open);
useEffect(() => {
if (open) {
setMounted(true);
return undefined;
}
const timer = window.setTimeout(() => setMounted(false), 220);
return () => window.clearTimeout(timer);
}, [open]);
if (!mounted) {
return null;
}
const CloseIcon = getIcon('close');
return (
<YStack
position="fixed"
top={0}
right={0}
bottom={0}
left={0}
zIndex={18000}
pointerEvents="box-none"
>
<YStack
position="absolute"
top={0}
right={0}
bottom={0}
left={0}
backgroundColor="rgba(15,23,42,0.26)"
opacity={open ? 1 : 0}
animation="quick"
onPress={onClose}
/>
<YStack
position="absolute"
top={0}
right={0}
bottom={0}
width={width}
maxWidth="96vw"
backgroundColor="$background"
borderLeftWidth={1}
borderLeftColor="$borderColor"
shadowColor="$shadowColor"
shadowOpacity={0.18}
shadowRadius={20}
shadowOffset={{ width: -4, height: 0 }}
style={{
transform: open ? 'translateX(0)' : 'translateX(100%)',
transition: 'transform 220ms ease'
}}
>
<XStack
alignItems="center"
justifyContent="space-between"
padding="$4"
gap="$3"
borderBottomWidth={1}
borderBottomColor="$borderColor"
backgroundColor="$accentSurface"
>
<Text fontSize="$7" fontWeight="700" color="$accentColor" flex={1}>
{title}
</Text>
<XStack alignItems="center" gap="$2" flexWrap="wrap" justifyContent="flex-end">
{toolbar.map((action, index) => (
<ActionButton key={action?.id || action?.label || index} action={action} />
))}
<Button
size="$3"
circular
chromeless
onPress={onClose}
icon={CloseIcon ? <CloseIcon size={18} /> : undefined}
aria-label="Close panel"
/>
</XStack>
</XStack>
<ScrollView flex={1}>
<YStack padding="$4" gap="$4">
{children}
</YStack>
</ScrollView>
{footerActions.length > 0 ? (
<XStack
justifyContent="flex-end"
gap="$2"
padding="$4"
borderTopWidth={1}
borderTopColor="$borderColor"
backgroundColor="$accentSurface"
flexWrap="wrap"
>
{footerActions.map((action, index) => (
<ActionButton key={action?.id || action?.label || index} action={action} />
))}
</XStack>
) : null}
</YStack>
</YStack>
);
}
export default SidePanelShell;
+417
View File
@@ -0,0 +1,417 @@
/**
* TopBar Component
* Horizontal navigation bar with three sections: leftSide, middleSide, rightSide
* Platform-agnostic using Tamagui components
* Responsive: Uses Adapt to switch between Wide and Narrow variants
*/
import React, { useMemo, useState, useEffect } from 'react';
import { XStack, YStack, Text, Image, Sheet, Button, useMedia } from 'tamagui';
import { View } from '@tamagui/core';
import { getConfig, setConfig, CONFIG_KEYS } from '../../platform/env.js';
import { getRootItem, subscribeToMenuChanges, getMenuVersion, MenuItem } from '../../platform/menu.js';
import { MenuItemButton } from './MenuItemButton.jsx';
import { PersonalMenuItem } from './PersonalMenuItem.jsx';
import { getIcon } from './IconMapper.jsx';
import { securityService, useSecurityState } from '../../security/runtime/security-service.js';
/**
* Hook to track menu changes and force re-render
*/
function useMenuVersion() {
const [version, setVersion] = useState(() => getMenuVersion());
useEffect(() => {
const unsubscribe = subscribeToMenuChanges((newVersion) => {
setVersion(newVersion);
});
return () => {
unsubscribe();
};
}, []);
return version;
}
/**
* Shared logic for organizing children and menu items
* Used by both Wide and Narrow variants
*/
function useTopBarContent(children) {
// Subscribe to menu changes to force re-render when items are registered
const menuVersion = useMenuVersion();
const securityState = useSecurityState();
return useMemo(() => {
const security = {
...securityState,
isPermitted: (rights, resourcePath, options = {}) => securityService.isPermitted(rights, resourcePath, options)
};
// Get menu items directly from menu.js (ground truth)
const primaryRoot = getRootItem('primary');
const secondaryRoot = getRootItem('secondary');
const personalRoot = getRootItem('personal');
const primaryMenuItems = primaryRoot ? Array.from(primaryRoot.items.values()).filter((item) => item.isRenderable(security)) : [];
const secondaryMenuItems = secondaryRoot ? Array.from(secondaryRoot.items.values()).filter((item) => item.isRenderable(security)) : [];
const sections = {
leftSide: [],
middleSide: [],
rightSide: []
};
// First, add primary menu items to leftSide
primaryMenuItems.forEach((item) => {
// Validate that item is a MenuItem instance
if (!(item instanceof MenuItem)) {
console.error('[TopBar] Expected MenuItem instance but got:', {
type: typeof item,
constructor: item?.constructor?.name,
item: item,
stack: new Error().stack
});
return; // Skip invalid items
}
sections.leftSide.push(
<MenuItemButton
key={item.id || item.path}
menuItem={item}
orientation="horizontal"
stateVersion={menuVersion}
/>
);
});
// Then, sift through children
React.Children.forEach(children, (child) => {
if (!React.isValidElement(child)) {
sections.leftSide.push(child);
return;
}
const placement = child.props?.placement || child.props?.topBarPlacement || 'leftSide';
switch (placement) {
case 'middleSide':
case 'middle':
sections.middleSide.push(child);
break;
case 'rightSide':
case 'right':
sections.rightSide.push(child);
break;
case 'leftSide':
case 'left':
default:
sections.leftSide.push(child);
break;
}
});
// Add secondary menu items to rightSide (icon only)
secondaryMenuItems.forEach((item) => {
// Validate that item is a MenuItem instance
if (!(item instanceof MenuItem)) {
console.error('[TopBar] Expected MenuItem instance but got:', {
type: typeof item,
constructor: item?.constructor?.name,
item: item,
stack: new Error().stack
});
return; // Skip invalid items
}
sections.rightSide.push(
<MenuItemButton
key={item.id || item.path}
menuItem={item}
orientation="horizontal"
displayStyle="icon_only"
padding="$1"
stateVersion={menuVersion}
/>
);
});
// Add personal menu item as last element in rightSide
if (personalRoot && personalRoot instanceof MenuItem) {
sections.rightSide.push(
<PersonalMenuItem
key="personal-menu"
personalRoot={personalRoot}
orientation="horizontal"
expand_mode="popup"
stateVersion={menuVersion}
/>
);
}
return {
sections,
hasRightSideContent: sections.rightSide.length > 0,
primaryMenuItems,
secondaryMenuItems,
personalRoot,
menuVersion
};
}, [children, menuVersion, securityState]); // Include menuVersion to re-compute when menu changes
}
/**
* TopBar.Wide - Desktop/tablet wide layout
* Horizontal navigation bar with all menu items visible
*/
function TopBarWide({
children,
leftSideWidth = '100%',
middleSideWidth = 0,
rightSideWidth = 0
}) {
const [brandLogo, setBrandLogo] = useState(null);
const [appName, setAppName] = useState(null);
useEffect(() => {
async function loadConfig() {
const logo = await getConfig(CONFIG_KEYS.BRAND_LOGO, null);
const name = await getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, null);
setBrandLogo(logo);
setAppName(name);
}
loadConfig();
}, []);
const organizedChildren = useTopBarContent(children);
const effectiveRightWidth = rightSideWidth > 0 ? rightSideWidth : (organizedChildren.hasRightSideContent ? 'auto' : 0);
const hasMiddleOrRight = middleSideWidth > 0 || effectiveRightWidth > 0;
const leftUsesFlex = leftSideWidth === '100%' && !hasMiddleOrRight;
return (
<XStack
width="100%"
height="100%"
alignItems="center"
gap="$2"
padding="$2"
backgroundColor="$accentSurface"
borderBottomWidth={1}
borderBottomColor="$accentBorder"
>
{/* Left Side */}
<XStack
flex={leftUsesFlex ? 1 : 0}
width={leftUsesFlex ? undefined : (typeof leftSideWidth === 'number' ? leftSideWidth : '100%')}
height="100%"
alignItems="center"
gap="$2"
style={{ flexShrink: 0 }}
>
{/* Brand Logo */}
{brandLogo && (
<Image
source={{ uri: brandLogo }}
width={32}
height={32}
resizeMode="contain"
/>
)}
{/* App Name */}
{appName && (
<Text fontWeight="bold" fontSize="$4" color="$accentColor">
{appName}
</Text>
)}
{/* Left Side Items */}
{organizedChildren.sections.leftSide}
</XStack>
{/* Middle Side */}
{middleSideWidth > 0 && (
<XStack
width={middleSideWidth}
height="100%"
alignItems="center"
gap="$2"
style={{ flexShrink: 0 }}
>
{organizedChildren.sections.middleSide}
</XStack>
)}
{/* Right Side */}
{(effectiveRightWidth > 0 || effectiveRightWidth === 'auto') && (
<XStack
width={effectiveRightWidth === 'auto' ? undefined : effectiveRightWidth}
flex={effectiveRightWidth === 'auto' ? 0 : undefined}
height="100%"
alignItems="center"
justifyContent="flex-end"
gap="$1"
style={{ flexShrink: 0 }}
>
{organizedChildren.sections.rightSide}
</XStack>
)}
</XStack>
);
}
/**
* TopBar.Narrow - Mobile narrow layout
* Hamburger menu button + Sheet for menu items
*/
function TopBarNarrow({ children }) {
const [brandLogo, setBrandLogo] = useState(null);
const [appName, setAppName] = useState(null);
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
async function loadConfig() {
const logo = await getConfig(CONFIG_KEYS.BRAND_LOGO, null);
const name = await getConfig(CONFIG_KEYS.APP_DISPLAY_NAME, null);
setBrandLogo(logo);
setAppName(name);
}
loadConfig();
}, []);
const organizedChildren = useTopBarContent(children);
return (
<>
<XStack
width="100%"
height="100%"
alignItems="center"
gap="$2"
padding="$2"
backgroundColor="$accentSurface"
borderBottomWidth={1}
borderBottomColor="$accentBorder"
>
{/* Hamburger Menu Button */}
<Button
size="$3"
circular
icon={getIcon('menu')}
backgroundColor="$accentBackground"
color="$accentColor"
onPress={() => setMenuOpen(true)}
/>
{/* Brand Logo */}
{brandLogo && (
<Image
source={{ uri: brandLogo }}
width={32}
height={32}
resizeMode="contain"
/>
)}
{/* App Name - takes remaining space */}
{appName && (
<Text fontWeight="bold" fontSize="$4" flex={1} numberOfLines={1} color="$accentColor">
{appName}
</Text>
)}
{/* Secondary Menu Items - render in topbar, left of personal menu */}
{organizedChildren.secondaryMenuItems.length > 0 && (
<XStack flexShrink={0} alignItems="center" gap="$1">
{organizedChildren.secondaryMenuItems.map((item) => (
<MenuItemButton
key={item.id || item.path}
menuItem={item}
orientation="horizontal"
displayStyle="icon_only"
padding="$1"
stateVersion={organizedChildren.menuVersion}
/>
))}
</XStack>
)}
{/* Personal Menu (only on mobile) - render with horizontal orientation, right-aligned */}
{organizedChildren.personalRoot && organizedChildren.personalRoot instanceof MenuItem && (
<XStack flexShrink={0}>
<PersonalMenuItem
key="personal-menu"
personalRoot={organizedChildren.personalRoot}
orientation="horizontal"
expand_mode="popup"
width="auto"
stateVersion={organizedChildren.menuVersion}
/>
</XStack>
)}
</XStack>
{/* Mobile Menu Sheet */}
<Sheet
modal
open={menuOpen}
onOpenChange={setMenuOpen}
snapPoints={[85]}
dismissOnSnapToBottom
>
<Sheet.Overlay />
<Sheet.Handle />
<Sheet.Frame padding="$4" gap="$2">
<YStack gap="$2" width="100%">
{/* Primary Menu Items - render with vertical orientation in Sheet */}
{organizedChildren.primaryMenuItems.map((item) => (
<MenuItemButton
key={item.id || item.path}
menuItem={item}
orientation="vertical"
stateVersion={organizedChildren.menuVersion}
onClick={(e, menuItem) => {
// Execute the menu item action if it's actionable
if (menuItem && menuItem.isActionable()) {
menuItem.execute(e.target, e);
}
// Close the sheet after clicking
setMenuOpen(false);
}}
/>
))}
</YStack>
</Sheet.Frame>
</Sheet>
</>
);
}
/**
* TopBar Component
* Responsive navigation bar that adapts to screen size
* Uses Adapt to switch between Wide and Narrow variants
*
* @param {Object} props - Component props
* @param {React.ReactNode} props.children - Content to render (defaults to leftSide)
* @param {number} [props.leftSideWidth='100%'] - Left side width (default: '100%', wide only)
* @param {number} [props.middleSideWidth=0] - Middle side width (default: 0, wide only)
* @param {number} [props.rightSideWidth=0] - Right side width (default: 0, wide only)
*/
export function TopBar(props) {
const media = useMedia();
const isNarrow = !media.gtSm; // Below 801px (sm breakpoint)
// Use conditional rendering based on screen size
if (isNarrow) {
return <TopBarNarrow {...props} />;
}
return <TopBarWide {...props} />;
}
// Export subcomponents
TopBar.Wide = TopBarWide;
TopBar.Narrow = TopBarNarrow;
export default TopBar;
+290
View File
@@ -0,0 +1,290 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { GridViewContext } from './context.js';
import { GridSegmentsLayout } from './layout.jsx';
import {
areSortEntriesEqual,
normalizeColumnDefinition,
normalizeColumnDefinitionsInput,
resolveVisibleColumns
} from './utils.js';
export function GridView({
dataModel = null,
columns = undefined,
direction = 'vertical',
header = null,
body = null,
footer = null,
headerSize = 'auto',
bodySize = 'auto',
footerSize = 'auto',
visible = true,
model = null,
columnDefinitions = {},
statusText = '',
selectable = false,
nested = false,
onClose = undefined,
onReload = undefined,
initialPageSize = 6,
initialFilterBy = {},
filterBy: controlledFilterBy = undefined,
onFilterByChange = undefined,
initialSortBy = [],
...layoutProps
}) {
const [rows, setRows] = useState([]);
const [total, setTotal] = useState(0);
const [offset, setOffset] = useState(0);
const [pageSize] = useState(initialPageSize);
const [sortBy, setSortBy] = useState(initialSortBy);
const [internalFilterBy, setInternalFilterBy] = useState(initialFilterBy);
const [structure, setStructure] = useState({ columns: {} });
const [selectedIds, setSelectedIds] = useState(() => new Set());
const [reloadTick, setReloadTick] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const [tableViewportWidth, setTableViewportWidth] = useState(0);
const effectiveModel = dataModel ?? model;
const effectiveColumnDefinitions = useMemo(
() => normalizeColumnDefinitionsInput(columns ?? columnDefinitions),
[columns, columnDefinitions]
);
const effectiveFilterBy = controlledFilterBy ?? internalFilterBy;
const setFilterBy = useCallback((nextValue) => {
const nextFilterBy =
typeof nextValue === 'function' ? nextValue(effectiveFilterBy) : nextValue;
if (controlledFilterBy !== undefined) {
onFilterByChange?.(nextFilterBy);
return;
}
setInternalFilterBy(nextFilterBy);
}, [controlledFilterBy, effectiveFilterBy, onFilterByChange]);
useEffect(() => {
setOffset(0);
}, [effectiveFilterBy]);
useEffect(() => {
let active = true;
async function loadStructure() {
if (!effectiveModel?.queryStructure) {
setStructure({ columns: {} });
return;
}
try {
const nextStructure = await effectiveModel.queryStructure();
if (active) {
setStructure(nextStructure || { columns: {} });
}
} catch (structureError) {
if (active) {
setError(String(structureError?.message || structureError));
}
}
}
loadStructure();
return () => {
active = false;
};
}, [effectiveModel]);
useEffect(() => {
let active = true;
async function loadRows() {
if (!effectiveModel?.queryRecords) {
setRows([]);
setTotal(0);
return;
}
setIsLoading(true);
setError('');
try {
const response = await effectiveModel.queryRecords({
offset,
page_size: pageSize,
sort_by: sortBy,
filter_by: effectiveFilterBy
});
if (!active) {
return;
}
setRows(response?.rows || []);
setTotal(response?.total || 0);
} catch (recordsError) {
if (active) {
setRows([]);
setTotal(0);
setError(String(recordsError?.message || recordsError));
}
} finally {
if (active) {
setIsLoading(false);
}
}
}
loadRows();
return () => {
active = false;
};
}, [effectiveFilterBy, effectiveModel, offset, pageSize, reloadTick, sortBy]);
const resolvedColumns = useMemo(() => {
const sourceColumns = structure?.columns || {};
const fieldOrder = Array.from(
new Set([...Object.keys(sourceColumns), ...Object.keys(effectiveColumnDefinitions || {})])
);
const rowsSample = rows[0] || {};
return fieldOrder.map((field) =>
normalizeColumnDefinition(
field,
{ ...(sourceColumns[field] || {}), ...(effectiveColumnDefinitions[field] || {}) },
rowsSample[field]
)
);
}, [effectiveColumnDefinitions, rows, structure]);
const visibleColumns = useMemo(
() => resolveVisibleColumns(resolvedColumns, tableViewportWidth),
[resolvedColumns, tableViewportWidth]
);
useEffect(() => {
const visibleFields = new Set(visibleColumns.map((column) => column.field));
setSortBy((current) => {
const next = current.filter((entry) => visibleFields.has(entry.field));
return areSortEntriesEqual(current, next) ? current : next;
});
}, [visibleColumns]);
const pageCount = Math.max(1, Math.ceil((total || 0) / pageSize));
const currentPage = Math.min(pageCount, Math.floor(offset / pageSize) + 1);
const resolvedStatusText =
statusText ||
(error
? `Error: ${error}`
: isLoading
? `Loading records ${offset + 1} to ${Math.min(offset + pageSize, total || offset + pageSize)}...`
: `${total} records available`);
const contextValue = useMemo(
() => ({
model: effectiveModel,
rows,
total,
offset,
pageSize,
pageCount,
currentPage,
sortBy,
setSortBy,
filterBy: effectiveFilterBy,
setFilterBy,
structure,
resolvedColumns,
visibleColumns,
selectedIds,
selectable,
nested,
isLoading,
error,
tableViewportWidth,
setTableViewportWidth,
statusText: resolvedStatusText,
reload: async () => {
onReload?.();
setReloadTick((current) => current + 1);
},
close: () => onClose?.(),
setPage: (pageNumber) => {
const normalizedPage = Math.max(1, Math.min(pageCount, pageNumber));
setOffset((normalizedPage - 1) * pageSize);
},
toggleSort: (field) => {
const current = sortBy.find((entry) => entry.field === field);
if (!current) {
setSortBy([{ field, direction: 'asc' }]);
return;
}
if (current.direction === 'asc') {
setSortBy([{ field, direction: 'desc' }]);
return;
}
setSortBy([]);
},
toggleSelectRow: (rowId) => {
setSelectedIds((current) => {
const next = new Set(current);
if (next.has(rowId)) {
next.delete(rowId);
} else {
next.add(rowId);
}
return next;
});
},
setFilterValue: (key, value) => {
setOffset(0);
setFilterBy((current) => ({
...current,
[key]: value
}));
}
}),
[
currentPage,
effectiveFilterBy,
error,
isLoading,
effectiveModel,
nested,
offset,
onClose,
onReload,
pageCount,
pageSize,
resolvedColumns,
resolvedStatusText,
rows,
selectable,
selectedIds,
sortBy,
structure,
tableViewportWidth,
total,
setFilterBy,
visibleColumns
]
);
return (
<GridViewContext.Provider value={contextValue}>
<GridSegmentsLayout
direction={direction}
header={header}
body={body}
footer={footer}
headerSize={headerSize}
bodySize={bodySize}
footerSize={footerSize}
visible={visible}
{...layoutProps}
/>
</GridViewContext.Provider>
);
}
export default GridView;
+110
View File
@@ -0,0 +1,110 @@
# Grid Components
The `grid/` package provides a composable dataset presentation primitive for `bface`.
## Mental Model
- `DirView` is the opinionated, fast-start directory component.
- `GridView` is the more composable shell for datasets that may need alternate presentations such as table and panel/card layouts.
They are intentionally related, and their data-facing APIs are aligned where practical:
- both accept `dataModel`
- both accept `columns`
- both support column-level renderers
- both support searching, sorting, and paging
- both can use action collections in a similar style (`toolbarItems` / `actions`)
`GridView` differs in one major way: instead of owning one fixed rendering pattern, it composes `header`, `body`, and `footer` subviews around a shared grid context.
## Exports
- `GridDataModel`
- `GridView`
- `GridSegmentsLayout`
- `PanelHeader`
- `PanelBodyView`
- `PanelFooter`
- `TableHeader`
- `TableBodyView`
- `TableFooter`
- `createPanelGridViewProps`
- `createTableGridViewProps`
## API Alignment With DirView
### Shared data props
Both `DirView` and `GridView` should prefer:
```jsx
dataModel={model}
columns={columns}
```
For compatibility, `GridView` still accepts `model` and `columnDefinitions`.
### Search and action alignment
`DirView` now supports both simple and more controlled usage:
- `searchConfig` for the built-in search input
- `searchValue` and `onSearchChange` for controlled search state
- `toolbarItems` or `actions` as aliases for `toolbarActions`
This keeps `DirView` closer to the way `GridView` is typically composed, while still preserving its more opinionated out-of-the-box behavior.
### Column shape
Both components tolerate either:
- array columns using `id`
- array columns using `field`
- object maps keyed by field name
Both also tolerate either:
- `render`
- `renderer`
for cell-level custom rendering.
## Example
```jsx
import {
GridDataModel,
GridView,
PanelHeader,
PanelBodyView,
PanelFooter,
createPanelGridViewProps
} from '@reliancy/bface/ui/components';
const model = new GridDataModel({
rows: [
{ id: 1, customer: 'Northwind', total: 1200 },
{ id: 2, customer: 'Blue Harbor', total: 980 }
],
columns: {
customer: { label: 'Customer', alwaysVisible: true },
total: { label: 'Total', type: 'currency', align: 'right' }
}
});
<GridView
dataModel={model}
columns={{
total: { type: 'currency', currency: 'USD' }
}}
header={<PanelHeader title="Customers" />}
body={<PanelBodyView />}
footer={<PanelFooter />}
{...createPanelGridViewProps()}
/>;
```
## When To Use What
- Use `DirView` when you want a straightforward directory/table with summaries and a minimal API.
- Use `GridView` when the same dataset may need different bodies or shell arrangements, or when you want to compose the table/panel fragments yourself.
+12
View File
@@ -0,0 +1,12 @@
import { createContext, useContext } from 'react';
export const GridViewContext = createContext(null);
export function useGridView() {
const context = useContext(GridViewContext);
if (!context) {
throw new Error('GridView subcomponents must be rendered inside GridView.');
}
return context;
}
+13
View File
@@ -0,0 +1,13 @@
export { GridView, default as GridViewDefault } from './GridView.jsx';
export { GridDataModel } from './model.js';
export { useGridView } from './context.js';
export { GridSegmentsLayout } from './layout.jsx';
export {
PanelHeader,
PanelBodyView,
PanelFooter,
PanelFooterStatusBar,
PanelToolBar
} from './panel.jsx';
export { TableHeader, TableBodyView, TableFooter } from './table.jsx';
export { createPanelGridViewProps, createTableGridViewProps } from './presets.js';
+115
View File
@@ -0,0 +1,115 @@
import React from 'react';
import { XStack, YStack } from 'tamagui';
function resolveSegmentLayout(direction, size, { isFlexible = false } = {}) {
const shared = {
minWidth: 0,
minHeight: 0
};
if (size == null || size === 'auto') {
return isFlexible
? { ...shared, flex: 1 }
: { ...shared, flexShrink: 0 };
}
if (typeof size === 'number') {
if (size > 0 && size <= 1) {
return {
...shared,
flex: size,
flexBasis: 0
};
}
if (direction === 'vertical') {
return {
...shared,
flexShrink: 0,
height: `${size}rem`
};
}
return {
...shared,
flexShrink: 0,
width: `${size}rem`
};
}
if (direction === 'vertical') {
return {
...shared,
flexShrink: 0,
height: size
};
}
return {
...shared,
flexShrink: 0,
width: size
};
}
function SegmentContainer({ direction, segmentKey, size, children }) {
if (children == null) {
return null;
}
return (
<YStack
key={segmentKey}
overflow="hidden"
{...resolveSegmentLayout(direction, size, { isFlexible: segmentKey === 'body' })}
>
{children}
</YStack>
);
}
export function GridSegmentsLayout({
direction = 'vertical',
header = null,
body = null,
footer = null,
headerSize = 'auto',
bodySize = 'auto',
footerSize = 'auto',
visible = true,
...stackProps
}) {
if (visible === false) {
return null;
}
const StackComponent = direction === 'horizontal' ? XStack : YStack;
const rootLayoutProps = {
width: '100%',
minWidth: 0,
minHeight: 0
};
if (
stackProps.height != null ||
stackProps.maxHeight != null ||
stackProps.minHeight != null ||
stackProps.flex != null
) {
rootLayoutProps.height = stackProps.height ?? '100%';
}
return (
<StackComponent {...rootLayoutProps} {...stackProps}>
<SegmentContainer direction={direction} segmentKey="header" size={headerSize}>
{header}
</SegmentContainer>
<SegmentContainer direction={direction} segmentKey="body" size={bodySize}>
{body}
</SegmentContainer>
<SegmentContainer direction={direction} segmentKey="footer" size={footerSize}>
{footer}
</SegmentContainer>
</StackComponent>
);
}
+123
View File
@@ -0,0 +1,123 @@
import {
compareValues,
getColumnKeysFromRows,
normalizeColumnDefinition
} from './utils.js';
export class GridDataModel {
constructor({ rows = [], columns = {}, latency = 0 } = {}) {
this.rows = rows;
this.columns = columns;
this.latency = latency;
}
async queryStructure() {
const sampleRow = this.rows[0] || {};
const inferredFields = getColumnKeysFromRows(this.rows);
const fields = inferredFields.length ? inferredFields : Object.keys(this.columns || {});
const resolvedColumns = {};
for (const field of fields) {
resolvedColumns[field] = normalizeColumnDefinition(
field,
this.columns[field],
sampleRow[field]
);
}
return { columns: resolvedColumns };
}
filterRows(rows, filterBy = {}) {
const filters = Object.entries(filterBy || {}).filter(
([, value]) => value !== null && value !== undefined && value !== ''
);
if (!filters.length) {
return rows;
}
return rows.filter((row) =>
filters.every(([field, value]) => {
if (field === 'search') {
const haystack = Object.values(row || {}).join(' ').toLowerCase();
return haystack.includes(String(value).trim().toLowerCase());
}
return String(row?.[field] ?? '')
.toLowerCase()
.includes(String(value).trim().toLowerCase());
})
);
}
sortRows(rows, sortBy = []) {
const activeSorts = Array.isArray(sortBy)
? sortBy.filter((entry) => entry?.field && entry?.direction)
: [];
if (!activeSorts.length) {
return rows;
}
return [...rows].sort((leftRow, rightRow) => {
for (const sort of activeSorts) {
const result = compareValues(
leftRow?.[sort.field],
rightRow?.[sort.field],
sort.direction
);
if (result !== 0) {
return result;
}
}
return 0;
});
}
async queryRecords({ offset = 0, page_size = 10, sort_by = [], filter_by = {} } = {}) {
const filteredRows = this.filterRows(this.rows, filter_by);
const sortedRows = this.sortRows(filteredRows, sort_by);
const rows = sortedRows.slice(offset, offset + page_size);
if (this.latency) {
await new Promise((resolve) => window.setTimeout(resolve, this.latency));
}
return {
rows,
total: sortedRows.length,
offset,
page_size
};
}
async queryAggregate({ metric, field, filter_by = {} } = {}) {
const filteredRows = this.filterRows(this.rows, filter_by);
if (metric === 'count') {
return filteredRows.length;
}
if (metric === 'sum' && field) {
return filteredRows.reduce((sum, row) => sum + (Number(row?.[field]) || 0), 0);
}
return null;
}
async queryAggregates({ metrics = [], filter_by = {} } = {}) {
const result = {};
for (const metric of metrics) {
if (typeof metric === 'string' && metric.startsWith('sum:')) {
const field = metric.slice(4);
result[metric] = await this.queryAggregate({ metric: 'sum', field, filter_by });
} else {
result[metric] = await this.queryAggregate({ metric, filter_by });
}
}
return result;
}
}
+313
View File
@@ -0,0 +1,313 @@
import React from 'react';
import { Button, Checkbox, Input, Paragraph, ScrollView, Text, XStack, YStack } from 'tamagui';
import { getIcon } from '../IconMapper.jsx';
import { useGridView } from './context.js';
import { formatValueByColumn } from './utils.js';
function renderToolbarItem(item) {
if (!item) {
return null;
}
if (React.isValidElement(item)) {
return item;
}
if (item.kind === 'button') {
const IconComponent = item.icon ? getIcon(item.icon) : null;
return (
<Button
key={item.key || item.label}
size="$3"
theme={item.theme}
chromeless={item.chromeless}
disabled={item.disabled}
icon={IconComponent ? <IconComponent size={16} /> : undefined}
onPress={item.onClick || item.onPress}
>
{item.label}
</Button>
);
}
if (item.kind === 'text') {
return (
<Text key={item.key || item.text} color="$color" opacity={0.7}>
{item.text}
</Text>
);
}
if (item.kind === 'search') {
return (
<Input
key={item.key || item.placeholder || 'search'}
width={item.width || 240}
value={item.value}
placeholder={item.placeholder || 'Search'}
onChangeText={(value) => item.onChange?.(value)}
/>
);
}
if (item.kind === 'node') {
return <React.Fragment key={item.key || 'node'}>{item.node}</React.Fragment>;
}
return <React.Fragment key={item.key || 'item'}>{item}</React.Fragment>;
}
function DefaultPanelRecordRenderer({ row }) {
const grid = useGridView();
const titleColumn =
grid.resolvedColumns.find((column) =>
['customer', 'name', 'title', 'label'].includes(column.field)
) ||
grid.resolvedColumns.find((column) => column.type === 'text') ||
grid.resolvedColumns[0];
const subtitleColumn = grid.resolvedColumns.find((column) =>
['description', 'region', 'owner', 'status'].includes(column.field)
);
const summaryColumns = grid.resolvedColumns.filter(
(column) => ![titleColumn?.field, subtitleColumn?.field, 'description'].includes(column.field)
);
return (
<YStack gap="$3">
<YStack gap="$1">
<Text fontSize="$3" letterSpacing={1} textTransform="uppercase" color="$accentColor">
Record Summary
</Text>
<Text fontSize="$6" fontWeight="700">
{titleColumn ? row?.[titleColumn.field] : row?.id}
</Text>
{subtitleColumn ? (
<Paragraph color="$color" opacity={0.7}>
{row?.[subtitleColumn.field] || ''}
</Paragraph>
) : null}
</YStack>
<XStack gap="$2" flexWrap="wrap">
{summaryColumns.slice(0, 3).map((column) => (
<YStack
key={`${row.id}-${column.field}-chip`}
paddingHorizontal="$3"
paddingVertical="$2"
borderRadius="$6"
backgroundColor="$accentSurface"
borderWidth={1}
borderColor="$accentBorder"
>
<Text fontSize="$2" color="$color" opacity={0.65}>
{column.label}
</Text>
<Text fontSize="$4" fontWeight="600">
{formatValueByColumn(row?.[column.field], column)}
</Text>
</YStack>
))}
</XStack>
<XStack gap="$3" flexWrap="wrap">
{summaryColumns.map((column) => (
<YStack
key={`${row.id}-${column.field}`}
minWidth={160}
flex={1}
padding="$3"
borderRadius="$4"
borderWidth={1}
borderColor="$borderColor"
backgroundColor="$background"
gap="$1"
>
<Text fontSize="$3" color="$color" opacity={0.65}>
{column.label}
</Text>
<Text>{formatValueByColumn(row?.[column.field], column)}</Text>
</YStack>
))}
</XStack>
</YStack>
);
}
export function PanelToolBar({ items = [], visible = true }) {
if (visible === false) {
return null;
}
return (
<XStack gap="$2" alignItems="center" justifyContent="flex-end" flexWrap="wrap">
{items.map((item, index) => (
<React.Fragment key={item?.key || item?.label || item?.text || index}>
{renderToolbarItem(item)}
</React.Fragment>
))}
</XStack>
);
}
export function PanelFooterStatusBar({ text, visible = true }) {
const grid = useGridView();
if (visible === false) {
return null;
}
return (
<Text color="$color" opacity={0.7}>
{text || grid.statusText}
</Text>
);
}
export function PanelHeader({ title, toolbarItems = [], visible = true, showDivider = true }) {
const grid = useGridView();
const RefreshIcon = getIcon('refresh');
const CloseIcon = getIcon('close');
if (visible === false) {
return null;
}
return (
<XStack
alignItems="center"
justifyContent="space-between"
gap="$3"
padding="$3"
minHeight={64}
borderBottomWidth={showDivider ? 1 : 0}
borderBottomColor="$borderColor"
backgroundColor="$accentSurface"
>
<Text fontSize="$6" fontWeight="700" color="$accentColor">
{title}
</Text>
<XStack gap="$2" alignItems="center" flexWrap="wrap" justifyContent="flex-end">
<PanelToolBar items={toolbarItems} />
<Button
size="$3"
chromeless
circular
icon={RefreshIcon ? <RefreshIcon size={16} /> : undefined}
onPress={grid.reload}
/>
<Button
size="$3"
chromeless
circular
disabled={!grid.close}
icon={CloseIcon ? <CloseIcon size={16} /> : undefined}
onPress={grid.close}
/>
</XStack>
</XStack>
);
}
export function PanelFooter({ toolbarItems = [], visible = true }) {
if (visible === false) {
return null;
}
return (
<XStack
alignItems="center"
justifyContent="space-between"
gap="$3"
padding="$3"
minHeight={56}
borderTopWidth={1}
borderTopColor="$borderColor"
backgroundColor="$background"
flexWrap="wrap"
>
<PanelFooterStatusBar />
<PanelToolBar items={toolbarItems} />
</XStack>
);
}
export function PanelBodyView({
visible = true,
recordRenderer: RecordRenderer = DefaultPanelRecordRenderer,
columns = 2
}) {
const grid = useGridView();
if (visible === false) {
return null;
}
if (grid.error) {
return (
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
<Text color="#b91c1c">{grid.error}</Text>
</YStack>
);
}
if (grid.isLoading && !grid.rows.length) {
return (
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
<Text color="$color" opacity={0.7}>Loading cards...</Text>
</YStack>
);
}
if (!grid.rows.length) {
return (
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
<Text color="$color" opacity={0.7}>No records available.</Text>
</YStack>
);
}
const responsiveColumns = typeof columns === 'number' ? columns : 2;
return (
<ScrollView flex={1}>
<YStack padding="$4" gap="$3">
<XStack gap="$3" flexWrap="wrap">
{grid.rows.map((row) => (
<YStack
key={row.id}
minWidth={responsiveColumns > 1 ? 320 : 240}
flex={1}
flexBasis={responsiveColumns > 1 ? '48%' : '100%'}
padding="$4"
borderWidth={1}
borderColor={grid.selectedIds.has(row.id) ? '$accentBorder' : '$borderColor'}
backgroundColor={grid.selectedIds.has(row.id) ? '$accentSurface' : '$background'}
borderRadius="$5"
gap="$3"
>
{grid.selectable ? (
<XStack justifyContent="flex-start">
<Checkbox
checked={grid.selectedIds.has(row.id)}
onCheckedChange={() => grid.toggleSelectRow(row.id)}
>
<Checkbox.Indicator />
</Checkbox>
</XStack>
) : null}
<RecordRenderer row={row} />
</YStack>
))}
</XStack>
{grid.isLoading ? (
<Text color="$color" opacity={0.7}>
Refreshing records...
</Text>
) : null}
</YStack>
</ScrollView>
);
}
+20
View File
@@ -0,0 +1,20 @@
export function createPanelGridViewProps(overrides = {}) {
return {
direction: 'vertical',
headerSize: 4.25,
bodySize: 18,
footerSize: 3.5,
...overrides
};
}
export function createTableGridViewProps(overrides = {}) {
return {
direction: 'vertical',
headerSize: 3.25,
bodySize: 20,
footerSize: 3.5,
...overrides
};
}
+256
View File
@@ -0,0 +1,256 @@
import React, { useEffect, useRef } from 'react';
import { Button, Checkbox, ScrollView, Separator, Text, XStack, YStack } from 'tamagui';
import { getIcon } from '../IconMapper.jsx';
import { useGridView } from './context.js';
import {
formatValueByColumn,
getColumnJustify,
getColumnLayoutStyle,
resolveCellAlignment,
resolveCellValue
} from './utils.js';
function DefaultGridCellRenderer({ value, column }) {
return (
<Text width="100%" textAlign={column.align || 'left'} opacity={value == null || value === '' ? 0.6 : 1}>
{formatValueByColumn(value, column)}
</Text>
);
}
function UtilityCell({ rowId }) {
const grid = useGridView();
const ChevronRightIcon = getIcon('chevron-right');
if (!grid.selectable && !grid.nested) {
return null;
}
if (grid.nested) {
return (
<XStack width={36} alignItems="center" justifyContent="center">
{ChevronRightIcon ? <ChevronRightIcon size={16} /> : <Text>{'>'}</Text>}
</XStack>
);
}
return (
<XStack width={36} alignItems="center" justifyContent="center">
<Checkbox checked={grid.selectedIds.has(rowId)} onCheckedChange={() => grid.toggleSelectRow(rowId)}>
<Checkbox.Indicator />
</Checkbox>
</XStack>
);
}
function useViewportTracking(enabled = true) {
const grid = useGridView();
const bodyRef = useRef(null);
useEffect(() => {
if (!enabled || typeof window === 'undefined') {
return undefined;
}
const updateViewportWidth = () => {
const element = bodyRef.current;
if (!element) {
return;
}
const nextWidth = Math.max(0, Math.round(element.getBoundingClientRect().width));
grid.setTableViewportWidth((current) => (current === nextWidth ? current : nextWidth));
};
updateViewportWidth();
window.addEventListener('resize', updateViewportWidth);
return () => {
window.removeEventListener('resize', updateViewportWidth);
};
}, [enabled, grid]);
return bodyRef;
}
export function TableHeader({ visible = true, showTopBorder = true }) {
const grid = useGridView();
if (visible === false) {
return null;
}
const activeColumns = grid.visibleColumns?.length ? grid.visibleColumns : grid.resolvedColumns;
return (
<XStack
alignItems="stretch"
borderTopWidth={showTopBorder ? 1 : 0}
borderBottomWidth={1}
borderColor="$accentBorder"
backgroundColor="$accentSurface"
paddingHorizontal="$2"
>
{grid.selectable || grid.nested ? <XStack width={36} /> : null}
{activeColumns.map((column) => {
const activeSort = grid.sortBy.find((entry) => entry.field === column.field);
const sortLabel =
activeSort?.direction === 'asc' ? '↑' : activeSort?.direction === 'desc' ? '↓' : '';
return (
<Button
key={column.field}
chromeless
disabled={!column.sortable}
onPress={() => column.sortable && grid.toggleSort(column.field)}
justifyContent={getColumnJustify(column.align)}
alignItems="center"
paddingVertical="$3"
paddingHorizontal="$2"
{...getColumnLayoutStyle(column)}
>
<Text width="100%" textAlign={column.align || 'left'} fontWeight="700">
{column.label}{sortLabel ? ` ${sortLabel}` : ''}
</Text>
</Button>
);
})}
</XStack>
);
}
export function TableBodyView({ visible = true }) {
const grid = useGridView();
const bodyRef = useViewportTracking(visible);
if (visible === false) {
return null;
}
const activeColumns = grid.visibleColumns?.length ? grid.visibleColumns : grid.resolvedColumns;
if (grid.error) {
return (
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
<Text color="#b91c1c">{grid.error}</Text>
</YStack>
);
}
return (
<ScrollView flex={1}>
<YStack ref={bodyRef}>
{grid.rows.map((row, index) => (
<YStack key={row.id ?? index}>
<XStack
alignItems="stretch"
paddingHorizontal="$2"
backgroundColor={grid.selectedIds.has(row.id) ? '$accentSurface' : '$background'}
>
{grid.selectable || grid.nested ? <UtilityCell rowId={row.id} /> : null}
{activeColumns.map((column) => {
const Renderer = column.renderer || DefaultGridCellRenderer;
const cellValue = resolveCellValue(row, column);
return (
<XStack
key={`${row.id}-${column.field}`}
alignItems="center"
justifyContent={getColumnJustify(resolveCellAlignment(column))}
paddingVertical="$3"
paddingHorizontal="$2"
minHeight={46}
{...getColumnLayoutStyle(column)}
>
<Renderer value={cellValue} row={row} column={column} grid={grid} />
</XStack>
);
})}
</XStack>
<Separator />
</YStack>
))}
{!grid.rows.length && !grid.isLoading ? (
<YStack minHeight={120} alignItems="center" justifyContent="center" padding="$5">
<Text color="$color" opacity={0.7}>No records available.</Text>
</YStack>
) : null}
{grid.isLoading ? (
<YStack minHeight={64} alignItems="center" justifyContent="center" padding="$4">
<Text color="$color" opacity={0.7}>Loading...</Text>
</YStack>
) : null}
</YStack>
</ScrollView>
);
}
export function TableFooter({ visible = true }) {
const grid = useGridView();
const FirstPageIcon = getIcon('first-page');
const PreviousPageIcon = getIcon('chevron-left');
const NextPageIcon = getIcon('chevron-right');
const LastPageIcon = getIcon('last-page');
if (visible === false) {
return null;
}
return (
<XStack
alignItems="center"
justifyContent="space-between"
gap="$3"
padding="$3"
minHeight={56}
borderTopWidth={1}
borderTopColor="$borderColor"
backgroundColor="$background"
flexWrap="wrap"
>
<Text color="$color" opacity={0.7}>
{grid.total} records
</Text>
<XStack gap="$1" alignItems="center" flexWrap="wrap">
<Button
size="$3"
chromeless
circular
disabled={grid.currentPage <= 1}
icon={FirstPageIcon ? <FirstPageIcon size={16} /> : undefined}
onPress={() => grid.setPage(1)}
/>
<Button
size="$3"
chromeless
circular
disabled={grid.currentPage <= 1}
icon={PreviousPageIcon ? <PreviousPageIcon size={16} /> : undefined}
onPress={() => grid.setPage(grid.currentPage - 1)}
/>
<Text color="$color" opacity={0.75}>
Page {grid.currentPage} of {grid.pageCount}
</Text>
<Button
size="$3"
chromeless
circular
disabled={grid.currentPage >= grid.pageCount}
icon={NextPageIcon ? <NextPageIcon size={16} /> : undefined}
onPress={() => grid.setPage(grid.currentPage + 1)}
/>
<Button
size="$3"
chromeless
circular
disabled={grid.currentPage >= grid.pageCount}
icon={LastPageIcon ? <LastPageIcon size={16} /> : undefined}
onPress={() => grid.setPage(grid.pageCount)}
/>
</XStack>
</XStack>
);
}
+259
View File
@@ -0,0 +1,259 @@
export function prettyLabel(value) {
if (!value) {
return '';
}
const withSpaces = String(value)
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
.trim();
return withSpaces.charAt(0).toUpperCase() + withSpaces.slice(1);
}
export function inferColumnType(value) {
if (typeof value === 'boolean') {
return 'boolean';
}
if (typeof value === 'number') {
return 'number';
}
if (typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value)) {
return 'number';
}
return 'text';
}
export function normalizeColumnDefinition(field, columnDefinition = {}, sampleValue) {
return {
field,
id: field,
label: columnDefinition.label || columnDefinition.display_name || prettyLabel(field),
sortable: columnDefinition.sortable ?? true,
filterable: columnDefinition.filterable ?? true,
align: columnDefinition.align || (inferColumnType(sampleValue) === 'number' ? 'right' : 'left'),
width: columnDefinition.width ?? null,
type: columnDefinition.type || inferColumnType(sampleValue),
format: columnDefinition.format || null,
renderer: columnDefinition.renderer || columnDefinition.render || null,
currency: columnDefinition.currency || 'USD',
priority: columnDefinition.priority || null,
alwaysVisible: columnDefinition.alwaysVisible ?? false
};
}
export function normalizeColumnDefinitionsInput(input = {}) {
if (Array.isArray(input)) {
return Object.fromEntries(
input
.map((column) => {
const field = column?.field || column?.id;
if (!field) {
return null;
}
return [
field,
{
...column,
field,
id: field,
renderer: column.renderer || column.render || null
}
];
})
.filter(Boolean)
);
}
if (input && typeof input === 'object') {
return Object.fromEntries(
Object.entries(input).map(([field, column]) => [
field,
{
...(column || {}),
field: column?.field || column?.id || field,
id: column?.id || column?.field || field,
renderer: column?.renderer || column?.render || null
}
])
);
}
return {};
}
export function normalizeColumnsArray(input = []) {
if (Array.isArray(input)) {
return input
.map((column) => {
const id = column?.id || column?.field;
if (!id) {
return null;
}
return {
...column,
id,
field: column.field || id,
render: column.render || column.renderer || null
};
})
.filter(Boolean);
}
if (input && typeof input === 'object') {
return Object.entries(input).map(([field, column]) => ({
...(column || {}),
id: column?.id || column?.field || field,
field: column?.field || column?.id || field,
render: column?.render || column?.renderer || null
}));
}
return [];
}
export function compareValues(left, right, direction = 'asc') {
if (left === right) {
return 0;
}
if (left === null || left === undefined || left === '') {
return 1;
}
if (right === null || right === undefined || right === '') {
return -1;
}
const leftNumber = Number(left);
const rightNumber = Number(right);
const bothNumeric = !Number.isNaN(leftNumber) && !Number.isNaN(rightNumber);
const result = bothNumeric
? leftNumber - rightNumber
: String(left).localeCompare(String(right), undefined, { sensitivity: 'base' });
return direction === 'desc' ? -result : result;
}
export function getColumnKeysFromRows(rows = []) {
const fields = new Set();
for (const row of rows) {
Object.keys(row || {}).forEach((field) => fields.add(field));
}
return Array.from(fields);
}
export function resolveCellValue(row, column) {
return row?.[column.field];
}
export function resolveCellAlignment(column) {
return column.align || 'left';
}
export function resolveVisibleColumns(columns = [], viewportWidth = 0) {
if (!columns.length) {
return [];
}
let hiddenPriorities = new Set();
if (viewportWidth > 0 && viewportWidth < 760) {
hiddenPriorities = new Set(['wide', 'mid']);
} else if (viewportWidth > 0 && viewportWidth < 980) {
hiddenPriorities = new Set(['wide']);
}
const filtered = columns.filter((column) => {
if (column.alwaysVisible || !column.priority) {
return true;
}
return !hiddenPriorities.has(column.priority);
});
return filtered.length ? filtered : columns.slice(0, 1);
}
export function areSortEntriesEqual(left = [], right = []) {
if (left.length !== right.length) {
return false;
}
return left.every(
(entry, index) =>
entry.field === right[index]?.field && entry.direction === right[index]?.direction
);
}
export function getColumnJustify(align = 'left') {
if (align === 'right') {
return 'flex-end';
}
if (align === 'center') {
return 'center';
}
return 'flex-start';
}
export function getColumnLayoutStyle(column = {}) {
const width = column.width;
if (typeof width === 'number') {
if (width > 0 && width <= 1) {
return {
flex: width,
flexBasis: 0,
minWidth: column.minWidth || 120
};
}
if (width > 1) {
return {
flexShrink: 0,
flexGrow: 0,
width: `${width}em`,
minWidth: `${width}em`
};
}
}
if (typeof width === 'string') {
return {
flexShrink: 0,
flexGrow: 0,
width,
minWidth: width
};
}
return {
flex: column.flex || 1,
flexBasis: 0,
minWidth: column.minWidth || 120
};
}
export function formatValueByColumn(value, column = {}) {
if (value == null || value === '') {
return '-';
}
if (typeof column.format === 'function') {
return column.format(value, column);
}
if (column.type === 'currency') {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return '-';
}
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: column.currency || 'USD'
}).format(numeric);
}
if (column.type === 'boolean') {
return value ? 'Yes' : 'No';
}
return String(value);
}
+30
View File
@@ -0,0 +1,30 @@
/**
* UI Components Index
* Central export point for all UI components
*/
export { EmptyShell, default as EmptyShellDefault } from './EmptyShell.jsx';
export { LandingShell, LandingShell as TopBarShell, default as LandingShellDefault } from './LandingShell.jsx';
export { DashboardShell, default as DashboardShellDefault } from './DashboardShell.jsx';
export { ShellProvider, useShell, ShellPlacement, ShellManager, ShellContext, ToastViewport, ToastManager } from './Shell.jsx';
export { AppInfo, default as AppInfoDefault } from './AppInfo.jsx';
export { TopBar, default as TopBarDefault } from './TopBar.jsx';
export { SideBar, default as SideBarDefault } from './SideBar.jsx';
export { MenuItemButton, default as MenuItemButtonDefault } from './MenuItemButton.jsx';
export { PersonalMenuItem, default as PersonalMenuItemDefault } from './PersonalMenuItem.jsx';
export { DirView, default as DirViewDefault } from './DirView.jsx';
export { DetView, default as DetViewDefault } from './DetView.jsx';
export { FormView, default as FormViewDefault } from './FormView.jsx';
export { FormField, default as FormFieldDefault } from './FormField.jsx';
export { Router, useRouter, useRoute } from './Router.jsx';
export { Page, default as PageDefault } from './Page.jsx';
export { ProgressBar, default as ProgressBarDefault } from './ProgressBar.jsx';
export { Panel, default as PanelDefault } from './Panel.jsx';
export { SettingsPanel, default as SettingsPanelDefault } from './SettingsPanel.jsx';
export { GeneralConfig, default as GeneralConfigDefault } from './GeneralConfig.jsx';
export { IdentityConfig, default as IdentityConfigDefault } from './IdentityConfig.jsx';
export * from './grid/index.js';
// Re-export App helpers for convenience.
// The App component itself is exported from src/index.js and src/ui/App.jsx.
export { useApp, useTheme, THEME_MODES } from '../App.jsx';
+143
View File
@@ -0,0 +1,143 @@
/**
* SettingsPage - Settings page component
* Derived from Page component
* Displays all settings fragment routes as panels
*/
import React, { useMemo } from 'react';
import { Page } from '../components/Page.jsx';
import { YStack, Input, Text } from 'tamagui';
import { useRouter, useRoute } from '../components/Router.jsx';
import { getRootItem } from '../../platform/menu.js';
import { securityService, useSecurityState } from '../../security/runtime/security-service.js';
/**
* SettingsPage Component
* Settings page with icon and title
* Queries Router for all routes under /settings and renders them as panels
*/
export function SettingsPage() {
const router = useRouter();
const route = useRoute();
const securityState = useSecurityState();
// Initialize search query from fragment path if we navigated via a fragment route
const initialSearchQuery = useMemo(() => {
if (route.fragment_path) {
// Extract the last segment from fragment path (e.g., "general" from "/settings/general")
const segments = route.fragment_path.split('/').filter(s => s.length > 0);
if (segments.length > 1) {
return segments[segments.length - 1]; // Return last segment
}
}
return ''; // No fragment, show all
}, [route.fragment_path]);
const [searchQuery, setSearchQuery] = React.useState(initialSearchQuery);
// Update search query when fragment path changes (e.g., navigating between fragments)
React.useEffect(() => {
setSearchQuery(initialSearchQuery);
}, [initialSearchQuery]);
// Get the base path for this settings page (flexible - could be /settings, /config, etc.)
const basePath = useMemo(() => {
// Get current route path and extract the base (e.g., /settings from /settings/general)
if (route.path) {
// If it's a fragment route, use fragment_path, otherwise use path
const currentPath = route.fragment_path || route.path;
// Extract base path (first segment)
const segments = currentPath.split('/').filter(s => s.length > 0);
return segments.length > 0 ? `/${segments[0]}` : '/settings';
}
return '/settings'; // Default fallback
}, [route.path, route.fragment_path]);
// Query Router for all routes under the base path
// Depend on router.currentRoute to trigger recalculation when routes are registered
// (currentRoute depends on routesVersion which changes when routes are registered)
const childRoutes = useMemo(() => {
const allRoutes = router.getRoutes();
const settingsRoot = getRootItem('settings');
const settingsItems = settingsRoot ? Array.from(settingsRoot.items.values()) : [];
const security = {
...securityState,
isPermitted: (rights, resourcePath, options = {}) => securityService.isPermitted(rights, resourcePath, options)
};
const routes = [];
// Find all routes that start with basePath but are not the basePath itself
for (const [path, routeData] of allRoutes.entries()) {
if (path.startsWith(basePath + '/') && path !== basePath) {
// Only include fragment routes
if (routeData.is_fragment) {
const matchingMenuItem = settingsItems.find((item) => item.invoke_target === path);
if (matchingMenuItem && !matchingMenuItem.isRenderable(security)) {
continue;
}
routes.push({
path,
component: routeData.component,
options: routeData.options || {}
});
}
}
}
// Sort by path for consistent ordering
routes.sort((a, b) => a.path.localeCompare(b.path));
return routes;
}, [router, basePath, router.currentRoute, securityState]); // Include router.currentRoute to trigger when routes are registered
// Filter routes based on search query
const filteredRoutes = useMemo(() => {
if (!searchQuery.trim()) {
return childRoutes;
}
const query = searchQuery.toLowerCase();
return childRoutes.filter(route => {
// Extract the last segment of the path as the route name
const segments = route.path.split('/').filter(s => s.length > 0);
const routeName = segments[segments.length - 1] || '';
return routeName.toLowerCase().includes(query);
});
}, [childRoutes, searchQuery]);
return (
<Page
icon="settings"
title="Settings"
headerRight={[
// Add buttons or controls here later
]}
>
<YStack gap="$4" width="100%">
{/* Search bar */}
<Input
placeholder="Search settings..."
value={searchQuery}
onChangeText={setSearchQuery}
size="$4"
/>
{/* Render all child route components */}
{filteredRoutes.length > 0 ? (
filteredRoutes.map((routeItem) => {
const RouteComponent = routeItem.component;
return (
<RouteComponent key={routeItem.path} />
);
})
) : (
<Text fontSize="$4" color="$color" opacity={0.6}>
{searchQuery ? 'No settings found matching your search.' : 'No settings available.'}
</Text>
)}
</YStack>
</Page>
);
}
export default SettingsPage;
+18
View File
@@ -0,0 +1,18 @@
import React from 'react';
/**
* Lazy route helper: `React.lazy` with `displayName` and optional named export.
* Progress / route-chunk UI belongs on the boot screen or in local Suspense fallbacks — not a second global bar in App.
*/
export function createLazyRoute(loader, label, exportName = 'default') {
const lazyComponent = React.lazy(() =>
loader().then((module) => ({
default: exportName === 'default'
? (module.default || module)
: (module[exportName] || module.default)
}))
);
lazyComponent.displayName = label;
return lazyComponent;
}
+66
View File
@@ -0,0 +1,66 @@
import { useSyncExternalStore } from 'react';
const generalSettingsViews = [];
const listeners = new Set();
let cachedViews = [];
function rebuildSnapshot() {
cachedViews = [...generalSettingsViews].sort((a, b) => {
const orderA = a.order || 0;
const orderB = b.order || 0;
if (orderA !== orderB) {
return orderA - orderB;
}
return (a.label || a.id || '').localeCompare(b.label || b.id || '');
});
}
function emit() {
listeners.forEach((listener) => {
try {
listener();
} catch (error) {
console.warn('[GeneralSettings] Listener failed:', error);
}
});
}
export function publishGeneralSettingsView(view) {
if (!view || !view.id || !view.label || !view.component) {
console.warn('[GeneralSettings] publishGeneralSettingsView() requires id, label, and component');
return;
}
const existingIndex = generalSettingsViews.findIndex((item) => item.id === view.id);
if (existingIndex >= 0) {
generalSettingsViews[existingIndex] = view;
} else {
generalSettingsViews.push(view);
}
rebuildSnapshot();
emit();
}
export function retractGeneralSettingsView(viewId) {
const nextViews = generalSettingsViews.filter((view) => view.id !== viewId);
if (nextViews.length !== generalSettingsViews.length) {
generalSettingsViews.length = 0;
generalSettingsViews.push(...nextViews);
rebuildSnapshot();
emit();
}
}
export function getGeneralSettingsViews() {
return cachedViews;
}
export function subscribeToGeneralSettingsViews(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function useGeneralSettingsViews() {
return useSyncExternalStore(subscribeToGeneralSettingsViews, getGeneralSettingsViews, getGeneralSettingsViews);
}
+121
View File
@@ -0,0 +1,121 @@
/**
* Colorful Theme
* Vibrant, colorful design with light and dark variants
*/
import { config as configBase } from '@tamagui/config/v3';
export const ColorfulTheme = {
...configBase,
name: 'colorful',
displayName: 'Colorful',
tokens: {
...configBase.tokens,
// Colorful palette (vibrant colors)
color: {
...configBase.tokens.color,
// Primary colors (vibrant blue)
primary: '#3b82f6',
primaryLight: '#60a5fa',
primaryDark: '#2563eb',
// Secondary colors (vibrant purple)
secondary: '#a855f7',
secondaryLight: '#c084fc',
secondaryDark: '#9333ea',
// Error, warning, info, success (vibrant)
error: '#ef4444',
warning: '#f59e0b',
info: '#06b6d4',
success: '#10b981',
},
// Colorful spacing (8px base unit, generous)
space: {
...configBase.tokens.space,
0: 0,
1: 4,
2: 8,
3: 12,
4: 16,
5: 24,
6: 32,
7: 48,
8: 64,
},
// Colorful typography (bold, expressive)
size: {
...configBase.tokens.size,
xs: 10,
sm: 12,
md: 14,
base: 16,
lg: 18,
xl: 22,
'2xl': 26,
'3xl': 32,
'4xl': 40,
'5xl': 52,
'6xl': 64,
},
// Colorful shadows (pronounced)
shadowColor: {
...configBase.tokens.shadowColor,
elevation0: 'transparent',
elevation1: 'rgba(0, 0, 0, 0.15)',
elevation2: 'rgba(0, 0, 0, 0.18)',
elevation4: 'rgba(0, 0, 0, 0.2)',
elevation8: 'rgba(0, 0, 0, 0.22)',
elevation12: 'rgba(0, 0, 0, 0.24)',
elevation16: 'rgba(0, 0, 0, 0.26)',
},
},
themes: {
...configBase.themes,
light: {
...configBase.themes.light,
background: '#ffffff',
backgroundHover: '#f1f5f9',
backgroundPress: '#e2e8f0',
backgroundFocus: '#e0ecff',
surface: '#ffffff',
surfaceVariant: '#f8fafc',
accentBackground: '#dbeafe',
accentSurface: '#eef4ff',
accentColor: '#2563eb',
accentBorder: 'rgba(59, 130, 246, 0.3)',
accentHover: '#c7ddff',
accentPress: '#b4d1ff',
color: '#0f172a',
colorHover: '#0f172a',
colorSecondary: '#475569',
colorDisabled: '#94a3b8',
borderColor: '#cbd5e1',
borderColorHover: '#94a3b8',
},
dark: {
...configBase.themes.dark,
background: '#0f172a',
backgroundHover: '#1e293b',
backgroundPress: '#334155',
backgroundFocus: '#1a305c',
surface: '#1e293b',
surfaceVariant: '#334155',
accentBackground: '#1d4ed8',
accentSurface: '#1e3a8a',
accentColor: '#bfdbfe',
accentBorder: 'rgba(147, 197, 253, 0.34)',
accentHover: '#2346b8',
accentPress: '#1f3f9f',
color: '#f1f5f9',
colorHover: '#f1f5f9',
colorSecondary: '#cbd5e1',
colorDisabled: '#64748b',
borderColor: '#334155',
borderColorHover: '#475569',
},
},
settings: {
...configBase.settings,
styleCompat: 'web',
},
};
+123
View File
@@ -0,0 +1,123 @@
/**
* Material Theme
* Material Design-inspired theme with light and dark variants
*/
import { config as configBase } from '@tamagui/config/v3';
export const MaterialTheme = {
...configBase,
name: 'material',
displayName: 'Material Design',
tokens: {
...configBase.tokens,
// Material UI color palette (base tokens - theme-agnostic)
color: {
...configBase.tokens.color,
// Primary colors (Material Blue)
primary: '#1976d2',
primaryLight: '#42a5f5',
primaryDark: '#1565c0',
// Secondary colors (Material Pink)
secondary: '#c2185b',
secondaryLight: '#f48fb1',
secondaryDark: '#880e4f',
// Error, warning, info, success
error: '#d32f2f',
warning: '#ed6c02',
info: '#0288d1',
success: '#2e7d32',
},
// Material UI spacing (8px base unit)
space: {
...configBase.tokens.space,
0: 0,
1: 4,
2: 8,
3: 12,
4: 16,
5: 24,
6: 32,
7: 48,
8: 64,
},
// Material UI typography scale
size: {
...configBase.tokens.size,
xs: 10,
sm: 12,
md: 14,
base: 16,
lg: 18,
xl: 20,
'2xl': 24,
'3xl': 30,
'4xl': 34,
'5xl': 48,
'6xl': 60,
},
// Material UI elevation shadows
shadowColor: {
...configBase.tokens.shadowColor,
elevation0: 'transparent',
elevation1: 'rgba(0, 0, 0, 0.2)',
elevation2: 'rgba(0, 0, 0, 0.14)',
elevation4: 'rgba(0, 0, 0, 0.12)',
elevation8: 'rgba(0, 0, 0, 0.1)',
elevation12: 'rgba(0, 0, 0, 0.08)',
elevation16: 'rgba(0, 0, 0, 0.06)',
},
},
themes: {
...configBase.themes,
light: {
...configBase.themes.light,
background: '#ffffff',
backgroundHover: '#f5f5f5',
backgroundPress: '#eeeeee',
backgroundFocus: '#e9f2fd',
surface: '#ffffff',
surfaceVariant: '#f5f5f5',
accentBackground: '#e3f2fd',
accentSurface: '#f4f8ff',
accentColor: '#1565c0',
accentBorder: 'rgba(25, 118, 210, 0.28)',
accentHover: '#d8ebfd',
accentPress: '#c8e2fb',
color: 'rgba(0, 0, 0, 0.87)',
colorHover: 'rgba(0, 0, 0, 0.87)',
colorSecondary: 'rgba(0, 0, 0, 0.6)',
colorDisabled: 'rgba(0, 0, 0, 0.38)',
borderColor: 'rgba(0, 0, 0, 0.12)',
borderColorHover: 'rgba(0, 0, 0, 0.23)',
},
dark: {
...configBase.themes.dark,
background: '#121212',
backgroundHover: '#1e1e1e',
backgroundPress: '#2c2c2c',
backgroundFocus: '#183148',
surface: '#1e1e1e',
surfaceVariant: '#2c2c2c',
accentBackground: '#17324d',
accentSurface: '#1b3c5b',
accentColor: '#90caf9',
accentBorder: 'rgba(144, 202, 249, 0.32)',
accentHover: '#204566',
accentPress: '#295373',
color: 'rgba(255, 255, 255, 0.87)',
colorHover: 'rgba(255, 255, 255, 0.87)',
colorSecondary: 'rgba(255, 255, 255, 0.6)',
colorDisabled: 'rgba(255, 255, 255, 0.38)',
borderColor: 'rgba(255, 255, 255, 0.12)',
borderColorHover: 'rgba(255, 255, 255, 0.23)',
},
},
settings: {
...configBase.settings,
styleCompat: 'web',
},
};
export default MaterialTheme;
+121
View File
@@ -0,0 +1,121 @@
/**
* Minimal Theme
* Clean, minimal design with light and dark variants
*/
import { config as configBase } from '@tamagui/config/v3';
export const MinimalTheme = {
...configBase,
name: 'minimal',
displayName: 'Minimal',
tokens: {
...configBase.tokens,
// Minimal color palette (neutral, monochromatic)
color: {
...configBase.tokens.color,
// Primary colors (neutral gray-blue)
primary: '#4a5568',
primaryLight: '#718096',
primaryDark: '#2d3748',
// Secondary colors (subtle accent)
secondary: '#667eea',
secondaryLight: '#818cf8',
secondaryDark: '#4f46e5',
// Error, warning, info, success (muted)
error: '#e53e3e',
warning: '#dd6b20',
info: '#3182ce',
success: '#38a169',
},
// Minimal spacing (8px base unit, tighter)
space: {
...configBase.tokens.space,
0: 0,
1: 4,
2: 8,
3: 12,
4: 16,
5: 20,
6: 24,
7: 32,
8: 48,
},
// Minimal typography (clean, readable)
size: {
...configBase.tokens.size,
xs: 11,
sm: 13,
md: 15,
base: 16,
lg: 18,
xl: 20,
'2xl': 22,
'3xl': 26,
'4xl': 32,
'5xl': 40,
'6xl': 48,
},
// Minimal shadows (subtle)
shadowColor: {
...configBase.tokens.shadowColor,
elevation0: 'transparent',
elevation1: 'rgba(0, 0, 0, 0.08)',
elevation2: 'rgba(0, 0, 0, 0.1)',
elevation4: 'rgba(0, 0, 0, 0.12)',
elevation8: 'rgba(0, 0, 0, 0.14)',
elevation12: 'rgba(0, 0, 0, 0.16)',
elevation16: 'rgba(0, 0, 0, 0.18)',
},
},
themes: {
...configBase.themes,
light: {
...configBase.themes.light,
background: '#fafafa',
backgroundHover: '#f0f0f0',
backgroundPress: '#e8e8e8',
backgroundFocus: '#e9edf6',
surface: '#ffffff',
surfaceVariant: '#f5f5f5',
accentBackground: '#eceff4',
accentSurface: '#f5f7fb',
accentColor: '#4f46e5',
accentBorder: 'rgba(79, 70, 229, 0.24)',
accentHover: '#e4e8f3',
accentPress: '#d8deef',
color: '#1a202c',
colorHover: '#1a202c',
colorSecondary: '#4a5568',
colorDisabled: '#a0aec0',
borderColor: '#e2e8f0',
borderColorHover: '#cbd5e0',
},
dark: {
...configBase.themes.dark,
background: '#0f1419',
backgroundHover: '#1a202c',
backgroundPress: '#2d3748',
backgroundFocus: '#223049',
surface: '#1a202c',
surfaceVariant: '#2d3748',
accentBackground: '#2c3650',
accentSurface: '#333f5d',
accentColor: '#c7d2fe',
accentBorder: 'rgba(129, 140, 248, 0.28)',
accentHover: '#354461',
accentPress: '#3c4c6d',
color: '#f7fafc',
colorHover: '#f7fafc',
colorSecondary: '#cbd5e0',
colorDisabled: '#718096',
borderColor: '#2d3748',
borderColorHover: '#4a5568',
},
},
settings: {
...configBase.settings,
styleCompat: 'web',
},
};
+51
View File
@@ -0,0 +1,51 @@
/**
* Style Themes Index
* Exports all available style themes
*/
import { MaterialTheme } from './MaterialTheme.js';
import { MinimalTheme } from './MinimalTheme.js';
import { ColorfulTheme } from './ColorfulTheme.js';
export const STYLE_THEMES = {
material: MaterialTheme,
minimal: MinimalTheme,
colorful: ColorfulTheme,
};
export const DEFAULT_STYLE_THEME = 'material';
/**
* Map arbitrary input (storage, profile, UI) to a registered style theme id.
* Case-insensitive; unknown values fall back to {@link DEFAULT_STYLE_THEME}.
* @param {string} [name]
* @returns {keyof typeof STYLE_THEMES}
*/
export function normalizeStyleThemeName(name) {
const key = typeof name === 'string' ? name.trim().toLowerCase() : '';
if (key && STYLE_THEMES[key]) {
return key;
}
return DEFAULT_STYLE_THEME;
}
/**
* Get a style theme by name
* @param {string} themeName - Theme name ('material', 'minimal', 'colorful')
* @returns {Object} Theme configuration
*/
export function getStyleTheme(themeName = DEFAULT_STYLE_THEME) {
const key = normalizeStyleThemeName(themeName);
return STYLE_THEMES[key];
}
/**
* Get all available style theme names
* @returns {string[]} Array of theme names
*/
export function getStyleThemeNames() {
return Object.keys(STYLE_THEMES);
}
export { MaterialTheme, MinimalTheme, ColorfulTheme };
+21
View File
@@ -0,0 +1,21 @@
/* Main Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
line-height: 1.6;
color: #333;
background-color: #fff;
}
#app {
min-height: 100vh;
}
/* Add your styles here */
+123
View File
@@ -0,0 +1,123 @@
import { getConfig, setConfig } from '../platform/env.js';
import { getSystemThemeMode } from '../platform/compat.js';
import { DEFAULT_STYLE_THEME, normalizeStyleThemeName } from './styles/index.js';
export const THEME_MODE_CONFIG_KEY = 'theme.mode';
export const THEME_NAME_CONFIG_KEY = 'theme.name';
export const THEME_MODES = {
LIGHT: 'light',
DARK: 'dark',
SYSTEM: 'system'
};
class ThemeController {
constructor() {
this._themeMode = THEME_MODES.SYSTEM;
this._systemScheme = getSystemThemeMode();
this._activeTheme = this._themeMode === THEME_MODES.SYSTEM ? this._systemScheme : this._themeMode;
this._styleThemeName = DEFAULT_STYLE_THEME;
this._listeners = new Set();
this._setThemeModeState = null;
this._setSystemScheme = null;
this._setStyleThemeName = null;
}
init(setThemeModeState, setSystemScheme, setStyleThemeName) {
this._setThemeModeState = setThemeModeState;
this._setSystemScheme = setSystemScheme;
this._setStyleThemeName = setStyleThemeName;
}
updateState(themeMode, systemScheme, activeTheme, styleThemeName = this._styleThemeName) {
this._themeMode = themeMode;
this._systemScheme = systemScheme;
this._activeTheme = activeTheme;
this._styleThemeName = styleThemeName;
this._notifyListeners();
}
_notifyListeners() {
this._listeners.forEach((listener) => {
try {
listener({
themeMode: this._themeMode,
activeTheme: this._activeTheme,
systemScheme: this._systemScheme,
styleThemeName: this._styleThemeName
});
} catch (error) {
console.warn('[ThemeManager] Listener error:', error);
}
});
}
getMode() {
return this._themeMode;
}
getActiveTheme() {
return this._activeTheme;
}
getSystemScheme() {
return this._systemScheme;
}
getModes() {
return { ...THEME_MODES };
}
getStyleThemeName() {
return this._styleThemeName;
}
async setMode(mode) {
if (!Object.values(THEME_MODES).includes(mode)) {
console.warn('[ThemeManager] Invalid theme mode:', mode);
return;
}
if (this._setThemeModeState) {
this._setThemeModeState(mode);
}
try {
await setConfig(THEME_MODE_CONFIG_KEY, mode);
} catch (error) {
console.warn('[ThemeManager] Failed to save theme preference:', error);
}
}
async setStyleTheme(styleThemeName) {
const resolved = normalizeStyleThemeName(styleThemeName);
if (this._setStyleThemeName) {
this._setStyleThemeName(resolved);
}
try {
await setConfig(THEME_NAME_CONFIG_KEY, resolved);
} catch (error) {
console.warn('[ThemeManager] Failed to save style theme preference:', error);
}
}
toggle() {
const nextMode =
this._themeMode === THEME_MODES.LIGHT ? THEME_MODES.DARK :
this._themeMode === THEME_MODES.DARK ? THEME_MODES.SYSTEM :
THEME_MODES.LIGHT;
this.setMode(nextMode);
}
subscribe(listener) {
this._listeners.add(listener);
return () => {
this._listeners.delete(listener);
};
}
}
export const themeManager = new ThemeController();
export default themeManager;