@metacells/mcellui-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +50 -0
- package/src/components/ErrorBoundary.tsx +188 -0
- package/src/components/index.ts +6 -0
- package/src/config/ConfigProvider.tsx +117 -0
- package/src/config/defineConfig.ts +75 -0
- package/src/config/index.ts +42 -0
- package/src/config/types.ts +185 -0
- package/src/constants.ts +203 -0
- package/src/index.ts +144 -0
- package/src/primitives/Portal.tsx +185 -0
- package/src/primitives/Pressable.tsx +114 -0
- package/src/primitives/Slot.tsx +177 -0
- package/src/primitives/index.ts +19 -0
- package/src/theme/ThemeProvider.tsx +475 -0
- package/src/theme/animations.ts +379 -0
- package/src/theme/colors.ts +266 -0
- package/src/theme/components.ts +267 -0
- package/src/theme/index.ts +130 -0
- package/src/theme/presets.ts +341 -0
- package/src/theme/radius.ts +175 -0
- package/src/theme/shadows.ts +166 -0
- package/src/theme/spacing.ts +41 -0
- package/src/theme/typography.ts +389 -0
- package/src/tokens/colors.ts +67 -0
- package/src/tokens/index.ts +29 -0
- package/src/tokens/radius.ts +18 -0
- package/src/tokens/shadows.ts +38 -0
- package/src/tokens/spacing.ts +45 -0
- package/src/tokens/typography.ts +70 -0
- package/src/utils/accessibility.ts +112 -0
- package/src/utils/cn.ts +58 -0
- package/src/utils/haptics.ts +125 -0
- package/src/utils/index.ts +28 -0
- package/src/utils/platform.ts +66 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slot Primitive
|
|
3
|
+
*
|
|
4
|
+
* Merges its props with its immediate child, similar to Radix UI's Slot.
|
|
5
|
+
* Enables flexible composition patterns where a component can either
|
|
6
|
+
* render its default element or forward props to a custom child.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* <Slot style={slotStyle} onPress={handlePress}>
|
|
10
|
+
* <Pressable style={childStyle}>Content</Pressable>
|
|
11
|
+
* </Slot>
|
|
12
|
+
*
|
|
13
|
+
* Result: Pressable receives merged style and both onPress handlers
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React, { Children, isValidElement, cloneElement } from 'react';
|
|
17
|
+
import { StyleProp, ViewStyle, TextStyle } from 'react-native';
|
|
18
|
+
|
|
19
|
+
// --- Types ---
|
|
20
|
+
|
|
21
|
+
type AnyProps = Record<string, unknown>;
|
|
22
|
+
|
|
23
|
+
export interface SlotProps {
|
|
24
|
+
children: React.ReactNode;
|
|
25
|
+
/**
|
|
26
|
+
* Additional props to merge with the child
|
|
27
|
+
*/
|
|
28
|
+
[key: string]: unknown;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SlottableProps {
|
|
32
|
+
/**
|
|
33
|
+
* When true, renders as Slot (forwarding props to child)
|
|
34
|
+
* When false/undefined, renders as the component's default element
|
|
35
|
+
*/
|
|
36
|
+
asChild?: boolean;
|
|
37
|
+
children?: React.ReactNode;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// --- Utilities ---
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Merges multiple style props into one
|
|
44
|
+
*/
|
|
45
|
+
function mergeStyles(
|
|
46
|
+
...styles: (StyleProp<ViewStyle | TextStyle> | undefined)[]
|
|
47
|
+
): StyleProp<ViewStyle | TextStyle> {
|
|
48
|
+
return styles.filter(Boolean) as StyleProp<ViewStyle | TextStyle>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Merges multiple event handlers into one that calls all of them
|
|
53
|
+
*/
|
|
54
|
+
function mergeHandlers<T extends (...args: unknown[]) => void>(
|
|
55
|
+
...handlers: (T | undefined)[]
|
|
56
|
+
): T | undefined {
|
|
57
|
+
const validHandlers = handlers.filter(Boolean) as T[];
|
|
58
|
+
if (validHandlers.length === 0) return undefined;
|
|
59
|
+
if (validHandlers.length === 1) return validHandlers[0];
|
|
60
|
+
|
|
61
|
+
return ((...args: unknown[]) => {
|
|
62
|
+
validHandlers.forEach((handler) => handler(...args));
|
|
63
|
+
}) as T;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Checks if a prop is an event handler (starts with 'on' + capital letter)
|
|
68
|
+
*/
|
|
69
|
+
function isEventHandler(key: string): boolean {
|
|
70
|
+
return /^on[A-Z]/.test(key);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Merges slot props with child props
|
|
75
|
+
*/
|
|
76
|
+
function mergeProps(slotProps: AnyProps, childProps: AnyProps): AnyProps {
|
|
77
|
+
const merged: AnyProps = { ...childProps };
|
|
78
|
+
|
|
79
|
+
for (const key of Object.keys(slotProps)) {
|
|
80
|
+
const slotValue = slotProps[key];
|
|
81
|
+
const childValue = childProps[key];
|
|
82
|
+
|
|
83
|
+
if (key === 'style') {
|
|
84
|
+
// Merge styles
|
|
85
|
+
merged[key] = mergeStyles(
|
|
86
|
+
slotValue as StyleProp<ViewStyle>,
|
|
87
|
+
childValue as StyleProp<ViewStyle>
|
|
88
|
+
);
|
|
89
|
+
} else if (key === 'className') {
|
|
90
|
+
// Merge classNames (for NativeWind)
|
|
91
|
+
merged[key] = [childValue, slotValue].filter(Boolean).join(' ');
|
|
92
|
+
} else if (isEventHandler(key)) {
|
|
93
|
+
// Merge event handlers
|
|
94
|
+
merged[key] = mergeHandlers(
|
|
95
|
+
slotValue as (() => void) | undefined,
|
|
96
|
+
childValue as (() => void) | undefined
|
|
97
|
+
);
|
|
98
|
+
} else if (childValue === undefined) {
|
|
99
|
+
// Only use slot value if child doesn't define it
|
|
100
|
+
merged[key] = slotValue;
|
|
101
|
+
}
|
|
102
|
+
// Otherwise, child props take precedence
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return merged;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- Components ---
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Slot component that forwards props to its single child.
|
|
112
|
+
* The child receives merged props (styles combined, handlers chained).
|
|
113
|
+
*/
|
|
114
|
+
export const Slot = React.forwardRef<unknown, SlotProps>(
|
|
115
|
+
({ children, ...slotProps }, forwardedRef) => {
|
|
116
|
+
const child = Children.only(children);
|
|
117
|
+
|
|
118
|
+
if (!isValidElement(child)) {
|
|
119
|
+
console.warn('Slot: Expected a single valid React element as child');
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const childProps = child.props as AnyProps;
|
|
124
|
+
const mergedProps = mergeProps(slotProps, childProps);
|
|
125
|
+
|
|
126
|
+
// Handle ref forwarding
|
|
127
|
+
const childRef = (child as { ref?: React.Ref<unknown> }).ref;
|
|
128
|
+
const ref = forwardedRef
|
|
129
|
+
? composeRefs(forwardedRef, childRef)
|
|
130
|
+
: childRef;
|
|
131
|
+
|
|
132
|
+
return cloneElement(child, {
|
|
133
|
+
...mergedProps,
|
|
134
|
+
ref,
|
|
135
|
+
} as AnyProps);
|
|
136
|
+
}
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
Slot.displayName = 'Slot';
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Helper component for conditional slot rendering.
|
|
143
|
+
* Use with asChild prop pattern.
|
|
144
|
+
*/
|
|
145
|
+
export function Slottable({ children }: { children: React.ReactNode }) {
|
|
146
|
+
return <>{children}</>;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
Slottable.displayName = 'Slottable';
|
|
150
|
+
|
|
151
|
+
// --- Ref Utilities ---
|
|
152
|
+
|
|
153
|
+
type PossibleRef<T> = React.Ref<T> | undefined;
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Composes multiple refs into a single ref callback
|
|
157
|
+
*/
|
|
158
|
+
function composeRefs<T>(...refs: PossibleRef<T>[]): React.RefCallback<T> {
|
|
159
|
+
return (node) => {
|
|
160
|
+
refs.forEach((ref) => {
|
|
161
|
+
if (typeof ref === 'function') {
|
|
162
|
+
ref(node);
|
|
163
|
+
} else if (ref != null) {
|
|
164
|
+
(ref as React.MutableRefObject<T | null>).current = node;
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Hook to compose refs
|
|
172
|
+
*/
|
|
173
|
+
export function useComposedRefs<T>(
|
|
174
|
+
...refs: PossibleRef<T>[]
|
|
175
|
+
): React.RefCallback<T> {
|
|
176
|
+
return React.useCallback(composeRefs(...refs), refs);
|
|
177
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export { Pressable, type PressableProps } from './Pressable';
|
|
2
|
+
export {
|
|
3
|
+
Portal,
|
|
4
|
+
PortalHost,
|
|
5
|
+
PortalProvider,
|
|
6
|
+
type PortalProps,
|
|
7
|
+
type PortalHostProps,
|
|
8
|
+
type PortalProviderProps,
|
|
9
|
+
} from './Portal';
|
|
10
|
+
export {
|
|
11
|
+
Slot,
|
|
12
|
+
Slottable,
|
|
13
|
+
useComposedRefs,
|
|
14
|
+
type SlotProps,
|
|
15
|
+
type SlottableProps,
|
|
16
|
+
} from './Slot';
|
|
17
|
+
|
|
18
|
+
// Future primitives:
|
|
19
|
+
// export { Presence, type PresenceProps } from './Presence';
|
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ThemeProvider
|
|
3
|
+
*
|
|
4
|
+
* Provides theme context to all nativeui components.
|
|
5
|
+
* Automatically detects system color scheme and provides all design tokens.
|
|
6
|
+
*
|
|
7
|
+
* Like shadcn/ui - change tokens here to transform the entire UI.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* ```tsx
|
|
11
|
+
* // Full customization
|
|
12
|
+
* <ThemeProvider
|
|
13
|
+
* theme="violet"
|
|
14
|
+
* radius="lg"
|
|
15
|
+
* fonts={{ heading: 'PlayfairDisplay_700Bold' }}
|
|
16
|
+
* colors={{ primary: '#7C3AED' }}
|
|
17
|
+
* >
|
|
18
|
+
* <App />
|
|
19
|
+
* </ThemeProvider>
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import React, { createContext, useContext, useMemo, useState, useCallback, useEffect, ReactNode } from 'react';
|
|
24
|
+
import { useColorScheme, ViewStyle } from 'react-native';
|
|
25
|
+
import { lightColors, darkColors, ThemeColors } from './colors';
|
|
26
|
+
import { setHapticsEnabled } from '../utils/haptics';
|
|
27
|
+
import { spacing } from './spacing';
|
|
28
|
+
import {
|
|
29
|
+
createRadius,
|
|
30
|
+
createComponentRadius,
|
|
31
|
+
defaultRadiusPreset,
|
|
32
|
+
type RadiusPreset,
|
|
33
|
+
type RadiusTokens,
|
|
34
|
+
type ComponentRadiusTokens,
|
|
35
|
+
} from './radius';
|
|
36
|
+
import {
|
|
37
|
+
fontSize,
|
|
38
|
+
fontWeight,
|
|
39
|
+
lineHeight,
|
|
40
|
+
letterSpacing,
|
|
41
|
+
fontFamily,
|
|
42
|
+
geistFontFamily,
|
|
43
|
+
defaultFonts,
|
|
44
|
+
createTypography,
|
|
45
|
+
type Fonts,
|
|
46
|
+
type Typography,
|
|
47
|
+
} from './typography';
|
|
48
|
+
import { getShadow, getPlatformShadow, ShadowSize, ShadowStyle } from './shadows';
|
|
49
|
+
import {
|
|
50
|
+
springs,
|
|
51
|
+
timing,
|
|
52
|
+
pressScale,
|
|
53
|
+
durations,
|
|
54
|
+
getAnimationPreset,
|
|
55
|
+
defaultAnimationPreset,
|
|
56
|
+
type AnimationPreset,
|
|
57
|
+
type SpringTokens,
|
|
58
|
+
type TimingTokens,
|
|
59
|
+
type PressScaleTokens,
|
|
60
|
+
type DurationTokens,
|
|
61
|
+
} from './animations';
|
|
62
|
+
import { components } from './components';
|
|
63
|
+
import {
|
|
64
|
+
themePresets,
|
|
65
|
+
defaultThemePreset,
|
|
66
|
+
type ThemePreset,
|
|
67
|
+
} from './presets';
|
|
68
|
+
|
|
69
|
+
export type ColorScheme = 'light' | 'dark';
|
|
70
|
+
export type ColorSchemePreference = 'light' | 'dark' | 'system';
|
|
71
|
+
|
|
72
|
+
export interface Theme {
|
|
73
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
74
|
+
// Color Scheme
|
|
75
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
76
|
+
/** Current color scheme */
|
|
77
|
+
colorScheme: ColorScheme;
|
|
78
|
+
/** User's preference: 'light', 'dark', or 'system' */
|
|
79
|
+
colorSchemePreference: ColorSchemePreference;
|
|
80
|
+
/** Whether dark mode is active */
|
|
81
|
+
isDark: boolean;
|
|
82
|
+
/** Set color scheme preference */
|
|
83
|
+
setColorScheme: (preference: ColorSchemePreference) => void;
|
|
84
|
+
|
|
85
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
86
|
+
// Theme Preset
|
|
87
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
88
|
+
/** Current theme preset (if using a preset) */
|
|
89
|
+
themePreset?: ThemePreset;
|
|
90
|
+
/** Current radius preset */
|
|
91
|
+
radiusPreset: RadiusPreset;
|
|
92
|
+
/** Current animation preset */
|
|
93
|
+
animationPreset: AnimationPreset;
|
|
94
|
+
|
|
95
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
96
|
+
// Design Tokens
|
|
97
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
98
|
+
/** Semantic color tokens */
|
|
99
|
+
colors: ThemeColors;
|
|
100
|
+
/** Spacing scale (0-32) */
|
|
101
|
+
spacing: typeof spacing;
|
|
102
|
+
/** Border radius tokens */
|
|
103
|
+
radius: RadiusTokens;
|
|
104
|
+
/** Component-specific radius presets */
|
|
105
|
+
componentRadius: ComponentRadiusTokens;
|
|
106
|
+
|
|
107
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
108
|
+
// Fonts & Typography
|
|
109
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
110
|
+
/** Semantic font tokens (sans, heading, mono) */
|
|
111
|
+
fonts: Fonts;
|
|
112
|
+
/** Font size scale */
|
|
113
|
+
fontSize: typeof fontSize;
|
|
114
|
+
/** Font weight scale */
|
|
115
|
+
fontWeight: typeof fontWeight;
|
|
116
|
+
/** Line height scale */
|
|
117
|
+
lineHeight: typeof lineHeight;
|
|
118
|
+
/** Letter spacing scale */
|
|
119
|
+
letterSpacing: typeof letterSpacing;
|
|
120
|
+
/** Font family tokens (sans, mono, system) @deprecated use fonts instead */
|
|
121
|
+
fontFamily: typeof fontFamily;
|
|
122
|
+
/** Geist font family with weight variants */
|
|
123
|
+
geistFontFamily: typeof geistFontFamily;
|
|
124
|
+
/** Pre-composed typography styles (uses fonts tokens) */
|
|
125
|
+
typography: Typography;
|
|
126
|
+
|
|
127
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
128
|
+
// Shadows
|
|
129
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
130
|
+
/** Get shadow style for a size */
|
|
131
|
+
shadow: (size: ShadowSize) => ShadowStyle;
|
|
132
|
+
/** Get platform-optimized shadow style (iOS: shadow*, Android: elevation) */
|
|
133
|
+
platformShadow: (size: ShadowSize) => ViewStyle;
|
|
134
|
+
|
|
135
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
136
|
+
// Animations
|
|
137
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
138
|
+
/** Spring animation presets */
|
|
139
|
+
springs: SpringTokens;
|
|
140
|
+
/** Timing animation presets */
|
|
141
|
+
timing: TimingTokens;
|
|
142
|
+
/** Press scale presets */
|
|
143
|
+
pressScale: PressScaleTokens;
|
|
144
|
+
/** Duration presets */
|
|
145
|
+
durations: DurationTokens;
|
|
146
|
+
|
|
147
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
148
|
+
// Component Tokens
|
|
149
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
150
|
+
/** Component-specific size/style tokens */
|
|
151
|
+
components: typeof components;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const ThemeContext = createContext<Theme | null>(null);
|
|
155
|
+
|
|
156
|
+
export interface ThemeProviderProps {
|
|
157
|
+
/** Initial color scheme preference */
|
|
158
|
+
defaultColorScheme?: ColorSchemePreference;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Theme preset for colors.
|
|
162
|
+
* Pre-designed color schemes: 'zinc', 'slate', 'stone', 'blue', 'green', 'rose', 'orange', 'violet'
|
|
163
|
+
*
|
|
164
|
+
* @example
|
|
165
|
+
* ```tsx
|
|
166
|
+
* <ThemeProvider theme="rose">
|
|
167
|
+
* ```
|
|
168
|
+
*/
|
|
169
|
+
theme?: ThemePreset;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Base radius preset.
|
|
173
|
+
* Controls roundness of all components: 'none', 'sm', 'md', 'lg', 'full'
|
|
174
|
+
*
|
|
175
|
+
* @example
|
|
176
|
+
* ```tsx
|
|
177
|
+
* <ThemeProvider radius="lg"> // Soft, rounded UI
|
|
178
|
+
* <ThemeProvider radius="none"> // Sharp, brutalist UI
|
|
179
|
+
* ```
|
|
180
|
+
*/
|
|
181
|
+
radius?: RadiusPreset;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Custom font configuration.
|
|
185
|
+
* Partial overrides are merged with defaults.
|
|
186
|
+
*
|
|
187
|
+
* @example
|
|
188
|
+
* ```tsx
|
|
189
|
+
* <ThemeProvider fonts={{
|
|
190
|
+
* sans: 'Inter_400Regular',
|
|
191
|
+
* heading: 'PlayfairDisplay_700Bold',
|
|
192
|
+
* }}>
|
|
193
|
+
* ```
|
|
194
|
+
*/
|
|
195
|
+
fonts?: Partial<Fonts>;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Color overrides (applied to both light and dark modes).
|
|
199
|
+
* Merged on top of theme preset colors.
|
|
200
|
+
*
|
|
201
|
+
* @example
|
|
202
|
+
* ```tsx
|
|
203
|
+
* <ThemeProvider
|
|
204
|
+
* theme="blue"
|
|
205
|
+
* colors={{ primary: '#6366F1' }} // Override blue's primary with indigo
|
|
206
|
+
* >
|
|
207
|
+
* ```
|
|
208
|
+
*/
|
|
209
|
+
colors?: Partial<ThemeColors>;
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Light mode specific color overrides.
|
|
213
|
+
* Applied only in light mode, after theme and colors.
|
|
214
|
+
*/
|
|
215
|
+
lightColors?: Partial<ThemeColors>;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Dark mode specific color overrides.
|
|
219
|
+
* Applied only in dark mode, after theme and colors.
|
|
220
|
+
*/
|
|
221
|
+
darkColors?: Partial<ThemeColors>;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Enable or disable haptic feedback globally.
|
|
225
|
+
* When false, all `haptic()` calls become no-ops.
|
|
226
|
+
* @default true
|
|
227
|
+
*
|
|
228
|
+
* @example
|
|
229
|
+
* ```tsx
|
|
230
|
+
* // Disable haptics for accessibility or user preference
|
|
231
|
+
* <ThemeProvider haptics={false}>
|
|
232
|
+
* ```
|
|
233
|
+
*/
|
|
234
|
+
haptics?: boolean;
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Animation preset.
|
|
238
|
+
* Controls the overall feel of animations throughout the app.
|
|
239
|
+
* - `subtle`: Professional, smooth animations with minimal overshoot
|
|
240
|
+
* - `playful`: Bouncy, energetic animations with more personality
|
|
241
|
+
* @default 'subtle'
|
|
242
|
+
*
|
|
243
|
+
* @example
|
|
244
|
+
* ```tsx
|
|
245
|
+
* // Professional, smooth animations
|
|
246
|
+
* <ThemeProvider animationPreset="subtle">
|
|
247
|
+
*
|
|
248
|
+
* // Bouncy, fun animations
|
|
249
|
+
* <ThemeProvider animationPreset="playful">
|
|
250
|
+
* ```
|
|
251
|
+
*/
|
|
252
|
+
animationPreset?: AnimationPreset;
|
|
253
|
+
|
|
254
|
+
/** Children components */
|
|
255
|
+
children: ReactNode;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export function ThemeProvider({
|
|
259
|
+
defaultColorScheme = 'system',
|
|
260
|
+
theme: themePreset,
|
|
261
|
+
radius: radiusPreset = defaultRadiusPreset,
|
|
262
|
+
fonts: fontOverrides,
|
|
263
|
+
colors: colorOverrides,
|
|
264
|
+
lightColors: lightColorOverrides,
|
|
265
|
+
darkColors: darkColorOverrides,
|
|
266
|
+
haptics = true,
|
|
267
|
+
animationPreset = defaultAnimationPreset,
|
|
268
|
+
children,
|
|
269
|
+
}: ThemeProviderProps) {
|
|
270
|
+
const systemColorScheme = useColorScheme();
|
|
271
|
+
const [preference, setPreference] = useState<ColorSchemePreference>(defaultColorScheme);
|
|
272
|
+
|
|
273
|
+
// Set global haptics enabled state
|
|
274
|
+
useEffect(() => {
|
|
275
|
+
setHapticsEnabled(haptics);
|
|
276
|
+
}, [haptics]);
|
|
277
|
+
|
|
278
|
+
const setColorScheme = useCallback((newPreference: ColorSchemePreference) => {
|
|
279
|
+
setPreference(newPreference);
|
|
280
|
+
}, []);
|
|
281
|
+
|
|
282
|
+
// Merge font overrides with defaults
|
|
283
|
+
const fonts = useMemo<Fonts>(
|
|
284
|
+
() => ({
|
|
285
|
+
...defaultFonts,
|
|
286
|
+
...fontOverrides,
|
|
287
|
+
}),
|
|
288
|
+
[fontOverrides]
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
// Generate typography with resolved fonts
|
|
292
|
+
const typography = useMemo(() => createTypography(fonts), [fonts]);
|
|
293
|
+
|
|
294
|
+
// Generate radius tokens from preset
|
|
295
|
+
const radius = useMemo(() => createRadius(radiusPreset), [radiusPreset]);
|
|
296
|
+
const componentRadius = useMemo(() => createComponentRadius(radius), [radius]);
|
|
297
|
+
|
|
298
|
+
const theme = useMemo<Theme>(() => {
|
|
299
|
+
// Resolve actual color scheme from preference
|
|
300
|
+
let resolvedScheme: ColorScheme;
|
|
301
|
+
if (preference === 'system') {
|
|
302
|
+
resolvedScheme = systemColorScheme === 'dark' ? 'dark' : 'light';
|
|
303
|
+
} else {
|
|
304
|
+
resolvedScheme = preference;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const isDark = resolvedScheme === 'dark';
|
|
308
|
+
|
|
309
|
+
// Build colors: base -> preset -> overrides -> mode-specific overrides
|
|
310
|
+
let colors: ThemeColors;
|
|
311
|
+
|
|
312
|
+
// Start with base colors or preset colors
|
|
313
|
+
if (themePreset) {
|
|
314
|
+
const presetColors = themePresets[themePreset];
|
|
315
|
+
colors = isDark ? { ...presetColors.dark } : { ...presetColors.light };
|
|
316
|
+
} else {
|
|
317
|
+
colors = isDark ? { ...darkColors } : { ...lightColors };
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Apply general color overrides
|
|
321
|
+
if (colorOverrides) {
|
|
322
|
+
colors = { ...colors, ...colorOverrides };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Apply mode-specific overrides
|
|
326
|
+
if (isDark && darkColorOverrides) {
|
|
327
|
+
colors = { ...colors, ...darkColorOverrides };
|
|
328
|
+
} else if (!isDark && lightColorOverrides) {
|
|
329
|
+
colors = { ...colors, ...lightColorOverrides };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return {
|
|
333
|
+
// Color Scheme
|
|
334
|
+
colorScheme: resolvedScheme,
|
|
335
|
+
colorSchemePreference: preference,
|
|
336
|
+
isDark,
|
|
337
|
+
setColorScheme,
|
|
338
|
+
|
|
339
|
+
// Theme Preset
|
|
340
|
+
themePreset,
|
|
341
|
+
radiusPreset,
|
|
342
|
+
animationPreset,
|
|
343
|
+
|
|
344
|
+
// Design Tokens
|
|
345
|
+
colors,
|
|
346
|
+
spacing,
|
|
347
|
+
radius,
|
|
348
|
+
componentRadius,
|
|
349
|
+
|
|
350
|
+
// Fonts & Typography
|
|
351
|
+
fonts,
|
|
352
|
+
fontSize,
|
|
353
|
+
fontWeight,
|
|
354
|
+
lineHeight,
|
|
355
|
+
letterSpacing,
|
|
356
|
+
fontFamily,
|
|
357
|
+
geistFontFamily,
|
|
358
|
+
typography,
|
|
359
|
+
|
|
360
|
+
// Shadows
|
|
361
|
+
shadow: (size: ShadowSize) => getShadow(size, isDark),
|
|
362
|
+
platformShadow: (size: ShadowSize) => getPlatformShadow(size, isDark),
|
|
363
|
+
|
|
364
|
+
// Animations (resolved from preset)
|
|
365
|
+
...getAnimationPreset(animationPreset),
|
|
366
|
+
|
|
367
|
+
// Component Tokens
|
|
368
|
+
components,
|
|
369
|
+
};
|
|
370
|
+
}, [
|
|
371
|
+
preference,
|
|
372
|
+
systemColorScheme,
|
|
373
|
+
setColorScheme,
|
|
374
|
+
themePreset,
|
|
375
|
+
radiusPreset,
|
|
376
|
+
animationPreset,
|
|
377
|
+
fonts,
|
|
378
|
+
typography,
|
|
379
|
+
radius,
|
|
380
|
+
componentRadius,
|
|
381
|
+
colorOverrides,
|
|
382
|
+
lightColorOverrides,
|
|
383
|
+
darkColorOverrides,
|
|
384
|
+
]);
|
|
385
|
+
|
|
386
|
+
return (
|
|
387
|
+
<ThemeContext.Provider value={theme}>
|
|
388
|
+
{children}
|
|
389
|
+
</ThemeContext.Provider>
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Hook to access theme values
|
|
395
|
+
*
|
|
396
|
+
* @example
|
|
397
|
+
* ```tsx
|
|
398
|
+
* const { colors, spacing, radius, components } = useTheme();
|
|
399
|
+
*
|
|
400
|
+
* // Use design tokens
|
|
401
|
+
* <View style={{
|
|
402
|
+
* backgroundColor: colors.card,
|
|
403
|
+
* padding: spacing[4],
|
|
404
|
+
* borderRadius: radius.lg,
|
|
405
|
+
* ...platformShadow('md')
|
|
406
|
+
* }}>
|
|
407
|
+
* <Text style={typography.body}>Hello</Text>
|
|
408
|
+
* </View>
|
|
409
|
+
*
|
|
410
|
+
* // Use component tokens
|
|
411
|
+
* const buttonSize = components.button.md;
|
|
412
|
+
* <View style={{
|
|
413
|
+
* height: buttonSize.height,
|
|
414
|
+
* paddingHorizontal: buttonSize.paddingHorizontal,
|
|
415
|
+
* borderRadius: buttonSize.borderRadius,
|
|
416
|
+
* }} />
|
|
417
|
+
* ```
|
|
418
|
+
*/
|
|
419
|
+
export function useTheme(): Theme {
|
|
420
|
+
const theme = useContext(ThemeContext);
|
|
421
|
+
|
|
422
|
+
if (!theme) {
|
|
423
|
+
// Fallback for when ThemeProvider is not present
|
|
424
|
+
const isDark = false;
|
|
425
|
+
const fallbackTypography = createTypography(defaultFonts);
|
|
426
|
+
const fallbackRadius = createRadius(defaultRadiusPreset);
|
|
427
|
+
const fallbackComponentRadius = createComponentRadius(fallbackRadius);
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
colorScheme: 'light',
|
|
431
|
+
colorSchemePreference: 'system',
|
|
432
|
+
isDark,
|
|
433
|
+
setColorScheme: () => {
|
|
434
|
+
console.warn('ThemeProvider not found. setColorScheme will not work.');
|
|
435
|
+
},
|
|
436
|
+
themePreset: undefined,
|
|
437
|
+
radiusPreset: defaultRadiusPreset,
|
|
438
|
+
animationPreset: defaultAnimationPreset,
|
|
439
|
+
colors: lightColors,
|
|
440
|
+
spacing,
|
|
441
|
+
radius: fallbackRadius,
|
|
442
|
+
componentRadius: fallbackComponentRadius,
|
|
443
|
+
fonts: defaultFonts,
|
|
444
|
+
fontSize,
|
|
445
|
+
fontWeight,
|
|
446
|
+
lineHeight,
|
|
447
|
+
letterSpacing,
|
|
448
|
+
fontFamily,
|
|
449
|
+
geistFontFamily,
|
|
450
|
+
typography: fallbackTypography,
|
|
451
|
+
shadow: (size: ShadowSize) => getShadow(size, isDark),
|
|
452
|
+
platformShadow: (size: ShadowSize) => getPlatformShadow(size, isDark),
|
|
453
|
+
...getAnimationPreset(defaultAnimationPreset),
|
|
454
|
+
components,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return theme;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Hook to get current color scheme
|
|
463
|
+
*/
|
|
464
|
+
export function useColorSchemeValue(): ColorScheme {
|
|
465
|
+
const theme = useTheme();
|
|
466
|
+
return theme.colorScheme;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Hook to check if dark mode is active
|
|
471
|
+
*/
|
|
472
|
+
export function useIsDark(): boolean {
|
|
473
|
+
const theme = useTheme();
|
|
474
|
+
return theme.isDark;
|
|
475
|
+
}
|