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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@phosphor-icons/react": "^2.1.10",
|
||||
"@tamagui/config": "^1.144.2",
|
||||
"@tamagui/core": "^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/components/index.js';
|
||||
export * from './ui/runtime/general-settings.js';
|
||||
export * from './ui/styles/index.js';
|
||||
|
||||
// Re-export security modules
|
||||
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 { 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 (
|
||||
<YStack gap="$2">
|
||||
<Label htmlFor={id} size="$3" color="$color" fontWeight="600">
|
||||
<Label htmlFor={id} size="$3" color="$textPrimary" fontWeight="600">
|
||||
{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>
|
||||
);
|
||||
});
|
||||
@@ -64,8 +71,8 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
|
||||
icon="login"
|
||||
title={title}
|
||||
width="100%"
|
||||
headerFront={{ color: '$accentColor' }}
|
||||
headerBack={{ backgroundColor: '$accentSurface' }}
|
||||
headerFront={{ color: '$textPrimary' }}
|
||||
headerBack={{ backgroundColor: '$bgPanel' }}
|
||||
>
|
||||
<YStack
|
||||
tag="form"
|
||||
@@ -73,12 +80,13 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
|
||||
onSubmit={handleFormSubmit}
|
||||
autoComplete="on"
|
||||
>
|
||||
<Paragraph color="$color" opacity={0.78}>
|
||||
<Paragraph color="$textMuted">
|
||||
{subtitle}
|
||||
</Paragraph>
|
||||
<LoginField
|
||||
id="login-identifier"
|
||||
label="Username or email"
|
||||
error={Boolean(errorMessage)}
|
||||
placeholder="Username or email"
|
||||
value={identifier}
|
||||
onChangeText={setIdentifier}
|
||||
@@ -96,6 +104,7 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
|
||||
<LoginField
|
||||
id="login-password"
|
||||
label="Password"
|
||||
error={Boolean(errorMessage)}
|
||||
ref={passwordInputRef}
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
@@ -111,30 +120,28 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
|
||||
onKeyDown={handlePasswordKeyDown}
|
||||
/>
|
||||
{errorMessage ? (
|
||||
<Text color="#ef4444" fontSize="$4">
|
||||
<Text color="$danger" fontSize="$4">
|
||||
{errorMessage}
|
||||
</Text>
|
||||
) : null}
|
||||
<Button
|
||||
themeInverse
|
||||
backgroundColor="$accentColor"
|
||||
color="white"
|
||||
theme="accent"
|
||||
onPress={handleSubmit}
|
||||
disabled={security.loading || !security.enabled || !security.initialized}
|
||||
>
|
||||
{security.loading ? 'Signing In...' : 'Sign In'}
|
||||
</Button>
|
||||
{!security.enabled ? (
|
||||
<Paragraph fontSize="$3" color="#8b5e3c">
|
||||
<Paragraph fontSize="$3" color="$textSecondary">
|
||||
Identity is currently disabled in the active app profile.
|
||||
</Paragraph>
|
||||
) : null}
|
||||
{security.enabled && !security.initialized ? (
|
||||
<Paragraph fontSize="$3" color="#8b5e3c">
|
||||
<Paragraph fontSize="$3" color="$textSecondary">
|
||||
Security is still initializing.
|
||||
</Paragraph>
|
||||
) : null}
|
||||
<Paragraph fontSize="$3" color="$color" opacity={0.65}>
|
||||
<Paragraph fontSize="$3" color="$textMuted">
|
||||
Demo credentials: admin / admin or demo / demo
|
||||
</Paragraph>
|
||||
</YStack>
|
||||
@@ -153,7 +160,7 @@ export function LoginPage({ compact = false, title = 'Login', subtitle = 'Sign i
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
padding="$6"
|
||||
backgroundColor="$background"
|
||||
backgroundColor="$bgPage"
|
||||
>
|
||||
<YStack width="100%" maxWidth={520}>
|
||||
{content}
|
||||
|
||||
@@ -36,22 +36,22 @@ export function AppInfo({ appName, swStatus, storageBackend, menuItems = [], ini
|
||||
|
||||
<XStack gap="$2">
|
||||
<Text>App:</Text>
|
||||
<Text fontWeight="bold">{appName || 'Loading...'}</Text>
|
||||
<Text fontWeight="600">{appName || 'Loading...'}</Text>
|
||||
</XStack>
|
||||
|
||||
<XStack gap="$2">
|
||||
<Text>Service Worker:</Text>
|
||||
<Text fontWeight="bold">{swStatus}</Text>
|
||||
<Text fontWeight="600">{swStatus}</Text>
|
||||
</XStack>
|
||||
|
||||
<XStack gap="$2">
|
||||
<Text>Storage Backend:</Text>
|
||||
<Text fontWeight="bold">{storageBackend}</Text>
|
||||
<Text fontWeight="600">{storageBackend}</Text>
|
||||
</XStack>
|
||||
|
||||
{initialized && menuItems.length > 0 && (
|
||||
<YStack marginTop="$4" gap="$2">
|
||||
<Text fontWeight="bold">Menu Items:</Text>
|
||||
<Text fontWeight="600">Menu Items:</Text>
|
||||
{menuItems.map((item) => (
|
||||
<XStack key={item.id} gap="$2">
|
||||
<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 { getIcon } from './IconMapper.jsx';
|
||||
import { normalizeColumnsArray } from './grid/utils.js';
|
||||
import { getTypographyRoleProps } from '../styles/index.js';
|
||||
|
||||
const EMPTY_COLUMNS = [];
|
||||
const EMPTY_ACTIONS = [];
|
||||
@@ -43,15 +44,15 @@ function SummaryCards({ summary }) {
|
||||
minWidth={140}
|
||||
padding="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
borderRadius="$4"
|
||||
backgroundColor="$accentSurface"
|
||||
borderColor="$lineSubtle"
|
||||
borderRadius="$radiusMd"
|
||||
backgroundColor="$bgPanel"
|
||||
gap="$1"
|
||||
>
|
||||
<Text fontSize="$3" color="$color" opacity={0.7}>
|
||||
<Text fontSize="$3" color="$textMuted">
|
||||
{item.label}
|
||||
</Text>
|
||||
<Text fontSize="$7" fontWeight="700" color="$accentColor">
|
||||
<Text fontSize="$7" fontWeight="700" color="$accent">
|
||||
{normalizeSummaryValue(item.value)}
|
||||
</Text>
|
||||
</YStack>
|
||||
@@ -63,7 +64,8 @@ function SummaryCards({ summary }) {
|
||||
function HeaderCell({ column, orderBy, order, onSort }) {
|
||||
const sortable = column.sortable !== false;
|
||||
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);
|
||||
|
||||
return (
|
||||
@@ -81,10 +83,23 @@ function HeaderCell({ column, orderBy, order, onSort }) {
|
||||
padding={0}
|
||||
justifyContent={justifyContent}
|
||||
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%">
|
||||
{column.label}{arrow ? ` ${arrow}` : ''}
|
||||
</Text>
|
||||
<XStack width="100%" alignItems="center" justifyContent={justifyContent} gap="$2">
|
||||
<Text {...getTypographyRoleProps('tableHeader')} textAlign={column.align || 'left'} numberOfLines={1}>
|
||||
{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>
|
||||
</XStack>
|
||||
);
|
||||
@@ -133,7 +148,11 @@ export function DirView({
|
||||
bodyMaxHeight = 480,
|
||||
onRowClick = null,
|
||||
onRowPress = null,
|
||||
onRefresh = null
|
||||
onRefresh = null,
|
||||
showHeader = true,
|
||||
showSummary = true,
|
||||
density = 'comfortable',
|
||||
striped = false
|
||||
}) {
|
||||
const [dataVersion, setDataVersion] = useState(0);
|
||||
const [records, setRecords] = useState([]);
|
||||
@@ -155,6 +174,7 @@ export function DirView({
|
||||
const PreviousPageIcon = getIcon('chevron-left');
|
||||
const NextPageIcon = getIcon('chevron-right');
|
||||
const LastPageIcon = getIcon('last-page');
|
||||
const paddingRow = density === 'compact' ? '$2' : density === 'spacious' ? '$4' : '$3';
|
||||
|
||||
useEffect(() => {
|
||||
if (!dataModel?.subscribe) {
|
||||
@@ -275,49 +295,54 @@ export function DirView({
|
||||
|
||||
return (
|
||||
<YStack gap="$4" width="100%">
|
||||
<XStack justifyContent="space-between" alignItems="center" gap="$4" flexWrap="wrap">
|
||||
<XStack alignItems="center" gap="$3" flex={1} minWidth={240} flexWrap="wrap">
|
||||
<Text fontSize="$8" fontWeight="800" color="$accentColor">
|
||||
{title}
|
||||
</Text>
|
||||
{topLeftContent}
|
||||
</XStack>
|
||||
{showHeader ? (
|
||||
<XStack justifyContent="space-between" alignItems="center" gap="$4" flexWrap="wrap">
|
||||
<XStack alignItems="center" gap="$3" flex={1} minWidth={240} flexWrap="wrap">
|
||||
<Text {...getTypographyRoleProps('sectionTitle')}>
|
||||
{title}
|
||||
</Text>
|
||||
{topLeftContent}
|
||||
</XStack>
|
||||
|
||||
<XStack alignItems="center" justifyContent="flex-end" gap="$2" flexWrap="wrap">
|
||||
{searchConfig?.enabled ? (
|
||||
<Input
|
||||
width={260}
|
||||
placeholder={searchConfig.placeholder || 'Search records...'}
|
||||
value={effectiveSearchTerm}
|
||||
onChangeText={updateSearchTerm}
|
||||
<XStack alignItems="center" justifyContent="flex-end" gap="$2" flexWrap="wrap">
|
||||
{searchConfig?.enabled ? (
|
||||
<Input
|
||||
width={260}
|
||||
placeholder={searchConfig.placeholder || 'Search records...'}
|
||||
value={effectiveSearchTerm}
|
||||
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}
|
||||
{effectiveToolbarItems.map(renderToolbarButton)}
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
circular
|
||||
aria-label="Refresh directory"
|
||||
icon={RefreshIcon ? <RefreshIcon size={16} /> : undefined}
|
||||
onPress={handleRefresh}
|
||||
disabled={loading}
|
||||
/>
|
||||
{topRightContent}
|
||||
{topRightContent}
|
||||
</XStack>
|
||||
</XStack>
|
||||
</XStack>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<YStack padding="$3" borderRadius="$4" backgroundColor="#fef2f2" borderWidth={1} borderColor="#fecaca">
|
||||
<Text color="#b91c1c">{error}</Text>
|
||||
<YStack padding="$3" borderRadius="$radiusMd" backgroundColor="$dangerBg" borderWidth={1} borderColor="$danger">
|
||||
<Text color="$danger" fontWeight="600">{error}</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
<SummaryCards summary={summary} />
|
||||
{showSummary ? <SummaryCards summary={summary} /> : null}
|
||||
|
||||
{bodyHeaderContent}
|
||||
|
||||
<YStack borderWidth={1} borderColor="$borderColor" borderRadius="$5" overflow="hidden" backgroundColor="$background">
|
||||
<XStack padding="$3" gap="$3" backgroundColor="$accentSurface" borderBottomWidth={1} borderBottomColor="$borderColor">
|
||||
<YStack borderWidth={1} borderColor="$lineSubtle" borderRadius="$radiusLg" overflow="hidden" backgroundColor="$bgPanel">
|
||||
<XStack padding={paddingRow} gap="$3" backgroundColor="transparent" borderBottomWidth={1} borderBottomColor="$lineSubtle">
|
||||
{resolvedColumns.map((column) => (
|
||||
<HeaderCell
|
||||
key={column.id}
|
||||
@@ -334,27 +359,29 @@ export function DirView({
|
||||
))}
|
||||
</XStack>
|
||||
|
||||
<ScrollView maxHeight={bodyMaxHeight}>
|
||||
<ScrollView {...(bodyMaxHeight != null ? { maxHeight: bodyMaxHeight } : {})}>
|
||||
<YStack>
|
||||
{loading ? (
|
||||
<XStack justifyContent="center" padding="$6">
|
||||
<Spinner size="large" color="$accentColor" />
|
||||
<Spinner size="large" color="$accent" />
|
||||
</XStack>
|
||||
) : records.length === 0 ? (
|
||||
<YStack padding="$6" alignItems="center">
|
||||
<Paragraph color="$color" opacity={0.7}>
|
||||
No records found.
|
||||
<YStack padding="$6" alignItems="center" gap="$2">
|
||||
<Paragraph color="$textSecondary">
|
||||
No records found
|
||||
</Paragraph>
|
||||
<Text color="$textMuted" fontSize="$3">Try adjusting your search or filters.</Text>
|
||||
</YStack>
|
||||
) : (
|
||||
records.map((record, index) => (
|
||||
<YStack key={record?.[dataModel?.getIdField?.() || 'id'] || index}>
|
||||
<XStack
|
||||
padding="$3"
|
||||
padding={paddingRow}
|
||||
gap="$3"
|
||||
alignItems="center"
|
||||
hoverStyle={{ backgroundColor: '$accentSurface' }}
|
||||
pressStyle={{ backgroundColor: '$accentSurface' }}
|
||||
backgroundColor={striped && index % 2 === 1 ? '$bgPage' : 'transparent'}
|
||||
hoverStyle={{ backgroundColor: '$bgPage' }}
|
||||
pressStyle={{ backgroundColor: '$bgPanelElev' }}
|
||||
cursor={effectiveRowPress ? 'pointer' : undefined}
|
||||
onPress={() => effectiveRowPress?.(record)}
|
||||
>
|
||||
@@ -362,7 +389,7 @@ export function DirView({
|
||||
<RowCell key={column.id} column={column} record={record} />
|
||||
))}
|
||||
</XStack>
|
||||
{index < records.length - 1 ? <Separator /> : null}
|
||||
{index < records.length - 1 ? <Separator borderColor="$lineSubtle" /> : null}
|
||||
</YStack>
|
||||
))
|
||||
)}
|
||||
@@ -373,15 +400,15 @@ export function DirView({
|
||||
{bodyFooterContent}
|
||||
|
||||
<XStack justifyContent="space-between" alignItems="center" gap="$3" flexWrap="wrap">
|
||||
<Text color="$color" opacity={0.7}>
|
||||
<Text color="$textMuted">
|
||||
Rows: {totalRecords}
|
||||
</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
|
||||
size="$3"
|
||||
chromeless
|
||||
aria-label="First page"
|
||||
icon={FirstPageIcon ? <FirstPageIcon size={16} /> : undefined}
|
||||
icon={FirstPageIcon ? <FirstPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||
onPress={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1 || loading}
|
||||
/>
|
||||
@@ -389,18 +416,18 @@ export function DirView({
|
||||
size="$3"
|
||||
chromeless
|
||||
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))}
|
||||
disabled={currentPage === 1 || loading}
|
||||
/>
|
||||
<Text color="$color" opacity={0.75}>
|
||||
<Text color="$textSecondary">
|
||||
Page {currentPage} of {totalPages}
|
||||
</Text>
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
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))}
|
||||
disabled={currentPage >= totalPages || loading}
|
||||
/>
|
||||
@@ -408,7 +435,7 @@ export function DirView({
|
||||
size="$3"
|
||||
chromeless
|
||||
aria-label="Last page"
|
||||
icon={LastPageIcon ? <LastPageIcon size={16} /> : undefined}
|
||||
icon={LastPageIcon ? <LastPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||
onPress={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage >= totalPages || loading}
|
||||
/>
|
||||
|
||||
+111
-43
@@ -1,19 +1,43 @@
|
||||
import React from 'react';
|
||||
import { Adapt, Button, Input, Label, Paragraph, Select, Separator, Sheet, Text, TextArea, XStack, YStack } from 'tamagui';
|
||||
import { Check, ChevronDown, ChevronUp } from '@tamagui/lucide-icons';
|
||||
import {
|
||||
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 { 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 }) {
|
||||
return (
|
||||
<YStack gap="$2" width="100%">
|
||||
{label ? (
|
||||
<Label color="$color" fontWeight="600">
|
||||
<Label {...getTypographyRoleProps('fieldLabel')}>
|
||||
{label}
|
||||
</Label>
|
||||
) : null}
|
||||
{children}
|
||||
{error || helperText ? (
|
||||
<Paragraph color={error ? '#dc2626' : '$color'} opacity={error ? 1 : 0.7} fontSize="$3">
|
||||
<Paragraph color={error ? '$danger' : '$textMuted'} fontSize="$3">
|
||||
{error || helperText}
|
||||
</Paragraph>
|
||||
) : 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 }) {
|
||||
return (
|
||||
<FieldShell label={label} error={error} helperText={helperText}>
|
||||
<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.Trigger>
|
||||
|
||||
<Adapt when="sm" platform="touch">
|
||||
<Sheet modal dismissOnSnapToBottom snapPoints={[55]}>
|
||||
<Sheet.Frame>
|
||||
<Sheet.Frame backgroundColor="$bgPanel">
|
||||
<Sheet.ScrollView>
|
||||
<Adapt.Contents />
|
||||
</Sheet.ScrollView>
|
||||
</Sheet.Frame>
|
||||
<Sheet.Overlay />
|
||||
<Sheet.Overlay backgroundColor="$scrim" />
|
||||
</Sheet>
|
||||
</Adapt>
|
||||
|
||||
<Select.Content zIndex={200000}>
|
||||
<Select.ScrollUpButton alignItems="center" justifyContent="center" position="relative" width="100%" height="$3">
|
||||
<YStack zIndex={10}>
|
||||
<ChevronUp size={18} />
|
||||
{ChevronUp ? <ChevronUp size="sm" color="$textSecondary" /> : null}
|
||||
</YStack>
|
||||
</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.ItemText>{option.label}</Select.ItemText>
|
||||
<Select.ItemIndicator marginLeft="auto">
|
||||
<Check size={16} />
|
||||
{CheckIcon ? <CheckIcon size="sm" color="$accent" /> : null}
|
||||
</Select.ItemIndicator>
|
||||
</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">
|
||||
<YStack zIndex={10}>
|
||||
<ChevronDown size={18} />
|
||||
{ChevronDown ? <ChevronDown size="sm" color="$textSecondary" /> : null}
|
||||
</YStack>
|
||||
</Select.ScrollDownButton>
|
||||
</Select.Content>
|
||||
@@ -74,7 +130,7 @@ function SelectField({ label, value, options = [], placeholder, onValueChange, e
|
||||
async function handleFilePick(fieldId, onChange, props = {}) {
|
||||
const selection = await pickFile({
|
||||
accept: props.accept || '*',
|
||||
readAs: props.readAs || null
|
||||
readAs: props.readAs || null,
|
||||
});
|
||||
|
||||
if (!selection?.file) {
|
||||
@@ -103,17 +159,17 @@ export function FormField({
|
||||
const fieldValue = value ?? (type === 'multiselect' ? [] : type === 'checkbox' ? false : '');
|
||||
|
||||
if (type === 'divider') {
|
||||
return <Separator />;
|
||||
return <Separator borderColor="$lineSubtle" />;
|
||||
}
|
||||
|
||||
if (type === 'title') {
|
||||
return (
|
||||
<YStack gap="$1">
|
||||
<Text fontSize="$7" fontWeight="700" color="$accentColor">
|
||||
<Text fontSize="$6" fontWeight="600" color="$textPrimary">
|
||||
{label}
|
||||
</Text>
|
||||
{helperText ? (
|
||||
<Paragraph color="$color" opacity={0.7}>
|
||||
<Paragraph color="$textMuted">
|
||||
{helperText}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
@@ -151,12 +207,7 @@ export function FormField({
|
||||
<Button
|
||||
key={option.value}
|
||||
size="$3"
|
||||
theme={selected ? 'active' : undefined}
|
||||
backgroundColor={selected ? '$accentColor' : '$background'}
|
||||
color={selected ? 'white' : '$color'}
|
||||
borderWidth={1}
|
||||
borderColor={selected ? '$accentColor' : '$borderColor'}
|
||||
disabled={disabled}
|
||||
{...chipProps(selected, disabled)}
|
||||
onPress={() => {
|
||||
const nextValues = selected
|
||||
? 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') {
|
||||
return (
|
||||
<FieldShell label={label} error={error} helperText={helperText}>
|
||||
<Button
|
||||
size="$3"
|
||||
alignSelf="flex-start"
|
||||
backgroundColor={fieldValue ? '$accentColor' : '$background'}
|
||||
color={fieldValue ? 'white' : '$color'}
|
||||
borderWidth={1}
|
||||
borderColor={fieldValue ? '$accentColor' : '$borderColor'}
|
||||
disabled={disabled}
|
||||
onPress={() => onChange?.(id, !fieldValue)}
|
||||
>
|
||||
{fieldValue ? 'Enabled' : 'Disabled'}
|
||||
</Button>
|
||||
<XStack alignItems="center" gap="$3">
|
||||
<Switch
|
||||
id={id}
|
||||
size="$3"
|
||||
checked={Boolean(fieldValue)}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(next) => onChange?.(id, next)}
|
||||
backgroundColor={fieldValue ? '$accent' : '$lineStrong'}
|
||||
borderColor={error ? '$danger' : 'transparent'}
|
||||
borderWidth={error ? 1 : 0}
|
||||
>
|
||||
<Switch.Thumb animation="quick" backgroundColor="$bgPanel" />
|
||||
</Switch>
|
||||
<Text color="$textSecondary" fontSize="$3">
|
||||
{fieldValue ? 'Enabled' : 'Disabled'}
|
||||
</Text>
|
||||
</XStack>
|
||||
</FieldShell>
|
||||
);
|
||||
}
|
||||
@@ -202,11 +260,7 @@ export function FormField({
|
||||
<Button
|
||||
key={option.value}
|
||||
size="$3"
|
||||
backgroundColor={selected ? '$accentColor' : '$background'}
|
||||
color={selected ? 'white' : '$color'}
|
||||
borderWidth={1}
|
||||
borderColor={selected ? '$accentColor' : '$borderColor'}
|
||||
disabled={disabled}
|
||||
{...chipProps(selected, disabled)}
|
||||
onPress={() => onChange?.(id, option.value)}
|
||||
>
|
||||
{option.label}
|
||||
@@ -222,10 +276,18 @@ export function FormField({
|
||||
return (
|
||||
<FieldShell label={label} error={error} helperText={helperText}>
|
||||
<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'}
|
||||
</Button>
|
||||
<Text color="$color" opacity={0.75}>
|
||||
<Text color="$textMuted">
|
||||
{fieldValue?.name || 'No file selected'}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -243,6 +305,9 @@ export function FormField({
|
||||
disabled={disabled}
|
||||
readOnly={readOnly}
|
||||
minHeight={120}
|
||||
backgroundColor="$bgPanel"
|
||||
borderColor={error ? '$danger' : '$lineSubtle'}
|
||||
focusStyle={{ borderColor: error ? '$danger' : '$accent' }}
|
||||
/>
|
||||
</FieldShell>
|
||||
);
|
||||
@@ -254,11 +319,11 @@ export function FormField({
|
||||
<YStack
|
||||
padding="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
borderRadius="$4"
|
||||
backgroundColor="$accentSurface"
|
||||
borderColor="$lineSubtle"
|
||||
borderRadius="$radiusMd"
|
||||
backgroundColor="$bgPage"
|
||||
>
|
||||
<Text>{String(fieldValue || '')}</Text>
|
||||
<Text color="$textPrimary">{String(fieldValue || '')}</Text>
|
||||
</YStack>
|
||||
</FieldShell>
|
||||
);
|
||||
@@ -275,6 +340,9 @@ export function FormField({
|
||||
type={type === 'datetime' ? 'datetime-local' : type}
|
||||
keyboardType={type === 'number' ? 'numeric' : undefined}
|
||||
autoCapitalize={type === 'email' || type === 'password' ? 'none' : undefined}
|
||||
backgroundColor="$bgPanel"
|
||||
borderColor={error ? '$danger' : '$lineSubtle'}
|
||||
focusStyle={{ borderColor: error ? '$danger' : '$accent' }}
|
||||
/>
|
||||
</FieldShell>
|
||||
);
|
||||
|
||||
@@ -134,7 +134,9 @@ export function FormView({
|
||||
) : null}
|
||||
{loading ? (
|
||||
<XStack justifyContent="flex-end">
|
||||
<Button disabled>Saving...</Button>
|
||||
<Button size="$3" theme="accent" disabled>
|
||||
Saving...
|
||||
</Button>
|
||||
</XStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
|
||||
+433
-273
@@ -1,324 +1,484 @@
|
||||
/**
|
||||
* IconMapper - Maps icon names to Lucide icons from @tamagui/lucide-icons
|
||||
* Cross-platform compatible (web + React Native)
|
||||
* IconMapper - Phosphor-backed icon system with Tamagui theme integration.
|
||||
*
|
||||
* 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 { useTheme } from '@tamagui/core';
|
||||
import { SizableText } from 'tamagui';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ArrowDown,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
BarChart3,
|
||||
// navigation & shell
|
||||
House,
|
||||
Gear,
|
||||
List as MenuList,
|
||||
MagnifyingGlass,
|
||||
Bell,
|
||||
Bold,
|
||||
Bookmark,
|
||||
Book,
|
||||
Calendar,
|
||||
Camera,
|
||||
Check,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
ChevronUp,
|
||||
Clock,
|
||||
Cloud,
|
||||
CloudDownload,
|
||||
CloudUpload,
|
||||
Clipboard,
|
||||
Code,
|
||||
Copy,
|
||||
Crop,
|
||||
DollarSign,
|
||||
Download,
|
||||
Eye,
|
||||
EyeOff,
|
||||
File,
|
||||
FileText,
|
||||
Filter,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Heart,
|
||||
HelpCircle,
|
||||
Home,
|
||||
Image,
|
||||
Info,
|
||||
Italic,
|
||||
LayoutDashboard,
|
||||
Library,
|
||||
Link,
|
||||
Lock,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Mail,
|
||||
Map,
|
||||
MapPin,
|
||||
Menu,
|
||||
MessageCircle,
|
||||
MessageSquare,
|
||||
Minus,
|
||||
MoreHorizontal,
|
||||
MoreVertical,
|
||||
Navigation,
|
||||
Network,
|
||||
Paperclip,
|
||||
Pause,
|
||||
Phone,
|
||||
Play,
|
||||
Plus,
|
||||
Power,
|
||||
Printer,
|
||||
RefreshCw,
|
||||
RotateCw,
|
||||
Save,
|
||||
Scissors,
|
||||
Search,
|
||||
Send,
|
||||
Settings,
|
||||
Share2,
|
||||
Signal,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
Square,
|
||||
SquarePen,
|
||||
Star,
|
||||
SlidersHorizontal,
|
||||
Sun,
|
||||
Trash2,
|
||||
TrendingUp,
|
||||
Underline,
|
||||
Unlock,
|
||||
Upload,
|
||||
User,
|
||||
UserCircle,
|
||||
Users,
|
||||
Video,
|
||||
Volume1,
|
||||
Volume2,
|
||||
VolumeX,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Envelope,
|
||||
// files
|
||||
File,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Article,
|
||||
Book,
|
||||
Books,
|
||||
// actions
|
||||
PencilSimple,
|
||||
Trash,
|
||||
FloppyDisk,
|
||||
X,
|
||||
ZoomIn,
|
||||
ZoomOut
|
||||
} from '@tamagui/lucide-icons';
|
||||
Check,
|
||||
Plus,
|
||||
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
|
||||
* Maps Material Design icon names to Lucide equivalents
|
||||
* Phosphor's `color` prop only accepts plain CSS colors. Callers often pass
|
||||
* 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 = {
|
||||
// Navigation & UI
|
||||
'home': Home,
|
||||
'settings': Settings,
|
||||
'user': User,
|
||||
'person': User,
|
||||
'account': UserCircle,
|
||||
'menu': Menu,
|
||||
'hamburger': Menu,
|
||||
'search': Search,
|
||||
'bell': Bell,
|
||||
'notifications': Bell,
|
||||
'mail': Mail,
|
||||
'email': Mail,
|
||||
// ── Navigation & UI ────────────────────────────────────────────────────
|
||||
'home': wrap(House, 'House'),
|
||||
'settings': wrap(Gear, 'Gear'),
|
||||
'gear': wrap(Gear, 'Gear'),
|
||||
'user': wrap(User, 'User'),
|
||||
'person': wrap(User, 'User'),
|
||||
'account': wrap(UserCircle, 'UserCircle'),
|
||||
'menu': wrap(MenuList, 'List'),
|
||||
'hamburger': wrap(MenuList, 'List'),
|
||||
'list': wrap(MenuList, 'List'),
|
||||
'search': wrap(MagnifyingGlass, 'MagnifyingGlass'),
|
||||
'bell': wrap(Bell, 'Bell'),
|
||||
'notifications': wrap(Bell, 'Bell'),
|
||||
'mail': wrap(Envelope, 'Envelope'),
|
||||
'email': wrap(Envelope, 'Envelope'),
|
||||
|
||||
// Files & Folders
|
||||
'file': File,
|
||||
'folder': Folder,
|
||||
'folder-open': FolderOpen,
|
||||
// ── Files & Folders ────────────────────────────────────────────────────
|
||||
'file': wrap(File, 'File'),
|
||||
'folder': wrap(Folder, 'Folder'),
|
||||
'folder-open': wrap(FolderOpen, 'FolderOpen'),
|
||||
|
||||
// Actions
|
||||
'edit': SquarePen,
|
||||
'delete': Trash2,
|
||||
'save': Save,
|
||||
'close': X,
|
||||
'x': X,
|
||||
'check': Check,
|
||||
'plus': Plus,
|
||||
'minus': Minus,
|
||||
// ── Actions ────────────────────────────────────────────────────────────
|
||||
'edit': wrap(PencilSimple, 'PencilSimple'),
|
||||
'pencil': wrap(PencilSimple, 'PencilSimple'),
|
||||
'delete': wrap(Trash, 'Trash'),
|
||||
'trash': wrap(Trash, 'Trash'),
|
||||
'save': wrap(FloppyDisk, 'FloppyDisk'),
|
||||
'close': wrap(X, 'X'),
|
||||
'x': wrap(X, 'X'),
|
||||
'check': wrap(Check, 'Check'),
|
||||
'plus': wrap(Plus, 'Plus'),
|
||||
'add': wrap(Plus, 'Plus'),
|
||||
'minus': wrap(Minus, 'Minus'),
|
||||
|
||||
// Arrows & Navigation
|
||||
'arrow-right': ArrowRight,
|
||||
'arrow-left': ArrowLeft,
|
||||
'arrow-up': ArrowUp,
|
||||
'arrow-down': ArrowDown,
|
||||
'chevron-right': ChevronRight,
|
||||
'chevron-down': ChevronDown,
|
||||
'chevron-up': ChevronUp,
|
||||
'chevron-left': ChevronLeft,
|
||||
'chevrons-right': ChevronsRight,
|
||||
'chevrons-left': ChevronsLeft,
|
||||
'more-vert': MoreVertical,
|
||||
'more-horiz': MoreHorizontal,
|
||||
// ── Arrows & Chevrons ──────────────────────────────────────────────────
|
||||
'arrow-right': wrap(ArrowRight, 'ArrowRight'),
|
||||
'arrow-left': wrap(ArrowLeft, 'ArrowLeft'),
|
||||
'arrow-up': wrap(ArrowUp, 'ArrowUp'),
|
||||
'arrow-down': wrap(ArrowDown, 'ArrowDown'),
|
||||
'chevron-right': wrap(CaretRight, 'CaretRight'),
|
||||
'chevron-down': wrap(CaretDown, 'CaretDown'),
|
||||
'chevron-up': wrap(CaretUp, 'CaretUp'),
|
||||
'chevron-left': wrap(CaretLeft, 'CaretLeft'),
|
||||
'chevrons-right': wrap(CaretDoubleRight, 'CaretDoubleRight'),
|
||||
'chevrons-left': wrap(CaretDoubleLeft, 'CaretDoubleLeft'),
|
||||
'caret-up': wrap(CaretUp, 'CaretUp'),
|
||||
'caret-down': wrap(CaretDown, 'CaretDown'),
|
||||
'caret-left': wrap(CaretLeft, 'CaretLeft'),
|
||||
'caret-right': wrap(CaretRight, 'CaretRight'),
|
||||
'more-vert': wrap(DotsThreeVertical, 'DotsThreeVertical'),
|
||||
'more-horiz': wrap(DotsThree, 'DotsThree'),
|
||||
|
||||
// Auth
|
||||
'logout': LogOut,
|
||||
'login': LogIn,
|
||||
'lock': Lock,
|
||||
'unlock': Unlock,
|
||||
// ── Auth ───────────────────────────────────────────────────────────────
|
||||
'logout': wrap(SignOut, 'SignOut'),
|
||||
'login': wrap(SignIn, 'SignIn'),
|
||||
'lock': wrap(Lock, 'Lock'),
|
||||
'unlock': wrap(LockOpen, 'LockOpen'),
|
||||
|
||||
// Dashboard & Analytics
|
||||
'dashboard': LayoutDashboard,
|
||||
'chart': BarChart3,
|
||||
'analytics': TrendingUp,
|
||||
'money': DollarSign,
|
||||
'group': Users,
|
||||
'report': FileText,
|
||||
// ── Dashboards & Analytics ─────────────────────────────────────────────
|
||||
'dashboard': wrap(SquaresFour, 'SquaresFour'),
|
||||
'chart': wrap(ChartBar, 'ChartBar'),
|
||||
'analytics': wrap(TrendUp, 'TrendUp'),
|
||||
'money': wrap(CurrencyDollar, 'CurrencyDollar'),
|
||||
'group': wrap(Users, 'Users'),
|
||||
'users': wrap(Users, 'Users'),
|
||||
'report': wrap(FileText, 'FileText'),
|
||||
|
||||
// Status & Feedback
|
||||
'info': Info,
|
||||
'warning': AlertTriangle,
|
||||
'error': AlertCircle,
|
||||
'success': CheckCircle,
|
||||
'help': HelpCircle,
|
||||
// ── Status ─────────────────────────────────────────────────────────────
|
||||
'info': wrap(Info, 'Info'),
|
||||
'warning': wrap(Warning, 'Warning'),
|
||||
'error': wrap(WarningCircle, 'WarningCircle'),
|
||||
'success': wrap(CheckCircle, 'CheckCircle'),
|
||||
'help': wrap(Question, 'Question'),
|
||||
|
||||
// Visibility
|
||||
'visibility': Eye,
|
||||
'visibility-off': EyeOff,
|
||||
// ── Visibility ─────────────────────────────────────────────────────────
|
||||
'visibility': wrap(Eye, 'Eye'),
|
||||
'visibility-off': wrap(EyeSlash, 'EyeSlash'),
|
||||
'eye': wrap(Eye, 'Eye'),
|
||||
'eye-off': wrap(EyeSlash, 'EyeSlash'),
|
||||
|
||||
// Media
|
||||
'image': Image,
|
||||
'photo': Camera,
|
||||
'video': Video,
|
||||
'play': Play,
|
||||
'pause': Pause,
|
||||
'stop': Square,
|
||||
// ── Media ──────────────────────────────────────────────────────────────
|
||||
'image': wrap(ImageIcon, 'Image'),
|
||||
'photo': wrap(Camera, 'Camera'),
|
||||
'video': wrap(VideoCamera, 'VideoCamera'),
|
||||
'play': wrap(Play, 'Play'),
|
||||
'pause': wrap(Pause, 'Pause'),
|
||||
'stop': wrap(Square, 'Square'),
|
||||
'square': wrap(Square, 'Square'),
|
||||
'circle': wrap(Circle, 'Circle'),
|
||||
|
||||
// Communication
|
||||
'chat': MessageCircle,
|
||||
'message': MessageSquare,
|
||||
'comment': MessageSquare,
|
||||
'send': Send,
|
||||
'phone': Phone,
|
||||
// ── Communication ──────────────────────────────────────────────────────
|
||||
'chat': wrap(ChatCircle, 'ChatCircle'),
|
||||
'message': wrap(ChatText, 'ChatText'),
|
||||
'comment': wrap(ChatText, 'ChatText'),
|
||||
'send': wrap(PaperPlaneTilt, 'PaperPlaneTilt'),
|
||||
'phone': wrap(Phone, 'Phone'),
|
||||
|
||||
// Content
|
||||
'copy': Copy,
|
||||
'cut': Scissors,
|
||||
'paste': Clipboard,
|
||||
'link': Link,
|
||||
'attach': Paperclip,
|
||||
// ── Content ────────────────────────────────────────────────────────────
|
||||
'copy': wrap(Copy, 'Copy'),
|
||||
'cut': wrap(Scissors, 'Scissors'),
|
||||
'paste': wrap(Clipboard, 'Clipboard'),
|
||||
'link': wrap(Link, 'Link'),
|
||||
'attach': wrap(Paperclip, 'Paperclip'),
|
||||
|
||||
// UI Controls
|
||||
'filter': Filter,
|
||||
'sort': ArrowUpDown,
|
||||
'refresh': RefreshCw,
|
||||
'download': Download,
|
||||
'upload': Upload,
|
||||
'share': Share2,
|
||||
'language': Globe,
|
||||
'locale': Globe,
|
||||
'tune': SlidersHorizontal,
|
||||
'first-page': SkipBack,
|
||||
'last-page': SkipForward,
|
||||
// ── UI Controls ────────────────────────────────────────────────────────
|
||||
'filter': wrap(FunnelSimple, 'FunnelSimple'),
|
||||
'sort': wrap(ArrowsDownUp, 'ArrowsDownUp'),
|
||||
'refresh': wrap(ArrowClockwise, 'ArrowClockwise'),
|
||||
'download': wrap(Download, 'Download'),
|
||||
'upload': wrap(Upload, 'Upload'),
|
||||
'share': wrap(ShareNetwork, 'ShareNetwork'),
|
||||
'language': wrap(Globe, 'Globe'),
|
||||
'locale': wrap(Globe, 'Globe'),
|
||||
'tune': wrap(Sliders, 'Sliders'),
|
||||
'first-page': wrap(SkipBack, 'SkipBack'),
|
||||
'last-page': wrap(SkipForward, 'SkipForward'),
|
||||
|
||||
// Favorites & Bookmarks
|
||||
'favorite': Heart,
|
||||
'favorite-border': Heart,
|
||||
'star': Star,
|
||||
'star-border': Star,
|
||||
'bookmark': Bookmark,
|
||||
// ── Favorites & Bookmarks ──────────────────────────────────────────────
|
||||
'favorite': wrap(Heart, 'Heart'),
|
||||
'favorite-border': wrap(Heart, 'Heart'),
|
||||
'star': wrap(Star, 'Star'),
|
||||
'star-border': wrap(Star, 'Star'),
|
||||
'bookmark': wrap(BookmarkSimple, 'BookmarkSimple'),
|
||||
|
||||
// Time & Calendar
|
||||
'calendar': Calendar,
|
||||
'time': Clock,
|
||||
// ── Time & Calendar ────────────────────────────────────────────────────
|
||||
'calendar': wrap(Calendar, 'Calendar'),
|
||||
'time': wrap(Clock, 'Clock'),
|
||||
'clock': wrap(Clock, 'Clock'),
|
||||
|
||||
// Location
|
||||
'location': MapPin,
|
||||
'location-on': MapPin,
|
||||
'map': Map,
|
||||
'navigation': Navigation,
|
||||
// ── Location ───────────────────────────────────────────────────────────
|
||||
'location': wrap(MapPin, 'MapPin'),
|
||||
'location-on': wrap(MapPin, 'MapPin'),
|
||||
'map': wrap(MapTrifold, 'MapTrifold'),
|
||||
'navigation': wrap(NavigationArrow, 'NavigationArrow'),
|
||||
|
||||
// System
|
||||
'power': Power,
|
||||
'brightness': Sun,
|
||||
'wifi': Wifi,
|
||||
'wifi-off': WifiOff,
|
||||
// ── System ─────────────────────────────────────────────────────────────
|
||||
'power': wrap(Power, 'Power'),
|
||||
'brightness': wrap(Sun, 'Sun'),
|
||||
'sun': wrap(Sun, 'Sun'),
|
||||
'wifi': wrap(WifiHigh, 'WifiHigh'),
|
||||
'wifi-off': wrap(WifiSlash, 'WifiSlash'),
|
||||
'lightning': wrap(Lightning, 'Lightning'),
|
||||
|
||||
// Media Controls
|
||||
'volume-up': Volume2,
|
||||
'volume-down': Volume1,
|
||||
'volume-off': VolumeX,
|
||||
'mute': VolumeX,
|
||||
// ── Volume ─────────────────────────────────────────────────────────────
|
||||
'volume-up': wrap(SpeakerHigh, 'SpeakerHigh'),
|
||||
'volume-down': wrap(SpeakerLow, 'SpeakerLow'),
|
||||
'volume-off': wrap(SpeakerSlash, 'SpeakerSlash'),
|
||||
'mute': wrap(SpeakerSlash, 'SpeakerSlash'),
|
||||
|
||||
// Editing
|
||||
'zoom-in': ZoomIn,
|
||||
'zoom-out': ZoomOut,
|
||||
'crop': Crop,
|
||||
'rotate': RotateCw,
|
||||
// ── Editing ────────────────────────────────────────────────────────────
|
||||
'zoom-in': wrap(MagnifyingGlassPlus, 'MagnifyingGlassPlus'),
|
||||
'zoom-out': wrap(MagnifyingGlassMinus, 'MagnifyingGlassMinus'),
|
||||
'crop': wrap(Crop, 'Crop'),
|
||||
'rotate': wrap(ArrowsClockwise, 'ArrowsClockwise'),
|
||||
|
||||
// Formatting
|
||||
'format-bold': Bold,
|
||||
'format-italic': Italic,
|
||||
'format-underline': Underline,
|
||||
'code': Code,
|
||||
// ── Text formatting ────────────────────────────────────────────────────
|
||||
'format-bold': wrap(TextB, 'TextB'),
|
||||
'format-italic': wrap(TextItalic, 'TextItalic'),
|
||||
'format-underline': wrap(TextUnderline, 'TextUnderline'),
|
||||
'code': wrap(Code, 'Code'),
|
||||
|
||||
// Files & Documents
|
||||
'document': FileText,
|
||||
'article': FileText,
|
||||
'book': Book,
|
||||
'library': Library,
|
||||
// ── Documents ──────────────────────────────────────────────────────────
|
||||
'document': wrap(FileText, 'FileText'),
|
||||
'article': wrap(Article, 'Article'),
|
||||
'book': wrap(Book, 'Book'),
|
||||
'library': wrap(Books, 'Books'),
|
||||
|
||||
// Cloud & Storage
|
||||
'cloud': Cloud,
|
||||
'cloud-upload': CloudUpload,
|
||||
'cloud-download': CloudDownload,
|
||||
'drive': HardDrive,
|
||||
// ── Cloud & Storage ────────────────────────────────────────────────────
|
||||
'cloud': wrap(Cloud, 'Cloud'),
|
||||
'cloud-upload': wrap(CloudArrowUp, 'CloudArrowUp'),
|
||||
'cloud-download': wrap(CloudArrowDown, 'CloudArrowDown'),
|
||||
'drive': wrap(HardDrive, 'HardDrive'),
|
||||
|
||||
// Network
|
||||
'network': Network,
|
||||
'signal': Signal,
|
||||
// ── Network ────────────────────────────────────────────────────────────
|
||||
'network': wrap(Network, 'Network'),
|
||||
'signal': wrap(CellSignalHigh, 'CellSignalHigh'),
|
||||
|
||||
// Print
|
||||
'print': Printer,
|
||||
|
||||
// Add more mappings as needed
|
||||
// ── Print ──────────────────────────────────────────────────────────────
|
||||
'print': wrap(Printer, 'Printer'),
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Public API */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get Lucide icon component by name
|
||||
* @param {string} iconName - Name of the icon (e.g., 'home', 'settings')
|
||||
* @returns {React.Component|null} Lucide icon component or null
|
||||
* Look up a wrapped Phosphor icon by name. Returns a React component (the
|
||||
* wrapper handles theme-aware color, sizing, and weight) or `null` if the
|
||||
* 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) {
|
||||
if (!iconName || typeof iconName !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedName = iconName.toLowerCase().trim();
|
||||
return iconMap[normalizedName] || null;
|
||||
if (typeof iconName !== 'string' || !iconName) return null;
|
||||
return iconMap[iconName.toLowerCase().trim()] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* IconMapper Component
|
||||
* Renders a Lucide icon by name with Tamagui theme support
|
||||
* @param {string} iconName - Name of the icon
|
||||
* @param {number|string} size - Size of the icon (number or Tamagui token like '$4')
|
||||
* @param {string} color - Color of the icon (CSS color or Tamagui token like '$color')
|
||||
* @returns {React.ReactElement|null} Rendered icon or null
|
||||
* Convenience component: `<IconMapper iconName="home" size="md" />`.
|
||||
* Falls back to a small Tamagui Text node when the input is an emoji or
|
||||
* single character (e.g. user-entered avatar glyphs).
|
||||
*/
|
||||
export function IconMapper({ iconName, size = 24, color = 'currentColor', ...props }) {
|
||||
const IconComponent = getIcon(iconName);
|
||||
|
||||
if (!IconComponent) {
|
||||
// Fallback for emojis or unknown icons
|
||||
if (iconName && (iconName.length <= 2 || /[\u{1F300}-\u{1F9FF}]/u.test(iconName))) {
|
||||
return <span style={{ fontSize: typeof size === 'string' ? size : `${size}px`, color }}>{iconName}</span>;
|
||||
}
|
||||
return null;
|
||||
export function IconMapper({ iconName, size = DEFAULT_SIZE, color = '$textPrimary', ...props }) {
|
||||
const Icon = getIcon(iconName);
|
||||
if (Icon) {
|
||||
return <Icon size={size} color={color} {...props} />;
|
||||
}
|
||||
if (typeof iconName === 'string' && (iconName.length <= 2 || /[\u{1F300}-\u{1F9FF}]/u.test(iconName))) {
|
||||
return <SizableText fontSize={resolveSize(size)} color={color}>{iconName}</SizableText>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Convert size if it's a number to a reasonable default
|
||||
const iconSize = typeof size === 'string' ? size : size;
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
return <IconComponent size={iconSize} color={color} {...props} />;
|
||||
/**
|
||||
* List of every registered alias. Handy for validation / docs.
|
||||
*/
|
||||
export function getIconNames() {
|
||||
return Object.keys(iconMap);
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
if (selected) {
|
||||
return '$accentBackground';
|
||||
}
|
||||
if (hovered) {
|
||||
return '$backgroundPress';
|
||||
}
|
||||
if (selected) return '$accentBg';
|
||||
if (hovered) return '$bgPage';
|
||||
return 'transparent';
|
||||
};
|
||||
|
||||
const getIconColor = () => {
|
||||
if (selected) {
|
||||
return '$accentColor';
|
||||
}
|
||||
if (menuItem.style === 'icon_only') {
|
||||
return '$accentColor';
|
||||
}
|
||||
return '$color';
|
||||
if (selected) return '$accent';
|
||||
if (menuItem.style === 'icon_only') return '$accent';
|
||||
return '$textPrimary';
|
||||
};
|
||||
|
||||
const getLabelColor = () => {
|
||||
if (selected) {
|
||||
return '$accentColor';
|
||||
}
|
||||
return '$color';
|
||||
if (selected) return '$accent';
|
||||
return '$textPrimary';
|
||||
};
|
||||
|
||||
const getArrowColor = () => {
|
||||
if (selected) {
|
||||
return '$accentColor';
|
||||
}
|
||||
return '$colorSecondary';
|
||||
if (selected) return '$accent';
|
||||
return '$textMuted';
|
||||
};
|
||||
|
||||
const ICON_SIZE = 'sm';
|
||||
const CHEVRON_SIZE = 'sm';
|
||||
|
||||
// Determine display style (both, label_only, icon_only)
|
||||
// Use displayStyle prop if provided, otherwise fall back to menuItem.style
|
||||
const effectiveDisplayStyle = displayStyle !== undefined ? displayStyle : (menuItem.style || 'both');
|
||||
@@ -325,11 +318,9 @@ export function MenuItemButton({
|
||||
width="100%"
|
||||
alignItems="center"
|
||||
backgroundColor={getBackgroundColor()}
|
||||
borderWidth={selected ? 1 : 0}
|
||||
borderColor={selected ? '$accentBorder' : 'transparent'}
|
||||
borderRadius="$2"
|
||||
borderRadius="$radiusSm"
|
||||
padding={padding}
|
||||
opacity={menuItem.is_active !== false ? 1 : 0.5}
|
||||
opacity={menuItem.is_active !== false ? 1 : 0.55}
|
||||
>
|
||||
{/* Icon + Label (clickable main area) */}
|
||||
<XStack
|
||||
@@ -337,10 +328,10 @@ export function MenuItemButton({
|
||||
alignItems="center"
|
||||
cursor={menuItem.isActionable() || hasSubitems ? 'pointer' : 'default'}
|
||||
hoverStyle={{
|
||||
backgroundColor: hovered || selected ? getBackgroundColor() : '$backgroundHover'
|
||||
backgroundColor: selected ? '$accentBgHover' : '$bgPage'
|
||||
}}
|
||||
pressStyle={{
|
||||
backgroundColor: selected ? '$accentHover' : '$backgroundPress'
|
||||
backgroundColor: selected ? '$accentBgHover' : '$bgPanelElev'
|
||||
}}
|
||||
onPress={handleMainClick}
|
||||
>
|
||||
@@ -348,18 +339,16 @@ export function MenuItemButton({
|
||||
{showIcon && (
|
||||
<XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center">
|
||||
{typeof IconComponent === 'string' ? (
|
||||
// Emoji fallback
|
||||
<Text fontSize={size} lineHeight={size}>{IconComponent}</Text>
|
||||
) : IconComponent ? (
|
||||
// Material Design icon component
|
||||
<IconComponent size={typeof size === 'string' ? 24 : (size || 24)} color={getIconColor()} />
|
||||
<IconComponent size={ICON_SIZE} color={getIconColor()} />
|
||||
) : null}
|
||||
</XStack>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
{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}
|
||||
</Text>
|
||||
)}
|
||||
@@ -372,16 +361,17 @@ export function MenuItemButton({
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
padding="$1"
|
||||
borderRadius="$radiusSm"
|
||||
hoverStyle={{
|
||||
backgroundColor: '$backgroundHover'
|
||||
backgroundColor: '$bgPage'
|
||||
}}
|
||||
pressStyle={{
|
||||
backgroundColor: selected ? '$accentHover' : '$backgroundPress'
|
||||
backgroundColor: '$bgPanelElev'
|
||||
}}
|
||||
onPress={handleToggleExpand}
|
||||
>
|
||||
<ArrowIcon
|
||||
size={16}
|
||||
size={CHEVRON_SIZE}
|
||||
color={getArrowColor()}
|
||||
style={{ marginLeft: 4, flexShrink: 0 }}
|
||||
/>
|
||||
@@ -394,15 +384,15 @@ export function MenuItemButton({
|
||||
<YStack
|
||||
ref={popupRef}
|
||||
position="fixed"
|
||||
backgroundColor="$background"
|
||||
borderRadius="$3"
|
||||
backgroundColor="$bgPanelElev"
|
||||
borderRadius="$radiusMd"
|
||||
padding={0}
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
borderColor="$lineSubtle"
|
||||
shadowColor="$shadowColor"
|
||||
shadowOffset={{ width: 0, height: 4 }}
|
||||
shadowOpacity={0.2}
|
||||
shadowRadius={8}
|
||||
shadowOpacity={0.18}
|
||||
shadowRadius={12}
|
||||
elevation={8}
|
||||
zIndex={9999}
|
||||
minWidth={200}
|
||||
@@ -457,11 +447,9 @@ export function MenuItemButton({
|
||||
width="100%"
|
||||
title={collapsed && menuItem.label ? menuItem.label : undefined}
|
||||
backgroundColor={getBackgroundColor()}
|
||||
borderWidth={selected ? 1 : 0}
|
||||
borderColor={selected ? '$accentBorder' : 'transparent'}
|
||||
borderRadius="$2"
|
||||
borderRadius="$radiusSm"
|
||||
padding={padding}
|
||||
opacity={menuItem.is_active !== false ? 1 : 0.5}
|
||||
opacity={menuItem.is_active !== false ? 1 : 0.55}
|
||||
>
|
||||
{/* Icon + Label (clickable main area) */}
|
||||
<XStack
|
||||
@@ -469,10 +457,10 @@ export function MenuItemButton({
|
||||
alignItems="center"
|
||||
cursor={menuItem.isActionable() || hasSubitems ? 'pointer' : 'default'}
|
||||
hoverStyle={{
|
||||
backgroundColor: hovered || selected ? getBackgroundColor() : '$backgroundHover'
|
||||
backgroundColor: selected ? '$accentBgHover' : '$bgPage'
|
||||
}}
|
||||
pressStyle={{
|
||||
backgroundColor: selected ? '$accentHover' : '$backgroundPress'
|
||||
backgroundColor: selected ? '$accentBgHover' : '$bgPanelElev'
|
||||
}}
|
||||
onPress={handleMainClick}
|
||||
>
|
||||
@@ -480,18 +468,16 @@ export function MenuItemButton({
|
||||
{showIcon && (
|
||||
<XStack marginRight={showLabel ? '$2' : 0} alignItems="center" justifyContent="center">
|
||||
{typeof IconComponent === 'string' ? (
|
||||
// Emoji fallback
|
||||
<Text fontSize={size} lineHeight={size}>{IconComponent}</Text>
|
||||
) : IconComponent ? (
|
||||
// Material Design icon component
|
||||
<IconComponent size={typeof size === 'string' ? 24 : (size || 24)} color={getIconColor()} />
|
||||
<IconComponent size={ICON_SIZE} color={getIconColor()} />
|
||||
) : null}
|
||||
</XStack>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
{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}
|
||||
</Text>
|
||||
)}
|
||||
@@ -504,16 +490,17 @@ export function MenuItemButton({
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
padding="$1"
|
||||
borderRadius="$radiusSm"
|
||||
hoverStyle={{
|
||||
backgroundColor: '$backgroundHover'
|
||||
backgroundColor: '$bgPage'
|
||||
}}
|
||||
pressStyle={{
|
||||
backgroundColor: selected ? '$accentHover' : '$backgroundPress'
|
||||
backgroundColor: '$bgPanelElev'
|
||||
}}
|
||||
onPress={handleToggleExpand}
|
||||
>
|
||||
<ArrowIcon
|
||||
size={16}
|
||||
size={CHEVRON_SIZE}
|
||||
color={getArrowColor()}
|
||||
style={{ marginLeft: 8, flexShrink: 0 }}
|
||||
/>
|
||||
@@ -553,15 +540,15 @@ export function MenuItemButton({
|
||||
<YStack
|
||||
ref={popupRef}
|
||||
position="fixed"
|
||||
backgroundColor="$background"
|
||||
borderRadius="$3"
|
||||
backgroundColor="$bgPanelElev"
|
||||
borderRadius="$radiusMd"
|
||||
padding={0}
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
borderColor="$lineSubtle"
|
||||
shadowColor="$shadowColor"
|
||||
shadowOffset={{ width: 0, height: 4 }}
|
||||
shadowOpacity={0.2}
|
||||
shadowRadius={8}
|
||||
shadowOpacity={0.18}
|
||||
shadowRadius={12}
|
||||
elevation={8}
|
||||
zIndex={9999}
|
||||
minWidth={200}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import React from 'react';
|
||||
import { XStack, YStack, Text } from 'tamagui';
|
||||
import { getIcon } from './IconMapper.jsx';
|
||||
import { getTypographyRoleProps } from '../styles/index.js';
|
||||
|
||||
/**
|
||||
* Page Component
|
||||
@@ -36,7 +37,7 @@ export function Page({
|
||||
if (IconComponent) {
|
||||
headerLeftItems.push(
|
||||
<XStack key="page-icon" alignItems="center" justifyContent="center" marginRight="$2">
|
||||
<IconComponent size={24} color="$accentColor" />
|
||||
<IconComponent size="md" color="$textPrimary" />
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
@@ -44,7 +45,7 @@ export function Page({
|
||||
|
||||
if (title) {
|
||||
headerLeftItems.push(
|
||||
<Text key="page-title" fontWeight="600" fontSize="$6" color="$accentColor">
|
||||
<Text key="page-title" {...getTypographyRoleProps('pageTitle')}>
|
||||
{title}
|
||||
</Text>
|
||||
);
|
||||
@@ -66,8 +67,8 @@ export function Page({
|
||||
width="100%"
|
||||
padding="$4"
|
||||
borderBottomWidth={1}
|
||||
borderBottomColor="$accentBorder"
|
||||
backgroundColor="$accentSurface"
|
||||
borderBottomColor="$lineSubtle"
|
||||
backgroundColor="$bgPanel"
|
||||
alignItems="center"
|
||||
gap="$3"
|
||||
minHeight={64}
|
||||
|
||||
+23
-21
@@ -7,6 +7,7 @@
|
||||
import React from 'react';
|
||||
import { XStack, YStack, Text } from 'tamagui';
|
||||
import { getIcon } from './IconMapper.jsx';
|
||||
import { getTypographyRoleProps } from '../styles/index.js';
|
||||
|
||||
/**
|
||||
* Header size mapping function
|
||||
@@ -19,29 +20,21 @@ import { getIcon } from './IconMapper.jsx';
|
||||
function getHeaderSizeStyles(headerSize) {
|
||||
const sizeMap = {
|
||||
1: {
|
||||
iconSize: 24,
|
||||
titleFontSize: '$6',
|
||||
padding: '$3',
|
||||
borderRadius: '$4',
|
||||
minHeight: 64
|
||||
},
|
||||
2: {
|
||||
iconSize: 20,
|
||||
titleFontSize: '$5',
|
||||
padding: '$2',
|
||||
borderRadius: '$3',
|
||||
minHeight: 48
|
||||
},
|
||||
3: {
|
||||
iconSize: 18,
|
||||
titleFontSize: '$4',
|
||||
padding: '$1.5',
|
||||
borderRadius: '$2',
|
||||
minHeight: 44
|
||||
},
|
||||
4: {
|
||||
iconSize: 16,
|
||||
titleFontSize: '$3',
|
||||
padding: '$1',
|
||||
borderRadius: '$2',
|
||||
minHeight: 36
|
||||
@@ -83,13 +76,16 @@ export function Panel({
|
||||
height = null,
|
||||
headerSize = 2,
|
||||
headerFront = null,
|
||||
headerBack = null
|
||||
headerBack = null,
|
||||
density = 'comfortable',
|
||||
bodyOverflow = 'auto'
|
||||
}) {
|
||||
// Get size-specific styles
|
||||
const sizeStyles = getHeaderSizeStyles(headerSize);
|
||||
|
||||
// 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
|
||||
const headerLeftItems = [];
|
||||
@@ -99,7 +95,7 @@ export function Panel({
|
||||
if (IconComponent) {
|
||||
headerLeftItems.push(
|
||||
<XStack key="panel-icon" alignItems="center" justifyContent="center" marginRight="$2" {...(headerFront || {})}>
|
||||
<IconComponent size={sizeStyles.iconSize} {...(headerFront || {})} />
|
||||
<IconComponent size="md" color="$textPrimary" {...(headerFront || {})} />
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
@@ -109,9 +105,15 @@ export function Panel({
|
||||
headerLeftItems.push(
|
||||
<Text
|
||||
key="panel-title"
|
||||
fontWeight="600"
|
||||
fontSize={sizeStyles.titleFontSize}
|
||||
color="$color"
|
||||
{...getTypographyRoleProps('panelTitle', {
|
||||
fontSize: headerSize === 1
|
||||
? '$6'
|
||||
: headerSize === 2
|
||||
? '$5'
|
||||
: headerSize === 3
|
||||
? '$4'
|
||||
: '$3'
|
||||
})}
|
||||
{...(headerFront || {})}
|
||||
>
|
||||
{title}
|
||||
@@ -142,19 +144,19 @@ export function Panel({
|
||||
{...(Object.keys(containerStyle).length > 0 ? containerStyle : {})}
|
||||
{...(border ? {
|
||||
borderWidth: 1,
|
||||
borderColor: '$borderColor',
|
||||
borderRadius: sizeStyles.borderRadius
|
||||
borderColor: '$lineSubtle',
|
||||
borderRadius: '$radiusMd'
|
||||
} : {})}
|
||||
backgroundColor="$background"
|
||||
backgroundColor="$bgPanel"
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<XStack
|
||||
width="100%"
|
||||
padding={sizeStyles.padding}
|
||||
padding={paddingByDensity}
|
||||
borderBottomWidth={border ? 1 : 0}
|
||||
borderBottomColor="$borderColor"
|
||||
backgroundColor="$background"
|
||||
borderBottomColor="$lineSubtle"
|
||||
backgroundColor="$bgPanel"
|
||||
alignItems="center"
|
||||
gap="$3"
|
||||
minHeight={sizeStyles.minHeight}
|
||||
@@ -187,7 +189,7 @@ export function Panel({
|
||||
</XStack>
|
||||
|
||||
{/* 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}
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
@@ -91,8 +91,7 @@ export function ProgressBar({
|
||||
width="100%"
|
||||
height={trackHeight}
|
||||
borderRadius="$1"
|
||||
backgroundColor="$borderColor"
|
||||
opacity={0.55}
|
||||
backgroundColor="$lineSubtle"
|
||||
overflow="hidden"
|
||||
>
|
||||
{mode === 'determinate' ? (
|
||||
@@ -103,7 +102,7 @@ export function ProgressBar({
|
||||
height="100%"
|
||||
width={determinateWidth}
|
||||
borderRadius="$1"
|
||||
backgroundColor="$accentColor"
|
||||
backgroundColor="$accent"
|
||||
/>
|
||||
) : (
|
||||
<YStack
|
||||
@@ -112,7 +111,7 @@ export function ProgressBar({
|
||||
height="100%"
|
||||
width="42%"
|
||||
borderRadius="$1"
|
||||
backgroundColor="$accentColor"
|
||||
backgroundColor="$accent"
|
||||
left={`${offset}%`}
|
||||
/>
|
||||
)}
|
||||
@@ -123,7 +122,7 @@ export function ProgressBar({
|
||||
<YStack flex={1} minWidth={0} alignItems="center" gap="$1">
|
||||
{label !== null && label !== undefined && label !== '' ? (
|
||||
typeof label === 'string' ? (
|
||||
<Text fontSize="$2" color="$colorSecondary" numberOfLines={1} textAlign="center" width="100%">
|
||||
<Text fontSize="$2" color="$textSecondary" numberOfLines={1} textAlign="center" width="100%">
|
||||
{label}
|
||||
</Text>
|
||||
) : (
|
||||
|
||||
@@ -11,13 +11,13 @@ function normalizeVariant(variant, styleVariant) {
|
||||
return styleVariant || variant || 'accordion';
|
||||
}
|
||||
|
||||
function renderHeaderIcon(icon, color = '$color') {
|
||||
function renderHeaderIcon(icon, color = '$textPrimary') {
|
||||
const IconComponent = getIcon(icon);
|
||||
if (!IconComponent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <IconComponent size={18} color={color} />;
|
||||
return <IconComponent size="sm" color={color} />;
|
||||
}
|
||||
|
||||
function normalizeContentStyle(contentStyle) {
|
||||
@@ -188,7 +188,7 @@ export function SettingsPanel({
|
||||
size="$3"
|
||||
aria-label={expanded ? `Collapse ${title}` : `Expand ${title}`}
|
||||
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"
|
||||
flexShrink={0}
|
||||
>
|
||||
{renderHeaderIcon(icon, effectiveVariant === 'panel' ? '$accentColor' : '$color')}
|
||||
{renderHeaderIcon(icon, '$textPrimary')}
|
||||
</XStack>
|
||||
) : null}
|
||||
<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}
|
||||
</Text>
|
||||
{description ? (
|
||||
<Paragraph size="$2" color="$color" opacity={0.72} flex={1} minWidth={160}>
|
||||
<Paragraph size="$2" color="$textMuted" flex={1} minWidth={160}>
|
||||
{description}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
@@ -240,8 +240,8 @@ export function SettingsPanel({
|
||||
>
|
||||
<Tabs.List
|
||||
disablePassBorderRadius
|
||||
backgroundColor="$backgroundStrong"
|
||||
borderRadius="$4"
|
||||
backgroundColor="$bgPage"
|
||||
borderRadius="$radiusMd"
|
||||
padding="$1"
|
||||
gap="$1"
|
||||
flexWrap="wrap"
|
||||
@@ -250,14 +250,14 @@ export function SettingsPanel({
|
||||
<Tabs.Tab
|
||||
key={item.id}
|
||||
value={item.id}
|
||||
borderRadius="$3"
|
||||
backgroundColor="$background"
|
||||
hoverStyle={{ backgroundColor: '$backgroundHover' }}
|
||||
pressStyle={{ backgroundColor: '$backgroundPress' }}
|
||||
borderRadius="$radiusSm"
|
||||
backgroundColor="transparent"
|
||||
hoverStyle={{ backgroundColor: '$bgPanel' }}
|
||||
pressStyle={{ backgroundColor: '$bgPanelElev' }}
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
{item.icon ? renderHeaderIcon(item.icon, '$accentColor') : null}
|
||||
<Text fontWeight="700" color="$color">
|
||||
{item.icon ? renderHeaderIcon(item.icon, '$textSecondary') : null}
|
||||
<Text fontWeight="600" color="$textPrimary">
|
||||
{item.label || item.title}
|
||||
</Text>
|
||||
</XStack>
|
||||
@@ -269,9 +269,9 @@ export function SettingsPanel({
|
||||
<Tabs.Content key={item.id} value={item.id}>
|
||||
<YStack
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
borderRadius="$4"
|
||||
backgroundColor="$background"
|
||||
borderColor="$lineSubtle"
|
||||
borderRadius="$radiusMd"
|
||||
backgroundColor="$bgPanel"
|
||||
padding="$3"
|
||||
gap="$3"
|
||||
>
|
||||
@@ -306,18 +306,18 @@ export function SettingsPanel({
|
||||
return (
|
||||
<YStack
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
borderRadius="$4"
|
||||
backgroundColor="$background"
|
||||
borderColor="$lineSubtle"
|
||||
borderRadius="$radiusMd"
|
||||
backgroundColor="$bgPanel"
|
||||
overflow="hidden"
|
||||
>
|
||||
<XStack
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical="$1.5"
|
||||
alignItems="center"
|
||||
backgroundColor="$backgroundHover"
|
||||
backgroundColor="$bgPanel"
|
||||
borderBottomWidth={expanded ? 1 : 0}
|
||||
borderBottomColor="$borderColor"
|
||||
borderBottomColor="$lineSubtle"
|
||||
>
|
||||
{headerContent}
|
||||
</XStack>
|
||||
@@ -339,8 +339,8 @@ export function SettingsPanel({
|
||||
borderWidth={0}
|
||||
padding={0}
|
||||
justifyContent="flex-start"
|
||||
hoverStyle={{ opacity: 0.9, backgroundColor: 'transparent' }}
|
||||
pressStyle={{ opacity: 0.75, backgroundColor: 'transparent' }}
|
||||
hoverStyle={{ backgroundColor: '$bgPage' }}
|
||||
pressStyle={{ backgroundColor: '$bgPanelElev' }}
|
||||
>
|
||||
<YStack gap="$2">
|
||||
{headerContent}
|
||||
@@ -351,7 +351,7 @@ export function SettingsPanel({
|
||||
{renderedBody}
|
||||
</YStack>
|
||||
) : null}
|
||||
<Separator borderColor="$borderColor" />
|
||||
<Separator borderColor="$lineSubtle" />
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -572,7 +572,7 @@ function Toast({ toast, onClose, onPause, onResume }) {
|
||||
>
|
||||
{Icon ? (
|
||||
<XStack alignItems="center" justifyContent="center" width={20} height={20} flexShrink={0}>
|
||||
<Icon size={18} />
|
||||
<Icon size="md" color="$textSecondary" />
|
||||
</XStack>
|
||||
) : null}
|
||||
<YStack flex={1} gap="$1">
|
||||
@@ -582,7 +582,7 @@ function Toast({ toast, onClose, onPause, onResume }) {
|
||||
</Text>
|
||||
)}
|
||||
{toast.message && (
|
||||
<Text fontSize="$3" color="$color" opacity={0.9}>
|
||||
<Text fontSize="$3" color="$textSecondary">
|
||||
{toast.message}
|
||||
</Text>
|
||||
)}
|
||||
@@ -596,7 +596,7 @@ function Toast({ toast, onClose, onPause, onResume }) {
|
||||
>
|
||||
{(() => {
|
||||
const CloseIcon = getIcon('close');
|
||||
return CloseIcon ? <CloseIcon size={16} /> : <Text>×</Text>;
|
||||
return CloseIcon ? <CloseIcon size="sm" color="$textSecondary" /> : <Text color="$textSecondary">×</Text>;
|
||||
})()}
|
||||
</Button>
|
||||
</XStack>
|
||||
|
||||
@@ -229,9 +229,9 @@ function SideBarWide({
|
||||
height="100%"
|
||||
gap="$2"
|
||||
padding="$2"
|
||||
backgroundColor="$accentSurface"
|
||||
backgroundColor="$bgPanel"
|
||||
borderRightWidth={1}
|
||||
borderRightColor="$accentBorder"
|
||||
borderRightColor="$lineSubtle"
|
||||
animation="quick"
|
||||
animateOnly={['width']}
|
||||
>
|
||||
@@ -266,7 +266,7 @@ function SideBarWide({
|
||||
)}
|
||||
|
||||
{appName && !isCollapsed && (
|
||||
<Text fontWeight="bold" fontSize="$4" flex={1} color="$accentColor">
|
||||
<Text fontWeight="600" fontSize="$5" flex={1} color="$textPrimary">
|
||||
{appName}
|
||||
</Text>
|
||||
)}
|
||||
@@ -278,10 +278,10 @@ function SideBarWide({
|
||||
justifyContent="center"
|
||||
padding="$1"
|
||||
hoverStyle={{
|
||||
backgroundColor: '$accentBackground'
|
||||
backgroundColor: '$bgPage'
|
||||
}}
|
||||
pressStyle={{
|
||||
backgroundColor: '$accentHover'
|
||||
backgroundColor: '$bgPanelElev'
|
||||
}}
|
||||
onPress={handleToggle}
|
||||
aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
@@ -291,8 +291,8 @@ function SideBarWide({
|
||||
if (!ChevronIcon) return null;
|
||||
return (
|
||||
<ChevronIcon
|
||||
size={16}
|
||||
color="$accentColor"
|
||||
size="sm"
|
||||
color="$textSecondary"
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
);
|
||||
@@ -366,17 +366,16 @@ function SideBarNarrow({ children }) {
|
||||
alignItems="center"
|
||||
gap="$2"
|
||||
padding="$2"
|
||||
backgroundColor="$accentSurface"
|
||||
backgroundColor="$bgPanel"
|
||||
borderBottomWidth={1}
|
||||
borderBottomColor="$accentBorder"
|
||||
borderBottomColor="$lineSubtle"
|
||||
>
|
||||
{/* Hamburger Menu Button */}
|
||||
<Button
|
||||
size="$3"
|
||||
circular
|
||||
chromeless
|
||||
icon={getIcon('menu')}
|
||||
backgroundColor="$accentBackground"
|
||||
color="$accentColor"
|
||||
color="$textPrimary"
|
||||
onPress={() => setMenuOpen(true)}
|
||||
/>
|
||||
|
||||
@@ -392,7 +391,7 @@ function SideBarNarrow({ children }) {
|
||||
|
||||
{/* App Name - takes remaining space */}
|
||||
{appName && (
|
||||
<Text fontWeight="bold" fontSize="$4" flex={1} numberOfLines={1} color="$accentColor">
|
||||
<Text fontWeight="600" fontSize="$5" flex={1} numberOfLines={1} color="$textPrimary">
|
||||
{appName}
|
||||
</Text>
|
||||
)}
|
||||
@@ -420,9 +419,9 @@ function SideBarNarrow({ children }) {
|
||||
snapPoints={[85]}
|
||||
dismissOnSnapToBottom
|
||||
>
|
||||
<Sheet.Overlay />
|
||||
<Sheet.Overlay backgroundColor="$scrim" />
|
||||
<Sheet.Handle />
|
||||
<Sheet.Frame padding="$4" gap="$2">
|
||||
<Sheet.Frame padding="$4" gap="$2" backgroundColor="$bgPanel">
|
||||
<YStack gap="$2" width="100%">
|
||||
{/* Primary Menu Items */}
|
||||
{organizedChildren.primaryMenuItems.map((item) => (
|
||||
|
||||
@@ -2,6 +2,11 @@ import React, { useEffect, useState } from 'react';
|
||||
import { Button, ScrollView, Text, XStack, YStack } from 'tamagui';
|
||||
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 }) {
|
||||
const IconComponent = action?.icon ? getIcon(action.icon) : null;
|
||||
return (
|
||||
@@ -11,7 +16,7 @@ function ActionButton({ action }) {
|
||||
chromeless={action?.chromeless}
|
||||
disabled={action?.disabled}
|
||||
onPress={action?.onPress}
|
||||
icon={IconComponent ? <IconComponent size={16} /> : undefined}
|
||||
icon={IconComponent ? <IconComponent size="sm" /> : undefined}
|
||||
>
|
||||
{action?.label}
|
||||
</Button>
|
||||
@@ -61,7 +66,7 @@ export function SidePanelShell({
|
||||
right={0}
|
||||
bottom={0}
|
||||
left={0}
|
||||
backgroundColor="rgba(15,23,42,0.26)"
|
||||
backgroundColor="$scrim"
|
||||
opacity={open ? 1 : 0}
|
||||
animation="quick"
|
||||
onPress={onClose}
|
||||
@@ -74,13 +79,13 @@ export function SidePanelShell({
|
||||
bottom={0}
|
||||
width={width}
|
||||
maxWidth="96vw"
|
||||
backgroundColor="$background"
|
||||
backgroundColor="$bgPanelElev"
|
||||
borderLeftWidth={1}
|
||||
borderLeftColor="$borderColor"
|
||||
borderLeftColor="$lineSubtle"
|
||||
shadowColor="$shadowColor"
|
||||
shadowOpacity={0.18}
|
||||
shadowRadius={20}
|
||||
shadowOffset={{ width: -4, height: 0 }}
|
||||
shadowOpacity={0.12}
|
||||
shadowRadius={24}
|
||||
shadowOffset={{ width: -6, height: 0 }}
|
||||
style={{
|
||||
transform: open ? 'translateX(0)' : 'translateX(100%)',
|
||||
transition: 'transform 220ms ease'
|
||||
@@ -92,10 +97,10 @@ export function SidePanelShell({
|
||||
padding="$4"
|
||||
gap="$3"
|
||||
borderBottomWidth={1}
|
||||
borderBottomColor="$borderColor"
|
||||
backgroundColor="$accentSurface"
|
||||
borderBottomColor="$lineSubtle"
|
||||
backgroundColor="$bgPanel"
|
||||
>
|
||||
<Text fontSize="$7" fontWeight="700" color="$accentColor" flex={1}>
|
||||
<Text fontSize="$6" fontWeight="600" color="$textPrimary" flex={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<XStack alignItems="center" gap="$2" flexWrap="wrap" justifyContent="flex-end">
|
||||
@@ -107,7 +112,7 @@ export function SidePanelShell({
|
||||
circular
|
||||
chromeless
|
||||
onPress={onClose}
|
||||
icon={CloseIcon ? <CloseIcon size={18} /> : undefined}
|
||||
icon={CloseIcon ? <CloseIcon size="sm" color="$textSecondary" /> : undefined}
|
||||
aria-label="Close panel"
|
||||
/>
|
||||
</XStack>
|
||||
@@ -125,8 +130,8 @@ export function SidePanelShell({
|
||||
gap="$2"
|
||||
padding="$4"
|
||||
borderTopWidth={1}
|
||||
borderTopColor="$borderColor"
|
||||
backgroundColor="$accentSurface"
|
||||
borderTopColor="$lineSubtle"
|
||||
backgroundColor="$bgPanel"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
{footerActions.map((action, index) => (
|
||||
|
||||
@@ -195,9 +195,9 @@ function TopBarWide({
|
||||
alignItems="center"
|
||||
gap="$2"
|
||||
padding="$2"
|
||||
backgroundColor="$accentSurface"
|
||||
backgroundColor="$bgPanel"
|
||||
borderBottomWidth={1}
|
||||
borderBottomColor="$accentBorder"
|
||||
borderBottomColor="$lineSubtle"
|
||||
>
|
||||
{/* Left Side */}
|
||||
<XStack
|
||||
@@ -220,7 +220,7 @@ function TopBarWide({
|
||||
|
||||
{/* App Name */}
|
||||
{appName && (
|
||||
<Text fontWeight="bold" fontSize="$4" color="$accentColor">
|
||||
<Text fontWeight="600" fontSize="$5" color="$textPrimary">
|
||||
{appName}
|
||||
</Text>
|
||||
)}
|
||||
@@ -242,7 +242,7 @@ function TopBarWide({
|
||||
</XStack>
|
||||
)}
|
||||
|
||||
{/* Right Side */}
|
||||
{/* Right Side — secondary actions get a hairline separator + tighter gap */}
|
||||
{(effectiveRightWidth > 0 || effectiveRightWidth === 'auto') && (
|
||||
<XStack
|
||||
width={effectiveRightWidth === 'auto' ? undefined : effectiveRightWidth}
|
||||
@@ -251,6 +251,9 @@ function TopBarWide({
|
||||
alignItems="center"
|
||||
justifyContent="flex-end"
|
||||
gap="$1"
|
||||
paddingLeft="$2"
|
||||
borderLeftWidth={1}
|
||||
borderLeftColor="$lineSubtle"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
{organizedChildren.sections.rightSide}
|
||||
@@ -289,17 +292,16 @@ function TopBarNarrow({ children }) {
|
||||
alignItems="center"
|
||||
gap="$2"
|
||||
padding="$2"
|
||||
backgroundColor="$accentSurface"
|
||||
backgroundColor="$bgPanel"
|
||||
borderBottomWidth={1}
|
||||
borderBottomColor="$accentBorder"
|
||||
borderBottomColor="$lineSubtle"
|
||||
>
|
||||
{/* Hamburger Menu Button */}
|
||||
{/* Hamburger Menu Button — chromeless to avoid pulling the eye */}
|
||||
<Button
|
||||
size="$3"
|
||||
circular
|
||||
chromeless
|
||||
icon={getIcon('menu')}
|
||||
backgroundColor="$accentBackground"
|
||||
color="$accentColor"
|
||||
color="$textPrimary"
|
||||
onPress={() => setMenuOpen(true)}
|
||||
/>
|
||||
|
||||
@@ -315,7 +317,7 @@ function TopBarNarrow({ children }) {
|
||||
|
||||
{/* App Name - takes remaining space */}
|
||||
{appName && (
|
||||
<Text fontWeight="bold" fontSize="$4" flex={1} numberOfLines={1} color="$accentColor">
|
||||
<Text fontWeight="600" fontSize="$5" flex={1} numberOfLines={1} color="$textPrimary">
|
||||
{appName}
|
||||
</Text>
|
||||
)}
|
||||
@@ -359,9 +361,9 @@ function TopBarNarrow({ children }) {
|
||||
snapPoints={[85]}
|
||||
dismissOnSnapToBottom
|
||||
>
|
||||
<Sheet.Overlay />
|
||||
<Sheet.Overlay backgroundColor="$scrim" />
|
||||
<Sheet.Handle />
|
||||
<Sheet.Frame padding="$4" gap="$2">
|
||||
<Sheet.Frame padding="$4" gap="$2" backgroundColor="$bgPanel">
|
||||
<YStack gap="$2" width="100%">
|
||||
{/* Primary Menu Items - render with vertical orientation in Sheet */}
|
||||
{organizedChildren.primaryMenuItems.map((item) => (
|
||||
|
||||
@@ -24,6 +24,7 @@ export function GridView({
|
||||
statusText = '',
|
||||
selectable = false,
|
||||
nested = false,
|
||||
closeable = true,
|
||||
onClose = undefined,
|
||||
onReload = undefined,
|
||||
initialPageSize = 6,
|
||||
@@ -197,8 +198,10 @@ export function GridView({
|
||||
resolvedColumns,
|
||||
visibleColumns,
|
||||
selectedIds,
|
||||
setSelectedIds,
|
||||
selectable,
|
||||
nested,
|
||||
closeable,
|
||||
isLoading,
|
||||
error,
|
||||
tableViewportWidth,
|
||||
@@ -236,6 +239,15 @@ export function GridView({
|
||||
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) => {
|
||||
setOffset(0);
|
||||
setFilterBy((current) => ({
|
||||
@@ -260,6 +272,7 @@ export function GridView({
|
||||
resolvedStatusText,
|
||||
rows,
|
||||
selectable,
|
||||
closeable,
|
||||
selectedIds,
|
||||
sortBy,
|
||||
structure,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button, Checkbox, Input, Paragraph, ScrollView, Text, XStack, YStack }
|
||||
import { getIcon } from '../IconMapper.jsx';
|
||||
import { useGridView } from './context.js';
|
||||
import { formatValueByColumn } from './utils.js';
|
||||
import { getTypographyRoleProps } from '../../styles/index.js';
|
||||
|
||||
function renderToolbarItem(item) {
|
||||
if (!item) {
|
||||
@@ -22,7 +23,7 @@ function renderToolbarItem(item) {
|
||||
theme={item.theme}
|
||||
chromeless={item.chromeless}
|
||||
disabled={item.disabled}
|
||||
icon={IconComponent ? <IconComponent size={16} /> : undefined}
|
||||
icon={IconComponent ? <IconComponent size="sm" /> : undefined}
|
||||
onPress={item.onClick || item.onPress}
|
||||
>
|
||||
{item.label}
|
||||
@@ -32,21 +33,27 @@ function renderToolbarItem(item) {
|
||||
|
||||
if (item.kind === 'text') {
|
||||
return (
|
||||
<Text key={item.key || item.text} color="$color" opacity={0.7}>
|
||||
<Text key={item.key || item.text} color="$textSecondary">
|
||||
{item.text}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.kind === 'search') {
|
||||
const SearchIcon = getIcon('search');
|
||||
return (
|
||||
<Input
|
||||
key={item.key || item.placeholder || 'search'}
|
||||
width={item.width || 240}
|
||||
value={item.value}
|
||||
placeholder={item.placeholder || 'Search'}
|
||||
onChangeText={(value) => item.onChange?.(value)}
|
||||
/>
|
||||
<XStack key={item.key || item.placeholder || 'search'} alignItems="center" gap="$2">
|
||||
{SearchIcon ? <SearchIcon size="sm" color="$textMuted" /> : null}
|
||||
<Input
|
||||
width={item.width || 240}
|
||||
value={item.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 (
|
||||
<YStack gap="$3">
|
||||
<YStack gap="$1">
|
||||
<Text fontSize="$3" letterSpacing={1} textTransform="uppercase" color="$accentColor">
|
||||
<Text fontSize="$2" letterSpacing={1} textTransform="uppercase" color="$textSecondary">
|
||||
Record Summary
|
||||
</Text>
|
||||
<Text fontSize="$6" fontWeight="700">
|
||||
<Text {...getTypographyRoleProps('sectionTitle')}>
|
||||
{titleColumn ? row?.[titleColumn.field] : row?.id}
|
||||
</Text>
|
||||
{subtitleColumn ? (
|
||||
<Paragraph color="$color" opacity={0.7}>
|
||||
<Paragraph color="$textSecondary">
|
||||
{row?.[subtitleColumn.field] || ''}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
@@ -94,15 +101,15 @@ function DefaultPanelRecordRenderer({ row }) {
|
||||
key={`${row.id}-${column.field}-chip`}
|
||||
paddingHorizontal="$3"
|
||||
paddingVertical="$2"
|
||||
borderRadius="$6"
|
||||
backgroundColor="$accentSurface"
|
||||
borderRadius="$radiusMd"
|
||||
backgroundColor="$bgPanel"
|
||||
borderWidth={1}
|
||||
borderColor="$accentBorder"
|
||||
borderColor="$lineSubtle"
|
||||
>
|
||||
<Text fontSize="$2" color="$color" opacity={0.65}>
|
||||
<Text fontSize="$2" color="$textMuted">
|
||||
{column.label}
|
||||
</Text>
|
||||
<Text fontSize="$4" fontWeight="600">
|
||||
<Text {...getTypographyRoleProps('tableHeader', { color: '$textPrimary' })}>
|
||||
{formatValueByColumn(row?.[column.field], column)}
|
||||
</Text>
|
||||
</YStack>
|
||||
@@ -116,16 +123,16 @@ function DefaultPanelRecordRenderer({ row }) {
|
||||
minWidth={160}
|
||||
flex={1}
|
||||
padding="$3"
|
||||
borderRadius="$4"
|
||||
borderRadius="$radiusMd"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
backgroundColor="$background"
|
||||
borderColor="$lineSubtle"
|
||||
backgroundColor="$bgPanel"
|
||||
gap="$1"
|
||||
>
|
||||
<Text fontSize="$3" color="$color" opacity={0.65}>
|
||||
<Text fontSize="$3" color="$textMuted">
|
||||
{column.label}
|
||||
</Text>
|
||||
<Text>{formatValueByColumn(row?.[column.field], column)}</Text>
|
||||
<Text color="$textPrimary">{formatValueByColumn(row?.[column.field], column)}</Text>
|
||||
</YStack>
|
||||
))}
|
||||
</XStack>
|
||||
@@ -156,7 +163,7 @@ export function PanelFooterStatusBar({ text, visible = true }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Text color="$color" opacity={0.7}>
|
||||
<Text color="$textMuted">
|
||||
{text || grid.statusText}
|
||||
</Text>
|
||||
);
|
||||
@@ -179,10 +186,10 @@ export function PanelHeader({ title, toolbarItems = [], visible = true, showDivi
|
||||
padding="$3"
|
||||
minHeight={64}
|
||||
borderBottomWidth={showDivider ? 1 : 0}
|
||||
borderBottomColor="$borderColor"
|
||||
backgroundColor="$accentSurface"
|
||||
borderBottomColor="$lineSubtle"
|
||||
backgroundColor="$bgPanel"
|
||||
>
|
||||
<Text fontSize="$6" fontWeight="700" color="$accentColor">
|
||||
<Text {...getTypographyRoleProps('panelTitle', { fontSize: '$6' })}>
|
||||
{title}
|
||||
</Text>
|
||||
|
||||
@@ -192,17 +199,19 @@ export function PanelHeader({ title, toolbarItems = [], visible = true, showDivi
|
||||
size="$3"
|
||||
chromeless
|
||||
circular
|
||||
icon={RefreshIcon ? <RefreshIcon size={16} /> : undefined}
|
||||
icon={RefreshIcon ? <RefreshIcon size="sm" color="$textSecondary" /> : undefined}
|
||||
onPress={grid.reload}
|
||||
/>
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
circular
|
||||
disabled={!grid.close}
|
||||
icon={CloseIcon ? <CloseIcon size={16} /> : undefined}
|
||||
onPress={grid.close}
|
||||
/>
|
||||
{grid.closeable !== false ? (
|
||||
<Button
|
||||
size="$3"
|
||||
chromeless
|
||||
circular
|
||||
disabled={!grid.close}
|
||||
icon={CloseIcon ? <CloseIcon size="sm" color="$textSecondary" /> : undefined}
|
||||
onPress={grid.close}
|
||||
/>
|
||||
) : null}
|
||||
</XStack>
|
||||
</XStack>
|
||||
);
|
||||
@@ -221,8 +230,8 @@ export function PanelFooter({ toolbarItems = [], visible = true }) {
|
||||
padding="$3"
|
||||
minHeight={56}
|
||||
borderTopWidth={1}
|
||||
borderTopColor="$borderColor"
|
||||
backgroundColor="$background"
|
||||
borderTopColor="$lineSubtle"
|
||||
backgroundColor="$bgPanel"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<PanelFooterStatusBar />
|
||||
@@ -243,9 +252,11 @@ export function PanelBodyView({
|
||||
}
|
||||
|
||||
if (grid.error) {
|
||||
const ErrorIcon = getIcon('error');
|
||||
return (
|
||||
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
|
||||
<Text color="#b91c1c">{grid.error}</Text>
|
||||
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5" gap="$2">
|
||||
{ErrorIcon ? <ErrorIcon size="lg" color="$danger" /> : null}
|
||||
<Text color="$danger" fontWeight="600">{grid.error}</Text>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
@@ -253,15 +264,18 @@ export function PanelBodyView({
|
||||
if (grid.isLoading && !grid.rows.length) {
|
||||
return (
|
||||
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
|
||||
<Text color="$color" opacity={0.7}>Loading cards...</Text>
|
||||
<Text color="$textMuted">Loading cards...</Text>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
|
||||
if (!grid.rows.length) {
|
||||
const EmptyIcon = getIcon('folder');
|
||||
return (
|
||||
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
|
||||
<Text color="$color" opacity={0.7}>No records available.</Text>
|
||||
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5" gap="$2">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -272,37 +286,49 @@ export function PanelBodyView({
|
||||
<ScrollView flex={1}>
|
||||
<YStack padding="$4" gap="$3">
|
||||
<XStack gap="$3" flexWrap="wrap">
|
||||
{grid.rows.map((row) => (
|
||||
<YStack
|
||||
key={row.id}
|
||||
minWidth={responsiveColumns > 1 ? 320 : 240}
|
||||
flex={1}
|
||||
flexBasis={responsiveColumns > 1 ? '48%' : '100%'}
|
||||
padding="$4"
|
||||
borderWidth={1}
|
||||
borderColor={grid.selectedIds.has(row.id) ? '$accentBorder' : '$borderColor'}
|
||||
backgroundColor={grid.selectedIds.has(row.id) ? '$accentSurface' : '$background'}
|
||||
borderRadius="$5"
|
||||
gap="$3"
|
||||
>
|
||||
{grid.selectable ? (
|
||||
<XStack justifyContent="flex-start">
|
||||
<Checkbox
|
||||
checked={grid.selectedIds.has(row.id)}
|
||||
onCheckedChange={() => grid.toggleSelectRow(row.id)}
|
||||
>
|
||||
<Checkbox.Indicator />
|
||||
</Checkbox>
|
||||
</XStack>
|
||||
) : null}
|
||||
{grid.rows.map((row) => {
|
||||
const isSelected = grid.selectedIds.has(row.id);
|
||||
return (
|
||||
<YStack
|
||||
key={row.id}
|
||||
minWidth={responsiveColumns > 1 ? 320 : 240}
|
||||
flex={1}
|
||||
flexBasis={responsiveColumns > 1 ? '48%' : '100%'}
|
||||
padding="$4"
|
||||
borderWidth={1}
|
||||
borderColor={isSelected ? '$accent' : '$lineSubtle'}
|
||||
backgroundColor={isSelected ? '$accentBg' : '$bgPanel'}
|
||||
borderRadius="$radiusLg"
|
||||
gap="$3"
|
||||
hoverStyle={isSelected ? undefined : { borderColor: '$lineStrong' }}
|
||||
>
|
||||
{grid.selectable ? (
|
||||
<XStack justifyContent="flex-start">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => grid.toggleSelectRow(row.id)}
|
||||
borderColor="$lineStrong"
|
||||
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} />
|
||||
</YStack>
|
||||
))}
|
||||
<RecordRenderer row={row} />
|
||||
</YStack>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
|
||||
{grid.isLoading ? (
|
||||
<Text color="$color" opacity={0.7}>
|
||||
<Text color="$textMuted">
|
||||
Refreshing records...
|
||||
</Text>
|
||||
) : null}
|
||||
@@ -310,4 +336,3 @@ export function PanelBodyView({
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useRef } from 'react';
|
||||
import { Button, Checkbox, ScrollView, Separator, Text, XStack, YStack } from 'tamagui';
|
||||
import { getIcon } from '../IconMapper.jsx';
|
||||
import { useGridView } from './context.js';
|
||||
import { getTypographyRoleProps } from '../../styles/index.js';
|
||||
import {
|
||||
formatValueByColumn,
|
||||
getColumnJustify,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
|
||||
function DefaultGridCellRenderer({ value, column }) {
|
||||
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)}
|
||||
</Text>
|
||||
);
|
||||
@@ -29,15 +30,26 @@ function UtilityCell({ rowId }) {
|
||||
if (grid.nested) {
|
||||
return (
|
||||
<XStack width={36} alignItems="center" justifyContent="center">
|
||||
{ChevronRightIcon ? <ChevronRightIcon size={16} /> : <Text>{'>'}</Text>}
|
||||
{ChevronRightIcon ? <ChevronRightIcon size="sm" color="$textSecondary" /> : <Text color="$textSecondary">{'>'}</Text>}
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<XStack width={36} alignItems="center" justifyContent="center">
|
||||
<Checkbox checked={grid.selectedIds.has(rowId)} onCheckedChange={() => grid.toggleSelectRow(rowId)}>
|
||||
<Checkbox.Indicator />
|
||||
<Checkbox
|
||||
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>
|
||||
</XStack>
|
||||
);
|
||||
@@ -80,21 +92,46 @@ export function TableHeader({ visible = true, showTopBorder = true }) {
|
||||
}
|
||||
|
||||
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 (
|
||||
<XStack
|
||||
alignItems="stretch"
|
||||
borderTopWidth={showTopBorder ? 1 : 0}
|
||||
borderBottomWidth={1}
|
||||
borderColor="$accentBorder"
|
||||
backgroundColor="$accentSurface"
|
||||
borderColor="$lineSubtle"
|
||||
backgroundColor="transparent"
|
||||
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) => {
|
||||
const activeSort = grid.sortBy.find((entry) => entry.field === column.field);
|
||||
const sortLabel =
|
||||
activeSort?.direction === 'asc' ? '↑' : activeSort?.direction === 'desc' ? '↓' : '';
|
||||
const isActive = Boolean(activeSort);
|
||||
const direction = activeSort?.direction;
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -107,10 +144,28 @@ export function TableHeader({ visible = true, showTopBorder = true }) {
|
||||
paddingVertical="$3"
|
||||
paddingHorizontal="$2"
|
||||
{...getColumnLayoutStyle(column)}
|
||||
hoverStyle={column.sortable ? { backgroundColor: '$bgPage' } : undefined}
|
||||
pressStyle={column.sortable ? { backgroundColor: '$bgPanelElev' } : undefined}
|
||||
>
|
||||
<Text width="100%" textAlign={column.align || 'left'} fontWeight="700">
|
||||
{column.label}{sortLabel ? ` ${sortLabel}` : ''}
|
||||
</Text>
|
||||
<XStack width="100%" alignItems="center" justifyContent={getColumnJustify(column.align)} gap="$2">
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
@@ -131,7 +186,7 @@ export function TableBodyView({ visible = true }) {
|
||||
if (grid.error) {
|
||||
return (
|
||||
<YStack flex={1} alignItems="center" justifyContent="center" padding="$5">
|
||||
<Text color="#b91c1c">{grid.error}</Text>
|
||||
<Text color="$danger" fontWeight="600">{grid.error}</Text>
|
||||
</YStack>
|
||||
);
|
||||
}
|
||||
@@ -144,7 +199,8 @@ export function TableBodyView({ visible = true }) {
|
||||
<XStack
|
||||
alignItems="stretch"
|
||||
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}
|
||||
{activeColumns.map((column) => {
|
||||
@@ -166,19 +222,22 @@ export function TableBodyView({ visible = true }) {
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
<Separator />
|
||||
<Separator borderColor="$lineSubtle" />
|
||||
</YStack>
|
||||
))}
|
||||
|
||||
{!grid.rows.length && !grid.isLoading ? (
|
||||
<YStack minHeight={120} alignItems="center" justifyContent="center" padding="$5">
|
||||
<Text color="$color" opacity={0.7}>No records available.</Text>
|
||||
<Text color="$textSecondary" fontWeight="600">No records available</Text>
|
||||
<Text color="$textMuted" fontSize="$3">There's nothing to show here yet.</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
|
||||
{grid.isLoading ? (
|
||||
<YStack minHeight={64} alignItems="center" justifyContent="center" padding="$4">
|
||||
<Text color="$color" opacity={0.7}>Loading...</Text>
|
||||
{grid.isLoading && !grid.rows.length ? (
|
||||
<YStack minHeight={64} padding="$4" gap="$2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<XStack key={i} height={46} borderRadius="$radiusMd" backgroundColor="$bgPage" />
|
||||
))}
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
@@ -205,21 +264,21 @@ export function TableFooter({ visible = true }) {
|
||||
padding="$3"
|
||||
minHeight={56}
|
||||
borderTopWidth={1}
|
||||
borderTopColor="$borderColor"
|
||||
backgroundColor="$background"
|
||||
borderTopColor="$lineSubtle"
|
||||
backgroundColor="$bgPanel"
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<Text color="$color" opacity={0.7}>
|
||||
<Text color="$textMuted">
|
||||
{grid.total} records
|
||||
</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
|
||||
size="$3"
|
||||
chromeless
|
||||
circular
|
||||
disabled={grid.currentPage <= 1}
|
||||
icon={FirstPageIcon ? <FirstPageIcon size={16} /> : undefined}
|
||||
icon={FirstPageIcon ? <FirstPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||
onPress={() => grid.setPage(1)}
|
||||
/>
|
||||
<Button
|
||||
@@ -227,10 +286,10 @@ export function TableFooter({ visible = true }) {
|
||||
chromeless
|
||||
circular
|
||||
disabled={grid.currentPage <= 1}
|
||||
icon={PreviousPageIcon ? <PreviousPageIcon size={16} /> : undefined}
|
||||
icon={PreviousPageIcon ? <PreviousPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||
onPress={() => grid.setPage(grid.currentPage - 1)}
|
||||
/>
|
||||
<Text color="$color" opacity={0.75}>
|
||||
<Text color="$textSecondary">
|
||||
Page {grid.currentPage} of {grid.pageCount}
|
||||
</Text>
|
||||
<Button
|
||||
@@ -238,7 +297,7 @@ export function TableFooter({ visible = true }) {
|
||||
chromeless
|
||||
circular
|
||||
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)}
|
||||
/>
|
||||
<Button
|
||||
@@ -246,11 +305,10 @@ export function TableFooter({ visible = true }) {
|
||||
chromeless
|
||||
circular
|
||||
disabled={grid.currentPage >= grid.pageCount}
|
||||
icon={LastPageIcon ? <LastPageIcon size={16} /> : undefined}
|
||||
icon={LastPageIcon ? <LastPageIcon size="sm" color="$textSecondary" /> : undefined}
|
||||
onPress={() => grid.setPage(grid.pageCount)}
|
||||
/>
|
||||
</XStack>
|
||||
</XStack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ export { SettingsPanel, default as SettingsPanelDefault } from './SettingsPanel.
|
||||
export { GeneralConfig, default as GeneralConfigDefault } from './GeneralConfig.jsx';
|
||||
export { IdentityConfig, default as IdentityConfigDefault } from './IdentityConfig.jsx';
|
||||
export * from './grid/index.js';
|
||||
export { getTypographyRoleProps, getStyleTypography, TYPOGRAPHY_ROLE_KEYS } from '../styles/index.js';
|
||||
|
||||
// Re-export App helpers for convenience.
|
||||
// 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,
|
||||
name: '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: {
|
||||
...configBase.tokens,
|
||||
radius: {
|
||||
...configBase.tokens.radius,
|
||||
radiusSm: 8,
|
||||
radiusMd: 12,
|
||||
radiusLg: 16,
|
||||
},
|
||||
// Colorful palette (vibrant colors)
|
||||
color: {
|
||||
...configBase.tokens.color,
|
||||
@@ -90,6 +105,29 @@ export const ColorfulTheme = {
|
||||
colorDisabled: '#94a3b8',
|
||||
borderColor: '#cbd5e1',
|
||||
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: {
|
||||
...configBase.themes.dark,
|
||||
@@ -111,6 +149,29 @@ export const ColorfulTheme = {
|
||||
colorDisabled: '#64748b',
|
||||
borderColor: '#334155',
|
||||
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: {
|
||||
|
||||
@@ -9,8 +9,24 @@ export const MaterialTheme = {
|
||||
...configBase,
|
||||
name: 'material',
|
||||
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: {
|
||||
...configBase.tokens,
|
||||
radius: {
|
||||
...configBase.tokens.radius,
|
||||
radiusSm: 4,
|
||||
radiusMd: 6,
|
||||
radiusLg: 8,
|
||||
},
|
||||
// Material UI color palette (base tokens - theme-agnostic)
|
||||
color: {
|
||||
...configBase.tokens.color,
|
||||
@@ -90,6 +106,29 @@ export const MaterialTheme = {
|
||||
colorDisabled: 'rgba(0, 0, 0, 0.38)',
|
||||
borderColor: 'rgba(0, 0, 0, 0.12)',
|
||||
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: {
|
||||
...configBase.themes.dark,
|
||||
@@ -111,6 +150,29 @@ export const MaterialTheme = {
|
||||
colorDisabled: 'rgba(255, 255, 255, 0.38)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.12)',
|
||||
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: {
|
||||
|
||||
@@ -9,8 +9,23 @@ export const MinimalTheme = {
|
||||
...configBase,
|
||||
name: '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: {
|
||||
...configBase.tokens,
|
||||
radius: {
|
||||
...configBase.tokens.radius,
|
||||
radiusSm: 2,
|
||||
radiusMd: 4,
|
||||
radiusLg: 6,
|
||||
},
|
||||
// Minimal color palette (neutral, monochromatic)
|
||||
color: {
|
||||
...configBase.tokens.color,
|
||||
@@ -90,6 +105,29 @@ export const MinimalTheme = {
|
||||
colorDisabled: '#a0aec0',
|
||||
borderColor: '#e2e8f0',
|
||||
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: {
|
||||
...configBase.themes.dark,
|
||||
@@ -111,6 +149,29 @@ export const MinimalTheme = {
|
||||
colorDisabled: '#718096',
|
||||
borderColor: '#2d3748',
|
||||
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: {
|
||||
|
||||
+228
-6
@@ -1,19 +1,202 @@
|
||||
/**
|
||||
* 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 { MinimalTheme } from './MinimalTheme.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 = {
|
||||
azure: AzureTheme,
|
||||
apple: AppleTheme,
|
||||
material: MaterialTheme,
|
||||
minimal: MinimalTheme,
|
||||
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.
|
||||
@@ -30,8 +213,8 @@ export function normalizeStyleThemeName(name) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a style theme by name
|
||||
* @param {string} themeName - Theme name ('material', 'minimal', 'colorful')
|
||||
* Get a style theme by name.
|
||||
* @param {string} themeName - Theme name ('azure', 'apple', 'material', 'minimal', 'colorful')
|
||||
* @returns {Object} Theme configuration
|
||||
*/
|
||||
export function getStyleTheme(themeName = DEFAULT_STYLE_THEME) {
|
||||
@@ -39,13 +222,52 @@ export function getStyleTheme(themeName = DEFAULT_STYLE_THEME) {
|
||||
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
|
||||
*/
|
||||
export function getStyleThemeNames() {
|
||||
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