@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
package/src/constants.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NativeUI Constants
|
|
3
|
+
*
|
|
4
|
+
* Zentralisierte Magic Numbers und Konfigurationswerte.
|
|
5
|
+
* Diese Werte werden von Components verwendet und können
|
|
6
|
+
* über Props überschrieben werden.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```tsx
|
|
10
|
+
* import { SHEET_CONSTANTS } from '@nativeui/core';
|
|
11
|
+
*
|
|
12
|
+
* // In SheetContent:
|
|
13
|
+
* if (translationY > height * SHEET_CONSTANTS.closeThreshold) {
|
|
14
|
+
* close();
|
|
15
|
+
* }
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// ============================================
|
|
20
|
+
// Sheet Constants
|
|
21
|
+
// ============================================
|
|
22
|
+
|
|
23
|
+
export const SHEET_CONSTANTS = {
|
|
24
|
+
/** Threshold (0-1) für automatisches Schließen beim Ziehen */
|
|
25
|
+
closeThreshold: 0.3,
|
|
26
|
+
/** Geschwindigkeit (px/s) für Swipe-to-Close */
|
|
27
|
+
velocityThreshold: 500,
|
|
28
|
+
/** Backdrop Fade-In Dauer (ms) */
|
|
29
|
+
backdropFadeInDuration: 200,
|
|
30
|
+
/** Backdrop Fade-Out Dauer (ms) */
|
|
31
|
+
backdropFadeOutDuration: 150,
|
|
32
|
+
/** Maximale Backdrop Opacity */
|
|
33
|
+
backdropMaxOpacity: 0.5,
|
|
34
|
+
/** Handle Breite (px) */
|
|
35
|
+
handleWidth: 36,
|
|
36
|
+
/** Handle Höhe (px) */
|
|
37
|
+
handleHeight: 4,
|
|
38
|
+
/** Handle Container Padding Top (px) */
|
|
39
|
+
handlePaddingTop: 12,
|
|
40
|
+
/** Handle Container Padding Bottom (px) */
|
|
41
|
+
handlePaddingBottom: 8,
|
|
42
|
+
/** Content Horizontal Padding (px) */
|
|
43
|
+
contentPaddingHorizontal: 16,
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
46
|
+
// ============================================
|
|
47
|
+
// Dialog Constants
|
|
48
|
+
// ============================================
|
|
49
|
+
|
|
50
|
+
export const DIALOG_CONSTANTS = {
|
|
51
|
+
/** Screen Margin für Dialog Breite (px) */
|
|
52
|
+
screenMargin: 48,
|
|
53
|
+
/** Close Animation Dauer (ms) */
|
|
54
|
+
closeAnimationDuration: 150,
|
|
55
|
+
/** Start Scale für Enter Animation */
|
|
56
|
+
enterStartScale: 0.95,
|
|
57
|
+
/** Dialog Content Padding (px) */
|
|
58
|
+
contentPadding: 24,
|
|
59
|
+
/** Backdrop Max Opacity */
|
|
60
|
+
backdropMaxOpacity: 0.5,
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
// ============================================
|
|
64
|
+
// Toast Constants
|
|
65
|
+
// ============================================
|
|
66
|
+
|
|
67
|
+
export const TOAST_CONSTANTS = {
|
|
68
|
+
/** Standard Anzeigedauer (ms) */
|
|
69
|
+
defaultDuration: 4000,
|
|
70
|
+
/** Maximale gleichzeitig sichtbare Toasts */
|
|
71
|
+
maxToasts: 3,
|
|
72
|
+
/** Fade-Out Dauer (ms) */
|
|
73
|
+
fadeOutDuration: 150,
|
|
74
|
+
/** Container Top Offset vom SafeArea (px) */
|
|
75
|
+
containerTopOffset: 8,
|
|
76
|
+
/** Toast Width Margin (px) - wird von Screen Width abgezogen */
|
|
77
|
+
widthMargin: 32,
|
|
78
|
+
/** Fallback Farben wenn Theme sie nicht hat */
|
|
79
|
+
fallbackColors: {
|
|
80
|
+
success: '#22c55e',
|
|
81
|
+
successForeground: '#ffffff',
|
|
82
|
+
warning: '#f59e0b',
|
|
83
|
+
warningForeground: '#000000',
|
|
84
|
+
},
|
|
85
|
+
} as const;
|
|
86
|
+
|
|
87
|
+
// ============================================
|
|
88
|
+
// Button Constants
|
|
89
|
+
// ============================================
|
|
90
|
+
|
|
91
|
+
export const BUTTON_CONSTANTS = {
|
|
92
|
+
/** Scale beim Drücken (0-1) */
|
|
93
|
+
pressScale: 0.97,
|
|
94
|
+
/** Opacity bei disabled */
|
|
95
|
+
disabledOpacity: 0.5,
|
|
96
|
+
} as const;
|
|
97
|
+
|
|
98
|
+
// ============================================
|
|
99
|
+
// Accordion Constants
|
|
100
|
+
// ============================================
|
|
101
|
+
|
|
102
|
+
export const ACCORDION_CONSTANTS = {
|
|
103
|
+
/** Animation Dauer (ms) */
|
|
104
|
+
animationDuration: 250,
|
|
105
|
+
/** Trigger Text Font Size */
|
|
106
|
+
triggerFontSize: 15,
|
|
107
|
+
/** Chevron Font Size */
|
|
108
|
+
chevronFontSize: 10,
|
|
109
|
+
/** Chevron Margin Left */
|
|
110
|
+
chevronMarginLeft: 8,
|
|
111
|
+
} as const;
|
|
112
|
+
|
|
113
|
+
// ============================================
|
|
114
|
+
// Swipeable Row Constants
|
|
115
|
+
// ============================================
|
|
116
|
+
|
|
117
|
+
export const SWIPEABLE_CONSTANTS = {
|
|
118
|
+
/** Standard Action Button Breite (px) */
|
|
119
|
+
actionWidth: 80,
|
|
120
|
+
/** Geschwindigkeit für Snap (px/s) */
|
|
121
|
+
velocityThreshold: 500,
|
|
122
|
+
/** Full Swipe Threshold (0-1 von Screen Width) */
|
|
123
|
+
fullSwipeThreshold: 0.5,
|
|
124
|
+
/** Full Swipe Animation Dauer (ms) */
|
|
125
|
+
fullSwipeAnimationDuration: 200,
|
|
126
|
+
/** Resistance Factor am Edge (0-1) */
|
|
127
|
+
resistanceFactor: 0.2,
|
|
128
|
+
/** Active Offset für Gesture Start (px) */
|
|
129
|
+
activeOffset: 10,
|
|
130
|
+
/** Action Icon Margin Bottom (px) */
|
|
131
|
+
actionIconMarginBottom: 4,
|
|
132
|
+
/** Action Button Horizontal Padding (px) */
|
|
133
|
+
actionButtonPaddingHorizontal: 8,
|
|
134
|
+
/** Action Label Font Size */
|
|
135
|
+
actionLabelFontSize: 12,
|
|
136
|
+
} as const;
|
|
137
|
+
|
|
138
|
+
// ============================================
|
|
139
|
+
// Animation Constants (ergänzend zu theme/animations)
|
|
140
|
+
// ============================================
|
|
141
|
+
|
|
142
|
+
export const ANIMATION_CONSTANTS = {
|
|
143
|
+
/** Standard Spring Damping */
|
|
144
|
+
springDamping: 20,
|
|
145
|
+
/** Standard Spring Stiffness */
|
|
146
|
+
springStiffness: 200,
|
|
147
|
+
} as const;
|
|
148
|
+
|
|
149
|
+
// ============================================
|
|
150
|
+
// Slider Constants
|
|
151
|
+
// ============================================
|
|
152
|
+
|
|
153
|
+
export const SLIDER_CONSTANTS = {
|
|
154
|
+
/** Track Höhe (px) */
|
|
155
|
+
trackHeight: 6,
|
|
156
|
+
/** Thumb Größe (px) */
|
|
157
|
+
thumbSize: 24,
|
|
158
|
+
/** Hit Slop für Touch (px) */
|
|
159
|
+
hitSlop: 10,
|
|
160
|
+
} as const;
|
|
161
|
+
|
|
162
|
+
// ============================================
|
|
163
|
+
// Stepper Constants
|
|
164
|
+
// ============================================
|
|
165
|
+
|
|
166
|
+
export const STEPPER_CONSTANTS = {
|
|
167
|
+
/** Button Größe (px) */
|
|
168
|
+
buttonSize: 36,
|
|
169
|
+
/** Long Press Delay (ms) */
|
|
170
|
+
longPressDelay: 500,
|
|
171
|
+
/** Repeat Interval (ms) */
|
|
172
|
+
repeatInterval: 100,
|
|
173
|
+
/** Min Input Width (px) */
|
|
174
|
+
minInputWidth: 48,
|
|
175
|
+
} as const;
|
|
176
|
+
|
|
177
|
+
// ============================================
|
|
178
|
+
// Typography Constants (für Component Styles)
|
|
179
|
+
// ============================================
|
|
180
|
+
|
|
181
|
+
export const TYPOGRAPHY_CONSTANTS = {
|
|
182
|
+
/** Title Font Size für Overlays (Sheet, Dialog) */
|
|
183
|
+
overlayTitleSize: 18,
|
|
184
|
+
/** Description Font Size für Overlays */
|
|
185
|
+
overlayDescriptionSize: 14,
|
|
186
|
+
/** Description Line Height für Dialogs */
|
|
187
|
+
overlayDescriptionLineHeight: 20,
|
|
188
|
+
} as const;
|
|
189
|
+
|
|
190
|
+
// ============================================
|
|
191
|
+
// Export Types
|
|
192
|
+
// ============================================
|
|
193
|
+
|
|
194
|
+
export type SheetConstants = typeof SHEET_CONSTANTS;
|
|
195
|
+
export type DialogConstants = typeof DIALOG_CONSTANTS;
|
|
196
|
+
export type ToastConstants = typeof TOAST_CONSTANTS;
|
|
197
|
+
export type ButtonConstants = typeof BUTTON_CONSTANTS;
|
|
198
|
+
export type AccordionConstants = typeof ACCORDION_CONSTANTS;
|
|
199
|
+
export type SwipeableConstants = typeof SWIPEABLE_CONSTANTS;
|
|
200
|
+
export type AnimationConstants = typeof ANIMATION_CONSTANTS;
|
|
201
|
+
export type SliderConstants = typeof SLIDER_CONSTANTS;
|
|
202
|
+
export type StepperConstants = typeof STEPPER_CONSTANTS;
|
|
203
|
+
export type TypographyConstants = typeof TYPOGRAPHY_CONSTANTS;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Theme System (primary API)
|
|
2
|
+
export {
|
|
3
|
+
// Colors
|
|
4
|
+
palette,
|
|
5
|
+
lightColors,
|
|
6
|
+
darkColors,
|
|
7
|
+
type ThemeColors,
|
|
8
|
+
type ColorKey,
|
|
9
|
+
// Spacing
|
|
10
|
+
spacing as themeSpacing,
|
|
11
|
+
type SpacingKey as ThemeSpacingKey,
|
|
12
|
+
// Radius
|
|
13
|
+
radius as themeRadius,
|
|
14
|
+
componentRadius,
|
|
15
|
+
createRadius,
|
|
16
|
+
createComponentRadius,
|
|
17
|
+
radiusPresetBase,
|
|
18
|
+
defaultRadiusPreset,
|
|
19
|
+
type RadiusKey as ThemeRadiusKey,
|
|
20
|
+
type RadiusPreset,
|
|
21
|
+
type RadiusTokens,
|
|
22
|
+
// Fonts
|
|
23
|
+
defaultFonts,
|
|
24
|
+
systemFonts,
|
|
25
|
+
createTypography,
|
|
26
|
+
type Fonts,
|
|
27
|
+
type Typography,
|
|
28
|
+
// Typography
|
|
29
|
+
fontSize as themeFontSize,
|
|
30
|
+
fontWeight as themeFontWeight,
|
|
31
|
+
lineHeight as themeLineHeight,
|
|
32
|
+
letterSpacing as themeLetterSpacing,
|
|
33
|
+
fontFamily,
|
|
34
|
+
geistFontFamily,
|
|
35
|
+
typography,
|
|
36
|
+
type FontSizeKey,
|
|
37
|
+
type FontWeightKey,
|
|
38
|
+
type FontFamilyKey,
|
|
39
|
+
type GeistFontFamilyKey,
|
|
40
|
+
type TypographyKey,
|
|
41
|
+
// Shadows
|
|
42
|
+
getShadow,
|
|
43
|
+
getPlatformShadow,
|
|
44
|
+
shadows,
|
|
45
|
+
type ShadowStyle,
|
|
46
|
+
type ShadowSize,
|
|
47
|
+
// Animations
|
|
48
|
+
springs,
|
|
49
|
+
timing,
|
|
50
|
+
pressScale,
|
|
51
|
+
durations,
|
|
52
|
+
subtleAnimations,
|
|
53
|
+
playfulAnimations,
|
|
54
|
+
getAnimationPreset,
|
|
55
|
+
defaultAnimationPreset,
|
|
56
|
+
type SpringConfig,
|
|
57
|
+
type TimingConfig,
|
|
58
|
+
type SpringTokens,
|
|
59
|
+
type TimingTokens,
|
|
60
|
+
type PressScaleTokens,
|
|
61
|
+
type DurationTokens,
|
|
62
|
+
type AnimationTokens,
|
|
63
|
+
type SpringPreset,
|
|
64
|
+
type TimingPreset,
|
|
65
|
+
type PressScalePreset,
|
|
66
|
+
type AnimationPreset,
|
|
67
|
+
// Component Tokens
|
|
68
|
+
components,
|
|
69
|
+
componentHeight,
|
|
70
|
+
iconSize,
|
|
71
|
+
buttonTokens,
|
|
72
|
+
inputTokens,
|
|
73
|
+
checkboxTokens,
|
|
74
|
+
switchTokens,
|
|
75
|
+
badgeTokens,
|
|
76
|
+
avatarTokens,
|
|
77
|
+
cardTokens,
|
|
78
|
+
type ComponentSize,
|
|
79
|
+
// Theme Presets
|
|
80
|
+
themePresets,
|
|
81
|
+
defaultThemePreset,
|
|
82
|
+
getPresetColors,
|
|
83
|
+
getPresetColorsForMode,
|
|
84
|
+
type ThemePreset,
|
|
85
|
+
type PresetColors,
|
|
86
|
+
// Provider & Hooks
|
|
87
|
+
ThemeProvider,
|
|
88
|
+
useTheme,
|
|
89
|
+
useColorSchemeValue,
|
|
90
|
+
useIsDark,
|
|
91
|
+
type Theme,
|
|
92
|
+
type ThemeProviderProps,
|
|
93
|
+
type ColorScheme,
|
|
94
|
+
type ColorSchemePreference,
|
|
95
|
+
} from './theme';
|
|
96
|
+
|
|
97
|
+
// Design Tokens (legacy - prefer theme)
|
|
98
|
+
// Export only non-conflicting tokens
|
|
99
|
+
export {
|
|
100
|
+
colors as legacyColors,
|
|
101
|
+
type Colors as LegacyColors,
|
|
102
|
+
} from './tokens/colors';
|
|
103
|
+
export { spacing, type Spacing, type SpacingKey } from './tokens/spacing';
|
|
104
|
+
export {
|
|
105
|
+
fontFamily as legacyFontFamily,
|
|
106
|
+
fontSize,
|
|
107
|
+
fontWeight,
|
|
108
|
+
lineHeight,
|
|
109
|
+
letterSpacing,
|
|
110
|
+
type FontFamily,
|
|
111
|
+
type FontSize,
|
|
112
|
+
type FontWeight,
|
|
113
|
+
type LineHeight,
|
|
114
|
+
type LetterSpacing,
|
|
115
|
+
} from './tokens/typography';
|
|
116
|
+
export { radius, type Radius, type RadiusKey } from './tokens/radius';
|
|
117
|
+
export {
|
|
118
|
+
shadows as legacyShadows,
|
|
119
|
+
type Shadows as LegacyShadows,
|
|
120
|
+
type ShadowKey,
|
|
121
|
+
} from './tokens/shadows';
|
|
122
|
+
|
|
123
|
+
// Configuration
|
|
124
|
+
export {
|
|
125
|
+
defineConfig,
|
|
126
|
+
resolveConfig,
|
|
127
|
+
ConfigProvider,
|
|
128
|
+
useConfig,
|
|
129
|
+
defaultConfig,
|
|
130
|
+
type NativeUIConfig,
|
|
131
|
+
type ResolvedNativeUIConfig,
|
|
132
|
+
} from './config';
|
|
133
|
+
|
|
134
|
+
// Utilities
|
|
135
|
+
export * from './utils';
|
|
136
|
+
|
|
137
|
+
// Primitives
|
|
138
|
+
export * from './primitives';
|
|
139
|
+
|
|
140
|
+
// Components (Error Boundaries, etc.)
|
|
141
|
+
export * from './components';
|
|
142
|
+
|
|
143
|
+
// Constants (Magic Numbers)
|
|
144
|
+
export * from './constants';
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portal Primitive
|
|
3
|
+
*
|
|
4
|
+
* Teleports children to a different part of the React tree.
|
|
5
|
+
* Essential for overlays like Dialogs, Toasts, Dropdowns.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* 1. Wrap your app with <PortalProvider>
|
|
9
|
+
* 2. Place <PortalHost /> at the root (after other content)
|
|
10
|
+
* 3. Use <Portal> anywhere to render content at the host
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import React, {
|
|
14
|
+
createContext,
|
|
15
|
+
useContext,
|
|
16
|
+
useState,
|
|
17
|
+
useCallback,
|
|
18
|
+
useMemo,
|
|
19
|
+
useId,
|
|
20
|
+
} from 'react';
|
|
21
|
+
import { View, StyleSheet } from 'react-native';
|
|
22
|
+
|
|
23
|
+
// --- Types ---
|
|
24
|
+
|
|
25
|
+
export interface PortalContextValue {
|
|
26
|
+
/**
|
|
27
|
+
* Register a portal to be rendered at the host
|
|
28
|
+
*/
|
|
29
|
+
mount: (key: string, element: React.ReactNode) => void;
|
|
30
|
+
/**
|
|
31
|
+
* Remove a portal from the host
|
|
32
|
+
*/
|
|
33
|
+
unmount: (key: string) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface PortalProviderProps {
|
|
37
|
+
children: React.ReactNode;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface PortalHostProps {
|
|
41
|
+
/**
|
|
42
|
+
* Name of the host (for multiple hosts)
|
|
43
|
+
* @default 'root'
|
|
44
|
+
*/
|
|
45
|
+
name?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface PortalProps {
|
|
49
|
+
children: React.ReactNode;
|
|
50
|
+
/**
|
|
51
|
+
* Name of the host to render into
|
|
52
|
+
* @default 'root'
|
|
53
|
+
*/
|
|
54
|
+
hostName?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- Context ---
|
|
58
|
+
|
|
59
|
+
const PortalContext = createContext<PortalContextValue | null>(null);
|
|
60
|
+
|
|
61
|
+
const usePortalContext = () => {
|
|
62
|
+
const context = useContext(PortalContext);
|
|
63
|
+
if (!context) {
|
|
64
|
+
throw new Error('Portal must be used within a PortalProvider');
|
|
65
|
+
}
|
|
66
|
+
return context;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// --- Components ---
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Provides the portal context to the app.
|
|
73
|
+
* Wrap your root component with this provider.
|
|
74
|
+
*/
|
|
75
|
+
export function PortalProvider({ children }: PortalProviderProps) {
|
|
76
|
+
const [portals, setPortals] = useState<Map<string, React.ReactNode>>(
|
|
77
|
+
new Map()
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const mount = useCallback((key: string, element: React.ReactNode) => {
|
|
81
|
+
setPortals((prev) => {
|
|
82
|
+
const next = new Map(prev);
|
|
83
|
+
next.set(key, element);
|
|
84
|
+
return next;
|
|
85
|
+
});
|
|
86
|
+
}, []);
|
|
87
|
+
|
|
88
|
+
const unmount = useCallback((key: string) => {
|
|
89
|
+
setPortals((prev) => {
|
|
90
|
+
const next = new Map(prev);
|
|
91
|
+
next.delete(key);
|
|
92
|
+
return next;
|
|
93
|
+
});
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
const contextValue = useMemo(() => ({ mount, unmount }), [mount, unmount]);
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<PortalContext.Provider value={contextValue}>
|
|
100
|
+
{children}
|
|
101
|
+
<PortalHost />
|
|
102
|
+
</PortalContext.Provider>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
PortalProvider.displayName = 'PortalProvider';
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The target where portals render their content.
|
|
110
|
+
* Automatically included in PortalProvider, but can be placed manually
|
|
111
|
+
* for custom layouts.
|
|
112
|
+
*/
|
|
113
|
+
export function PortalHost({ name = 'root' }: PortalHostProps) {
|
|
114
|
+
const [portals, setPortals] = useState<Map<string, React.ReactNode>>(
|
|
115
|
+
new Map()
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// Create a separate context for this specific host
|
|
119
|
+
const mount = useCallback((key: string, element: React.ReactNode) => {
|
|
120
|
+
setPortals((prev) => {
|
|
121
|
+
const next = new Map(prev);
|
|
122
|
+
next.set(key, element);
|
|
123
|
+
return next;
|
|
124
|
+
});
|
|
125
|
+
}, []);
|
|
126
|
+
|
|
127
|
+
const unmount = useCallback((key: string) => {
|
|
128
|
+
setPortals((prev) => {
|
|
129
|
+
const next = new Map(prev);
|
|
130
|
+
next.delete(key);
|
|
131
|
+
return next;
|
|
132
|
+
});
|
|
133
|
+
}, []);
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<PortalHostContext.Provider value={{ mount, unmount, name }}>
|
|
137
|
+
<View style={styles.host} pointerEvents="box-none">
|
|
138
|
+
{Array.from(portals.values())}
|
|
139
|
+
</View>
|
|
140
|
+
</PortalHostContext.Provider>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
PortalHost.displayName = 'PortalHost';
|
|
145
|
+
|
|
146
|
+
// Host-specific context for named hosts
|
|
147
|
+
interface PortalHostContextValue extends PortalContextValue {
|
|
148
|
+
name: string;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const PortalHostContext = createContext<PortalHostContextValue | null>(null);
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Renders children into the nearest PortalHost.
|
|
155
|
+
*/
|
|
156
|
+
export function Portal({ children, hostName = 'root' }: PortalProps) {
|
|
157
|
+
const portalContext = useContext(PortalContext);
|
|
158
|
+
const key = useId();
|
|
159
|
+
|
|
160
|
+
React.useEffect(() => {
|
|
161
|
+
if (!portalContext) {
|
|
162
|
+
console.warn('Portal: No PortalProvider found. Content will not render.');
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
portalContext.mount(key, children);
|
|
167
|
+
|
|
168
|
+
return () => {
|
|
169
|
+
portalContext.unmount(key);
|
|
170
|
+
};
|
|
171
|
+
}, [portalContext, key, children]);
|
|
172
|
+
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
Portal.displayName = 'Portal';
|
|
177
|
+
|
|
178
|
+
// --- Styles ---
|
|
179
|
+
|
|
180
|
+
const styles = StyleSheet.create({
|
|
181
|
+
host: {
|
|
182
|
+
...StyleSheet.absoluteFillObject,
|
|
183
|
+
zIndex: 9999,
|
|
184
|
+
},
|
|
185
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pressable Primitive
|
|
3
|
+
*
|
|
4
|
+
* Enhanced Pressable with:
|
|
5
|
+
* - Built-in haptic feedback
|
|
6
|
+
* - Reduce motion support
|
|
7
|
+
* - Configurable press states
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import React, { useCallback } from 'react';
|
|
11
|
+
import {
|
|
12
|
+
Pressable as RNPressable,
|
|
13
|
+
PressableProps as RNPressableProps,
|
|
14
|
+
ViewStyle,
|
|
15
|
+
AccessibilityInfo,
|
|
16
|
+
} from 'react-native';
|
|
17
|
+
import { haptic } from '../utils/haptics';
|
|
18
|
+
|
|
19
|
+
export interface PressableProps extends Omit<RNPressableProps, 'style'> {
|
|
20
|
+
/**
|
|
21
|
+
* Style or style function receiving pressed state
|
|
22
|
+
*/
|
|
23
|
+
style?: ViewStyle | ((state: { pressed: boolean }) => ViewStyle);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Haptic feedback style on press
|
|
27
|
+
* @default 'light'
|
|
28
|
+
*/
|
|
29
|
+
hapticStyle?: 'none' | 'light' | 'medium' | 'heavy' | 'selection';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Opacity when pressed
|
|
33
|
+
* @default 0.7
|
|
34
|
+
*/
|
|
35
|
+
pressedOpacity?: number;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Scale when pressed (respects reduce motion)
|
|
39
|
+
* @default 1
|
|
40
|
+
*/
|
|
41
|
+
pressedScale?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const Pressable = React.forwardRef<
|
|
45
|
+
React.ElementRef<typeof RNPressable>,
|
|
46
|
+
PressableProps
|
|
47
|
+
>(
|
|
48
|
+
(
|
|
49
|
+
{
|
|
50
|
+
style,
|
|
51
|
+
hapticStyle = 'light',
|
|
52
|
+
pressedOpacity = 0.7,
|
|
53
|
+
pressedScale = 1,
|
|
54
|
+
onPressIn,
|
|
55
|
+
disabled,
|
|
56
|
+
children,
|
|
57
|
+
...props
|
|
58
|
+
},
|
|
59
|
+
ref
|
|
60
|
+
) => {
|
|
61
|
+
const [reduceMotion, setReduceMotion] = React.useState(false);
|
|
62
|
+
|
|
63
|
+
React.useEffect(() => {
|
|
64
|
+
AccessibilityInfo.isReduceMotionEnabled().then(setReduceMotion);
|
|
65
|
+
const subscription = AccessibilityInfo.addEventListener(
|
|
66
|
+
'reduceMotionChanged',
|
|
67
|
+
setReduceMotion
|
|
68
|
+
);
|
|
69
|
+
return () => subscription.remove();
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
const handlePressIn = useCallback(
|
|
73
|
+
(event: Parameters<NonNullable<RNPressableProps['onPressIn']>>[0]) => {
|
|
74
|
+
if (hapticStyle !== 'none') {
|
|
75
|
+
haptic(hapticStyle === 'selection' ? 'selection' : hapticStyle);
|
|
76
|
+
}
|
|
77
|
+
onPressIn?.(event);
|
|
78
|
+
},
|
|
79
|
+
[hapticStyle, onPressIn]
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const getStyle = useCallback(
|
|
83
|
+
({ pressed }: { pressed: boolean }): ViewStyle => {
|
|
84
|
+
const baseStyle = typeof style === 'function' ? style({ pressed }) : style;
|
|
85
|
+
const pressedStyle: ViewStyle = pressed
|
|
86
|
+
? {
|
|
87
|
+
opacity: pressedOpacity,
|
|
88
|
+
transform: reduceMotion ? undefined : [{ scale: pressedScale }],
|
|
89
|
+
}
|
|
90
|
+
: {};
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
...baseStyle,
|
|
94
|
+
...pressedStyle,
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
[style, pressedOpacity, pressedScale, reduceMotion]
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<RNPressable
|
|
102
|
+
ref={ref}
|
|
103
|
+
style={getStyle}
|
|
104
|
+
onPressIn={handlePressIn}
|
|
105
|
+
disabled={disabled}
|
|
106
|
+
{...props}
|
|
107
|
+
>
|
|
108
|
+
{children}
|
|
109
|
+
</RNPressable>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
Pressable.displayName = 'Pressable';
|