Expand theme system and refresh UI components
This commit is contained in:
Generated
+3450
-87
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,7 @@
|
|||||||
"test:watch": "node --localstorage-file=.node-localstorage --test --watch test/**/*.test.js"
|
"test:watch": "node --localstorage-file=.node-localstorage --test --watch test/**/*.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@phosphor-icons/react": "^2.1.10",
|
||||||
"@tamagui/config": "^1.144.2",
|
"@tamagui/config": "^1.144.2",
|
||||||
"@tamagui/core": "^1.144.2",
|
"@tamagui/core": "^1.144.2",
|
||||||
"@tamagui/lucide-icons": "^1.144.2",
|
"@tamagui/lucide-icons": "^1.144.2",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export * from './data/index.js';
|
|||||||
export * from './ui/App.jsx';
|
export * from './ui/App.jsx';
|
||||||
export * from './ui/components/index.js';
|
export * from './ui/components/index.js';
|
||||||
export * from './ui/runtime/general-settings.js';
|
export * from './ui/runtime/general-settings.js';
|
||||||
|
export * from './ui/styles/index.js';
|
||||||
|
|
||||||
// Re-export security modules
|
// Re-export security modules
|
||||||
export * from './security/index.js';
|
export * from './security/index.js';
|
||||||
|
|||||||
@@ -4,13 +4,20 @@ import { getRouterPath, setRouterPath } from '../../platform/compat.js';
|
|||||||
import { securityService, useSecurityState } from '../runtime/security-service.js';
|
import { securityService, useSecurityState } from '../runtime/security-service.js';
|
||||||
import { Panel } from '../../ui/components/Panel.jsx';
|
import { Panel } from '../../ui/components/Panel.jsx';
|
||||||
|
|
||||||
const LoginField = forwardRef(function LoginField({ id, label, ...props }, ref) {
|
const LoginField = forwardRef(function LoginField({ id, label, error, ...props }, ref) {
|
||||||
return (
|
return (
|
||||||
<YStack gap="$2">
|
<YStack gap="$2">
|
||||||
<Label htmlFor={id} size="$3" color="$color" fontWeight="600">
|
<Label htmlFor={id} size="$3" color="$textPrimary" fontWeight="600">
|
||||||
{label}
|
{label}
|
||||||
</Label>
|
</Label>
|
||||||
<Input ref={ref} id={id} {...props} />
|
<Input
|
||||||
|
ref={ref}
|
||||||
|
id={id}
|
||||||
|
backgroundColor="$bgPanel"
|
||||||
|
borderColor={error ? '$danger' : '$lineSubtle'}
|
||||||
|
focusStyle={{ borderColor: error ? '$danger' : '$accent' }}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
</YStack>
|
</YStack>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -64,8 +71,8 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
|
|||||||
icon="login"
|
icon="login"
|
||||||
title={title}
|
title={title}
|
||||||
width="100%"
|
width="100%"
|
||||||
headerFront={{ color: '$accentColor' }}
|
headerFront={{ color: '$textPrimary' }}
|
||||||
headerBack={{ backgroundColor: '$accentSurface' }}
|
headerBack={{ backgroundColor: '$bgPanel' }}
|
||||||
>
|
>
|
||||||
<YStack
|
<YStack
|
||||||
tag="form"
|
tag="form"
|
||||||
@@ -73,12 +80,13 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
|
|||||||
onSubmit={handleFormSubmit}
|
onSubmit={handleFormSubmit}
|
||||||
autoComplete="on"
|
autoComplete="on"
|
||||||
>
|
>
|
||||||
<Paragraph color="$color" opacity={0.78}>
|
<Paragraph color="$textMuted">
|
||||||
{subtitle}
|
{subtitle}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
<LoginField
|
<LoginField
|
||||||
id="login-identifier"
|
id="login-identifier"
|
||||||
label="Username or email"
|
label="Username or email"
|
||||||
|
error={Boolean(errorMessage)}
|
||||||
placeholder="Username or email"
|
placeholder="Username or email"
|
||||||
value={identifier}
|
value={identifier}
|
||||||
onChangeText={setIdentifier}
|
onChangeText={setIdentifier}
|
||||||
@@ -96,6 +104,7 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
|
|||||||
<LoginField
|
<LoginField
|
||||||
id="login-password"
|
id="login-password"
|
||||||
label="Password"
|
label="Password"
|
||||||
|
error={Boolean(errorMessage)}
|
||||||
ref={passwordInputRef}
|
ref={passwordInputRef}
|
||||||
placeholder="Password"
|
placeholder="Password"
|
||||||
value={password}
|
value={password}
|
||||||
@@ -111,30 +120,28 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
|
|||||||
onKeyDown={handlePasswordKeyDown}
|
onKeyDown={handlePasswordKeyDown}
|
||||||
/>
|
/>
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<Text color="#ef4444" fontSize="$4">
|
<Text color="$danger" fontSize="$4">
|
||||||
{errorMessage}
|
{errorMessage}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
themeInverse
|
theme="accent"
|
||||||
backgroundColor="$accentColor"
|
|
||||||
color="white"
|
|
||||||
onPress={handleSubmit}
|
onPress={handleSubmit}
|
||||||
disabled={security.loading || !security.enabled || !security.initialized}
|
disabled={security.loading || !security.enabled || !security.initialized}
|
||||||
>
|
>
|
||||||
{security.loading ? 'Signing In...' : 'Sign In'}
|
{security.loading ? 'Signing In...' : 'Sign In'}
|
||||||
</Button>
|
</Button>
|
||||||
{!security.enabled ? (
|
{!security.enabled ? (
|
||||||
<Paragraph fontSize="$3" color="#8b5e3c">
|
<Paragraph fontSize="$3" color="$textSecondary">
|
||||||
Identity is currently disabled in the active app profile.
|
Identity is currently disabled in the active app profile.
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
) : null}
|
) : null}
|
||||||
{security.enabled && !security.initialized ? (
|
{security.enabled && !security.initialized ? (
|
||||||
<Paragraph fontSize="$3" color="#8b5e3c">
|
<Paragraph fontSize="$3" color="$textSecondary">
|
||||||
Security is still initializing.
|
Security is still initializing.
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
) : null}
|
) : null}
|
||||||
<Paragraph fontSize="$3" color="$color" opacity={0.65}>
|
<Paragraph fontSize="$3" color="$textMuted">
|
||||||
Demo credentials: admin / admin or demo / demo
|
Demo credentials: admin / admin or demo / demo
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
</YStack>
|
</YStack>
|
||||||
@@ -153,7 +160,7 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
|
|||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
padding="$6"
|
padding="$6"
|
||||||
backgroundColor="$background"
|
backgroundColor="$bgPage"
|
||||||
>
|
>
|
||||||
<YStack width="100%" maxWidth={520}>
|
<YStack width="100%" maxWidth={520}>
|
||||||
{content}
|
{content}
|
||||||
|
|||||||
@@ -36,22 +36,22 @@ export function AppInfo({ appName, swStatus, storageBackend, menuItems = [], ini
|
|||||||
|
|
||||||
<XStack gap="$2">
|
<XStack gap="$2">
|
||||||
<Text>App:</Text>
|
<Text>App:</Text>
|
||||||
<Text fontWeight="bold">{appName || 'Loading...'}</Text>
|
<Text fontWeight="600">{appName || 'Loading...'}</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<XStack gap="$2">
|
<XStack gap="$2">
|
||||||
<Text>Service Worker:</Text>
|
<Text>Service Worker:</Text>
|
||||||
<Text fontWeight="bold">{swStatus}</Text>
|
<Text fontWeight="600">{swStatus}</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<XStack gap="$2">
|
<XStack gap="$2">
|
||||||
<Text>Storage Backend:</Text>
|
<Text>Storage Backend:</Text>
|
||||||
<Text fontWeight="bold">{storageBackend}</Text>
|
<Text fontWeight="600">{storageBackend}</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
{initialized && menuItems.length > 0 && (
|
{initialized && menuItems.length > 0 && (
|
||||||
<YStack marginTop="$4" gap="$2">
|
<YStack marginTop="$4" gap="$2">
|
||||||
<Text fontWeight="bold">Menu Items:</Text>
|
<Text fontWeight="600">Menu Items:</Text>
|
||||||
{menuItems.map((item) => (
|
{menuItems.map((item) => (
|
||||||
<XStack key={item.id} gap="$2">
|
<XStack key={item.id} gap="$2">
|
||||||
<Text>• {item.label}</Text>
|
<Text>• {item.label}</Text>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||||||
import { Button, Input, Paragraph, ScrollView, Separator, Spinner, Text, XStack, YStack } from 'tamagui';
|
import { Button, Input, Paragraph, ScrollView, Separator, Spinner, Text, XStack, YStack } from 'tamagui';
|
||||||
import { getIcon } from './IconMapper.jsx';
|
import { getIcon } from './IconMapper.jsx';
|
||||||
import { normalizeColumnsArray } from './grid/utils.js';
|
import { normalizeColumnsArray } from './grid/utils.js';
|
||||||
|
import { getTypographyRoleProps } from '../styles/index.js';
|
||||||
|
|
||||||
const EMPTY_COLUMNS = [];
|
const EMPTY_COLUMNS = [];
|
||||||
const EMPTY_ACTIONS = [];
|
const EMPTY_ACTIONS = [];
|
||||||
@@ -43,15 +44,15 @@ function SummaryCards({ summary }) {
|
|||||||
minWidth={140}
|
minWidth={140}
|
||||||
padding="$3"
|
padding="$3"
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor="$borderColor"
|
borderColor="$lineSubtle"
|
||||||
borderRadius="$4"
|
borderRadius="$radiusMd"
|
||||||
backgroundColor="$accentSurface"
|
backgroundColor="$bgPanel"
|
||||||
gap="$1"
|
gap="$1"
|
||||||
>
|
>
|
||||||
<Text fontSize="$3" color="$color" opacity={0.7}>
|
<Text fontSize="$3" color="$textMuted">
|
||||||
{item.label}
|
{item.label}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="$7" fontWeight="700" color="$accentColor">
|
<Text fontSize="$7" fontWeight="700" color="$accent">
|
||||||
{normalizeSummaryValue(item.value)}
|
{normalizeSummaryValue(item.value)}
|
||||||
</Text>
|
</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
@@ -63,7 +64,8 @@ function SummaryCards({ summary }) {
|
|||||||
function HeaderCell({ column, orderBy, order, onSort }) {
|
function HeaderCell({ column, orderBy, order, onSort }) {
|
||||||
const sortable = column.sortable !== false;
|
const sortable = column.sortable !== false;
|
||||||
const isActive = orderBy === column.id;
|
const isActive = orderBy === column.id;
|
||||||
const arrow = isActive ? (order === 'asc' ? '↑' : '↓') : '';
|
const CaretUp = getIcon('caret-up');
|
||||||
|
const CaretDown = getIcon('caret-down');
|
||||||
const justifyContent = getColumnJustify(column.align);
|
const justifyContent = getColumnJustify(column.align);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -81,10 +83,23 @@ function HeaderCell({ column, orderBy, order, onSort }) {
|
|||||||
padding={0}
|
padding={0}
|
||||||
justifyContent={justifyContent}
|
justifyContent={justifyContent}
|
||||||
width="100%"
|
width="100%"
|
||||||
|
hoverStyle={sortable ? { backgroundColor: '$bgPage' } : undefined}
|
||||||
|
pressStyle={sortable ? { backgroundColor: '$bgPanelElev' } : undefined}
|
||||||
>
|
>
|
||||||
<Text fontSize="$4" fontWeight="700" color="$color" textAlign={column.align || 'left'} width="100%">
|
<XStack width="100%" alignItems="center" justifyContent={justifyContent} gap="$2">
|
||||||
{column.label}{arrow ? ` ${arrow}` : ''}
|
<Text {...getTypographyRoleProps('tableHeader')} textAlign={column.align || 'left'} numberOfLines={1}>
|
||||||
</Text>
|
{column.label}
|
||||||
|
</Text>
|
||||||
|
{sortable ? (
|
||||||
|
isActive ? (
|
||||||
|
order === 'asc'
|
||||||
|
? (CaretUp ? <CaretUp size="xs" color="$textSecondary" /> : null)
|
||||||
|
: (CaretDown ? <CaretDown size="xs" color="$textSecondary" /> : null)
|
||||||
|
) : (
|
||||||
|
CaretDown ? <CaretDown size="xs" color="$textMuted" style={{ opacity: 0.6 }} /> : null
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</XStack>
|
||||||
</Button>
|
</Button>
|
||||||
</XStack>
|
</XStack>
|
||||||
);
|
);
|
||||||
@@ -133,7 +148,11 @@ export function DirView({
|
|||||||
bodyMaxHeight = 480,
|
bodyMaxHeight = 480,
|
||||||
onRowClick = null,
|
onRowClick = null,
|
||||||
onRowPress = null,
|
onRowPress = null,
|
||||||
onRefresh = null
|
onRefresh = null,
|
||||||
|
showHeader = true,
|
||||||
|
showSummary = true,
|
||||||
|
density = 'comfortable',
|
||||||
|
striped = false
|
||||||
}) {
|
}) {
|
||||||
const [dataVersion, setDataVersion] = useState(0);
|
const [dataVersion, setDataVersion] = useState(0);
|
||||||
const [records, setRecords] = useState([]);
|
const [records, setRecords] = useState([]);
|
||||||
@@ -155,6 +174,7 @@ export function DirView({
|
|||||||
const PreviousPageIcon = getIcon('chevron-left');
|
const PreviousPageIcon = getIcon('chevron-left');
|
||||||
const NextPageIcon = getIcon('chevron-right');
|
const NextPageIcon = getIcon('chevron-right');
|
||||||
const LastPageIcon = getIcon('last-page');
|
const LastPageIcon = getIcon('last-page');
|
||||||
|
const paddingRow = density === 'compact' ? '$2' : density === 'spacious' ? '$4' : '$3';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!dataModel?.subscribe) {
|
if (!dataModel?.subscribe) {
|
||||||
@@ -275,49 +295,54 @@ export function DirView({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<YStack gap="$4" width="100%">
|
<YStack gap="$4" width="100%">
|
||||||
<XStack justifyContent="space-between" alignItems="center" gap="$4" flexWrap="wrap">
|
{showHeader ? (
|
||||||
<XStack alignItems="center" gap="$3" flex={1} minWidth={240} flexWrap="wrap">
|
<XStack justifyContent="space-between" alignItems="center" gap="$4" flexWrap="wrap">
|
||||||
<Text fontSize="$8" fontWeight="800" color="$accentColor">
|
<XStack alignItems="center" gap="$3" flex={1} minWidth={240} flexWrap="wrap">
|
||||||
{title}
|
<Text {...getTypographyRoleProps('sectionTitle')}>
|
||||||
</Text>
|
{title}
|
||||||
{topLeftContent}
|
</Text>
|
||||||
</XStack>
|
{topLeftContent}
|
||||||
|
</XStack>
|
||||||
|
|
||||||
<XStack alignItems="center" justifyContent="flex-end" gap="$2" flexWrap="wrap">
|
<XStack alignItems="center" justifyContent="flex-end" gap="$2" flexWrap="wrap">
|
||||||
{searchConfig?.enabled ? (
|
{searchConfig?.enabled ? (
|
||||||
<Input
|
<Input
|
||||||
width={260}
|
width={260}
|
||||||
placeholder={searchConfig.placeholder || 'Search records...'}
|
placeholder={searchConfig.placeholder || 'Search records...'}
|
||||||
value={effectiveSearchTerm}
|
value={effectiveSearchTerm}
|
||||||
onChangeText={updateSearchTerm}
|
onChangeText={updateSearchTerm}
|
||||||
|
backgroundColor="$bgPanel"
|
||||||
|
borderColor="$lineSubtle"
|
||||||
|
focusStyle={{ borderColor: '$accent' }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{effectiveToolbarItems.map(renderToolbarButton)}
|
||||||
|
<Button
|
||||||
|
size="$3"
|
||||||
|
chromeless
|
||||||
|
circular
|
||||||
|
aria-label="Refresh directory"
|
||||||
|
icon={RefreshIcon ? <RefreshIcon size="sm" color="$textSecondary" /> : undefined}
|
||||||
|
onPress={handleRefresh}
|
||||||
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
) : null}
|
{topRightContent}
|
||||||
{effectiveToolbarItems.map(renderToolbarButton)}
|
</XStack>
|
||||||
<Button
|
|
||||||
size="$3"
|
|
||||||
chromeless
|
|
||||||
circular
|
|
||||||
aria-label="Refresh directory"
|
|
||||||
icon={RefreshIcon ? <RefreshIcon size={16} /> : undefined}
|
|
||||||
onPress={handleRefresh}
|
|
||||||
disabled={loading}
|
|
||||||
/>
|
|
||||||
{topRightContent}
|
|
||||||
</XStack>
|
</XStack>
|
||||||
</XStack>
|
) : null}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<YStack padding="$3" borderRadius="$4" backgroundColor="#fef2f2" borderWidth={1} borderColor="#fecaca">
|
<YStack padding="$3" borderRadius="$radiusMd" backgroundColor="$dangerBg" borderWidth={1} borderColor="$danger">
|
||||||
<Text color="#b91c1c">{error}</Text>
|
<Text color="$danger" fontWeight="600">{error}</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<SummaryCards summary={summary} />
|
{showSummary ? <SummaryCards summary={summary} /> : null}
|
||||||
|
|
||||||
{bodyHeaderContent}
|
{bodyHeaderContent}
|
||||||
|
|
||||||
<YStack borderWidth={1} borderColor="$borderColor" borderRadius="$5" overflow="hidden" backgroundColor="$background">
|
<YStack borderWidth={1} borderColor="$lineSubtle" borderRadius="$radiusLg" overflow="hidden" backgroundColor="$bgPanel">
|
||||||
<XStack padding="$3" gap="$3" backgroundColor="$accentSurface" borderBottomWidth={1} borderBottomColor="$borderColor">
|
<XStack padding={paddingRow} gap="$3" backgroundColor="transparent" borderBottomWidth={1} borderBottomColor="$lineSubtle">
|
||||||
{resolvedColumns.map((column) => (
|
{resolvedColumns.map((column) => (
|
||||||
<HeaderCell
|
<HeaderCell
|
||||||
key={column.id}
|
key={column.id}
|
||||||
@@ -334,27 +359,29 @@ export function DirView({
|
|||||||
))}
|
))}
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
<ScrollView maxHeight={bodyMaxHeight}>
|
<ScrollView {...(bodyMaxHeight != null ? { maxHeight: bodyMaxHeight } : {})}>
|
||||||
<YStack>
|
<YStack>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<XStack justifyContent="center" padding="$6">
|
<XStack justifyContent="center" padding="$6">
|
||||||
<Spinner size="large" color="$accentColor" />
|
<Spinner size="large" color="$accent" />
|
||||||
</XStack>
|
</XStack>
|
||||||
) : records.length === 0 ? (
|
) : records.length === 0 ? (
|
||||||
<YStack padding="$6" alignItems="center">
|
<YStack padding="$6" alignItems="center" gap="$2">
|
||||||
<Paragraph color="$color" opacity={0.7}>
|
<Paragraph color="$textSecondary">
|
||||||
No records found.
|
No records found
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
|
<Text color="$textMuted" fontSize="$3">Try adjusting your search or filters.</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
) : (
|
) : (
|
||||||
records.map((record, index) => (
|
records.map((record, index) => (
|
||||||
<YStack key={record?.[dataModel?.getIdField?.() || 'id'] || index}>
|
<YStack key={record?.[dataModel?.getIdField?.() || 'id'] || index}>
|
||||||
<XStack
|
<XStack
|
||||||
padding="$3"
|
padding={paddingRow}
|
||||||
gap="$3"
|
gap="$3"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
hoverStyle={{ backgroundColor: '$accentSurface' }}
|
backgroundColor={striped && index % 2 === 1 ? '$bgPage' : 'transparent'}
|
||||||
pressStyle={{ backgroundColor: '$accentSurface' }}
|
hoverStyle={{ backgroundColor: '$bgPage' }}
|
||||||
|
pressStyle={{ backgroundColor: '$bgPanelElev' }}
|
||||||
cursor={effectiveRowPress ? 'pointer' : undefined}
|
cursor={effectiveRowPress ? 'pointer' : undefined}
|
||||||
onPress={() => effectiveRowPress?.(record)}
|
onPress={() => effectiveRowPress?.(record)}
|
||||||
>
|
>
|
||||||
@@ -362,7 +389,7 @@ export function DirView({
|
|||||||
<RowCell key={column.id} column={column} record={record} />
|
<RowCell key={column.id} column={column} record={record} />
|
||||||
))}
|
))}
|
||||||
</XStack>
|
</XStack>
|
||||||
{index < records.length - 1 ? <Separator /> : null}
|
{index < records.length - 1 ? <Separator borderColor="$lineSubtle" /> : null}
|
||||||
</YStack>
|
</YStack>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -373,15 +400,15 @@ export function DirView({
|
|||||||
{bodyFooterContent}
|
{bodyFooterContent}
|
||||||
|
|
||||||
<XStack justifyContent="space-between" alignItems="center" gap="$3" flexWrap="wrap">
|
<XStack justifyContent="space-between" alignItems="center" gap="$3" flexWrap="wrap">
|
||||||
<Text color="$color" opacity={0.7}>
|
<Text color="$textMuted">
|
||||||
Rows: {totalRecords}
|
Rows: {totalRecords}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack alignItems="center" gap="$2" flexWrap="wrap">
|
<XStack alignItems="center" gap="$2" flexWrap="wrap" padding="$1" borderWidth={1} borderColor="$lineSubtle" borderRadius="$radiusMd" backgroundColor="$bgPanel">
|
||||||
<Button
|
<Button
|
||||||
size="$3"
|
size="$3"
|
||||||
chromeless
|
chromeless
|
||||||
aria-label="First page"
|
aria-label="First page"
|
||||||
icon={FirstPageIcon ? <FirstPageIcon size={16} /> : undefined}
|
icon={FirstPageIcon ? <FirstPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||||
onPress={() => setCurrentPage(1)}
|
onPress={() => setCurrentPage(1)}
|
||||||
disabled={currentPage === 1 || loading}
|
disabled={currentPage === 1 || loading}
|
||||||
/>
|
/>
|
||||||
@@ -389,18 +416,18 @@ export function DirView({
|
|||||||
size="$3"
|
size="$3"
|
||||||
chromeless
|
chromeless
|
||||||
aria-label="Previous page"
|
aria-label="Previous page"
|
||||||
icon={PreviousPageIcon ? <PreviousPageIcon size={16} /> : undefined}
|
icon={PreviousPageIcon ? <PreviousPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||||
onPress={() => setCurrentPage((value) => Math.max(1, value - 1))}
|
onPress={() => setCurrentPage((value) => Math.max(1, value - 1))}
|
||||||
disabled={currentPage === 1 || loading}
|
disabled={currentPage === 1 || loading}
|
||||||
/>
|
/>
|
||||||
<Text color="$color" opacity={0.75}>
|
<Text color="$textSecondary">
|
||||||
Page {currentPage} of {totalPages}
|
Page {currentPage} of {totalPages}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
size="$3"
|
size="$3"
|
||||||
chromeless
|
chromeless
|
||||||
aria-label="Next page"
|
aria-label="Next page"
|
||||||
icon={NextPageIcon ? <NextPageIcon size={16} /> : undefined}
|
icon={NextPageIcon ? <NextPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||||
onPress={() => setCurrentPage((value) => Math.min(totalPages, value + 1))}
|
onPress={() => setCurrentPage((value) => Math.min(totalPages, value + 1))}
|
||||||
disabled={currentPage >= totalPages || loading}
|
disabled={currentPage >= totalPages || loading}
|
||||||
/>
|
/>
|
||||||
@@ -408,7 +435,7 @@ export function DirView({
|
|||||||
size="$3"
|
size="$3"
|
||||||
chromeless
|
chromeless
|
||||||
aria-label="Last page"
|
aria-label="Last page"
|
||||||
icon={LastPageIcon ? <LastPageIcon size={16} /> : undefined}
|
icon={LastPageIcon ? <LastPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||||
onPress={() => setCurrentPage(totalPages)}
|
onPress={() => setCurrentPage(totalPages)}
|
||||||
disabled={currentPage >= totalPages || loading}
|
disabled={currentPage >= totalPages || loading}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+111
-43
@@ -1,19 +1,43 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Adapt, Button, Input, Label, Paragraph, Select, Separator, Sheet, Text, TextArea, XStack, YStack } from 'tamagui';
|
import {
|
||||||
import { Check, ChevronDown, ChevronUp } from '@tamagui/lucide-icons';
|
Adapt,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Paragraph,
|
||||||
|
Select,
|
||||||
|
Separator,
|
||||||
|
Sheet,
|
||||||
|
Switch,
|
||||||
|
Text,
|
||||||
|
TextArea,
|
||||||
|
XStack,
|
||||||
|
YStack,
|
||||||
|
} from 'tamagui';
|
||||||
|
import { getIcon } from './IconMapper.jsx';
|
||||||
import { pickFile } from '../../platform/compat.js';
|
import { pickFile } from '../../platform/compat.js';
|
||||||
|
import { getTypographyRoleProps } from '../styles/index.js';
|
||||||
|
|
||||||
|
const ChevronDown = getIcon('chevron-down');
|
||||||
|
const ChevronUp = getIcon('chevron-up');
|
||||||
|
const CheckIcon = getIcon('check');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper that renders a label, the field's children, and either a helper
|
||||||
|
* line or an error line below. Error styling reads from the semantic
|
||||||
|
* `$danger` token so it follows the active theme.
|
||||||
|
*/
|
||||||
function FieldShell({ label, helperText, error, children }) {
|
function FieldShell({ label, helperText, error, children }) {
|
||||||
return (
|
return (
|
||||||
<YStack gap="$2" width="100%">
|
<YStack gap="$2" width="100%">
|
||||||
{label ? (
|
{label ? (
|
||||||
<Label color="$color" fontWeight="600">
|
<Label {...getTypographyRoleProps('fieldLabel')}>
|
||||||
{label}
|
{label}
|
||||||
</Label>
|
</Label>
|
||||||
) : null}
|
) : null}
|
||||||
{children}
|
{children}
|
||||||
{error || helperText ? (
|
{error || helperText ? (
|
||||||
<Paragraph color={error ? '#dc2626' : '$color'} opacity={error ? 1 : 0.7} fontSize="$3">
|
<Paragraph color={error ? '$danger' : '$textMuted'} fontSize="$3">
|
||||||
{error || helperText}
|
{error || helperText}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -21,29 +45,61 @@ function FieldShell({ label, helperText, error, children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Style fragment shared by all chip-like buttons (multiselect, radio).
|
||||||
|
* Selected: tinted accent background, accent text + border.
|
||||||
|
* Unselected: panel background, hairline border, primary text.
|
||||||
|
*/
|
||||||
|
function chipProps(selected, disabled) {
|
||||||
|
return selected
|
||||||
|
? {
|
||||||
|
backgroundColor: '$accentBg',
|
||||||
|
color: '$accent',
|
||||||
|
borderColor: '$accent',
|
||||||
|
borderWidth: 1,
|
||||||
|
hoverStyle: { backgroundColor: '$accentBgHover' },
|
||||||
|
pressStyle: { backgroundColor: '$accentBgHover' },
|
||||||
|
disabled,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
backgroundColor: '$bgPanel',
|
||||||
|
color: '$textPrimary',
|
||||||
|
borderColor: '$lineSubtle',
|
||||||
|
borderWidth: 1,
|
||||||
|
hoverStyle: { backgroundColor: '$bgPage', borderColor: '$lineStrong' },
|
||||||
|
pressStyle: { backgroundColor: '$bgPage' },
|
||||||
|
disabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function SelectField({ label, value, options = [], placeholder, onValueChange, error, helperText, disabled = false }) {
|
function SelectField({ label, value, options = [], placeholder, onValueChange, error, helperText, disabled = false }) {
|
||||||
return (
|
return (
|
||||||
<FieldShell label={label} error={error} helperText={helperText}>
|
<FieldShell label={label} error={error} helperText={helperText}>
|
||||||
<Select value={value ?? ''} onValueChange={onValueChange} disabled={disabled}>
|
<Select value={value ?? ''} onValueChange={onValueChange} disabled={disabled}>
|
||||||
<Select.Trigger iconAfter={ChevronDown}>
|
<Select.Trigger
|
||||||
|
iconAfter={ChevronDown ? <ChevronDown size="sm" color="$textSecondary" /> : undefined}
|
||||||
|
borderColor={error ? '$danger' : '$lineSubtle'}
|
||||||
|
backgroundColor="$bgPanel"
|
||||||
|
focusStyle={{ borderColor: error ? '$danger' : '$accent' }}
|
||||||
|
>
|
||||||
<Select.Value placeholder={placeholder || label || 'Select'} />
|
<Select.Value placeholder={placeholder || label || 'Select'} />
|
||||||
</Select.Trigger>
|
</Select.Trigger>
|
||||||
|
|
||||||
<Adapt when="sm" platform="touch">
|
<Adapt when="sm" platform="touch">
|
||||||
<Sheet modal dismissOnSnapToBottom snapPoints={[55]}>
|
<Sheet modal dismissOnSnapToBottom snapPoints={[55]}>
|
||||||
<Sheet.Frame>
|
<Sheet.Frame backgroundColor="$bgPanel">
|
||||||
<Sheet.ScrollView>
|
<Sheet.ScrollView>
|
||||||
<Adapt.Contents />
|
<Adapt.Contents />
|
||||||
</Sheet.ScrollView>
|
</Sheet.ScrollView>
|
||||||
</Sheet.Frame>
|
</Sheet.Frame>
|
||||||
<Sheet.Overlay />
|
<Sheet.Overlay backgroundColor="$scrim" />
|
||||||
</Sheet>
|
</Sheet>
|
||||||
</Adapt>
|
</Adapt>
|
||||||
|
|
||||||
<Select.Content zIndex={200000}>
|
<Select.Content zIndex={200000}>
|
||||||
<Select.ScrollUpButton alignItems="center" justifyContent="center" position="relative" width="100%" height="$3">
|
<Select.ScrollUpButton alignItems="center" justifyContent="center" position="relative" width="100%" height="$3">
|
||||||
<YStack zIndex={10}>
|
<YStack zIndex={10}>
|
||||||
<ChevronUp size={18} />
|
{ChevronUp ? <ChevronUp size="sm" color="$textSecondary" /> : null}
|
||||||
</YStack>
|
</YStack>
|
||||||
</Select.ScrollUpButton>
|
</Select.ScrollUpButton>
|
||||||
|
|
||||||
@@ -53,7 +109,7 @@ function SelectField({ label, value, options = [], placeholder, onValueChange, e
|
|||||||
<Select.Item key={option.value} index={index} value={String(option.value)}>
|
<Select.Item key={option.value} index={index} value={String(option.value)}>
|
||||||
<Select.ItemText>{option.label}</Select.ItemText>
|
<Select.ItemText>{option.label}</Select.ItemText>
|
||||||
<Select.ItemIndicator marginLeft="auto">
|
<Select.ItemIndicator marginLeft="auto">
|
||||||
<Check size={16} />
|
{CheckIcon ? <CheckIcon size="sm" color="$accent" /> : null}
|
||||||
</Select.ItemIndicator>
|
</Select.ItemIndicator>
|
||||||
</Select.Item>
|
</Select.Item>
|
||||||
))}
|
))}
|
||||||
@@ -62,7 +118,7 @@ function SelectField({ label, value, options = [], placeholder, onValueChange, e
|
|||||||
|
|
||||||
<Select.ScrollDownButton alignItems="center" justifyContent="center" position="relative" width="100%" height="$3">
|
<Select.ScrollDownButton alignItems="center" justifyContent="center" position="relative" width="100%" height="$3">
|
||||||
<YStack zIndex={10}>
|
<YStack zIndex={10}>
|
||||||
<ChevronDown size={18} />
|
{ChevronDown ? <ChevronDown size="sm" color="$textSecondary" /> : null}
|
||||||
</YStack>
|
</YStack>
|
||||||
</Select.ScrollDownButton>
|
</Select.ScrollDownButton>
|
||||||
</Select.Content>
|
</Select.Content>
|
||||||
@@ -74,7 +130,7 @@ function SelectField({ label, value, options = [], placeholder, onValueChange, e
|
|||||||
async function handleFilePick(fieldId, onChange, props = {}) {
|
async function handleFilePick(fieldId, onChange, props = {}) {
|
||||||
const selection = await pickFile({
|
const selection = await pickFile({
|
||||||
accept: props.accept || '*',
|
accept: props.accept || '*',
|
||||||
readAs: props.readAs || null
|
readAs: props.readAs || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!selection?.file) {
|
if (!selection?.file) {
|
||||||
@@ -103,17 +159,17 @@ export function FormField({
|
|||||||
const fieldValue = value ?? (type === 'multiselect' ? [] : type === 'checkbox' ? false : '');
|
const fieldValue = value ?? (type === 'multiselect' ? [] : type === 'checkbox' ? false : '');
|
||||||
|
|
||||||
if (type === 'divider') {
|
if (type === 'divider') {
|
||||||
return <Separator />;
|
return <Separator borderColor="$lineSubtle" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'title') {
|
if (type === 'title') {
|
||||||
return (
|
return (
|
||||||
<YStack gap="$1">
|
<YStack gap="$1">
|
||||||
<Text fontSize="$7" fontWeight="700" color="$accentColor">
|
<Text fontSize="$6" fontWeight="600" color="$textPrimary">
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
{helperText ? (
|
{helperText ? (
|
||||||
<Paragraph color="$color" opacity={0.7}>
|
<Paragraph color="$textMuted">
|
||||||
{helperText}
|
{helperText}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -151,12 +207,7 @@ export function FormField({
|
|||||||
<Button
|
<Button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
size="$3"
|
size="$3"
|
||||||
theme={selected ? 'active' : undefined}
|
{...chipProps(selected, disabled)}
|
||||||
backgroundColor={selected ? '$accentColor' : '$background'}
|
|
||||||
color={selected ? 'white' : '$color'}
|
|
||||||
borderWidth={1}
|
|
||||||
borderColor={selected ? '$accentColor' : '$borderColor'}
|
|
||||||
disabled={disabled}
|
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
const nextValues = selected
|
const nextValues = selected
|
||||||
? selectedValues.filter((item) => item !== String(option.value))
|
? selectedValues.filter((item) => item !== String(option.value))
|
||||||
@@ -173,21 +224,28 @@ export function FormField({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// True boolean control instead of a Button-pretending-to-be-a-checkbox.
|
||||||
|
// Switch is the right semantic for an on/off setting.
|
||||||
if (type === 'checkbox') {
|
if (type === 'checkbox') {
|
||||||
return (
|
return (
|
||||||
<FieldShell label={label} error={error} helperText={helperText}>
|
<FieldShell label={label} error={error} helperText={helperText}>
|
||||||
<Button
|
<XStack alignItems="center" gap="$3">
|
||||||
size="$3"
|
<Switch
|
||||||
alignSelf="flex-start"
|
id={id}
|
||||||
backgroundColor={fieldValue ? '$accentColor' : '$background'}
|
size="$3"
|
||||||
color={fieldValue ? 'white' : '$color'}
|
checked={Boolean(fieldValue)}
|
||||||
borderWidth={1}
|
disabled={disabled}
|
||||||
borderColor={fieldValue ? '$accentColor' : '$borderColor'}
|
onCheckedChange={(next) => onChange?.(id, next)}
|
||||||
disabled={disabled}
|
backgroundColor={fieldValue ? '$accent' : '$lineStrong'}
|
||||||
onPress={() => onChange?.(id, !fieldValue)}
|
borderColor={error ? '$danger' : 'transparent'}
|
||||||
>
|
borderWidth={error ? 1 : 0}
|
||||||
{fieldValue ? 'Enabled' : 'Disabled'}
|
>
|
||||||
</Button>
|
<Switch.Thumb animation="quick" backgroundColor="$bgPanel" />
|
||||||
|
</Switch>
|
||||||
|
<Text color="$textSecondary" fontSize="$3">
|
||||||
|
{fieldValue ? 'Enabled' : 'Disabled'}
|
||||||
|
</Text>
|
||||||
|
</XStack>
|
||||||
</FieldShell>
|
</FieldShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -202,11 +260,7 @@ export function FormField({
|
|||||||
<Button
|
<Button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
size="$3"
|
size="$3"
|
||||||
backgroundColor={selected ? '$accentColor' : '$background'}
|
{...chipProps(selected, disabled)}
|
||||||
color={selected ? 'white' : '$color'}
|
|
||||||
borderWidth={1}
|
|
||||||
borderColor={selected ? '$accentColor' : '$borderColor'}
|
|
||||||
disabled={disabled}
|
|
||||||
onPress={() => onChange?.(id, option.value)}
|
onPress={() => onChange?.(id, option.value)}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
@@ -222,10 +276,18 @@ export function FormField({
|
|||||||
return (
|
return (
|
||||||
<FieldShell label={label} error={error} helperText={helperText}>
|
<FieldShell label={label} error={error} helperText={helperText}>
|
||||||
<XStack alignItems="center" gap="$3" flexWrap="wrap">
|
<XStack alignItems="center" gap="$3" flexWrap="wrap">
|
||||||
<Button disabled={disabled} onPress={() => handleFilePick(id, onChange, props)}>
|
<Button
|
||||||
|
size="$3"
|
||||||
|
chromeless
|
||||||
|
backgroundColor="$bgPanel"
|
||||||
|
borderColor="$lineSubtle"
|
||||||
|
borderWidth={1}
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={() => handleFilePick(id, onChange, props)}
|
||||||
|
>
|
||||||
{fieldValue?.name ? 'Replace File' : 'Choose File'}
|
{fieldValue?.name ? 'Replace File' : 'Choose File'}
|
||||||
</Button>
|
</Button>
|
||||||
<Text color="$color" opacity={0.75}>
|
<Text color="$textMuted">
|
||||||
{fieldValue?.name || 'No file selected'}
|
{fieldValue?.name || 'No file selected'}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
@@ -243,6 +305,9 @@ export function FormField({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
minHeight={120}
|
minHeight={120}
|
||||||
|
backgroundColor="$bgPanel"
|
||||||
|
borderColor={error ? '$danger' : '$lineSubtle'}
|
||||||
|
focusStyle={{ borderColor: error ? '$danger' : '$accent' }}
|
||||||
/>
|
/>
|
||||||
</FieldShell>
|
</FieldShell>
|
||||||
);
|
);
|
||||||
@@ -254,11 +319,11 @@ export function FormField({
|
|||||||
<YStack
|
<YStack
|
||||||
padding="$3"
|
padding="$3"
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor="$borderColor"
|
borderColor="$lineSubtle"
|
||||||
borderRadius="$4"
|
borderRadius="$radiusMd"
|
||||||
backgroundColor="$accentSurface"
|
backgroundColor="$bgPage"
|
||||||
>
|
>
|
||||||
<Text>{String(fieldValue || '')}</Text>
|
<Text color="$textPrimary">{String(fieldValue || '')}</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
</FieldShell>
|
</FieldShell>
|
||||||
);
|
);
|
||||||
@@ -275,6 +340,9 @@ export function FormField({
|
|||||||
type={type === 'datetime' ? 'datetime-local' : type}
|
type={type === 'datetime' ? 'datetime-local' : type}
|
||||||
keyboardType={type === 'number' ? 'numeric' : undefined}
|
keyboardType={type === 'number' ? 'numeric' : undefined}
|
||||||
autoCapitalize={type === 'email' || type === 'password' ? 'none' : undefined}
|
autoCapitalize={type === 'email' || type === 'password' ? 'none' : undefined}
|
||||||
|
backgroundColor="$bgPanel"
|
||||||
|
borderColor={error ? '$danger' : '$lineSubtle'}
|
||||||
|
focusStyle={{ borderColor: error ? '$danger' : '$accent' }}
|
||||||
/>
|
/>
|
||||||
</FieldShell>
|
</FieldShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -134,7 +134,9 @@ export function FormView({
|
|||||||
) : null}
|
) : null}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<XStack justifyContent="flex-end">
|
<XStack justifyContent="flex-end">
|
||||||
<Button disabled>Saving...</Button>
|
<Button size="$3" theme="accent" disabled>
|
||||||
|
Saving...
|
||||||
|
</Button>
|
||||||
</XStack>
|
</XStack>
|
||||||
) : null}
|
) : null}
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|||||||
+457
-297
@@ -1,324 +1,484 @@
|
|||||||
/**
|
/**
|
||||||
* IconMapper - Maps icon names to Lucide icons from @tamagui/lucide-icons
|
* IconMapper - Phosphor-backed icon system with Tamagui theme integration.
|
||||||
* Cross-platform compatible (web + React Native)
|
*
|
||||||
|
* Why a wrapper layer:
|
||||||
|
* - Lets us swap icon families without touching call sites.
|
||||||
|
* - Each style preset declares an `iconWeight` (regular / light / bold /
|
||||||
|
* duotone ...). The wrapper reads the active preset and applies that
|
||||||
|
* weight, so themes feel cohesive without per-call configuration.
|
||||||
|
* - Lets callers pass Tamagui token strings like `"$accent"` or
|
||||||
|
* `"$textPrimary"` for `color`. Phosphor itself only accepts CSS
|
||||||
|
* colors; the wrapper resolves tokens against the live theme.
|
||||||
|
*
|
||||||
|
* Call-site contract (back-compatible with Lucide era):
|
||||||
|
* const Icon = getIcon('home');
|
||||||
|
* <Icon size="md" color="$textPrimary" /> // semantic size
|
||||||
|
* <Icon size={20} color="$accent" /> // numeric size
|
||||||
|
* <Icon size="sm" color="#0a84ff" weight="bold" /> // override weight
|
||||||
|
*
|
||||||
|
* Semantic sizes (px):
|
||||||
|
* xs:14 sm:16 md:20 lg:24 xl:32
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useTheme } from '@tamagui/core';
|
||||||
|
import { SizableText } from 'tamagui';
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
// navigation & shell
|
||||||
AlertTriangle,
|
House,
|
||||||
ArrowDown,
|
Gear,
|
||||||
ArrowLeft,
|
List as MenuList,
|
||||||
ArrowRight,
|
MagnifyingGlass,
|
||||||
ArrowUp,
|
|
||||||
ArrowUpDown,
|
|
||||||
BarChart3,
|
|
||||||
Bell,
|
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,
|
User,
|
||||||
UserCircle,
|
UserCircle,
|
||||||
Users,
|
Users,
|
||||||
Video,
|
Envelope,
|
||||||
Volume1,
|
// files
|
||||||
Volume2,
|
File,
|
||||||
VolumeX,
|
FileText,
|
||||||
Wifi,
|
Folder,
|
||||||
WifiOff,
|
FolderOpen,
|
||||||
|
Article,
|
||||||
|
Book,
|
||||||
|
Books,
|
||||||
|
// actions
|
||||||
|
PencilSimple,
|
||||||
|
Trash,
|
||||||
|
FloppyDisk,
|
||||||
X,
|
X,
|
||||||
ZoomIn,
|
Check,
|
||||||
ZoomOut
|
Plus,
|
||||||
} from '@tamagui/lucide-icons';
|
Minus,
|
||||||
|
// arrows / chevrons
|
||||||
|
ArrowRight,
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
CaretRight,
|
||||||
|
CaretLeft,
|
||||||
|
CaretUp,
|
||||||
|
CaretDown,
|
||||||
|
CaretDoubleRight,
|
||||||
|
CaretDoubleLeft,
|
||||||
|
DotsThreeVertical,
|
||||||
|
DotsThree,
|
||||||
|
// auth
|
||||||
|
SignIn,
|
||||||
|
SignOut,
|
||||||
|
Lock,
|
||||||
|
LockOpen,
|
||||||
|
// dashboards
|
||||||
|
SquaresFour,
|
||||||
|
ChartBar,
|
||||||
|
TrendUp,
|
||||||
|
CurrencyDollar,
|
||||||
|
// status
|
||||||
|
Info,
|
||||||
|
Warning,
|
||||||
|
WarningCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Question,
|
||||||
|
// visibility
|
||||||
|
Eye,
|
||||||
|
EyeSlash,
|
||||||
|
// media
|
||||||
|
Image as ImageIcon,
|
||||||
|
Camera,
|
||||||
|
VideoCamera,
|
||||||
|
Play,
|
||||||
|
Pause,
|
||||||
|
Square,
|
||||||
|
// communication
|
||||||
|
ChatCircle,
|
||||||
|
ChatText,
|
||||||
|
PaperPlaneTilt,
|
||||||
|
Phone,
|
||||||
|
// content
|
||||||
|
Copy,
|
||||||
|
Scissors,
|
||||||
|
Clipboard,
|
||||||
|
Link,
|
||||||
|
Paperclip,
|
||||||
|
// controls
|
||||||
|
FunnelSimple,
|
||||||
|
ArrowsDownUp,
|
||||||
|
ArrowClockwise,
|
||||||
|
Download,
|
||||||
|
Upload,
|
||||||
|
ShareNetwork,
|
||||||
|
Globe,
|
||||||
|
Sliders,
|
||||||
|
SkipBack,
|
||||||
|
SkipForward,
|
||||||
|
// favorites
|
||||||
|
Heart,
|
||||||
|
Star,
|
||||||
|
BookmarkSimple,
|
||||||
|
// time / location
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
MapPin,
|
||||||
|
MapTrifold,
|
||||||
|
NavigationArrow,
|
||||||
|
// system
|
||||||
|
Power,
|
||||||
|
Sun,
|
||||||
|
WifiHigh,
|
||||||
|
WifiSlash,
|
||||||
|
// volume
|
||||||
|
SpeakerHigh,
|
||||||
|
SpeakerLow,
|
||||||
|
SpeakerSlash,
|
||||||
|
// editing
|
||||||
|
MagnifyingGlassPlus,
|
||||||
|
MagnifyingGlassMinus,
|
||||||
|
Crop,
|
||||||
|
ArrowsClockwise,
|
||||||
|
// formatting
|
||||||
|
TextB,
|
||||||
|
TextItalic,
|
||||||
|
TextUnderline,
|
||||||
|
Code,
|
||||||
|
// cloud / storage
|
||||||
|
Cloud,
|
||||||
|
CloudArrowUp,
|
||||||
|
CloudArrowDown,
|
||||||
|
HardDrive,
|
||||||
|
// network
|
||||||
|
Network,
|
||||||
|
CellSignalHigh,
|
||||||
|
// print / misc
|
||||||
|
Printer,
|
||||||
|
Circle,
|
||||||
|
Lightning,
|
||||||
|
} from '@phosphor-icons/react';
|
||||||
|
|
||||||
|
import { themeManager } from '../theme-controller.js';
|
||||||
|
import { getIconWeight, ICON_WEIGHTS } from '../styles/index.js';
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Sizing */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
const SIZE_PX = Object.freeze({
|
||||||
|
xs: 14,
|
||||||
|
sm: 16,
|
||||||
|
md: 20,
|
||||||
|
lg: 24,
|
||||||
|
xl: 32,
|
||||||
|
});
|
||||||
|
|
||||||
|
const DEFAULT_SIZE = 'sm';
|
||||||
|
|
||||||
|
function resolveSize(size) {
|
||||||
|
if (typeof size === 'number' && Number.isFinite(size)) return size;
|
||||||
|
if (typeof size === 'string' && SIZE_PX[size] != null) return SIZE_PX[size];
|
||||||
|
// numeric string ('20', '24px') — best-effort parse
|
||||||
|
if (typeof size === 'string') {
|
||||||
|
const n = parseFloat(size);
|
||||||
|
if (!Number.isNaN(n)) return n;
|
||||||
|
}
|
||||||
|
return SIZE_PX[DEFAULT_SIZE];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Color resolution */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Icon name to Lucide icon component mapping
|
* Phosphor's `color` prop only accepts plain CSS colors. Callers often pass
|
||||||
* Maps Material Design icon names to Lucide equivalents
|
* Tamagui token strings like `"$accent"`. We resolve those against the live
|
||||||
|
* theme via Tamagui's `useTheme()`. Anything else is passed through.
|
||||||
*/
|
*/
|
||||||
|
function resolveColor(color, theme) {
|
||||||
|
if (color == null) return undefined;
|
||||||
|
if (typeof color !== 'string') return color;
|
||||||
|
if (color === 'currentColor') return 'currentColor';
|
||||||
|
if (!color.startsWith('$')) return color;
|
||||||
|
|
||||||
|
const key = color.slice(1);
|
||||||
|
const token = theme?.[key];
|
||||||
|
if (!token) return undefined;
|
||||||
|
if (typeof token.val !== 'undefined') return token.val;
|
||||||
|
if (typeof token.get === 'function') return token.get();
|
||||||
|
return String(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Active icon weight (subscribes to themeManager) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
function useActiveIconWeight() {
|
||||||
|
const [name, setName] = React.useState(() => themeManager.getStyleThemeName());
|
||||||
|
React.useEffect(() => {
|
||||||
|
return themeManager.subscribe((s) => setName(s.styleThemeName));
|
||||||
|
}, []);
|
||||||
|
return getIconWeight(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Wrapper factory */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
function wrap(Icon, displayName) {
|
||||||
|
const Wrapped = React.forwardRef(function PhosphorIcon(
|
||||||
|
{ size = DEFAULT_SIZE, color = '$textPrimary', weight, ...rest },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const presetWeight = useActiveIconWeight();
|
||||||
|
const finalWeight = ICON_WEIGHTS.includes(weight) ? weight : presetWeight;
|
||||||
|
return (
|
||||||
|
<Icon
|
||||||
|
ref={ref}
|
||||||
|
size={resolveSize(size)}
|
||||||
|
color={resolveColor(color, theme)}
|
||||||
|
weight={finalWeight}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Wrapped.displayName = `Icon(${displayName})`;
|
||||||
|
return Wrapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Name → Phosphor wrapper map */
|
||||||
|
/* */
|
||||||
|
/* Keys preserve every Lucide-era alias used in the codebase so existing */
|
||||||
|
/* call sites work without changes. Add new aliases freely — keep the */
|
||||||
|
/* vocabulary stable. */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
const iconMap = {
|
const iconMap = {
|
||||||
// Navigation & UI
|
// ── Navigation & UI ────────────────────────────────────────────────────
|
||||||
'home': Home,
|
'home': wrap(House, 'House'),
|
||||||
'settings': Settings,
|
'settings': wrap(Gear, 'Gear'),
|
||||||
'user': User,
|
'gear': wrap(Gear, 'Gear'),
|
||||||
'person': User,
|
'user': wrap(User, 'User'),
|
||||||
'account': UserCircle,
|
'person': wrap(User, 'User'),
|
||||||
'menu': Menu,
|
'account': wrap(UserCircle, 'UserCircle'),
|
||||||
'hamburger': Menu,
|
'menu': wrap(MenuList, 'List'),
|
||||||
'search': Search,
|
'hamburger': wrap(MenuList, 'List'),
|
||||||
'bell': Bell,
|
'list': wrap(MenuList, 'List'),
|
||||||
'notifications': Bell,
|
'search': wrap(MagnifyingGlass, 'MagnifyingGlass'),
|
||||||
'mail': Mail,
|
'bell': wrap(Bell, 'Bell'),
|
||||||
'email': Mail,
|
'notifications': wrap(Bell, 'Bell'),
|
||||||
|
'mail': wrap(Envelope, 'Envelope'),
|
||||||
// Files & Folders
|
'email': wrap(Envelope, 'Envelope'),
|
||||||
'file': File,
|
|
||||||
'folder': Folder,
|
// ── Files & Folders ────────────────────────────────────────────────────
|
||||||
'folder-open': FolderOpen,
|
'file': wrap(File, 'File'),
|
||||||
|
'folder': wrap(Folder, 'Folder'),
|
||||||
// Actions
|
'folder-open': wrap(FolderOpen, 'FolderOpen'),
|
||||||
'edit': SquarePen,
|
|
||||||
'delete': Trash2,
|
// ── Actions ────────────────────────────────────────────────────────────
|
||||||
'save': Save,
|
'edit': wrap(PencilSimple, 'PencilSimple'),
|
||||||
'close': X,
|
'pencil': wrap(PencilSimple, 'PencilSimple'),
|
||||||
'x': X,
|
'delete': wrap(Trash, 'Trash'),
|
||||||
'check': Check,
|
'trash': wrap(Trash, 'Trash'),
|
||||||
'plus': Plus,
|
'save': wrap(FloppyDisk, 'FloppyDisk'),
|
||||||
'minus': Minus,
|
'close': wrap(X, 'X'),
|
||||||
|
'x': wrap(X, 'X'),
|
||||||
// Arrows & Navigation
|
'check': wrap(Check, 'Check'),
|
||||||
'arrow-right': ArrowRight,
|
'plus': wrap(Plus, 'Plus'),
|
||||||
'arrow-left': ArrowLeft,
|
'add': wrap(Plus, 'Plus'),
|
||||||
'arrow-up': ArrowUp,
|
'minus': wrap(Minus, 'Minus'),
|
||||||
'arrow-down': ArrowDown,
|
|
||||||
'chevron-right': ChevronRight,
|
// ── Arrows & Chevrons ──────────────────────────────────────────────────
|
||||||
'chevron-down': ChevronDown,
|
'arrow-right': wrap(ArrowRight, 'ArrowRight'),
|
||||||
'chevron-up': ChevronUp,
|
'arrow-left': wrap(ArrowLeft, 'ArrowLeft'),
|
||||||
'chevron-left': ChevronLeft,
|
'arrow-up': wrap(ArrowUp, 'ArrowUp'),
|
||||||
'chevrons-right': ChevronsRight,
|
'arrow-down': wrap(ArrowDown, 'ArrowDown'),
|
||||||
'chevrons-left': ChevronsLeft,
|
'chevron-right': wrap(CaretRight, 'CaretRight'),
|
||||||
'more-vert': MoreVertical,
|
'chevron-down': wrap(CaretDown, 'CaretDown'),
|
||||||
'more-horiz': MoreHorizontal,
|
'chevron-up': wrap(CaretUp, 'CaretUp'),
|
||||||
|
'chevron-left': wrap(CaretLeft, 'CaretLeft'),
|
||||||
// Auth
|
'chevrons-right': wrap(CaretDoubleRight, 'CaretDoubleRight'),
|
||||||
'logout': LogOut,
|
'chevrons-left': wrap(CaretDoubleLeft, 'CaretDoubleLeft'),
|
||||||
'login': LogIn,
|
'caret-up': wrap(CaretUp, 'CaretUp'),
|
||||||
'lock': Lock,
|
'caret-down': wrap(CaretDown, 'CaretDown'),
|
||||||
'unlock': Unlock,
|
'caret-left': wrap(CaretLeft, 'CaretLeft'),
|
||||||
|
'caret-right': wrap(CaretRight, 'CaretRight'),
|
||||||
// Dashboard & Analytics
|
'more-vert': wrap(DotsThreeVertical, 'DotsThreeVertical'),
|
||||||
'dashboard': LayoutDashboard,
|
'more-horiz': wrap(DotsThree, 'DotsThree'),
|
||||||
'chart': BarChart3,
|
|
||||||
'analytics': TrendingUp,
|
// ── Auth ───────────────────────────────────────────────────────────────
|
||||||
'money': DollarSign,
|
'logout': wrap(SignOut, 'SignOut'),
|
||||||
'group': Users,
|
'login': wrap(SignIn, 'SignIn'),
|
||||||
'report': FileText,
|
'lock': wrap(Lock, 'Lock'),
|
||||||
|
'unlock': wrap(LockOpen, 'LockOpen'),
|
||||||
// Status & Feedback
|
|
||||||
'info': Info,
|
// ── Dashboards & Analytics ─────────────────────────────────────────────
|
||||||
'warning': AlertTriangle,
|
'dashboard': wrap(SquaresFour, 'SquaresFour'),
|
||||||
'error': AlertCircle,
|
'chart': wrap(ChartBar, 'ChartBar'),
|
||||||
'success': CheckCircle,
|
'analytics': wrap(TrendUp, 'TrendUp'),
|
||||||
'help': HelpCircle,
|
'money': wrap(CurrencyDollar, 'CurrencyDollar'),
|
||||||
|
'group': wrap(Users, 'Users'),
|
||||||
// Visibility
|
'users': wrap(Users, 'Users'),
|
||||||
'visibility': Eye,
|
'report': wrap(FileText, 'FileText'),
|
||||||
'visibility-off': EyeOff,
|
|
||||||
|
// ── Status ─────────────────────────────────────────────────────────────
|
||||||
// Media
|
'info': wrap(Info, 'Info'),
|
||||||
'image': Image,
|
'warning': wrap(Warning, 'Warning'),
|
||||||
'photo': Camera,
|
'error': wrap(WarningCircle, 'WarningCircle'),
|
||||||
'video': Video,
|
'success': wrap(CheckCircle, 'CheckCircle'),
|
||||||
'play': Play,
|
'help': wrap(Question, 'Question'),
|
||||||
'pause': Pause,
|
|
||||||
'stop': Square,
|
// ── Visibility ─────────────────────────────────────────────────────────
|
||||||
|
'visibility': wrap(Eye, 'Eye'),
|
||||||
// Communication
|
'visibility-off': wrap(EyeSlash, 'EyeSlash'),
|
||||||
'chat': MessageCircle,
|
'eye': wrap(Eye, 'Eye'),
|
||||||
'message': MessageSquare,
|
'eye-off': wrap(EyeSlash, 'EyeSlash'),
|
||||||
'comment': MessageSquare,
|
|
||||||
'send': Send,
|
// ── Media ──────────────────────────────────────────────────────────────
|
||||||
'phone': Phone,
|
'image': wrap(ImageIcon, 'Image'),
|
||||||
|
'photo': wrap(Camera, 'Camera'),
|
||||||
// Content
|
'video': wrap(VideoCamera, 'VideoCamera'),
|
||||||
'copy': Copy,
|
'play': wrap(Play, 'Play'),
|
||||||
'cut': Scissors,
|
'pause': wrap(Pause, 'Pause'),
|
||||||
'paste': Clipboard,
|
'stop': wrap(Square, 'Square'),
|
||||||
'link': Link,
|
'square': wrap(Square, 'Square'),
|
||||||
'attach': Paperclip,
|
'circle': wrap(Circle, 'Circle'),
|
||||||
|
|
||||||
// UI Controls
|
// ── Communication ──────────────────────────────────────────────────────
|
||||||
'filter': Filter,
|
'chat': wrap(ChatCircle, 'ChatCircle'),
|
||||||
'sort': ArrowUpDown,
|
'message': wrap(ChatText, 'ChatText'),
|
||||||
'refresh': RefreshCw,
|
'comment': wrap(ChatText, 'ChatText'),
|
||||||
'download': Download,
|
'send': wrap(PaperPlaneTilt, 'PaperPlaneTilt'),
|
||||||
'upload': Upload,
|
'phone': wrap(Phone, 'Phone'),
|
||||||
'share': Share2,
|
|
||||||
'language': Globe,
|
// ── Content ────────────────────────────────────────────────────────────
|
||||||
'locale': Globe,
|
'copy': wrap(Copy, 'Copy'),
|
||||||
'tune': SlidersHorizontal,
|
'cut': wrap(Scissors, 'Scissors'),
|
||||||
'first-page': SkipBack,
|
'paste': wrap(Clipboard, 'Clipboard'),
|
||||||
'last-page': SkipForward,
|
'link': wrap(Link, 'Link'),
|
||||||
|
'attach': wrap(Paperclip, 'Paperclip'),
|
||||||
// Favorites & Bookmarks
|
|
||||||
'favorite': Heart,
|
// ── UI Controls ────────────────────────────────────────────────────────
|
||||||
'favorite-border': Heart,
|
'filter': wrap(FunnelSimple, 'FunnelSimple'),
|
||||||
'star': Star,
|
'sort': wrap(ArrowsDownUp, 'ArrowsDownUp'),
|
||||||
'star-border': Star,
|
'refresh': wrap(ArrowClockwise, 'ArrowClockwise'),
|
||||||
'bookmark': Bookmark,
|
'download': wrap(Download, 'Download'),
|
||||||
|
'upload': wrap(Upload, 'Upload'),
|
||||||
// Time & Calendar
|
'share': wrap(ShareNetwork, 'ShareNetwork'),
|
||||||
'calendar': Calendar,
|
'language': wrap(Globe, 'Globe'),
|
||||||
'time': Clock,
|
'locale': wrap(Globe, 'Globe'),
|
||||||
|
'tune': wrap(Sliders, 'Sliders'),
|
||||||
// Location
|
'first-page': wrap(SkipBack, 'SkipBack'),
|
||||||
'location': MapPin,
|
'last-page': wrap(SkipForward, 'SkipForward'),
|
||||||
'location-on': MapPin,
|
|
||||||
'map': Map,
|
// ── Favorites & Bookmarks ──────────────────────────────────────────────
|
||||||
'navigation': Navigation,
|
'favorite': wrap(Heart, 'Heart'),
|
||||||
|
'favorite-border': wrap(Heart, 'Heart'),
|
||||||
// System
|
'star': wrap(Star, 'Star'),
|
||||||
'power': Power,
|
'star-border': wrap(Star, 'Star'),
|
||||||
'brightness': Sun,
|
'bookmark': wrap(BookmarkSimple, 'BookmarkSimple'),
|
||||||
'wifi': Wifi,
|
|
||||||
'wifi-off': WifiOff,
|
// ── Time & Calendar ────────────────────────────────────────────────────
|
||||||
|
'calendar': wrap(Calendar, 'Calendar'),
|
||||||
// Media Controls
|
'time': wrap(Clock, 'Clock'),
|
||||||
'volume-up': Volume2,
|
'clock': wrap(Clock, 'Clock'),
|
||||||
'volume-down': Volume1,
|
|
||||||
'volume-off': VolumeX,
|
// ── Location ───────────────────────────────────────────────────────────
|
||||||
'mute': VolumeX,
|
'location': wrap(MapPin, 'MapPin'),
|
||||||
|
'location-on': wrap(MapPin, 'MapPin'),
|
||||||
// Editing
|
'map': wrap(MapTrifold, 'MapTrifold'),
|
||||||
'zoom-in': ZoomIn,
|
'navigation': wrap(NavigationArrow, 'NavigationArrow'),
|
||||||
'zoom-out': ZoomOut,
|
|
||||||
'crop': Crop,
|
// ── System ─────────────────────────────────────────────────────────────
|
||||||
'rotate': RotateCw,
|
'power': wrap(Power, 'Power'),
|
||||||
|
'brightness': wrap(Sun, 'Sun'),
|
||||||
// Formatting
|
'sun': wrap(Sun, 'Sun'),
|
||||||
'format-bold': Bold,
|
'wifi': wrap(WifiHigh, 'WifiHigh'),
|
||||||
'format-italic': Italic,
|
'wifi-off': wrap(WifiSlash, 'WifiSlash'),
|
||||||
'format-underline': Underline,
|
'lightning': wrap(Lightning, 'Lightning'),
|
||||||
'code': Code,
|
|
||||||
|
// ── Volume ─────────────────────────────────────────────────────────────
|
||||||
// Files & Documents
|
'volume-up': wrap(SpeakerHigh, 'SpeakerHigh'),
|
||||||
'document': FileText,
|
'volume-down': wrap(SpeakerLow, 'SpeakerLow'),
|
||||||
'article': FileText,
|
'volume-off': wrap(SpeakerSlash, 'SpeakerSlash'),
|
||||||
'book': Book,
|
'mute': wrap(SpeakerSlash, 'SpeakerSlash'),
|
||||||
'library': Library,
|
|
||||||
|
// ── Editing ────────────────────────────────────────────────────────────
|
||||||
// Cloud & Storage
|
'zoom-in': wrap(MagnifyingGlassPlus, 'MagnifyingGlassPlus'),
|
||||||
'cloud': Cloud,
|
'zoom-out': wrap(MagnifyingGlassMinus, 'MagnifyingGlassMinus'),
|
||||||
'cloud-upload': CloudUpload,
|
'crop': wrap(Crop, 'Crop'),
|
||||||
'cloud-download': CloudDownload,
|
'rotate': wrap(ArrowsClockwise, 'ArrowsClockwise'),
|
||||||
'drive': HardDrive,
|
|
||||||
|
// ── Text formatting ────────────────────────────────────────────────────
|
||||||
// Network
|
'format-bold': wrap(TextB, 'TextB'),
|
||||||
'network': Network,
|
'format-italic': wrap(TextItalic, 'TextItalic'),
|
||||||
'signal': Signal,
|
'format-underline': wrap(TextUnderline, 'TextUnderline'),
|
||||||
|
'code': wrap(Code, 'Code'),
|
||||||
// Print
|
|
||||||
'print': Printer,
|
// ── Documents ──────────────────────────────────────────────────────────
|
||||||
|
'document': wrap(FileText, 'FileText'),
|
||||||
// Add more mappings as needed
|
'article': wrap(Article, 'Article'),
|
||||||
|
'book': wrap(Book, 'Book'),
|
||||||
|
'library': wrap(Books, 'Books'),
|
||||||
|
|
||||||
|
// ── Cloud & Storage ────────────────────────────────────────────────────
|
||||||
|
'cloud': wrap(Cloud, 'Cloud'),
|
||||||
|
'cloud-upload': wrap(CloudArrowUp, 'CloudArrowUp'),
|
||||||
|
'cloud-download': wrap(CloudArrowDown, 'CloudArrowDown'),
|
||||||
|
'drive': wrap(HardDrive, 'HardDrive'),
|
||||||
|
|
||||||
|
// ── Network ────────────────────────────────────────────────────────────
|
||||||
|
'network': wrap(Network, 'Network'),
|
||||||
|
'signal': wrap(CellSignalHigh, 'CellSignalHigh'),
|
||||||
|
|
||||||
|
// ── Print ──────────────────────────────────────────────────────────────
|
||||||
|
'print': wrap(Printer, 'Printer'),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Public API */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get Lucide icon component by name
|
* Look up a wrapped Phosphor icon by name. Returns a React component (the
|
||||||
* @param {string} iconName - Name of the icon (e.g., 'home', 'settings')
|
* wrapper handles theme-aware color, sizing, and weight) or `null` if the
|
||||||
* @returns {React.Component|null} Lucide icon component or null
|
* name is unknown.
|
||||||
|
*
|
||||||
|
* @param {string} iconName - canonical alias from {@link iconMap}
|
||||||
|
* @returns {React.ComponentType<{size?:string|number,color?:string,weight?:string}>|null}
|
||||||
*/
|
*/
|
||||||
export function getIcon(iconName) {
|
export function getIcon(iconName) {
|
||||||
if (!iconName || typeof iconName !== 'string') {
|
if (typeof iconName !== 'string' || !iconName) return null;
|
||||||
return null;
|
return iconMap[iconName.toLowerCase().trim()] || null;
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedName = iconName.toLowerCase().trim();
|
|
||||||
return iconMap[normalizedName] || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* IconMapper Component
|
* Convenience component: `<IconMapper iconName="home" size="md" />`.
|
||||||
* Renders a Lucide icon by name with Tamagui theme support
|
* Falls back to a small Tamagui Text node when the input is an emoji or
|
||||||
* @param {string} iconName - Name of the icon
|
* single character (e.g. user-entered avatar glyphs).
|
||||||
* @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 }) {
|
export function IconMapper({ iconName, size = DEFAULT_SIZE, color = '$textPrimary', ...props }) {
|
||||||
const IconComponent = getIcon(iconName);
|
const Icon = getIcon(iconName);
|
||||||
|
if (Icon) {
|
||||||
if (!IconComponent) {
|
return <Icon size={size} color={color} {...props} />;
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
if (typeof iconName === 'string' && (iconName.length <= 2 || /[\u{1F300}-\u{1F9FF}]/u.test(iconName))) {
|
||||||
// Convert size if it's a number to a reasonable default
|
return <SizableText fontSize={resolveSize(size)} color={color}>{iconName}</SizableText>;
|
||||||
const iconSize = typeof size === 'string' ? size : size;
|
}
|
||||||
|
return null;
|
||||||
return <IconComponent size={iconSize} color={color} {...props} />;
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the px value for a semantic size token. Useful when laying out
|
||||||
|
* non-icon children (avatars, badges) alongside icons.
|
||||||
|
*/
|
||||||
|
export function getIconSize(size) {
|
||||||
|
return resolveSize(size);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List of every registered alias. Handy for validation / docs.
|
||||||
|
*/
|
||||||
|
export function getIconNames() {
|
||||||
|
return Object.keys(iconMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default IconMapper;
|
export default IconMapper;
|
||||||
|
|||||||
@@ -258,41 +258,34 @@ export function MenuItemButton({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine background color based on state
|
// Static state colors. Hover treatments live in `hoverStyle`/`pressStyle`
|
||||||
|
// below so they animate via Tamagui's pseudo-state machinery instead of
|
||||||
|
// being baked into the resting style.
|
||||||
const getBackgroundColor = () => {
|
const getBackgroundColor = () => {
|
||||||
if (selected) {
|
if (selected) return '$accentBg';
|
||||||
return '$accentBackground';
|
if (hovered) return '$bgPage';
|
||||||
}
|
|
||||||
if (hovered) {
|
|
||||||
return '$backgroundPress';
|
|
||||||
}
|
|
||||||
return 'transparent';
|
return 'transparent';
|
||||||
};
|
};
|
||||||
|
|
||||||
const getIconColor = () => {
|
const getIconColor = () => {
|
||||||
if (selected) {
|
if (selected) return '$accent';
|
||||||
return '$accentColor';
|
if (menuItem.style === 'icon_only') return '$accent';
|
||||||
}
|
return '$textPrimary';
|
||||||
if (menuItem.style === 'icon_only') {
|
|
||||||
return '$accentColor';
|
|
||||||
}
|
|
||||||
return '$color';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getLabelColor = () => {
|
const getLabelColor = () => {
|
||||||
if (selected) {
|
if (selected) return '$accent';
|
||||||
return '$accentColor';
|
return '$textPrimary';
|
||||||
}
|
|
||||||
return '$color';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getArrowColor = () => {
|
const getArrowColor = () => {
|
||||||
if (selected) {
|
if (selected) return '$accent';
|
||||||
return '$accentColor';
|
return '$textMuted';
|
||||||
}
|
|
||||||
return '$colorSecondary';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const ICON_SIZE = 'sm';
|
||||||
|
const CHEVRON_SIZE = 'sm';
|
||||||
|
|
||||||
// Determine display style (both, label_only, icon_only)
|
// Determine display style (both, label_only, icon_only)
|
||||||
// Use displayStyle prop if provided, otherwise fall back to menuItem.style
|
// Use displayStyle prop if provided, otherwise fall back to menuItem.style
|
||||||
const effectiveDisplayStyle = displayStyle !== undefined ? displayStyle : (menuItem.style || 'both');
|
const effectiveDisplayStyle = displayStyle !== undefined ? displayStyle : (menuItem.style || 'both');
|
||||||
@@ -325,11 +318,9 @@ export function MenuItemButton({
|
|||||||
width="100%"
|
width="100%"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
backgroundColor={getBackgroundColor()}
|
backgroundColor={getBackgroundColor()}
|
||||||
borderWidth={selected ? 1 : 0}
|
borderRadius="$radiusSm"
|
||||||
borderColor={selected ? '$accentBorder' : 'transparent'}
|
|
||||||
borderRadius="$2"
|
|
||||||
padding={padding}
|
padding={padding}
|
||||||
opacity={menuItem.is_active !== false ? 1 : 0.5}
|
opacity={menuItem.is_active !== false ? 1 : 0.55}
|
||||||
>
|
>
|
||||||
{/* Icon + Label (clickable main area) */}
|
{/* Icon + Label (clickable main area) */}
|
||||||
<XStack
|
<XStack
|
||||||
@@ -337,10 +328,10 @@ export function MenuItemButton({
|
|||||||
alignItems="center"
|
alignItems="center"
|
||||||
cursor={menuItem.isActionable() || hasSubitems ? 'pointer' : 'default'}
|
cursor={menuItem.isActionable() || hasSubitems ? 'pointer' : 'default'}
|
||||||
hoverStyle={{
|
hoverStyle={{
|
||||||
backgroundColor: hovered || selected ? getBackgroundColor() : '$backgroundHover'
|
backgroundColor: selected ? '$accentBgHover' : '$bgPage'
|
||||||
}}
|
}}
|
||||||
pressStyle={{
|
pressStyle={{
|
||||||
backgroundColor: selected ? '$accentHover' : '$backgroundPress'
|
backgroundColor: selected ? '$accentBgHover' : '$bgPanelElev'
|
||||||
}}
|
}}
|
||||||
onPress={handleMainClick}
|
onPress={handleMainClick}
|
||||||
>
|
>
|
||||||
@@ -348,18 +339,16 @@ export function MenuItemButton({
|
|||||||
{showIcon && (
|
{showIcon && (
|
||||||
<XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center">
|
<XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center">
|
||||||
{typeof IconComponent === 'string' ? (
|
{typeof IconComponent === 'string' ? (
|
||||||
// Emoji fallback
|
|
||||||
<Text fontSize={size} lineHeight={size}>{IconComponent}</Text>
|
<Text fontSize={size} lineHeight={size}>{IconComponent}</Text>
|
||||||
) : IconComponent ? (
|
) : IconComponent ? (
|
||||||
// Material Design icon component
|
<IconComponent size={ICON_SIZE} color={getIconColor()} />
|
||||||
<IconComponent size={typeof size === 'string' ? 24 : (size || 24)} color={getIconColor()} />
|
|
||||||
) : null}
|
) : null}
|
||||||
</XStack>
|
</XStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Label */}
|
{/* Label */}
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
<Text flex={1} fontSize={size} fontWeight={selected ? 'bold' : 'normal'} color={getLabelColor()}>
|
<Text flex={1} fontSize={size} fontWeight={selected ? '600' : '400'} color={getLabelColor()}>
|
||||||
{menuItem.label}
|
{menuItem.label}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -372,16 +361,17 @@ export function MenuItemButton({
|
|||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
padding="$1"
|
padding="$1"
|
||||||
|
borderRadius="$radiusSm"
|
||||||
hoverStyle={{
|
hoverStyle={{
|
||||||
backgroundColor: '$backgroundHover'
|
backgroundColor: '$bgPage'
|
||||||
}}
|
}}
|
||||||
pressStyle={{
|
pressStyle={{
|
||||||
backgroundColor: selected ? '$accentHover' : '$backgroundPress'
|
backgroundColor: '$bgPanelElev'
|
||||||
}}
|
}}
|
||||||
onPress={handleToggleExpand}
|
onPress={handleToggleExpand}
|
||||||
>
|
>
|
||||||
<ArrowIcon
|
<ArrowIcon
|
||||||
size={16}
|
size={CHEVRON_SIZE}
|
||||||
color={getArrowColor()}
|
color={getArrowColor()}
|
||||||
style={{ marginLeft: 4, flexShrink: 0 }}
|
style={{ marginLeft: 4, flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
@@ -394,15 +384,15 @@ export function MenuItemButton({
|
|||||||
<YStack
|
<YStack
|
||||||
ref={popupRef}
|
ref={popupRef}
|
||||||
position="fixed"
|
position="fixed"
|
||||||
backgroundColor="$background"
|
backgroundColor="$bgPanelElev"
|
||||||
borderRadius="$3"
|
borderRadius="$radiusMd"
|
||||||
padding={0}
|
padding={0}
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor="$borderColor"
|
borderColor="$lineSubtle"
|
||||||
shadowColor="$shadowColor"
|
shadowColor="$shadowColor"
|
||||||
shadowOffset={{ width: 0, height: 4 }}
|
shadowOffset={{ width: 0, height: 4 }}
|
||||||
shadowOpacity={0.2}
|
shadowOpacity={0.18}
|
||||||
shadowRadius={8}
|
shadowRadius={12}
|
||||||
elevation={8}
|
elevation={8}
|
||||||
zIndex={9999}
|
zIndex={9999}
|
||||||
minWidth={200}
|
minWidth={200}
|
||||||
@@ -457,11 +447,9 @@ export function MenuItemButton({
|
|||||||
width="100%"
|
width="100%"
|
||||||
title={collapsed && menuItem.label ? menuItem.label : undefined}
|
title={collapsed && menuItem.label ? menuItem.label : undefined}
|
||||||
backgroundColor={getBackgroundColor()}
|
backgroundColor={getBackgroundColor()}
|
||||||
borderWidth={selected ? 1 : 0}
|
borderRadius="$radiusSm"
|
||||||
borderColor={selected ? '$accentBorder' : 'transparent'}
|
|
||||||
borderRadius="$2"
|
|
||||||
padding={padding}
|
padding={padding}
|
||||||
opacity={menuItem.is_active !== false ? 1 : 0.5}
|
opacity={menuItem.is_active !== false ? 1 : 0.55}
|
||||||
>
|
>
|
||||||
{/* Icon + Label (clickable main area) */}
|
{/* Icon + Label (clickable main area) */}
|
||||||
<XStack
|
<XStack
|
||||||
@@ -469,10 +457,10 @@ export function MenuItemButton({
|
|||||||
alignItems="center"
|
alignItems="center"
|
||||||
cursor={menuItem.isActionable() || hasSubitems ? 'pointer' : 'default'}
|
cursor={menuItem.isActionable() || hasSubitems ? 'pointer' : 'default'}
|
||||||
hoverStyle={{
|
hoverStyle={{
|
||||||
backgroundColor: hovered || selected ? getBackgroundColor() : '$backgroundHover'
|
backgroundColor: selected ? '$accentBgHover' : '$bgPage'
|
||||||
}}
|
}}
|
||||||
pressStyle={{
|
pressStyle={{
|
||||||
backgroundColor: selected ? '$accentHover' : '$backgroundPress'
|
backgroundColor: selected ? '$accentBgHover' : '$bgPanelElev'
|
||||||
}}
|
}}
|
||||||
onPress={handleMainClick}
|
onPress={handleMainClick}
|
||||||
>
|
>
|
||||||
@@ -480,18 +468,16 @@ export function MenuItemButton({
|
|||||||
{showIcon && (
|
{showIcon && (
|
||||||
<XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center">
|
<XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center">
|
||||||
{typeof IconComponent === 'string' ? (
|
{typeof IconComponent === 'string' ? (
|
||||||
// Emoji fallback
|
|
||||||
<Text fontSize={size} lineHeight={size}>{IconComponent}</Text>
|
<Text fontSize={size} lineHeight={size}>{IconComponent}</Text>
|
||||||
) : IconComponent ? (
|
) : IconComponent ? (
|
||||||
// Material Design icon component
|
<IconComponent size={ICON_SIZE} color={getIconColor()} />
|
||||||
<IconComponent size={typeof size === 'string' ? 24 : (size || 24)} color={getIconColor()} />
|
|
||||||
) : null}
|
) : null}
|
||||||
</XStack>
|
</XStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Label */}
|
{/* Label */}
|
||||||
{showLabel && (
|
{showLabel && (
|
||||||
<Text flex={1} fontSize={size} fontWeight={selected ? 'bold' : 'normal'} color={getLabelColor()}>
|
<Text flex={1} fontSize={size} fontWeight={selected ? '600' : '400'} color={getLabelColor()}>
|
||||||
{menuItem.label}
|
{menuItem.label}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -504,16 +490,17 @@ export function MenuItemButton({
|
|||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
padding="$1"
|
padding="$1"
|
||||||
|
borderRadius="$radiusSm"
|
||||||
hoverStyle={{
|
hoverStyle={{
|
||||||
backgroundColor: '$backgroundHover'
|
backgroundColor: '$bgPage'
|
||||||
}}
|
}}
|
||||||
pressStyle={{
|
pressStyle={{
|
||||||
backgroundColor: selected ? '$accentHover' : '$backgroundPress'
|
backgroundColor: '$bgPanelElev'
|
||||||
}}
|
}}
|
||||||
onPress={handleToggleExpand}
|
onPress={handleToggleExpand}
|
||||||
>
|
>
|
||||||
<ArrowIcon
|
<ArrowIcon
|
||||||
size={16}
|
size={CHEVRON_SIZE}
|
||||||
color={getArrowColor()}
|
color={getArrowColor()}
|
||||||
style={{ marginLeft: 8, flexShrink: 0 }}
|
style={{ marginLeft: 8, flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
@@ -553,15 +540,15 @@ export function MenuItemButton({
|
|||||||
<YStack
|
<YStack
|
||||||
ref={popupRef}
|
ref={popupRef}
|
||||||
position="fixed"
|
position="fixed"
|
||||||
backgroundColor="$background"
|
backgroundColor="$bgPanelElev"
|
||||||
borderRadius="$3"
|
borderRadius="$radiusMd"
|
||||||
padding={0}
|
padding={0}
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor="$borderColor"
|
borderColor="$lineSubtle"
|
||||||
shadowColor="$shadowColor"
|
shadowColor="$shadowColor"
|
||||||
shadowOffset={{ width: 0, height: 4 }}
|
shadowOffset={{ width: 0, height: 4 }}
|
||||||
shadowOpacity={0.2}
|
shadowOpacity={0.18}
|
||||||
shadowRadius={8}
|
shadowRadius={12}
|
||||||
elevation={8}
|
elevation={8}
|
||||||
zIndex={9999}
|
zIndex={9999}
|
||||||
minWidth={200}
|
minWidth={200}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { XStack, YStack, Text } from 'tamagui';
|
import { XStack, YStack, Text } from 'tamagui';
|
||||||
import { getIcon } from './IconMapper.jsx';
|
import { getIcon } from './IconMapper.jsx';
|
||||||
|
import { getTypographyRoleProps } from '../styles/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Page Component
|
* Page Component
|
||||||
@@ -36,15 +37,15 @@ export function Page({
|
|||||||
if (IconComponent) {
|
if (IconComponent) {
|
||||||
headerLeftItems.push(
|
headerLeftItems.push(
|
||||||
<XStack key="page-icon" alignItems="center" justifyContent="center" marginRight="$2">
|
<XStack key="page-icon" alignItems="center" justifyContent="center" marginRight="$2">
|
||||||
<IconComponent size={24} color="$accentColor" />
|
<IconComponent size="md" color="$textPrimary" />
|
||||||
</XStack>
|
</XStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (title) {
|
if (title) {
|
||||||
headerLeftItems.push(
|
headerLeftItems.push(
|
||||||
<Text key="page-title" fontWeight="600" fontSize="$6" color="$accentColor">
|
<Text key="page-title" {...getTypographyRoleProps('pageTitle')}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
@@ -66,8 +67,8 @@ export function Page({
|
|||||||
width="100%"
|
width="100%"
|
||||||
padding="$4"
|
padding="$4"
|
||||||
borderBottomWidth={1}
|
borderBottomWidth={1}
|
||||||
borderBottomColor="$accentBorder"
|
borderBottomColor="$lineSubtle"
|
||||||
backgroundColor="$accentSurface"
|
backgroundColor="$bgPanel"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
gap="$3"
|
gap="$3"
|
||||||
minHeight={64}
|
minHeight={64}
|
||||||
|
|||||||
+25
-23
@@ -7,6 +7,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { XStack, YStack, Text } from 'tamagui';
|
import { XStack, YStack, Text } from 'tamagui';
|
||||||
import { getIcon } from './IconMapper.jsx';
|
import { getIcon } from './IconMapper.jsx';
|
||||||
|
import { getTypographyRoleProps } from '../styles/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Header size mapping function
|
* Header size mapping function
|
||||||
@@ -19,29 +20,21 @@ import { getIcon } from './IconMapper.jsx';
|
|||||||
function getHeaderSizeStyles(headerSize) {
|
function getHeaderSizeStyles(headerSize) {
|
||||||
const sizeMap = {
|
const sizeMap = {
|
||||||
1: {
|
1: {
|
||||||
iconSize: 24,
|
|
||||||
titleFontSize: '$6',
|
|
||||||
padding: '$3',
|
padding: '$3',
|
||||||
borderRadius: '$4',
|
borderRadius: '$4',
|
||||||
minHeight: 64
|
minHeight: 64
|
||||||
},
|
},
|
||||||
2: {
|
2: {
|
||||||
iconSize: 20,
|
|
||||||
titleFontSize: '$5',
|
|
||||||
padding: '$2',
|
padding: '$2',
|
||||||
borderRadius: '$3',
|
borderRadius: '$3',
|
||||||
minHeight: 48
|
minHeight: 48
|
||||||
},
|
},
|
||||||
3: {
|
3: {
|
||||||
iconSize: 18,
|
|
||||||
titleFontSize: '$4',
|
|
||||||
padding: '$1.5',
|
padding: '$1.5',
|
||||||
borderRadius: '$2',
|
borderRadius: '$2',
|
||||||
minHeight: 44
|
minHeight: 44
|
||||||
},
|
},
|
||||||
4: {
|
4: {
|
||||||
iconSize: 16,
|
|
||||||
titleFontSize: '$3',
|
|
||||||
padding: '$1',
|
padding: '$1',
|
||||||
borderRadius: '$2',
|
borderRadius: '$2',
|
||||||
minHeight: 36
|
minHeight: 36
|
||||||
@@ -83,13 +76,16 @@ export function Panel({
|
|||||||
height = null,
|
height = null,
|
||||||
headerSize = 2,
|
headerSize = 2,
|
||||||
headerFront = null,
|
headerFront = null,
|
||||||
headerBack = null
|
headerBack = null,
|
||||||
|
density = 'comfortable',
|
||||||
|
bodyOverflow = 'auto'
|
||||||
}) {
|
}) {
|
||||||
// Get size-specific styles
|
// Get size-specific styles
|
||||||
const sizeStyles = getHeaderSizeStyles(headerSize);
|
const sizeStyles = getHeaderSizeStyles(headerSize);
|
||||||
|
|
||||||
// Set default headerBack if not provided
|
// Set default headerBack if not provided
|
||||||
const effectiveHeaderBack = headerBack || { backgroundColor: '$backgroundHover' };
|
const effectiveHeaderBack = headerBack || { backgroundColor: '$bgPanel' };
|
||||||
|
const paddingByDensity = density === 'compact' ? '$2' : density === 'spacious' ? '$4' : sizeStyles.padding;
|
||||||
|
|
||||||
// Build headerLeft array: icon, title, then custom components
|
// Build headerLeft array: icon, title, then custom components
|
||||||
const headerLeftItems = [];
|
const headerLeftItems = [];
|
||||||
@@ -99,7 +95,7 @@ export function Panel({
|
|||||||
if (IconComponent) {
|
if (IconComponent) {
|
||||||
headerLeftItems.push(
|
headerLeftItems.push(
|
||||||
<XStack key="panel-icon" alignItems="center" justifyContent="center" marginRight="$2" {...(headerFront || {})}>
|
<XStack key="panel-icon" alignItems="center" justifyContent="center" marginRight="$2" {...(headerFront || {})}>
|
||||||
<IconComponent size={sizeStyles.iconSize} {...(headerFront || {})} />
|
<IconComponent size="md" color="$textPrimary" {...(headerFront || {})} />
|
||||||
</XStack>
|
</XStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -107,11 +103,17 @@ export function Panel({
|
|||||||
|
|
||||||
if (title) {
|
if (title) {
|
||||||
headerLeftItems.push(
|
headerLeftItems.push(
|
||||||
<Text
|
<Text
|
||||||
key="panel-title"
|
key="panel-title"
|
||||||
fontWeight="600"
|
{...getTypographyRoleProps('panelTitle', {
|
||||||
fontSize={sizeStyles.titleFontSize}
|
fontSize: headerSize === 1
|
||||||
color="$color"
|
? '$6'
|
||||||
|
: headerSize === 2
|
||||||
|
? '$5'
|
||||||
|
: headerSize === 3
|
||||||
|
? '$4'
|
||||||
|
: '$3'
|
||||||
|
})}
|
||||||
{...(headerFront || {})}
|
{...(headerFront || {})}
|
||||||
>
|
>
|
||||||
{title}
|
{title}
|
||||||
@@ -142,19 +144,19 @@ export function Panel({
|
|||||||
{...(Object.keys(containerStyle).length > 0 ? containerStyle : {})}
|
{...(Object.keys(containerStyle).length > 0 ? containerStyle : {})}
|
||||||
{...(border ? {
|
{...(border ? {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: '$borderColor',
|
borderColor: '$lineSubtle',
|
||||||
borderRadius: sizeStyles.borderRadius
|
borderRadius: '$radiusMd'
|
||||||
} : {})}
|
} : {})}
|
||||||
backgroundColor="$background"
|
backgroundColor="$bgPanel"
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<XStack
|
<XStack
|
||||||
width="100%"
|
width="100%"
|
||||||
padding={sizeStyles.padding}
|
padding={paddingByDensity}
|
||||||
borderBottomWidth={border ? 1 : 0}
|
borderBottomWidth={border ? 1 : 0}
|
||||||
borderBottomColor="$borderColor"
|
borderBottomColor="$lineSubtle"
|
||||||
backgroundColor="$background"
|
backgroundColor="$bgPanel"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
gap="$3"
|
gap="$3"
|
||||||
minHeight={sizeStyles.minHeight}
|
minHeight={sizeStyles.minHeight}
|
||||||
@@ -187,7 +189,7 @@ export function Panel({
|
|||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<YStack width="100%" padding={sizeStyles.padding} overflow="auto" {...(height !== null ? { flex: 1 } : {})}>
|
<YStack width="100%" padding={paddingByDensity} overflow={bodyOverflow} {...(height !== null ? { flex: 1 } : {})}>
|
||||||
{children}
|
{children}
|
||||||
</YStack>
|
</YStack>
|
||||||
</YStack>
|
</YStack>
|
||||||
|
|||||||
@@ -91,8 +91,7 @@ export function ProgressBar({
|
|||||||
width="100%"
|
width="100%"
|
||||||
height={trackHeight}
|
height={trackHeight}
|
||||||
borderRadius="$1"
|
borderRadius="$1"
|
||||||
backgroundColor="$borderColor"
|
backgroundColor="$lineSubtle"
|
||||||
opacity={0.55}
|
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
>
|
>
|
||||||
{mode === 'determinate' ? (
|
{mode === 'determinate' ? (
|
||||||
@@ -103,7 +102,7 @@ export function ProgressBar({
|
|||||||
height="100%"
|
height="100%"
|
||||||
width={determinateWidth}
|
width={determinateWidth}
|
||||||
borderRadius="$1"
|
borderRadius="$1"
|
||||||
backgroundColor="$accentColor"
|
backgroundColor="$accent"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<YStack
|
<YStack
|
||||||
@@ -112,7 +111,7 @@ export function ProgressBar({
|
|||||||
height="100%"
|
height="100%"
|
||||||
width="42%"
|
width="42%"
|
||||||
borderRadius="$1"
|
borderRadius="$1"
|
||||||
backgroundColor="$accentColor"
|
backgroundColor="$accent"
|
||||||
left={`${offset}%`}
|
left={`${offset}%`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -123,7 +122,7 @@ export function ProgressBar({
|
|||||||
<YStack flex={1} minWidth={0} alignItems="center" gap="$1">
|
<YStack flex={1} minWidth={0} alignItems="center" gap="$1">
|
||||||
{label !== null && label !== undefined && label !== '' ? (
|
{label !== null && label !== undefined && label !== '' ? (
|
||||||
typeof label === 'string' ? (
|
typeof label === 'string' ? (
|
||||||
<Text fontSize="$2" color="$colorSecondary" numberOfLines={1} textAlign="center" width="100%">
|
<Text fontSize="$2" color="$textSecondary" numberOfLines={1} textAlign="center" width="100%">
|
||||||
{label}
|
{label}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ function normalizeVariant(variant, styleVariant) {
|
|||||||
return styleVariant || variant || 'accordion';
|
return styleVariant || variant || 'accordion';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHeaderIcon(icon, color = '$color') {
|
function renderHeaderIcon(icon, color = '$textPrimary') {
|
||||||
const IconComponent = getIcon(icon);
|
const IconComponent = getIcon(icon);
|
||||||
if (!IconComponent) {
|
if (!IconComponent) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <IconComponent size={18} color={color} />;
|
return <IconComponent size="sm" color={color} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeContentStyle(contentStyle) {
|
function normalizeContentStyle(contentStyle) {
|
||||||
@@ -188,7 +188,7 @@ export function SettingsPanel({
|
|||||||
size="$3"
|
size="$3"
|
||||||
aria-label={expanded ? `Collapse ${title}` : `Expand ${title}`}
|
aria-label={expanded ? `Collapse ${title}` : `Expand ${title}`}
|
||||||
onPress={handleToggle}
|
onPress={handleToggle}
|
||||||
icon={ToggleIcon ? <ToggleIcon size={16} /> : undefined}
|
icon={ToggleIcon ? <ToggleIcon size="sm" color="$textSecondary" /> : undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -208,15 +208,15 @@ export function SettingsPanel({
|
|||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
flexShrink={0}
|
flexShrink={0}
|
||||||
>
|
>
|
||||||
{renderHeaderIcon(icon, effectiveVariant === 'panel' ? '$accentColor' : '$color')}
|
{renderHeaderIcon(icon, '$textPrimary')}
|
||||||
</XStack>
|
</XStack>
|
||||||
) : null}
|
) : null}
|
||||||
<XStack flex={1} minWidth={0} gap="$2" alignItems="baseline" flexWrap="wrap">
|
<XStack flex={1} minWidth={0} gap="$2" alignItems="baseline" flexWrap="wrap">
|
||||||
<Text fontWeight="700" fontSize={effectiveVariant === 'panel' ? '$4' : '$5'} color="$color">
|
<Text fontWeight="600" fontSize={effectiveVariant === 'panel' ? '$4' : '$5'} color="$textPrimary">
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
{description ? (
|
{description ? (
|
||||||
<Paragraph size="$2" color="$color" opacity={0.72} flex={1} minWidth={160}>
|
<Paragraph size="$2" color="$textMuted" flex={1} minWidth={160}>
|
||||||
{description}
|
{description}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -240,8 +240,8 @@ export function SettingsPanel({
|
|||||||
>
|
>
|
||||||
<Tabs.List
|
<Tabs.List
|
||||||
disablePassBorderRadius
|
disablePassBorderRadius
|
||||||
backgroundColor="$backgroundStrong"
|
backgroundColor="$bgPage"
|
||||||
borderRadius="$4"
|
borderRadius="$radiusMd"
|
||||||
padding="$1"
|
padding="$1"
|
||||||
gap="$1"
|
gap="$1"
|
||||||
flexWrap="wrap"
|
flexWrap="wrap"
|
||||||
@@ -250,14 +250,14 @@ export function SettingsPanel({
|
|||||||
<Tabs.Tab
|
<Tabs.Tab
|
||||||
key={item.id}
|
key={item.id}
|
||||||
value={item.id}
|
value={item.id}
|
||||||
borderRadius="$3"
|
borderRadius="$radiusSm"
|
||||||
backgroundColor="$background"
|
backgroundColor="transparent"
|
||||||
hoverStyle={{ backgroundColor: '$backgroundHover' }}
|
hoverStyle={{ backgroundColor: '$bgPanel' }}
|
||||||
pressStyle={{ backgroundColor: '$backgroundPress' }}
|
pressStyle={{ backgroundColor: '$bgPanelElev' }}
|
||||||
>
|
>
|
||||||
<XStack alignItems="center" gap="$2">
|
<XStack alignItems="center" gap="$2">
|
||||||
{item.icon ? renderHeaderIcon(item.icon, '$accentColor') : null}
|
{item.icon ? renderHeaderIcon(item.icon, '$textSecondary') : null}
|
||||||
<Text fontWeight="700" color="$color">
|
<Text fontWeight="600" color="$textPrimary">
|
||||||
{item.label || item.title}
|
{item.label || item.title}
|
||||||
</Text>
|
</Text>
|
||||||
</XStack>
|
</XStack>
|
||||||
@@ -269,9 +269,9 @@ export function SettingsPanel({
|
|||||||
<Tabs.Content key={item.id} value={item.id}>
|
<Tabs.Content key={item.id} value={item.id}>
|
||||||
<YStack
|
<YStack
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor="$borderColor"
|
borderColor="$lineSubtle"
|
||||||
borderRadius="$4"
|
borderRadius="$radiusMd"
|
||||||
backgroundColor="$background"
|
backgroundColor="$bgPanel"
|
||||||
padding="$3"
|
padding="$3"
|
||||||
gap="$3"
|
gap="$3"
|
||||||
>
|
>
|
||||||
@@ -306,18 +306,18 @@ export function SettingsPanel({
|
|||||||
return (
|
return (
|
||||||
<YStack
|
<YStack
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor="$borderColor"
|
borderColor="$lineSubtle"
|
||||||
borderRadius="$4"
|
borderRadius="$radiusMd"
|
||||||
backgroundColor="$background"
|
backgroundColor="$bgPanel"
|
||||||
overflow="hidden"
|
overflow="hidden"
|
||||||
>
|
>
|
||||||
<XStack
|
<XStack
|
||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
paddingVertical="$1.5"
|
paddingVertical="$1.5"
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
backgroundColor="$backgroundHover"
|
backgroundColor="$bgPanel"
|
||||||
borderBottomWidth={expanded ? 1 : 0}
|
borderBottomWidth={expanded ? 1 : 0}
|
||||||
borderBottomColor="$borderColor"
|
borderBottomColor="$lineSubtle"
|
||||||
>
|
>
|
||||||
{headerContent}
|
{headerContent}
|
||||||
</XStack>
|
</XStack>
|
||||||
@@ -339,8 +339,8 @@ export function SettingsPanel({
|
|||||||
borderWidth={0}
|
borderWidth={0}
|
||||||
padding={0}
|
padding={0}
|
||||||
justifyContent="flex-start"
|
justifyContent="flex-start"
|
||||||
hoverStyle={{ opacity: 0.9, backgroundColor: 'transparent' }}
|
hoverStyle={{ backgroundColor: '$bgPage' }}
|
||||||
pressStyle={{ opacity: 0.75, backgroundColor: 'transparent' }}
|
pressStyle={{ backgroundColor: '$bgPanelElev' }}
|
||||||
>
|
>
|
||||||
<YStack gap="$2">
|
<YStack gap="$2">
|
||||||
{headerContent}
|
{headerContent}
|
||||||
@@ -351,7 +351,7 @@ export function SettingsPanel({
|
|||||||
{renderedBody}
|
{renderedBody}
|
||||||
</YStack>
|
</YStack>
|
||||||
) : null}
|
) : null}
|
||||||
<Separator borderColor="$borderColor" />
|
<Separator borderColor="$lineSubtle" />
|
||||||
</YStack>
|
</YStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -572,7 +572,7 @@ function Toast({ toast, onClose, onPause, onResume }) {
|
|||||||
>
|
>
|
||||||
{Icon ? (
|
{Icon ? (
|
||||||
<XStack alignItems="center" justifyContent="center" width={20} height={20} flexShrink={0}>
|
<XStack alignItems="center" justifyContent="center" width={20} height={20} flexShrink={0}>
|
||||||
<Icon size={18} />
|
<Icon size="md" color="$textSecondary" />
|
||||||
</XStack>
|
</XStack>
|
||||||
) : null}
|
) : null}
|
||||||
<YStack flex={1} gap="$1">
|
<YStack flex={1} gap="$1">
|
||||||
@@ -582,7 +582,7 @@ function Toast({ toast, onClose, onPause, onResume }) {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
{toast.message && (
|
{toast.message && (
|
||||||
<Text fontSize="$3" color="$color" opacity={0.9}>
|
<Text fontSize="$3" color="$textSecondary">
|
||||||
{toast.message}
|
{toast.message}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -596,7 +596,7 @@ function Toast({ toast, onClose, onPause, onResume }) {
|
|||||||
>
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
const CloseIcon = getIcon('close');
|
const CloseIcon = getIcon('close');
|
||||||
return CloseIcon ? <CloseIcon size={16} /> : <Text>×</Text>;
|
return CloseIcon ? <CloseIcon size="sm" color="$textSecondary" /> : <Text color="$textSecondary">×</Text>;
|
||||||
})()}
|
})()}
|
||||||
</Button>
|
</Button>
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|||||||
@@ -229,9 +229,9 @@ function SideBarWide({
|
|||||||
height="100%"
|
height="100%"
|
||||||
gap="$2"
|
gap="$2"
|
||||||
padding="$2"
|
padding="$2"
|
||||||
backgroundColor="$accentSurface"
|
backgroundColor="$bgPanel"
|
||||||
borderRightWidth={1}
|
borderRightWidth={1}
|
||||||
borderRightColor="$accentBorder"
|
borderRightColor="$lineSubtle"
|
||||||
animation="quick"
|
animation="quick"
|
||||||
animateOnly={['width']}
|
animateOnly={['width']}
|
||||||
>
|
>
|
||||||
@@ -266,7 +266,7 @@ function SideBarWide({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{appName && !isCollapsed && (
|
{appName && !isCollapsed && (
|
||||||
<Text fontWeight="bold" fontSize="$4" flex={1} color="$accentColor">
|
<Text fontWeight="600" fontSize="$5" flex={1} color="$textPrimary">
|
||||||
{appName}
|
{appName}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -278,10 +278,10 @@ function SideBarWide({
|
|||||||
justifyContent="center"
|
justifyContent="center"
|
||||||
padding="$1"
|
padding="$1"
|
||||||
hoverStyle={{
|
hoverStyle={{
|
||||||
backgroundColor: '$accentBackground'
|
backgroundColor: '$bgPage'
|
||||||
}}
|
}}
|
||||||
pressStyle={{
|
pressStyle={{
|
||||||
backgroundColor: '$accentHover'
|
backgroundColor: '$bgPanelElev'
|
||||||
}}
|
}}
|
||||||
onPress={handleToggle}
|
onPress={handleToggle}
|
||||||
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
@@ -291,8 +291,8 @@ function SideBarWide({
|
|||||||
if (!ChevronIcon) return null;
|
if (!ChevronIcon) return null;
|
||||||
return (
|
return (
|
||||||
<ChevronIcon
|
<ChevronIcon
|
||||||
size={16}
|
size="sm"
|
||||||
color="$accentColor"
|
color="$textSecondary"
|
||||||
style={{ flexShrink: 0 }}
|
style={{ flexShrink: 0 }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -366,17 +366,16 @@ function SideBarNarrow({ children }) {
|
|||||||
alignItems="center"
|
alignItems="center"
|
||||||
gap="$2"
|
gap="$2"
|
||||||
padding="$2"
|
padding="$2"
|
||||||
backgroundColor="$accentSurface"
|
backgroundColor="$bgPanel"
|
||||||
borderBottomWidth={1}
|
borderBottomWidth={1}
|
||||||
borderBottomColor="$accentBorder"
|
borderBottomColor="$lineSubtle"
|
||||||
>
|
>
|
||||||
{/* Hamburger Menu Button */}
|
{/* Hamburger Menu Button */}
|
||||||
<Button
|
<Button
|
||||||
size="$3"
|
size="$3"
|
||||||
circular
|
chromeless
|
||||||
icon={getIcon('menu')}
|
icon={getIcon('menu')}
|
||||||
backgroundColor="$accentBackground"
|
color="$textPrimary"
|
||||||
color="$accentColor"
|
|
||||||
onPress={() => setMenuOpen(true)}
|
onPress={() => setMenuOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -392,7 +391,7 @@ function SideBarNarrow({ children }) {
|
|||||||
|
|
||||||
{/* App Name - takes remaining space */}
|
{/* App Name - takes remaining space */}
|
||||||
{appName && (
|
{appName && (
|
||||||
<Text fontWeight="bold" fontSize="$4" flex={1} numberOfLines={1} color="$accentColor">
|
<Text fontWeight="600" fontSize="$5" flex={1} numberOfLines={1} color="$textPrimary">
|
||||||
{appName}
|
{appName}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -420,9 +419,9 @@ function SideBarNarrow({ children }) {
|
|||||||
snapPoints={[85]}
|
snapPoints={[85]}
|
||||||
dismissOnSnapToBottom
|
dismissOnSnapToBottom
|
||||||
>
|
>
|
||||||
<Sheet.Overlay />
|
<Sheet.Overlay backgroundColor="$scrim" />
|
||||||
<Sheet.Handle />
|
<Sheet.Handle />
|
||||||
<Sheet.Frame padding="$4" gap="$2">
|
<Sheet.Frame padding="$4" gap="$2" backgroundColor="$bgPanel">
|
||||||
<YStack gap="$2" width="100%">
|
<YStack gap="$2" width="100%">
|
||||||
{/* Primary Menu Items */}
|
{/* Primary Menu Items */}
|
||||||
{organizedChildren.primaryMenuItems.map((item) => (
|
{organizedChildren.primaryMenuItems.map((item) => (
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ import React, { useEffect, useState } from 'react';
|
|||||||
import { Button, ScrollView, Text, XStack, YStack } from 'tamagui';
|
import { Button, ScrollView, Text, XStack, YStack } from 'tamagui';
|
||||||
import { getIcon } from './IconMapper.jsx';
|
import { getIcon } from './IconMapper.jsx';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toolbar / footer action button.
|
||||||
|
* Pass `theme="accent"` on a single primary action; everything else stays
|
||||||
|
* chromeless to keep the action hierarchy clear.
|
||||||
|
*/
|
||||||
function ActionButton({ action }) {
|
function ActionButton({ action }) {
|
||||||
const IconComponent = action?.icon ? getIcon(action.icon) : null;
|
const IconComponent = action?.icon ? getIcon(action.icon) : null;
|
||||||
return (
|
return (
|
||||||
@@ -11,7 +16,7 @@ function ActionButton({ action }) {
|
|||||||
chromeless={action?.chromeless}
|
chromeless={action?.chromeless}
|
||||||
disabled={action?.disabled}
|
disabled={action?.disabled}
|
||||||
onPress={action?.onPress}
|
onPress={action?.onPress}
|
||||||
icon={IconComponent ? <IconComponent size={16} /> : undefined}
|
icon={IconComponent ? <IconComponent size="sm" /> : undefined}
|
||||||
>
|
>
|
||||||
{action?.label}
|
{action?.label}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -61,7 +66,7 @@ export function SidePanelShell({
|
|||||||
right={0}
|
right={0}
|
||||||
bottom={0}
|
bottom={0}
|
||||||
left={0}
|
left={0}
|
||||||
backgroundColor="rgba(15,23,42,0.26)"
|
backgroundColor="$scrim"
|
||||||
opacity={open ? 1 : 0}
|
opacity={open ? 1 : 0}
|
||||||
animation="quick"
|
animation="quick"
|
||||||
onPress={onClose}
|
onPress={onClose}
|
||||||
@@ -74,13 +79,13 @@ export function SidePanelShell({
|
|||||||
bottom={0}
|
bottom={0}
|
||||||
width={width}
|
width={width}
|
||||||
maxWidth="96vw"
|
maxWidth="96vw"
|
||||||
backgroundColor="$background"
|
backgroundColor="$bgPanelElev"
|
||||||
borderLeftWidth={1}
|
borderLeftWidth={1}
|
||||||
borderLeftColor="$borderColor"
|
borderLeftColor="$lineSubtle"
|
||||||
shadowColor="$shadowColor"
|
shadowColor="$shadowColor"
|
||||||
shadowOpacity={0.18}
|
shadowOpacity={0.12}
|
||||||
shadowRadius={20}
|
shadowRadius={24}
|
||||||
shadowOffset={{ width: -4, height: 0 }}
|
shadowOffset={{ width: -6, height: 0 }}
|
||||||
style={{
|
style={{
|
||||||
transform: open ? 'translateX(0)' : 'translateX(100%)',
|
transform: open ? 'translateX(0)' : 'translateX(100%)',
|
||||||
transition: 'transform 220ms ease'
|
transition: 'transform 220ms ease'
|
||||||
@@ -92,10 +97,10 @@ export function SidePanelShell({
|
|||||||
padding="$4"
|
padding="$4"
|
||||||
gap="$3"
|
gap="$3"
|
||||||
borderBottomWidth={1}
|
borderBottomWidth={1}
|
||||||
borderBottomColor="$borderColor"
|
borderBottomColor="$lineSubtle"
|
||||||
backgroundColor="$accentSurface"
|
backgroundColor="$bgPanel"
|
||||||
>
|
>
|
||||||
<Text fontSize="$7" fontWeight="700" color="$accentColor" flex={1}>
|
<Text fontSize="$6" fontWeight="600" color="$textPrimary" flex={1}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
<XStack alignItems="center" gap="$2" flexWrap="wrap" justifyContent="flex-end">
|
<XStack alignItems="center" gap="$2" flexWrap="wrap" justifyContent="flex-end">
|
||||||
@@ -107,7 +112,7 @@ export function SidePanelShell({
|
|||||||
circular
|
circular
|
||||||
chromeless
|
chromeless
|
||||||
onPress={onClose}
|
onPress={onClose}
|
||||||
icon={CloseIcon ? <CloseIcon size={18} /> : undefined}
|
icon={CloseIcon ? <CloseIcon size="sm" color="$textSecondary" /> : undefined}
|
||||||
aria-label="Close panel"
|
aria-label="Close panel"
|
||||||
/>
|
/>
|
||||||
</XStack>
|
</XStack>
|
||||||
@@ -125,8 +130,8 @@ export function SidePanelShell({
|
|||||||
gap="$2"
|
gap="$2"
|
||||||
padding="$4"
|
padding="$4"
|
||||||
borderTopWidth={1}
|
borderTopWidth={1}
|
||||||
borderTopColor="$borderColor"
|
borderTopColor="$lineSubtle"
|
||||||
backgroundColor="$accentSurface"
|
backgroundColor="$bgPanel"
|
||||||
flexWrap="wrap"
|
flexWrap="wrap"
|
||||||
>
|
>
|
||||||
{footerActions.map((action, index) => (
|
{footerActions.map((action, index) => (
|
||||||
|
|||||||
@@ -195,9 +195,9 @@ function TopBarWide({
|
|||||||
alignItems="center"
|
alignItems="center"
|
||||||
gap="$2"
|
gap="$2"
|
||||||
padding="$2"
|
padding="$2"
|
||||||
backgroundColor="$accentSurface"
|
backgroundColor="$bgPanel"
|
||||||
borderBottomWidth={1}
|
borderBottomWidth={1}
|
||||||
borderBottomColor="$accentBorder"
|
borderBottomColor="$lineSubtle"
|
||||||
>
|
>
|
||||||
{/* Left Side */}
|
{/* Left Side */}
|
||||||
<XStack
|
<XStack
|
||||||
@@ -217,14 +217,14 @@ function TopBarWide({
|
|||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* App Name */}
|
{/* App Name */}
|
||||||
{appName && (
|
{appName && (
|
||||||
<Text fontWeight="bold" fontSize="$4" color="$accentColor">
|
<Text fontWeight="600" fontSize="$5" color="$textPrimary">
|
||||||
{appName}
|
{appName}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Left Side Items */}
|
{/* Left Side Items */}
|
||||||
{organizedChildren.sections.leftSide}
|
{organizedChildren.sections.leftSide}
|
||||||
</XStack>
|
</XStack>
|
||||||
@@ -242,7 +242,7 @@ function TopBarWide({
|
|||||||
</XStack>
|
</XStack>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Right Side */}
|
{/* Right Side — secondary actions get a hairline separator + tighter gap */}
|
||||||
{(effectiveRightWidth > 0 || effectiveRightWidth === 'auto') && (
|
{(effectiveRightWidth > 0 || effectiveRightWidth === 'auto') && (
|
||||||
<XStack
|
<XStack
|
||||||
width={effectiveRightWidth === 'auto' ? undefined : effectiveRightWidth}
|
width={effectiveRightWidth === 'auto' ? undefined : effectiveRightWidth}
|
||||||
@@ -251,6 +251,9 @@ function TopBarWide({
|
|||||||
alignItems="center"
|
alignItems="center"
|
||||||
justifyContent="flex-end"
|
justifyContent="flex-end"
|
||||||
gap="$1"
|
gap="$1"
|
||||||
|
paddingLeft="$2"
|
||||||
|
borderLeftWidth={1}
|
||||||
|
borderLeftColor="$lineSubtle"
|
||||||
style={{ flexShrink: 0 }}
|
style={{ flexShrink: 0 }}
|
||||||
>
|
>
|
||||||
{organizedChildren.sections.rightSide}
|
{organizedChildren.sections.rightSide}
|
||||||
@@ -289,20 +292,19 @@ function TopBarNarrow({ children }) {
|
|||||||
alignItems="center"
|
alignItems="center"
|
||||||
gap="$2"
|
gap="$2"
|
||||||
padding="$2"
|
padding="$2"
|
||||||
backgroundColor="$accentSurface"
|
backgroundColor="$bgPanel"
|
||||||
borderBottomWidth={1}
|
borderBottomWidth={1}
|
||||||
borderBottomColor="$accentBorder"
|
borderBottomColor="$lineSubtle"
|
||||||
>
|
>
|
||||||
{/* Hamburger Menu Button */}
|
{/* Hamburger Menu Button — chromeless to avoid pulling the eye */}
|
||||||
<Button
|
<Button
|
||||||
size="$3"
|
size="$3"
|
||||||
circular
|
chromeless
|
||||||
icon={getIcon('menu')}
|
icon={getIcon('menu')}
|
||||||
backgroundColor="$accentBackground"
|
color="$textPrimary"
|
||||||
color="$accentColor"
|
|
||||||
onPress={() => setMenuOpen(true)}
|
onPress={() => setMenuOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Brand Logo */}
|
{/* Brand Logo */}
|
||||||
{brandLogo && (
|
{brandLogo && (
|
||||||
<Image
|
<Image
|
||||||
@@ -312,10 +314,10 @@ function TopBarNarrow({ children }) {
|
|||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* App Name - takes remaining space */}
|
{/* App Name - takes remaining space */}
|
||||||
{appName && (
|
{appName && (
|
||||||
<Text fontWeight="bold" fontSize="$4" flex={1} numberOfLines={1} color="$accentColor">
|
<Text fontWeight="600" fontSize="$5" flex={1} numberOfLines={1} color="$textPrimary">
|
||||||
{appName}
|
{appName}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
@@ -359,9 +361,9 @@ function TopBarNarrow({ children }) {
|
|||||||
snapPoints={[85]}
|
snapPoints={[85]}
|
||||||
dismissOnSnapToBottom
|
dismissOnSnapToBottom
|
||||||
>
|
>
|
||||||
<Sheet.Overlay />
|
<Sheet.Overlay backgroundColor="$scrim" />
|
||||||
<Sheet.Handle />
|
<Sheet.Handle />
|
||||||
<Sheet.Frame padding="$4" gap="$2">
|
<Sheet.Frame padding="$4" gap="$2" backgroundColor="$bgPanel">
|
||||||
<YStack gap="$2" width="100%">
|
<YStack gap="$2" width="100%">
|
||||||
{/* Primary Menu Items - render with vertical orientation in Sheet */}
|
{/* Primary Menu Items - render with vertical orientation in Sheet */}
|
||||||
{organizedChildren.primaryMenuItems.map((item) => (
|
{organizedChildren.primaryMenuItems.map((item) => (
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export function GridView({
|
|||||||
statusText = '',
|
statusText = '',
|
||||||
selectable = false,
|
selectable = false,
|
||||||
nested = false,
|
nested = false,
|
||||||
|
closeable = true,
|
||||||
onClose = undefined,
|
onClose = undefined,
|
||||||
onReload = undefined,
|
onReload = undefined,
|
||||||
initialPageSize = 6,
|
initialPageSize = 6,
|
||||||
@@ -197,8 +198,10 @@ export function GridView({
|
|||||||
resolvedColumns,
|
resolvedColumns,
|
||||||
visibleColumns,
|
visibleColumns,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
|
setSelectedIds,
|
||||||
selectable,
|
selectable,
|
||||||
nested,
|
nested,
|
||||||
|
closeable,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
tableViewportWidth,
|
tableViewportWidth,
|
||||||
@@ -236,6 +239,15 @@ export function GridView({
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
toggleSelectAll: () => {
|
||||||
|
setSelectedIds((current) => {
|
||||||
|
const allIds = (rows || []).map((row) => row?.id).filter((id) => id != null);
|
||||||
|
if (!allIds.length) return new Set();
|
||||||
|
const allSelected = allIds.every((id) => current.has(id));
|
||||||
|
return allSelected ? new Set() : new Set(allIds);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clearSelection: () => setSelectedIds(new Set()),
|
||||||
setFilterValue: (key, value) => {
|
setFilterValue: (key, value) => {
|
||||||
setOffset(0);
|
setOffset(0);
|
||||||
setFilterBy((current) => ({
|
setFilterBy((current) => ({
|
||||||
@@ -260,6 +272,7 @@ export function GridView({
|
|||||||
resolvedStatusText,
|
resolvedStatusText,
|
||||||
rows,
|
rows,
|
||||||
selectable,
|
selectable,
|
||||||
|
closeable,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
sortBy,
|
sortBy,
|
||||||
structure,
|
structure,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Button, Checkbox, Input, Paragraph, ScrollView, Text, XStack, YStack }
|
|||||||
import { getIcon } from '../IconMapper.jsx';
|
import { getIcon } from '../IconMapper.jsx';
|
||||||
import { useGridView } from './context.js';
|
import { useGridView } from './context.js';
|
||||||
import { formatValueByColumn } from './utils.js';
|
import { formatValueByColumn } from './utils.js';
|
||||||
|
import { getTypographyRoleProps } from '../../styles/index.js';
|
||||||
|
|
||||||
function renderToolbarItem(item) {
|
function renderToolbarItem(item) {
|
||||||
if (!item) {
|
if (!item) {
|
||||||
@@ -22,7 +23,7 @@ function renderToolbarItem(item) {
|
|||||||
theme={item.theme}
|
theme={item.theme}
|
||||||
chromeless={item.chromeless}
|
chromeless={item.chromeless}
|
||||||
disabled={item.disabled}
|
disabled={item.disabled}
|
||||||
icon={IconComponent ? <IconComponent size={16} /> : undefined}
|
icon={IconComponent ? <IconComponent size="sm" /> : undefined}
|
||||||
onPress={item.onClick || item.onPress}
|
onPress={item.onClick || item.onPress}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
@@ -32,21 +33,27 @@ function renderToolbarItem(item) {
|
|||||||
|
|
||||||
if (item.kind === 'text') {
|
if (item.kind === 'text') {
|
||||||
return (
|
return (
|
||||||
<Text key={item.key || item.text} color="$color" opacity={0.7}>
|
<Text key={item.key || item.text} color="$textSecondary">
|
||||||
{item.text}
|
{item.text}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.kind === 'search') {
|
if (item.kind === 'search') {
|
||||||
|
const SearchIcon = getIcon('search');
|
||||||
return (
|
return (
|
||||||
<Input
|
<XStack key={item.key || item.placeholder || 'search'} alignItems="center" gap="$2">
|
||||||
key={item.key || item.placeholder || 'search'}
|
{SearchIcon ? <SearchIcon size="sm" color="$textMuted" /> : null}
|
||||||
width={item.width || 240}
|
<Input
|
||||||
value={item.value}
|
width={item.width || 240}
|
||||||
placeholder={item.placeholder || 'Search'}
|
value={item.value}
|
||||||
onChangeText={(value) => item.onChange?.(value)}
|
placeholder={item.placeholder || 'Search'}
|
||||||
/>
|
onChangeText={(value) => item.onChange?.(value)}
|
||||||
|
backgroundColor="$bgPanel"
|
||||||
|
borderColor="$lineSubtle"
|
||||||
|
focusStyle={{ borderColor: '$accent' }}
|
||||||
|
/>
|
||||||
|
</XStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,14 +82,14 @@ function DefaultPanelRecordRenderer({ row }) {
|
|||||||
return (
|
return (
|
||||||
<YStack gap="$3">
|
<YStack gap="$3">
|
||||||
<YStack gap="$1">
|
<YStack gap="$1">
|
||||||
<Text fontSize="$3" letterSpacing={1} textTransform="uppercase" color="$accentColor">
|
<Text fontSize="$2" letterSpacing={1} textTransform="uppercase" color="$textSecondary">
|
||||||
Record Summary
|
Record Summary
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="$6" fontWeight="700">
|
<Text {...getTypographyRoleProps('sectionTitle')}>
|
||||||
{titleColumn ? row?.[titleColumn.field] : row?.id}
|
{titleColumn ? row?.[titleColumn.field] : row?.id}
|
||||||
</Text>
|
</Text>
|
||||||
{subtitleColumn ? (
|
{subtitleColumn ? (
|
||||||
<Paragraph color="$color" opacity={0.7}>
|
<Paragraph color="$textSecondary">
|
||||||
{row?.[subtitleColumn.field] || ''}
|
{row?.[subtitleColumn.field] || ''}
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -94,15 +101,15 @@ function DefaultPanelRecordRenderer({ row }) {
|
|||||||
key={`${row.id}-${column.field}-chip`}
|
key={`${row.id}-${column.field}-chip`}
|
||||||
paddingHorizontal="$3"
|
paddingHorizontal="$3"
|
||||||
paddingVertical="$2"
|
paddingVertical="$2"
|
||||||
borderRadius="$6"
|
borderRadius="$radiusMd"
|
||||||
backgroundColor="$accentSurface"
|
backgroundColor="$bgPanel"
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor="$accentBorder"
|
borderColor="$lineSubtle"
|
||||||
>
|
>
|
||||||
<Text fontSize="$2" color="$color" opacity={0.65}>
|
<Text fontSize="$2" color="$textMuted">
|
||||||
{column.label}
|
{column.label}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fontSize="$4" fontWeight="600">
|
<Text {...getTypographyRoleProps('tableHeader', { color: '$textPrimary' })}>
|
||||||
{formatValueByColumn(row?.[column.field], column)}
|
{formatValueByColumn(row?.[column.field], column)}
|
||||||
</Text>
|
</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
@@ -116,16 +123,16 @@ function DefaultPanelRecordRenderer({ row }) {
|
|||||||
minWidth={160}
|
minWidth={160}
|
||||||
flex={1}
|
flex={1}
|
||||||
padding="$3"
|
padding="$3"
|
||||||
borderRadius="$4"
|
borderRadius="$radiusMd"
|
||||||
borderWidth={1}
|
borderWidth={1}
|
||||||
borderColor="$borderColor"
|
borderColor="$lineSubtle"
|
||||||
backgroundColor="$background"
|
backgroundColor="$bgPanel"
|
||||||
gap="$1"
|
gap="$1"
|
||||||
>
|
>
|
||||||
<Text fontSize="$3" color="$color" opacity={0.65}>
|
<Text fontSize="$3" color="$textMuted">
|
||||||
{column.label}
|
{column.label}
|
||||||
</Text>
|
</Text>
|
||||||
<Text>{formatValueByColumn(row?.[column.field], column)}</Text>
|
<Text color="$textPrimary">{formatValueByColumn(row?.[column.field], column)}</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
))}
|
))}
|
||||||
</XStack>
|
</XStack>
|
||||||
@@ -156,7 +163,7 @@ export function PanelFooterStatusBar({ text, visible = true }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Text color="$color" opacity={0.7}>
|
<Text color="$textMuted">
|
||||||
{text || grid.statusText}
|
{text || grid.statusText}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
@@ -179,10 +186,10 @@ export function PanelHeader({ title, toolbarItems = [], visible = true, showDivi
|
|||||||
padding="$3"
|
padding="$3"
|
||||||
minHeight={64}
|
minHeight={64}
|
||||||
borderBottomWidth={showDivider ? 1 : 0}
|
borderBottomWidth={showDivider ? 1 : 0}
|
||||||
borderBottomColor="$borderColor"
|
borderBottomColor="$lineSubtle"
|
||||||
backgroundColor="$accentSurface"
|
backgroundColor="$bgPanel"
|
||||||
>
|
>
|
||||||
<Text fontSize="$6" fontWeight="700" color="$accentColor">
|
<Text {...getTypographyRoleProps('panelTitle', { fontSize: '$6' })}>
|
||||||
{title}
|
{title}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
@@ -192,17 +199,19 @@ export function PanelHeader({ title, toolbarItems = [], visible = true, showDivi
|
|||||||
size="$3"
|
size="$3"
|
||||||
chromeless
|
chromeless
|
||||||
circular
|
circular
|
||||||
icon={RefreshIcon ? <RefreshIcon size={16} /> : undefined}
|
icon={RefreshIcon ? <RefreshIcon size="sm" color="$textSecondary" /> : undefined}
|
||||||
onPress={grid.reload}
|
onPress={grid.reload}
|
||||||
/>
|
/>
|
||||||
<Button
|
{grid.closeable !== false ? (
|
||||||
size="$3"
|
<Button
|
||||||
chromeless
|
size="$3"
|
||||||
circular
|
chromeless
|
||||||
disabled={!grid.close}
|
circular
|
||||||
icon={CloseIcon ? <CloseIcon size={16} /> : undefined}
|
disabled={!grid.close}
|
||||||
onPress={grid.close}
|
icon={CloseIcon ? <CloseIcon size="sm" color="$textSecondary" /> : undefined}
|
||||||
/>
|
onPress={grid.close}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</XStack>
|
</XStack>
|
||||||
</XStack>
|
</XStack>
|
||||||
);
|
);
|
||||||
@@ -221,8 +230,8 @@ export function PanelFooter({ toolbarItems = [], visible = true }) {
|
|||||||
padding="$3"
|
padding="$3"
|
||||||
minHeight={56}
|
minHeight={56}
|
||||||
borderTopWidth={1}
|
borderTopWidth={1}
|
||||||
borderTopColor="$borderColor"
|
borderTopColor="$lineSubtle"
|
||||||
backgroundColor="$background"
|
backgroundColor="$bgPanel"
|
||||||
flexWrap="wrap"
|
flexWrap="wrap"
|
||||||
>
|
>
|
||||||
<PanelFooterStatusBar />
|
<PanelFooterStatusBar />
|
||||||
@@ -243,9 +252,11 @@ export function PanelBodyView({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (grid.error) {
|
if (grid.error) {
|
||||||
|
const ErrorIcon = getIcon('error');
|
||||||
return (
|
return (
|
||||||
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
|
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5" gap="$2">
|
||||||
<Text color="#b91c1c">{grid.error}</Text>
|
{ErrorIcon ? <ErrorIcon size="lg" color="$danger" /> : null}
|
||||||
|
<Text color="$danger" fontWeight="600">{grid.error}</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -253,15 +264,18 @@ export function PanelBodyView({
|
|||||||
if (grid.isLoading && !grid.rows.length) {
|
if (grid.isLoading && !grid.rows.length) {
|
||||||
return (
|
return (
|
||||||
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
|
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
|
||||||
<Text color="$color" opacity={0.7}>Loading cards...</Text>
|
<Text color="$textMuted">Loading cards...</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!grid.rows.length) {
|
if (!grid.rows.length) {
|
||||||
|
const EmptyIcon = getIcon('folder');
|
||||||
return (
|
return (
|
||||||
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
|
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5" gap="$2">
|
||||||
<Text color="$color" opacity={0.7}>No records available.</Text>
|
{EmptyIcon ? <EmptyIcon size="xl" color="$textMuted" /> : null}
|
||||||
|
<Text color="$textSecondary" fontWeight="600">No records available</Text>
|
||||||
|
<Text color="$textMuted" fontSize="$3">There's nothing to show here yet.</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -272,37 +286,49 @@ export function PanelBodyView({
|
|||||||
<ScrollView flex={1}>
|
<ScrollView flex={1}>
|
||||||
<YStack padding="$4" gap="$3">
|
<YStack padding="$4" gap="$3">
|
||||||
<XStack gap="$3" flexWrap="wrap">
|
<XStack gap="$3" flexWrap="wrap">
|
||||||
{grid.rows.map((row) => (
|
{grid.rows.map((row) => {
|
||||||
<YStack
|
const isSelected = grid.selectedIds.has(row.id);
|
||||||
key={row.id}
|
return (
|
||||||
minWidth={responsiveColumns > 1 ? 320 : 240}
|
<YStack
|
||||||
flex={1}
|
key={row.id}
|
||||||
flexBasis={responsiveColumns > 1 ? '48%' : '100%'}
|
minWidth={responsiveColumns > 1 ? 320 : 240}
|
||||||
padding="$4"
|
flex={1}
|
||||||
borderWidth={1}
|
flexBasis={responsiveColumns > 1 ? '48%' : '100%'}
|
||||||
borderColor={grid.selectedIds.has(row.id) ? '$accentBorder' : '$borderColor'}
|
padding="$4"
|
||||||
backgroundColor={grid.selectedIds.has(row.id) ? '$accentSurface' : '$background'}
|
borderWidth={1}
|
||||||
borderRadius="$5"
|
borderColor={isSelected ? '$accent' : '$lineSubtle'}
|
||||||
gap="$3"
|
backgroundColor={isSelected ? '$accentBg' : '$bgPanel'}
|
||||||
>
|
borderRadius="$radiusLg"
|
||||||
{grid.selectable ? (
|
gap="$3"
|
||||||
<XStack justifyContent="flex-start">
|
hoverStyle={isSelected ? undefined : { borderColor: '$lineStrong' }}
|
||||||
<Checkbox
|
>
|
||||||
checked={grid.selectedIds.has(row.id)}
|
{grid.selectable ? (
|
||||||
onCheckedChange={() => grid.toggleSelectRow(row.id)}
|
<XStack justifyContent="flex-start">
|
||||||
>
|
<Checkbox
|
||||||
<Checkbox.Indicator />
|
checked={isSelected}
|
||||||
</Checkbox>
|
onCheckedChange={() => grid.toggleSelectRow(row.id)}
|
||||||
</XStack>
|
borderColor="$lineStrong"
|
||||||
) : null}
|
backgroundColor="$bgPanel"
|
||||||
|
focusStyle={{ borderColor: '$accent' }}
|
||||||
|
>
|
||||||
|
<Checkbox.Indicator>
|
||||||
|
{(() => {
|
||||||
|
const Check = getIcon('check');
|
||||||
|
return Check ? <Check size="sm" color="$accent" /> : null;
|
||||||
|
})()}
|
||||||
|
</Checkbox.Indicator>
|
||||||
|
</Checkbox>
|
||||||
|
</XStack>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<RecordRenderer row={row} />
|
<RecordRenderer row={row} />
|
||||||
</YStack>
|
</YStack>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</XStack>
|
</XStack>
|
||||||
|
|
||||||
{grid.isLoading ? (
|
{grid.isLoading ? (
|
||||||
<Text color="$color" opacity={0.7}>
|
<Text color="$textMuted">
|
||||||
Refreshing records...
|
Refreshing records...
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -310,4 +336,3 @@ export function PanelBodyView({
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react';
|
|||||||
import { Button, Checkbox, ScrollView, Separator, Text, XStack, YStack } from 'tamagui';
|
import { Button, Checkbox, ScrollView, Separator, Text, XStack, YStack } from 'tamagui';
|
||||||
import { getIcon } from '../IconMapper.jsx';
|
import { getIcon } from '../IconMapper.jsx';
|
||||||
import { useGridView } from './context.js';
|
import { useGridView } from './context.js';
|
||||||
|
import { getTypographyRoleProps } from '../../styles/index.js';
|
||||||
import {
|
import {
|
||||||
formatValueByColumn,
|
formatValueByColumn,
|
||||||
getColumnJustify,
|
getColumnJustify,
|
||||||
@@ -12,7 +13,7 @@ import {
|
|||||||
|
|
||||||
function DefaultGridCellRenderer({ value, column }) {
|
function DefaultGridCellRenderer({ value, column }) {
|
||||||
return (
|
return (
|
||||||
<Text width="100%" textAlign={column.align || 'left'} opacity={value == null || value === '' ? 0.6 : 1}>
|
<Text width="100%" textAlign={column.align || 'left'} color="$textPrimary" opacity={value == null || value === '' ? 0.6 : 1}>
|
||||||
{formatValueByColumn(value, column)}
|
{formatValueByColumn(value, column)}
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
@@ -29,15 +30,26 @@ function UtilityCell({ rowId }) {
|
|||||||
if (grid.nested) {
|
if (grid.nested) {
|
||||||
return (
|
return (
|
||||||
<XStack width={36} alignItems="center" justifyContent="center">
|
<XStack width={36} alignItems="center" justifyContent="center">
|
||||||
{ChevronRightIcon ? <ChevronRightIcon size={16} /> : <Text>{'>'}</Text>}
|
{ChevronRightIcon ? <ChevronRightIcon size="sm" color="$textSecondary" /> : <Text color="$textSecondary">{'>'}</Text>}
|
||||||
</XStack>
|
</XStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<XStack width={36} alignItems="center" justifyContent="center">
|
<XStack width={36} alignItems="center" justifyContent="center">
|
||||||
<Checkbox checked={grid.selectedIds.has(rowId)} onCheckedChange={() => grid.toggleSelectRow(rowId)}>
|
<Checkbox
|
||||||
<Checkbox.Indicator />
|
checked={grid.selectedIds.has(rowId)}
|
||||||
|
onCheckedChange={() => grid.toggleSelectRow(rowId)}
|
||||||
|
borderColor="$lineStrong"
|
||||||
|
backgroundColor="$bgPanel"
|
||||||
|
focusStyle={{ borderColor: '$accent' }}
|
||||||
|
>
|
||||||
|
<Checkbox.Indicator>
|
||||||
|
{(() => {
|
||||||
|
const Check = getIcon('check');
|
||||||
|
return Check ? <Check size="sm" color="$accent" /> : null;
|
||||||
|
})()}
|
||||||
|
</Checkbox.Indicator>
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</XStack>
|
</XStack>
|
||||||
);
|
);
|
||||||
@@ -80,21 +92,46 @@ export function TableHeader({ visible = true, showTopBorder = true }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const activeColumns = grid.visibleColumns?.length ? grid.visibleColumns : grid.resolvedColumns;
|
const activeColumns = grid.visibleColumns?.length ? grid.visibleColumns : grid.resolvedColumns;
|
||||||
|
const CaretUp = getIcon('caret-up');
|
||||||
|
const CaretDown = getIcon('caret-down');
|
||||||
|
const allIds = (grid.rows || []).map((row) => row?.id).filter((id) => id != null);
|
||||||
|
const selectedCount = allIds.filter((id) => grid.selectedIds.has(id)).length;
|
||||||
|
const allSelected = allIds.length > 0 && selectedCount === allIds.length;
|
||||||
|
const someSelected = selectedCount > 0 && !allSelected;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<XStack
|
<XStack
|
||||||
alignItems="stretch"
|
alignItems="stretch"
|
||||||
borderTopWidth={showTopBorder ? 1 : 0}
|
borderTopWidth={showTopBorder ? 1 : 0}
|
||||||
borderBottomWidth={1}
|
borderBottomWidth={1}
|
||||||
borderColor="$accentBorder"
|
borderColor="$lineSubtle"
|
||||||
backgroundColor="$accentSurface"
|
backgroundColor="transparent"
|
||||||
paddingHorizontal="$2"
|
paddingHorizontal="$2"
|
||||||
>
|
>
|
||||||
{grid.selectable || grid.nested ? <XStack width={36} /> : null}
|
{grid.selectable || grid.nested ? (
|
||||||
|
<XStack width={36} alignItems="center" justifyContent="center">
|
||||||
|
{grid.selectable ? (
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected ? true : someSelected ? 'indeterminate' : false}
|
||||||
|
onCheckedChange={() => grid.toggleSelectAll?.()}
|
||||||
|
borderColor="$lineStrong"
|
||||||
|
backgroundColor="$bgPanel"
|
||||||
|
focusStyle={{ borderColor: '$accent' }}
|
||||||
|
>
|
||||||
|
<Checkbox.Indicator>
|
||||||
|
{(() => {
|
||||||
|
const Check = getIcon('check');
|
||||||
|
return Check ? <Check size="sm" color="$accent" /> : null;
|
||||||
|
})()}
|
||||||
|
</Checkbox.Indicator>
|
||||||
|
</Checkbox>
|
||||||
|
) : null}
|
||||||
|
</XStack>
|
||||||
|
) : null}
|
||||||
{activeColumns.map((column) => {
|
{activeColumns.map((column) => {
|
||||||
const activeSort = grid.sortBy.find((entry) => entry.field === column.field);
|
const activeSort = grid.sortBy.find((entry) => entry.field === column.field);
|
||||||
const sortLabel =
|
const isActive = Boolean(activeSort);
|
||||||
activeSort?.direction === 'asc' ? '↑' : activeSort?.direction === 'desc' ? '↓' : '';
|
const direction = activeSort?.direction;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
@@ -107,10 +144,28 @@ export function TableHeader({ visible = true, showTopBorder = true }) {
|
|||||||
paddingVertical="$3"
|
paddingVertical="$3"
|
||||||
paddingHorizontal="$2"
|
paddingHorizontal="$2"
|
||||||
{...getColumnLayoutStyle(column)}
|
{...getColumnLayoutStyle(column)}
|
||||||
|
hoverStyle={column.sortable ? { backgroundColor: '$bgPage' } : undefined}
|
||||||
|
pressStyle={column.sortable ? { backgroundColor: '$bgPanelElev' } : undefined}
|
||||||
>
|
>
|
||||||
<Text width="100%" textAlign={column.align || 'left'} fontWeight="700">
|
<XStack width="100%" alignItems="center" justifyContent={getColumnJustify(column.align)} gap="$2">
|
||||||
{column.label}{sortLabel ? ` ${sortLabel}` : ''}
|
<Text
|
||||||
</Text>
|
width="auto"
|
||||||
|
textAlign={column.align || 'left'}
|
||||||
|
numberOfLines={1}
|
||||||
|
{...getTypographyRoleProps('tableHeader')}
|
||||||
|
>
|
||||||
|
{column.label}
|
||||||
|
</Text>
|
||||||
|
{column.sortable ? (
|
||||||
|
isActive ? (
|
||||||
|
direction === 'asc'
|
||||||
|
? (CaretUp ? <CaretUp size="xs" color="$textSecondary" /> : null)
|
||||||
|
: (CaretDown ? <CaretDown size="xs" color="$textSecondary" /> : null)
|
||||||
|
) : (
|
||||||
|
CaretDown ? <CaretDown size="xs" color="$textMuted" style={{ opacity: 0.6 }} /> : null
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</XStack>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -131,7 +186,7 @@ export function TableBodyView({ visible = true }) {
|
|||||||
if (grid.error) {
|
if (grid.error) {
|
||||||
return (
|
return (
|
||||||
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
|
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
|
||||||
<Text color="#b91c1c">{grid.error}</Text>
|
<Text color="$danger" fontWeight="600">{grid.error}</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -144,7 +199,8 @@ export function TableBodyView({ visible = true }) {
|
|||||||
<XStack
|
<XStack
|
||||||
alignItems="stretch"
|
alignItems="stretch"
|
||||||
paddingHorizontal="$2"
|
paddingHorizontal="$2"
|
||||||
backgroundColor={grid.selectedIds.has(row.id) ? '$accentSurface' : '$background'}
|
backgroundColor={grid.selectedIds.has(row.id) ? '$accentBg' : index % 2 === 1 ? '$bgPage' : 'transparent'}
|
||||||
|
hoverStyle={{ backgroundColor: '$bgPage' }}
|
||||||
>
|
>
|
||||||
{grid.selectable || grid.nested ? <UtilityCell rowId={row.id} /> : null}
|
{grid.selectable || grid.nested ? <UtilityCell rowId={row.id} /> : null}
|
||||||
{activeColumns.map((column) => {
|
{activeColumns.map((column) => {
|
||||||
@@ -166,19 +222,22 @@ export function TableBodyView({ visible = true }) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</XStack>
|
</XStack>
|
||||||
<Separator />
|
<Separator borderColor="$lineSubtle" />
|
||||||
</YStack>
|
</YStack>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{!grid.rows.length && !grid.isLoading ? (
|
{!grid.rows.length && !grid.isLoading ? (
|
||||||
<YStack minHeight={120} alignItems="center" justifyContent="center" padding="$5">
|
<YStack minHeight={120} alignItems="center" justifyContent="center" padding="$5">
|
||||||
<Text color="$color" opacity={0.7}>No records available.</Text>
|
<Text color="$textSecondary" fontWeight="600">No records available</Text>
|
||||||
|
<Text color="$textMuted" fontSize="$3">There's nothing to show here yet.</Text>
|
||||||
</YStack>
|
</YStack>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{grid.isLoading ? (
|
{grid.isLoading && !grid.rows.length ? (
|
||||||
<YStack minHeight={64} alignItems="center" justifyContent="center" padding="$4">
|
<YStack minHeight={64} padding="$4" gap="$2">
|
||||||
<Text color="$color" opacity={0.7}>Loading...</Text>
|
{[0, 1, 2].map((i) => (
|
||||||
|
<XStack key={i} height={46} borderRadius="$radiusMd" backgroundColor="$bgPage" />
|
||||||
|
))}
|
||||||
</YStack>
|
</YStack>
|
||||||
) : null}
|
) : null}
|
||||||
</YStack>
|
</YStack>
|
||||||
@@ -205,21 +264,21 @@ export function TableFooter({ visible = true }) {
|
|||||||
padding="$3"
|
padding="$3"
|
||||||
minHeight={56}
|
minHeight={56}
|
||||||
borderTopWidth={1}
|
borderTopWidth={1}
|
||||||
borderTopColor="$borderColor"
|
borderTopColor="$lineSubtle"
|
||||||
backgroundColor="$background"
|
backgroundColor="$bgPanel"
|
||||||
flexWrap="wrap"
|
flexWrap="wrap"
|
||||||
>
|
>
|
||||||
<Text color="$color" opacity={0.7}>
|
<Text color="$textMuted">
|
||||||
{grid.total} records
|
{grid.total} records
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<XStack gap="$1" alignItems="center" flexWrap="wrap">
|
<XStack gap="$1" alignItems="center" flexWrap="wrap" padding="$1" borderWidth={1} borderColor="$lineSubtle" borderRadius="$radiusMd" backgroundColor="$bgPanel">
|
||||||
<Button
|
<Button
|
||||||
size="$3"
|
size="$3"
|
||||||
chromeless
|
chromeless
|
||||||
circular
|
circular
|
||||||
disabled={grid.currentPage <= 1}
|
disabled={grid.currentPage <= 1}
|
||||||
icon={FirstPageIcon ? <FirstPageIcon size={16} /> : undefined}
|
icon={FirstPageIcon ? <FirstPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||||
onPress={() => grid.setPage(1)}
|
onPress={() => grid.setPage(1)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -227,10 +286,10 @@ export function TableFooter({ visible = true }) {
|
|||||||
chromeless
|
chromeless
|
||||||
circular
|
circular
|
||||||
disabled={grid.currentPage <= 1}
|
disabled={grid.currentPage <= 1}
|
||||||
icon={PreviousPageIcon ? <PreviousPageIcon size={16} /> : undefined}
|
icon={PreviousPageIcon ? <PreviousPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||||
onPress={() => grid.setPage(grid.currentPage - 1)}
|
onPress={() => grid.setPage(grid.currentPage - 1)}
|
||||||
/>
|
/>
|
||||||
<Text color="$color" opacity={0.75}>
|
<Text color="$textSecondary">
|
||||||
Page {grid.currentPage} of {grid.pageCount}
|
Page {grid.currentPage} of {grid.pageCount}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
@@ -238,7 +297,7 @@ export function TableFooter({ visible = true }) {
|
|||||||
chromeless
|
chromeless
|
||||||
circular
|
circular
|
||||||
disabled={grid.currentPage >= grid.pageCount}
|
disabled={grid.currentPage >= grid.pageCount}
|
||||||
icon={NextPageIcon ? <NextPageIcon size={16} /> : undefined}
|
icon={NextPageIcon ? <NextPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||||
onPress={() => grid.setPage(grid.currentPage + 1)}
|
onPress={() => grid.setPage(grid.currentPage + 1)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
@@ -246,11 +305,10 @@ export function TableFooter({ visible = true }) {
|
|||||||
chromeless
|
chromeless
|
||||||
circular
|
circular
|
||||||
disabled={grid.currentPage >= grid.pageCount}
|
disabled={grid.currentPage >= grid.pageCount}
|
||||||
icon={LastPageIcon ? <LastPageIcon size={16} /> : undefined}
|
icon={LastPageIcon ? <LastPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||||
onPress={() => grid.setPage(grid.pageCount)}
|
onPress={() => grid.setPage(grid.pageCount)}
|
||||||
/>
|
/>
|
||||||
</XStack>
|
</XStack>
|
||||||
</XStack>
|
</XStack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export { SettingsPanel, default as SettingsPanelDefault } from './SettingsPanel.
|
|||||||
export { GeneralConfig, default as GeneralConfigDefault } from './GeneralConfig.jsx';
|
export { GeneralConfig, default as GeneralConfigDefault } from './GeneralConfig.jsx';
|
||||||
export { IdentityConfig, default as IdentityConfigDefault } from './IdentityConfig.jsx';
|
export { IdentityConfig, default as IdentityConfigDefault } from './IdentityConfig.jsx';
|
||||||
export * from './grid/index.js';
|
export * from './grid/index.js';
|
||||||
|
export { getTypographyRoleProps, getStyleTypography, TYPOGRAPHY_ROLE_KEYS } from '../styles/index.js';
|
||||||
|
|
||||||
// Re-export App helpers for convenience.
|
// Re-export App helpers for convenience.
|
||||||
// The App component itself is exported from src/index.js and src/ui/App.jsx.
|
// The App component itself is exported from src/index.js and src/ui/App.jsx.
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Apple Theme
|
||||||
|
* Warm, glassy look inspired by macOS / iOS Human Interface Guidelines.
|
||||||
|
*
|
||||||
|
* Aesthetic intent
|
||||||
|
* ----------------
|
||||||
|
* - Warm neutral surfaces (slightly off-white page, pure white panels)
|
||||||
|
* - Subtle but visible shadows on raised surfaces
|
||||||
|
* - Generous radii (8 / 12 / 16) — pill controls, rounded cards
|
||||||
|
* - System-blue accent for primary actions
|
||||||
|
* - Slightly larger typography, comfortable density
|
||||||
|
* - Phosphor icons at "duotone" weight for a friendly, premium feel
|
||||||
|
*
|
||||||
|
* Implements the semantic token contract documented in styles/index.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { config as configBase } from '@tamagui/config/v3';
|
||||||
|
|
||||||
|
export const AppleTheme = {
|
||||||
|
...configBase,
|
||||||
|
name: 'apple',
|
||||||
|
displayName: 'Apple',
|
||||||
|
iconWeight: 'duotone',
|
||||||
|
typography: {
|
||||||
|
fieldLabel: { fontSize: '$4', fontWeight: '500', color: '$textPrimary' },
|
||||||
|
detailLabel: { fontSize: '$4', fontWeight: '500', color: '$textPrimary' },
|
||||||
|
pageTitle: { fontSize: '$6', fontWeight: '600', color: '$textPrimary' },
|
||||||
|
panelTitle: { fontWeight: '600', color: '$textPrimary' },
|
||||||
|
tableHeader: { fontSize: '$4', fontWeight: '500', color: '$textSecondary' },
|
||||||
|
sectionTitle: { fontSize: '$6', fontWeight: '600', color: '$textPrimary' },
|
||||||
|
},
|
||||||
|
tokens: {
|
||||||
|
...configBase.tokens,
|
||||||
|
radius: {
|
||||||
|
...configBase.tokens.radius,
|
||||||
|
radiusSm: 8,
|
||||||
|
radiusMd: 12,
|
||||||
|
radiusLg: 16,
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
...configBase.tokens.color,
|
||||||
|
// System blue
|
||||||
|
primary: '#0a84ff',
|
||||||
|
primaryLight: '#5ea8ff',
|
||||||
|
primaryDark: '#0066cc',
|
||||||
|
// Secondary system gray
|
||||||
|
secondary: '#8e8e93',
|
||||||
|
secondaryLight: '#aeaeb2',
|
||||||
|
secondaryDark: '#636366',
|
||||||
|
// System status colors
|
||||||
|
error: '#ff3b30',
|
||||||
|
warning: '#ff9500',
|
||||||
|
info: '#0a84ff',
|
||||||
|
success: '#34c759',
|
||||||
|
},
|
||||||
|
space: {
|
||||||
|
...configBase.tokens.space,
|
||||||
|
0: 0,
|
||||||
|
1: 4,
|
||||||
|
2: 8,
|
||||||
|
3: 12,
|
||||||
|
4: 16,
|
||||||
|
5: 20,
|
||||||
|
6: 28,
|
||||||
|
7: 36,
|
||||||
|
8: 48,
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
...configBase.tokens.size,
|
||||||
|
xs: 11,
|
||||||
|
sm: 13,
|
||||||
|
md: 15,
|
||||||
|
base: 15,
|
||||||
|
lg: 17,
|
||||||
|
xl: 19,
|
||||||
|
'2xl': 22,
|
||||||
|
'3xl': 26,
|
||||||
|
'4xl': 32,
|
||||||
|
'5xl': 40,
|
||||||
|
'6xl': 56,
|
||||||
|
},
|
||||||
|
// Soft, visible shadows
|
||||||
|
shadowColor: {
|
||||||
|
...configBase.tokens.shadowColor,
|
||||||
|
elevation0: 'transparent',
|
||||||
|
elevation1: 'rgba(0, 0, 0, 0.04)',
|
||||||
|
elevation2: 'rgba(0, 0, 0, 0.06)',
|
||||||
|
elevation4: 'rgba(0, 0, 0, 0.08)',
|
||||||
|
elevation8: 'rgba(0, 0, 0, 0.12)',
|
||||||
|
elevation12: 'rgba(0, 0, 0, 0.14)',
|
||||||
|
elevation16: 'rgba(0, 0, 0, 0.16)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
themes: {
|
||||||
|
...configBase.themes,
|
||||||
|
light: {
|
||||||
|
...configBase.themes.light,
|
||||||
|
// ---- Legacy keys ----
|
||||||
|
background: '#ffffff',
|
||||||
|
backgroundHover: '#f2f2f7',
|
||||||
|
backgroundPress: '#e5e5ea',
|
||||||
|
backgroundFocus: '#e8f1ff',
|
||||||
|
surface: '#ffffff',
|
||||||
|
surfaceVariant: '#fbfbfd',
|
||||||
|
accentBackground: '#d6e8ff',
|
||||||
|
accentSurface: '#eaf3ff',
|
||||||
|
accentColor: '#0a84ff',
|
||||||
|
accentBorder: 'rgba(10, 132, 255, 0.28)',
|
||||||
|
accentHover: '#cee0ff',
|
||||||
|
accentPress: '#bcd3ff',
|
||||||
|
color: '#1d1d1f',
|
||||||
|
colorHover: '#1d1d1f',
|
||||||
|
colorSecondary: '#6e6e73',
|
||||||
|
colorDisabled: '#aeaeb2',
|
||||||
|
borderColor: '#e5e5ea',
|
||||||
|
borderColorHover: '#d1d1d6',
|
||||||
|
// ---- Contract tokens ----
|
||||||
|
bgPage: '#fbfbfd',
|
||||||
|
bgPanel: '#ffffff',
|
||||||
|
bgPanelElev: '#ffffff',
|
||||||
|
bgInverse: '#1d1d1f',
|
||||||
|
scrim: 'rgba(0, 0, 0, 0.32)',
|
||||||
|
lineSubtle: '#e5e5ea',
|
||||||
|
lineStrong: '#d1d1d6',
|
||||||
|
textPrimary: '#1d1d1f',
|
||||||
|
textSecondary: '#6e6e73',
|
||||||
|
textMuted: '#8e8e93',
|
||||||
|
textOnAccent: '#ffffff',
|
||||||
|
accent: '#0a84ff',
|
||||||
|
accentBg: '#eaf3ff',
|
||||||
|
accentBgHover: '#d6e8ff',
|
||||||
|
danger: '#ff3b30',
|
||||||
|
dangerBg: '#ffe5e3',
|
||||||
|
warning: '#ff9500',
|
||||||
|
warningBg: '#fff1de',
|
||||||
|
success: '#34c759',
|
||||||
|
successBg: '#e2f7e6',
|
||||||
|
info: '#0a84ff',
|
||||||
|
infoBg: '#eaf3ff',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
...configBase.themes.dark,
|
||||||
|
// ---- Legacy keys ----
|
||||||
|
background: '#000000',
|
||||||
|
backgroundHover: '#1c1c1e',
|
||||||
|
backgroundPress: '#2c2c2e',
|
||||||
|
backgroundFocus: '#1a3a66',
|
||||||
|
surface: '#1c1c1e',
|
||||||
|
surfaceVariant: '#2c2c2e',
|
||||||
|
accentBackground: '#0b3866',
|
||||||
|
accentSurface: '#0a2a4d',
|
||||||
|
accentColor: '#5ea8ff',
|
||||||
|
accentBorder: 'rgba(94, 168, 255, 0.32)',
|
||||||
|
accentHover: '#11437a',
|
||||||
|
accentPress: '#0e3868',
|
||||||
|
color: '#f5f5f7',
|
||||||
|
colorHover: '#f5f5f7',
|
||||||
|
colorSecondary: '#aeaeb2',
|
||||||
|
colorDisabled: '#636366',
|
||||||
|
borderColor: '#2c2c2e',
|
||||||
|
borderColorHover: '#3a3a3c',
|
||||||
|
// ---- Contract tokens ----
|
||||||
|
bgPage: '#000000',
|
||||||
|
bgPanel: '#1c1c1e',
|
||||||
|
bgPanelElev: '#2c2c2e',
|
||||||
|
bgInverse: '#f5f5f7',
|
||||||
|
scrim: 'rgba(0, 0, 0, 0.6)',
|
||||||
|
lineSubtle: '#2c2c2e',
|
||||||
|
lineStrong: '#3a3a3c',
|
||||||
|
textPrimary: '#f5f5f7',
|
||||||
|
textSecondary: '#aeaeb2',
|
||||||
|
textMuted: '#8e8e93',
|
||||||
|
textOnAccent: '#0b1f3d',
|
||||||
|
accent: '#5ea8ff',
|
||||||
|
accentBg: '#0a2a4d',
|
||||||
|
accentBgHover: '#0b3866',
|
||||||
|
danger: '#ff6961',
|
||||||
|
dangerBg: 'rgba(255, 105, 97, 0.14)',
|
||||||
|
warning: '#ffb340',
|
||||||
|
warningBg: 'rgba(255, 179, 64, 0.14)',
|
||||||
|
success: '#5fd66f',
|
||||||
|
successBg: 'rgba(95, 214, 111, 0.14)',
|
||||||
|
info: '#5ea8ff',
|
||||||
|
infoBg: 'rgba(94, 168, 255, 0.14)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
...configBase.settings,
|
||||||
|
styleCompat: 'web',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AppleTheme;
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/**
|
||||||
|
* Azure Theme
|
||||||
|
* Clean enterprise look inspired by Microsoft Azure / Fluent UI.
|
||||||
|
*
|
||||||
|
* Aesthetic intent
|
||||||
|
* ----------------
|
||||||
|
* - Cool neutral surfaces (very subtle gray page, pure white panels)
|
||||||
|
* - Hairline borders, shadows reserved for popovers / modals only
|
||||||
|
* - Tight radii (4 / 6 / 8) — confident rectangles, not pills
|
||||||
|
* - Neutral blue accent reserved for primary action and selected state
|
||||||
|
* - Compact typography weights (400 body, 600 titles, no 700 bold)
|
||||||
|
* - Phosphor icons at "regular" weight, 1.5px stroke
|
||||||
|
*
|
||||||
|
* Implements the semantic token contract documented in styles/index.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { config as configBase } from '@tamagui/config/v3';
|
||||||
|
|
||||||
|
export const AzureTheme = {
|
||||||
|
...configBase,
|
||||||
|
name: 'azure',
|
||||||
|
displayName: 'Azure',
|
||||||
|
iconWeight: 'regular',
|
||||||
|
typography: {
|
||||||
|
fieldLabel: { fontSize: '$4', fontWeight: '500', color: '$textPrimary' },
|
||||||
|
detailLabel: { fontSize: '$4', fontWeight: '500', color: '$textPrimary' },
|
||||||
|
pageTitle: { fontSize: '$6', fontWeight: '600', color: '$textPrimary' },
|
||||||
|
panelTitle: { fontWeight: '600', color: '$textPrimary' },
|
||||||
|
tableHeader: { fontSize: '$4', fontWeight: '600', color: '$textSecondary' },
|
||||||
|
sectionTitle: { fontSize: '$6', fontWeight: '600', color: '$textPrimary' },
|
||||||
|
},
|
||||||
|
tokens: {
|
||||||
|
...configBase.tokens,
|
||||||
|
radius: {
|
||||||
|
...configBase.tokens.radius,
|
||||||
|
radiusSm: 4,
|
||||||
|
radiusMd: 6,
|
||||||
|
radiusLg: 8,
|
||||||
|
},
|
||||||
|
color: {
|
||||||
|
...configBase.tokens.color,
|
||||||
|
// Neutral-blue accent (Microsoft brand-adjacent, not exact)
|
||||||
|
primary: '#2563eb',
|
||||||
|
primaryLight: '#3b82f6',
|
||||||
|
primaryDark: '#1d4ed8',
|
||||||
|
// Secondary kept as a quiet teal-ish neutral
|
||||||
|
secondary: '#475569',
|
||||||
|
secondaryLight: '#64748b',
|
||||||
|
secondaryDark: '#334155',
|
||||||
|
// Status (Fluent-inspired)
|
||||||
|
error: '#c5221f',
|
||||||
|
warning: '#bf6900',
|
||||||
|
info: '#0078d4',
|
||||||
|
success: '#0f7b3a',
|
||||||
|
},
|
||||||
|
space: {
|
||||||
|
...configBase.tokens.space,
|
||||||
|
0: 0,
|
||||||
|
1: 4,
|
||||||
|
2: 8,
|
||||||
|
3: 12,
|
||||||
|
4: 16,
|
||||||
|
5: 20,
|
||||||
|
6: 24,
|
||||||
|
7: 32,
|
||||||
|
8: 40,
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
...configBase.tokens.size,
|
||||||
|
xs: 11,
|
||||||
|
sm: 12,
|
||||||
|
md: 14,
|
||||||
|
base: 14,
|
||||||
|
lg: 16,
|
||||||
|
xl: 18,
|
||||||
|
'2xl': 20,
|
||||||
|
'3xl': 24,
|
||||||
|
'4xl': 28,
|
||||||
|
'5xl': 36,
|
||||||
|
'6xl': 48,
|
||||||
|
},
|
||||||
|
// Near-zero shadows — surfaces are defined by border, not depth
|
||||||
|
shadowColor: {
|
||||||
|
...configBase.tokens.shadowColor,
|
||||||
|
elevation0: 'transparent',
|
||||||
|
elevation1: 'rgba(15, 23, 42, 0.04)',
|
||||||
|
elevation2: 'rgba(15, 23, 42, 0.06)',
|
||||||
|
elevation4: 'rgba(15, 23, 42, 0.08)',
|
||||||
|
elevation8: 'rgba(15, 23, 42, 0.10)',
|
||||||
|
elevation12: 'rgba(15, 23, 42, 0.12)',
|
||||||
|
elevation16: 'rgba(15, 23, 42, 0.14)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
themes: {
|
||||||
|
...configBase.themes,
|
||||||
|
light: {
|
||||||
|
...configBase.themes.light,
|
||||||
|
// ---- Legacy Tamagui keys (kept for backward compat) ----
|
||||||
|
background: '#ffffff',
|
||||||
|
backgroundHover: '#f3f4f6',
|
||||||
|
backgroundPress: '#e5e7eb',
|
||||||
|
backgroundFocus: '#eef4ff',
|
||||||
|
surface: '#ffffff',
|
||||||
|
surfaceVariant: '#f7f8fa',
|
||||||
|
accentBackground: '#dbeafe',
|
||||||
|
accentSurface: '#eef4ff',
|
||||||
|
accentColor: '#2563eb',
|
||||||
|
accentBorder: 'rgba(37, 99, 235, 0.28)',
|
||||||
|
accentHover: '#dbe6ff',
|
||||||
|
accentPress: '#c7d8ff',
|
||||||
|
color: '#0f172a',
|
||||||
|
colorHover: '#0f172a',
|
||||||
|
colorSecondary: '#475569',
|
||||||
|
colorDisabled: '#94a3b8',
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
borderColorHover: '#cbd5e1',
|
||||||
|
// ---- Contract tokens ----
|
||||||
|
bgPage: '#f7f8fa',
|
||||||
|
bgPanel: '#ffffff',
|
||||||
|
bgPanelElev: '#ffffff',
|
||||||
|
bgInverse: '#0f172a',
|
||||||
|
scrim: 'rgba(15, 23, 42, 0.32)',
|
||||||
|
lineSubtle: '#e5e7eb',
|
||||||
|
lineStrong: '#cbd5e1',
|
||||||
|
textPrimary: '#0f172a',
|
||||||
|
textSecondary: '#475569',
|
||||||
|
textMuted: '#64748b',
|
||||||
|
textOnAccent: '#ffffff',
|
||||||
|
accent: '#2563eb',
|
||||||
|
accentBg: '#eef4ff',
|
||||||
|
accentBgHover: '#dbeafe',
|
||||||
|
danger: '#c5221f',
|
||||||
|
dangerBg: '#fdecea',
|
||||||
|
warning: '#bf6900',
|
||||||
|
warningBg: '#fdf3e2',
|
||||||
|
success: '#0f7b3a',
|
||||||
|
successBg: '#e6f4ea',
|
||||||
|
info: '#0078d4',
|
||||||
|
infoBg: '#deecf9',
|
||||||
|
},
|
||||||
|
dark: {
|
||||||
|
...configBase.themes.dark,
|
||||||
|
// ---- Legacy keys ----
|
||||||
|
background: '#0b1220',
|
||||||
|
backgroundHover: '#111827',
|
||||||
|
backgroundPress: '#1f2937',
|
||||||
|
backgroundFocus: '#1e3a8a',
|
||||||
|
surface: '#111827',
|
||||||
|
surfaceVariant: '#1f2937',
|
||||||
|
accentBackground: '#1e3a8a',
|
||||||
|
accentSurface: '#1e293b',
|
||||||
|
accentColor: '#60a5fa',
|
||||||
|
accentBorder: 'rgba(96, 165, 250, 0.32)',
|
||||||
|
accentHover: '#243763',
|
||||||
|
accentPress: '#2a4079',
|
||||||
|
color: '#f1f5f9',
|
||||||
|
colorHover: '#f1f5f9',
|
||||||
|
colorSecondary: '#cbd5e1',
|
||||||
|
colorDisabled: '#64748b',
|
||||||
|
borderColor: '#1f2937',
|
||||||
|
borderColorHover: '#334155',
|
||||||
|
// ---- Contract tokens ----
|
||||||
|
bgPage: '#0b1220',
|
||||||
|
bgPanel: '#111827',
|
||||||
|
bgPanelElev: '#1f2937',
|
||||||
|
bgInverse: '#f1f5f9',
|
||||||
|
scrim: 'rgba(0, 0, 0, 0.6)',
|
||||||
|
lineSubtle: '#1f2937',
|
||||||
|
lineStrong: '#334155',
|
||||||
|
textPrimary: '#f1f5f9',
|
||||||
|
textSecondary: '#cbd5e1',
|
||||||
|
textMuted: '#94a3b8',
|
||||||
|
textOnAccent: '#0b1220',
|
||||||
|
accent: '#60a5fa',
|
||||||
|
accentBg: '#1e293b',
|
||||||
|
accentBgHover: '#1e3a8a',
|
||||||
|
danger: '#f87171',
|
||||||
|
dangerBg: 'rgba(248, 113, 113, 0.14)',
|
||||||
|
warning: '#fbbf24',
|
||||||
|
warningBg: 'rgba(251, 191, 36, 0.14)',
|
||||||
|
success: '#4ade80',
|
||||||
|
successBg: 'rgba(74, 222, 128, 0.14)',
|
||||||
|
info: '#60a5fa',
|
||||||
|
infoBg: 'rgba(96, 165, 250, 0.14)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
...configBase.settings,
|
||||||
|
styleCompat: 'web',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AzureTheme;
|
||||||
@@ -9,8 +9,23 @@ export const ColorfulTheme = {
|
|||||||
...configBase,
|
...configBase,
|
||||||
name: 'colorful',
|
name: 'colorful',
|
||||||
displayName: 'Colorful',
|
displayName: 'Colorful',
|
||||||
|
iconWeight: 'bold',
|
||||||
|
typography: {
|
||||||
|
fieldLabel: { fontSize: '$4', fontWeight: '600', color: '$textPrimary' },
|
||||||
|
detailLabel: { fontSize: '$4', fontWeight: '600', color: '$textPrimary' },
|
||||||
|
pageTitle: { fontSize: '$6', fontWeight: '700', color: '$textPrimary' },
|
||||||
|
panelTitle: { fontWeight: '600', color: '$textPrimary' },
|
||||||
|
tableHeader: { fontSize: '$4', fontWeight: '600', color: '$textSecondary' },
|
||||||
|
sectionTitle: { fontSize: '$6', fontWeight: '700', color: '$textPrimary' },
|
||||||
|
},
|
||||||
tokens: {
|
tokens: {
|
||||||
...configBase.tokens,
|
...configBase.tokens,
|
||||||
|
radius: {
|
||||||
|
...configBase.tokens.radius,
|
||||||
|
radiusSm: 8,
|
||||||
|
radiusMd: 12,
|
||||||
|
radiusLg: 16,
|
||||||
|
},
|
||||||
// Colorful palette (vibrant colors)
|
// Colorful palette (vibrant colors)
|
||||||
color: {
|
color: {
|
||||||
...configBase.tokens.color,
|
...configBase.tokens.color,
|
||||||
@@ -90,6 +105,29 @@ export const ColorfulTheme = {
|
|||||||
colorDisabled: '#94a3b8',
|
colorDisabled: '#94a3b8',
|
||||||
borderColor: '#cbd5e1',
|
borderColor: '#cbd5e1',
|
||||||
borderColorHover: '#94a3b8',
|
borderColorHover: '#94a3b8',
|
||||||
|
// ---- Contract tokens (mapped to Colorful palette) ----
|
||||||
|
bgPage: '#f8fafc',
|
||||||
|
bgPanel: '#ffffff',
|
||||||
|
bgPanelElev: '#ffffff',
|
||||||
|
bgInverse: '#0f172a',
|
||||||
|
scrim: 'rgba(15, 23, 42, 0.45)',
|
||||||
|
lineSubtle: '#e2e8f0',
|
||||||
|
lineStrong: '#cbd5e1',
|
||||||
|
textPrimary: '#0f172a',
|
||||||
|
textSecondary: '#475569',
|
||||||
|
textMuted: '#94a3b8',
|
||||||
|
textOnAccent: '#ffffff',
|
||||||
|
accent: '#2563eb',
|
||||||
|
accentBg: '#eef4ff',
|
||||||
|
accentBgHover: '#dbeafe',
|
||||||
|
danger: '#ef4444',
|
||||||
|
dangerBg: '#fee2e2',
|
||||||
|
warning: '#f59e0b',
|
||||||
|
warningBg: '#fef3c7',
|
||||||
|
success: '#10b981',
|
||||||
|
successBg: '#d1fae5',
|
||||||
|
info: '#06b6d4',
|
||||||
|
infoBg: '#cffafe',
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
...configBase.themes.dark,
|
...configBase.themes.dark,
|
||||||
@@ -111,6 +149,29 @@ export const ColorfulTheme = {
|
|||||||
colorDisabled: '#64748b',
|
colorDisabled: '#64748b',
|
||||||
borderColor: '#334155',
|
borderColor: '#334155',
|
||||||
borderColorHover: '#475569',
|
borderColorHover: '#475569',
|
||||||
|
// ---- Contract tokens ----
|
||||||
|
bgPage: '#0f172a',
|
||||||
|
bgPanel: '#1e293b',
|
||||||
|
bgPanelElev: '#334155',
|
||||||
|
bgInverse: '#f1f5f9',
|
||||||
|
scrim: 'rgba(0, 0, 0, 0.6)',
|
||||||
|
lineSubtle: '#334155',
|
||||||
|
lineStrong: '#475569',
|
||||||
|
textPrimary: '#f1f5f9',
|
||||||
|
textSecondary: '#cbd5e1',
|
||||||
|
textMuted: '#64748b',
|
||||||
|
textOnAccent: '#0a1929',
|
||||||
|
accent: '#bfdbfe',
|
||||||
|
accentBg: '#1e3a8a',
|
||||||
|
accentBgHover: '#1d4ed8',
|
||||||
|
danger: '#fca5a5',
|
||||||
|
dangerBg: 'rgba(252, 165, 165, 0.14)',
|
||||||
|
warning: '#fbbf24',
|
||||||
|
warningBg: 'rgba(251, 191, 36, 0.14)',
|
||||||
|
success: '#6ee7b7',
|
||||||
|
successBg: 'rgba(110, 231, 183, 0.14)',
|
||||||
|
info: '#67e8f9',
|
||||||
|
infoBg: 'rgba(103, 232, 249, 0.14)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
@@ -9,8 +9,24 @@ export const MaterialTheme = {
|
|||||||
...configBase,
|
...configBase,
|
||||||
name: 'material',
|
name: 'material',
|
||||||
displayName: 'Material Design',
|
displayName: 'Material Design',
|
||||||
|
// Preset-level metadata (consumed by IconMapper)
|
||||||
|
iconWeight: 'regular',
|
||||||
|
typography: {
|
||||||
|
fieldLabel: { fontSize: '$4', fontWeight: '500', color: '$textPrimary' },
|
||||||
|
detailLabel: { fontSize: '$4', fontWeight: '500', color: '$textPrimary' },
|
||||||
|
pageTitle: { fontSize: '$6', fontWeight: '500', color: '$textPrimary' },
|
||||||
|
panelTitle: { fontWeight: '500', color: '$textPrimary' },
|
||||||
|
tableHeader: { fontSize: '$4', fontWeight: '500', color: '$textSecondary' },
|
||||||
|
sectionTitle: { fontSize: '$6', fontWeight: '500', color: '$textPrimary' },
|
||||||
|
},
|
||||||
tokens: {
|
tokens: {
|
||||||
...configBase.tokens,
|
...configBase.tokens,
|
||||||
|
radius: {
|
||||||
|
...configBase.tokens.radius,
|
||||||
|
radiusSm: 4,
|
||||||
|
radiusMd: 6,
|
||||||
|
radiusLg: 8,
|
||||||
|
},
|
||||||
// Material UI color palette (base tokens - theme-agnostic)
|
// Material UI color palette (base tokens - theme-agnostic)
|
||||||
color: {
|
color: {
|
||||||
...configBase.tokens.color,
|
...configBase.tokens.color,
|
||||||
@@ -90,6 +106,29 @@ export const MaterialTheme = {
|
|||||||
colorDisabled: 'rgba(0, 0, 0, 0.38)',
|
colorDisabled: 'rgba(0, 0, 0, 0.38)',
|
||||||
borderColor: 'rgba(0, 0, 0, 0.12)',
|
borderColor: 'rgba(0, 0, 0, 0.12)',
|
||||||
borderColorHover: 'rgba(0, 0, 0, 0.23)',
|
borderColorHover: 'rgba(0, 0, 0, 0.23)',
|
||||||
|
// ---- Contract tokens (mapped to Material palette) ----
|
||||||
|
bgPage: '#fafafa',
|
||||||
|
bgPanel: '#ffffff',
|
||||||
|
bgPanelElev: '#ffffff',
|
||||||
|
bgInverse: '#323232',
|
||||||
|
scrim: 'rgba(0, 0, 0, 0.32)',
|
||||||
|
lineSubtle: 'rgba(0, 0, 0, 0.12)',
|
||||||
|
lineStrong: 'rgba(0, 0, 0, 0.23)',
|
||||||
|
textPrimary: 'rgba(0, 0, 0, 0.87)',
|
||||||
|
textSecondary: 'rgba(0, 0, 0, 0.6)',
|
||||||
|
textMuted: 'rgba(0, 0, 0, 0.38)',
|
||||||
|
textOnAccent: '#ffffff',
|
||||||
|
accent: '#1976d2',
|
||||||
|
accentBg: '#f4f8ff',
|
||||||
|
accentBgHover: '#e3f2fd',
|
||||||
|
danger: '#d32f2f',
|
||||||
|
dangerBg: 'rgba(211, 47, 47, 0.08)',
|
||||||
|
warning: '#ed6c02',
|
||||||
|
warningBg: 'rgba(237, 108, 2, 0.08)',
|
||||||
|
success: '#2e7d32',
|
||||||
|
successBg: 'rgba(46, 125, 50, 0.08)',
|
||||||
|
info: '#0288d1',
|
||||||
|
infoBg: 'rgba(2, 136, 209, 0.08)',
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
...configBase.themes.dark,
|
...configBase.themes.dark,
|
||||||
@@ -111,6 +150,29 @@ export const MaterialTheme = {
|
|||||||
colorDisabled: 'rgba(255, 255, 255, 0.38)',
|
colorDisabled: 'rgba(255, 255, 255, 0.38)',
|
||||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||||
borderColorHover: 'rgba(255, 255, 255, 0.23)',
|
borderColorHover: 'rgba(255, 255, 255, 0.23)',
|
||||||
|
// ---- Contract tokens ----
|
||||||
|
bgPage: '#121212',
|
||||||
|
bgPanel: '#1e1e1e',
|
||||||
|
bgPanelElev: '#2c2c2c',
|
||||||
|
bgInverse: '#f0f0f0',
|
||||||
|
scrim: 'rgba(0, 0, 0, 0.6)',
|
||||||
|
lineSubtle: 'rgba(255, 255, 255, 0.12)',
|
||||||
|
lineStrong: 'rgba(255, 255, 255, 0.23)',
|
||||||
|
textPrimary: 'rgba(255, 255, 255, 0.87)',
|
||||||
|
textSecondary: 'rgba(255, 255, 255, 0.6)',
|
||||||
|
textMuted: 'rgba(255, 255, 255, 0.38)',
|
||||||
|
textOnAccent: '#0a1929',
|
||||||
|
accent: '#90caf9',
|
||||||
|
accentBg: '#1b3c5b',
|
||||||
|
accentBgHover: '#17324d',
|
||||||
|
danger: '#ef9a9a',
|
||||||
|
dangerBg: 'rgba(239, 154, 154, 0.12)',
|
||||||
|
warning: '#ffb74d',
|
||||||
|
warningBg: 'rgba(255, 183, 77, 0.12)',
|
||||||
|
success: '#81c784',
|
||||||
|
successBg: 'rgba(129, 199, 132, 0.12)',
|
||||||
|
info: '#4fc3f7',
|
||||||
|
infoBg: 'rgba(79, 195, 247, 0.12)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
@@ -9,8 +9,23 @@ export const MinimalTheme = {
|
|||||||
...configBase,
|
...configBase,
|
||||||
name: 'minimal',
|
name: 'minimal',
|
||||||
displayName: 'Minimal',
|
displayName: 'Minimal',
|
||||||
|
iconWeight: 'light',
|
||||||
|
typography: {
|
||||||
|
fieldLabel: { fontSize: '$4', fontWeight: '500', color: '$textPrimary' },
|
||||||
|
detailLabel: { fontSize: '$4', fontWeight: '500', color: '$textPrimary' },
|
||||||
|
pageTitle: { fontSize: '$6', fontWeight: '600', color: '$textPrimary' },
|
||||||
|
panelTitle: { fontWeight: '500', color: '$textPrimary' },
|
||||||
|
tableHeader: { fontSize: '$4', fontWeight: '500', color: '$textSecondary' },
|
||||||
|
sectionTitle: { fontSize: '$6', fontWeight: '600', color: '$textPrimary' },
|
||||||
|
},
|
||||||
tokens: {
|
tokens: {
|
||||||
...configBase.tokens,
|
...configBase.tokens,
|
||||||
|
radius: {
|
||||||
|
...configBase.tokens.radius,
|
||||||
|
radiusSm: 2,
|
||||||
|
radiusMd: 4,
|
||||||
|
radiusLg: 6,
|
||||||
|
},
|
||||||
// Minimal color palette (neutral, monochromatic)
|
// Minimal color palette (neutral, monochromatic)
|
||||||
color: {
|
color: {
|
||||||
...configBase.tokens.color,
|
...configBase.tokens.color,
|
||||||
@@ -90,6 +105,29 @@ export const MinimalTheme = {
|
|||||||
colorDisabled: '#a0aec0',
|
colorDisabled: '#a0aec0',
|
||||||
borderColor: '#e2e8f0',
|
borderColor: '#e2e8f0',
|
||||||
borderColorHover: '#cbd5e0',
|
borderColorHover: '#cbd5e0',
|
||||||
|
// ---- Contract tokens (mapped to Minimal palette) ----
|
||||||
|
bgPage: '#fafafa',
|
||||||
|
bgPanel: '#ffffff',
|
||||||
|
bgPanelElev: '#ffffff',
|
||||||
|
bgInverse: '#1a202c',
|
||||||
|
scrim: 'rgba(26, 32, 44, 0.42)',
|
||||||
|
lineSubtle: '#e2e8f0',
|
||||||
|
lineStrong: '#cbd5e0',
|
||||||
|
textPrimary: '#1a202c',
|
||||||
|
textSecondary: '#4a5568',
|
||||||
|
textMuted: '#a0aec0',
|
||||||
|
textOnAccent: '#ffffff',
|
||||||
|
accent: '#4f46e5',
|
||||||
|
accentBg: '#f5f7fb',
|
||||||
|
accentBgHover: '#eceff4',
|
||||||
|
danger: '#e53e3e',
|
||||||
|
dangerBg: 'rgba(229, 62, 62, 0.08)',
|
||||||
|
warning: '#dd6b20',
|
||||||
|
warningBg: 'rgba(221, 107, 32, 0.08)',
|
||||||
|
success: '#38a169',
|
||||||
|
successBg: 'rgba(56, 161, 105, 0.08)',
|
||||||
|
info: '#3182ce',
|
||||||
|
infoBg: 'rgba(49, 130, 206, 0.08)',
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
...configBase.themes.dark,
|
...configBase.themes.dark,
|
||||||
@@ -111,6 +149,29 @@ export const MinimalTheme = {
|
|||||||
colorDisabled: '#718096',
|
colorDisabled: '#718096',
|
||||||
borderColor: '#2d3748',
|
borderColor: '#2d3748',
|
||||||
borderColorHover: '#4a5568',
|
borderColorHover: '#4a5568',
|
||||||
|
// ---- Contract tokens ----
|
||||||
|
bgPage: '#0f1419',
|
||||||
|
bgPanel: '#1a202c',
|
||||||
|
bgPanelElev: '#2d3748',
|
||||||
|
bgInverse: '#f7fafc',
|
||||||
|
scrim: 'rgba(0, 0, 0, 0.6)',
|
||||||
|
lineSubtle: '#2d3748',
|
||||||
|
lineStrong: '#4a5568',
|
||||||
|
textPrimary: '#f7fafc',
|
||||||
|
textSecondary: '#cbd5e0',
|
||||||
|
textMuted: '#718096',
|
||||||
|
textOnAccent: '#0f1419',
|
||||||
|
accent: '#c7d2fe',
|
||||||
|
accentBg: '#333f5d',
|
||||||
|
accentBgHover: '#2c3650',
|
||||||
|
danger: '#feb2b2',
|
||||||
|
dangerBg: 'rgba(254, 178, 178, 0.12)',
|
||||||
|
warning: '#fbd38d',
|
||||||
|
warningBg: 'rgba(251, 211, 141, 0.12)',
|
||||||
|
success: '#9ae6b4',
|
||||||
|
successBg: 'rgba(154, 230, 180, 0.12)',
|
||||||
|
info: '#90cdf4',
|
||||||
|
infoBg: 'rgba(144, 205, 244, 0.12)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
+228
-6
@@ -1,19 +1,202 @@
|
|||||||
/**
|
/**
|
||||||
* Style Themes Index
|
* Style Themes Index
|
||||||
* Exports all available style themes
|
* Exports all available style themes and the semantic token contract.
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* SEMANTIC TOKEN CONTRACT
|
||||||
|
* ============================================================================
|
||||||
|
* Every style theme MUST expose this set of tokens in `themes.light` and
|
||||||
|
* `themes.dark`. Component code should read ONLY these contract tokens —
|
||||||
|
* never raw colors, and never interaction tokens (`$accentSurface`,
|
||||||
|
* `$backgroundHover`, etc.) for static surfaces.
|
||||||
|
*
|
||||||
|
* Surfaces
|
||||||
|
* --------
|
||||||
|
* bgPage page background (subtly off-white in light)
|
||||||
|
* bgPanel card / panel surface (usually pure white in light)
|
||||||
|
* bgPanelElev raised panel (shadow OR slightly different bg)
|
||||||
|
* bgInverse inverted surface (toasts, popovers on dark)
|
||||||
|
* scrim modal/drawer overlay (semi-transparent backdrop)
|
||||||
|
*
|
||||||
|
* Lines
|
||||||
|
* -----
|
||||||
|
* lineSubtle panel borders, hairline (~6-10% black equivalent)
|
||||||
|
* lineStrong separators, dividers (~14-20% black equivalent)
|
||||||
|
*
|
||||||
|
* Text
|
||||||
|
* ----
|
||||||
|
* textPrimary body text (high contrast)
|
||||||
|
* textSecondary supporting text (medium contrast)
|
||||||
|
* textMuted hints, disabled, captions (low contrast)
|
||||||
|
* textOnAccent text on accent-colored backgrounds (usually white)
|
||||||
|
*
|
||||||
|
* Accent
|
||||||
|
* ------
|
||||||
|
* accent brand / primary action color
|
||||||
|
* accentBg ~6% accent tint (selected backgrounds)
|
||||||
|
* accentBgHover ~12% accent tint
|
||||||
|
* accentBorder accent-colored border
|
||||||
|
*
|
||||||
|
* Status (each + Bg variant)
|
||||||
|
* --------------------------
|
||||||
|
* danger / dangerBg
|
||||||
|
* warning / warningBg
|
||||||
|
* success / successBg
|
||||||
|
* info / infoBg
|
||||||
|
*
|
||||||
|
* Radii (preset overrides via tokens.radius)
|
||||||
|
* ------------------------------------------
|
||||||
|
* radiusSm tight controls (4-8)
|
||||||
|
* radiusMd cards, inputs (6-12)
|
||||||
|
* radiusLg large containers (8-16)
|
||||||
|
*
|
||||||
|
* Font weights (preset overrides via tokens.weight)
|
||||||
|
* -------------------------------------------------
|
||||||
|
* weightRegular 400
|
||||||
|
* weightMedium 500
|
||||||
|
* weightSemibold 600
|
||||||
|
* weightBold 700
|
||||||
|
*
|
||||||
|
* Preset-level metadata (NOT theme tokens — top-level preset properties)
|
||||||
|
* -----------------------------------------------------------------------
|
||||||
|
* iconWeight Phosphor weight: 'thin' | 'light' | 'regular' |
|
||||||
|
* 'bold' | 'fill' | 'duotone'
|
||||||
|
*
|
||||||
|
* Typography roles (preset-level contract)
|
||||||
|
* ----------------------------------------
|
||||||
|
* typography.fieldLabel
|
||||||
|
* typography.detailLabel
|
||||||
|
* typography.pageTitle
|
||||||
|
* typography.panelTitle
|
||||||
|
* typography.tableHeader
|
||||||
|
* typography.sectionTitle
|
||||||
|
* ============================================================================
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { MaterialTheme } from './MaterialTheme.js';
|
import { MaterialTheme } from './MaterialTheme.js';
|
||||||
import { MinimalTheme } from './MinimalTheme.js';
|
import { MinimalTheme } from './MinimalTheme.js';
|
||||||
import { ColorfulTheme } from './ColorfulTheme.js';
|
import { ColorfulTheme } from './ColorfulTheme.js';
|
||||||
|
import { AzureTheme } from './AzureTheme.js';
|
||||||
|
import { AppleTheme } from './AppleTheme.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme keys that every preset MUST define in both light and dark variants.
|
||||||
|
* Used by `validateStyleTheme` for diagnostics.
|
||||||
|
*/
|
||||||
|
export const CONTRACT_KEYS = Object.freeze([
|
||||||
|
// surfaces
|
||||||
|
'bgPage', 'bgPanel', 'bgPanelElev', 'bgInverse', 'scrim',
|
||||||
|
// lines
|
||||||
|
'lineSubtle', 'lineStrong',
|
||||||
|
// text
|
||||||
|
'textPrimary', 'textSecondary', 'textMuted', 'textOnAccent',
|
||||||
|
// accent
|
||||||
|
'accent', 'accentBg', 'accentBgHover', 'accentBorder',
|
||||||
|
// status
|
||||||
|
'danger', 'dangerBg',
|
||||||
|
'warning', 'warningBg',
|
||||||
|
'success', 'successBg',
|
||||||
|
'info', 'infoBg',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valid Phosphor icon weights.
|
||||||
|
*/
|
||||||
|
export const ICON_WEIGHTS = Object.freeze([
|
||||||
|
'thin', 'light', 'regular', 'bold', 'fill', 'duotone',
|
||||||
|
]);
|
||||||
|
|
||||||
export const STYLE_THEMES = {
|
export const STYLE_THEMES = {
|
||||||
|
azure: AzureTheme,
|
||||||
|
apple: AppleTheme,
|
||||||
material: MaterialTheme,
|
material: MaterialTheme,
|
||||||
minimal: MinimalTheme,
|
minimal: MinimalTheme,
|
||||||
colorful: ColorfulTheme,
|
colorful: ColorfulTheme,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_STYLE_THEME = 'material';
|
export const DEFAULT_STYLE_THEME = 'azure';
|
||||||
|
|
||||||
|
export const TYPOGRAPHY_ROLE_KEYS = Object.freeze([
|
||||||
|
'fieldLabel',
|
||||||
|
'detailLabel',
|
||||||
|
'pageTitle',
|
||||||
|
'panelTitle',
|
||||||
|
'tableHeader',
|
||||||
|
'sectionTitle',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const DEFAULT_TYPOGRAPHY_ROLES = Object.freeze({
|
||||||
|
fieldLabel: Object.freeze({
|
||||||
|
fontSize: '$4',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '$textPrimary',
|
||||||
|
}),
|
||||||
|
detailLabel: Object.freeze({
|
||||||
|
fontSize: '$4',
|
||||||
|
fontWeight: '500',
|
||||||
|
color: '$textPrimary',
|
||||||
|
}),
|
||||||
|
pageTitle: Object.freeze({
|
||||||
|
fontSize: '$6',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '$textPrimary',
|
||||||
|
}),
|
||||||
|
panelTitle: Object.freeze({
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '$textPrimary',
|
||||||
|
}),
|
||||||
|
tableHeader: Object.freeze({
|
||||||
|
fontSize: '$4',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '$textSecondary',
|
||||||
|
}),
|
||||||
|
sectionTitle: Object.freeze({
|
||||||
|
fontSize: '$6',
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '$textPrimary',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
let activeStyleThemeName = DEFAULT_STYLE_THEME;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a preset implements the contract.
|
||||||
|
* Logs a warning per missing key per variant; never throws.
|
||||||
|
* Useful while authoring presets — silent in production.
|
||||||
|
* @param {Object} preset
|
||||||
|
* @returns {{ ok: boolean, missing: string[] }}
|
||||||
|
*/
|
||||||
|
export function validateStyleTheme(preset) {
|
||||||
|
if (!preset || !preset.themes) {
|
||||||
|
return { ok: false, missing: ['themes'] };
|
||||||
|
}
|
||||||
|
const missing = [];
|
||||||
|
for (const variant of ['light', 'dark']) {
|
||||||
|
const theme = preset.themes[variant];
|
||||||
|
if (!theme) {
|
||||||
|
missing.push(`themes.${variant}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const key of CONTRACT_KEYS) {
|
||||||
|
if (theme[key] == null) {
|
||||||
|
missing.push(`themes.${variant}.${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!preset.typography) {
|
||||||
|
missing.push('typography');
|
||||||
|
} else {
|
||||||
|
for (const key of TYPOGRAPHY_ROLE_KEYS) {
|
||||||
|
if (!preset.typography[key]) {
|
||||||
|
missing.push(`typography.${key}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (missing.length && typeof console !== 'undefined') {
|
||||||
|
console.warn(`[styles] Preset "${preset.name || 'unknown'}" is missing contract tokens:`, missing);
|
||||||
|
}
|
||||||
|
return { ok: missing.length === 0, missing };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Map arbitrary input (storage, profile, UI) to a registered style theme id.
|
* Map arbitrary input (storage, profile, UI) to a registered style theme id.
|
||||||
@@ -30,8 +213,8 @@ export function normalizeStyleThemeName(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a style theme by name
|
* Get a style theme by name.
|
||||||
* @param {string} themeName - Theme name ('material', 'minimal', 'colorful')
|
* @param {string} themeName - Theme name ('azure', 'apple', 'material', 'minimal', 'colorful')
|
||||||
* @returns {Object} Theme configuration
|
* @returns {Object} Theme configuration
|
||||||
*/
|
*/
|
||||||
export function getStyleTheme(themeName = DEFAULT_STYLE_THEME) {
|
export function getStyleTheme(themeName = DEFAULT_STYLE_THEME) {
|
||||||
@@ -39,13 +222,52 @@ export function getStyleTheme(themeName = DEFAULT_STYLE_THEME) {
|
|||||||
return STYLE_THEMES[key];
|
return STYLE_THEMES[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setActiveStyleThemeName(themeName) {
|
||||||
|
activeStyleThemeName = normalizeStyleThemeName(themeName);
|
||||||
|
return activeStyleThemeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveStyleThemeName() {
|
||||||
|
return activeStyleThemeName;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStyleTypography(themeName = activeStyleThemeName) {
|
||||||
|
const preset = getStyleTheme(themeName);
|
||||||
|
const typography = preset?.typography || {};
|
||||||
|
const resolved = {};
|
||||||
|
for (const key of TYPOGRAPHY_ROLE_KEYS) {
|
||||||
|
resolved[key] = {
|
||||||
|
...(DEFAULT_TYPOGRAPHY_ROLES[key] || {}),
|
||||||
|
...(typography[key] || {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTypographyRoleProps(role, overrides = null, themeName = activeStyleThemeName) {
|
||||||
|
const typography = getStyleTypography(themeName);
|
||||||
|
const base = typography[role] || {};
|
||||||
|
return overrides ? { ...base, ...overrides } : { ...base };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all available style theme names
|
* Get all available style theme names.
|
||||||
* @returns {string[]} Array of theme names
|
* @returns {string[]} Array of theme names
|
||||||
*/
|
*/
|
||||||
export function getStyleThemeNames() {
|
export function getStyleThemeNames() {
|
||||||
return Object.keys(STYLE_THEMES);
|
return Object.keys(STYLE_THEMES);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { MaterialTheme, MinimalTheme, ColorfulTheme };
|
/**
|
||||||
|
* Get the active preset's icon weight (for Phosphor).
|
||||||
|
* Falls back to 'regular'.
|
||||||
|
* @param {string} [themeName]
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function getIconWeight(themeName = DEFAULT_STYLE_THEME) {
|
||||||
|
const preset = getStyleTheme(themeName);
|
||||||
|
const weight = preset?.iconWeight;
|
||||||
|
return ICON_WEIGHTS.includes(weight) ? weight : 'regular';
|
||||||
|
}
|
||||||
|
|
||||||
|
export { MaterialTheme, MinimalTheme, ColorfulTheme, AzureTheme, AppleTheme };
|
||||||
|
|||||||
Reference in New Issue
Block a user