Initial commit: bface library, build fixes, and refreshed docs
- Externalize all @tamagui/* and tamagui subpaths so dist no longer vendors Tamagui. - Emit TypeScript declarations with vite-plugin-dts; fix package exports types for ui/*. - Align initEnv with profiles: displayName, brandLogo, api.baseURL, themeColor, uiShell. - Stabilize tests with Node localStorage file; env tests pass. - Update README and component docs for services, menus, API client, and development.
This commit is contained in:
@@ -0,0 +1,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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user