@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.
@@ -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
+ }