UNPKG

@productshiv/baapui

Version:

A truly cross-platform multi-design UI framework that works with React, Next.js, React Native, and any React-based framework.

607 lines (546 loc) 18 kB
// Platform abstraction layer for cross-platform compatibility import React, { DetailedHTMLProps, HTMLAttributes } from 'react'; // Platform detection using environment-specific globals const PlatformInfo = { isReactNative: (typeof navigator !== 'undefined' && navigator.product === 'ReactNative') || (typeof global !== 'undefined' && // @ts-ignore global.__DEV__ !== undefined && typeof window === 'undefined'), isWeb: typeof window !== 'undefined' && typeof document !== 'undefined', }; // Enhanced style conversion function const convertRNStyleToCSS = (style: any): React.CSSProperties => { if (!style) return {}; const cssStyle: any = {}; // Handle transform array FIRST before copying other properties if (style.transform && Array.isArray(style.transform)) { const transforms = style.transform.map((transform: any) => { const key = Object.keys(transform)[0]; let value = transform[key]; // Convert Animated.Value to number if needed if (value && typeof value.valueOf === 'function') { value = value.valueOf(); } // Handle different transform types if (key === 'translateX' || key === 'translateY') { return `${key}(${value}px)`; } else if (key === 'scale' || key === 'scaleX' || key === 'scaleY') { return `${key}(${value})`; } else if (key === 'rotate') { return `${key}(${value}deg)`; } return `${key}(${value})`; }); cssStyle.transform = transforms.join(' '); } // Copy only valid CSS properties (excluding transform which we handled above) Object.keys(style).forEach(key => { if (key === 'transform') return; // Skip transform, we handled it above const value = style[key]; // Skip undefined, null, or function values if (value === undefined || value === null || typeof value === 'function') { return; } // Skip array values that aren't transform (they shouldn't be in CSS) if (Array.isArray(value)) { return; } cssStyle[key] = value; }); // Handle React Native specific properties if (style.paddingHorizontal !== undefined) { cssStyle.paddingLeft = style.paddingHorizontal; cssStyle.paddingRight = style.paddingHorizontal; delete cssStyle.paddingHorizontal; } if (style.paddingVertical !== undefined) { cssStyle.paddingTop = style.paddingVertical; cssStyle.paddingBottom = style.paddingVertical; delete cssStyle.paddingVertical; } if (style.marginHorizontal !== undefined) { cssStyle.marginLeft = style.marginHorizontal; cssStyle.marginRight = style.marginHorizontal; delete cssStyle.marginHorizontal; } if (style.marginVertical !== undefined) { cssStyle.marginTop = style.marginVertical; cssStyle.marginBottom = style.marginVertical; delete cssStyle.marginVertical; } // Handle text shadow properties if (style.textShadowColor || style.textShadowOffset || style.textShadowRadius) { const color = style.textShadowColor || 'rgba(0,0,0,0.3)'; const offsetX = style.textShadowOffset?.width || 1; const offsetY = style.textShadowOffset?.height || 1; const radius = style.textShadowRadius || 1; cssStyle.textShadow = `${offsetX}px ${offsetY}px ${radius}px ${color}`; delete cssStyle.textShadowColor; delete cssStyle.textShadowOffset; delete cssStyle.textShadowRadius; } // Handle shadow properties if (style.shadowColor || style.shadowOffset || style.shadowRadius || style.shadowOpacity) { const color = style.shadowColor || 'rgba(0,0,0,0.3)'; const offsetX = style.shadowOffset?.width || 0; const offsetY = style.shadowOffset?.height || 2; const radius = style.shadowRadius || 4; const opacity = style.shadowOpacity || 0.3; cssStyle.boxShadow = `${offsetX}px ${offsetY}px ${radius}px rgba(0,0,0,${opacity})`; delete cssStyle.shadowColor; delete cssStyle.shadowOffset; delete cssStyle.shadowRadius; delete cssStyle.shadowOpacity; } // Handle elevation (Android) if (style.elevation !== undefined) { cssStyle.boxShadow = `0px ${style.elevation}px ${style.elevation * 2}px rgba(0,0,0,0.2)`; delete cssStyle.elevation; } // Remove React Native-specific properties that don't exist in CSS const rnOnlyProps = [ 'includeFontPadding', 'textAlignVertical', 'fontVariant', 'letterSpacing', // CSS has letter-spacing but RN uses letterSpacing 'lineHeight', // Can cause issues if not a number 'textDecorationColor', 'textDecorationStyle', 'writingDirection', 'backfaceVisibility', 'borderBottomEndRadius', 'borderBottomStartRadius', 'borderTopEndRadius', 'borderTopStartRadius', 'borderEndColor', 'borderStartColor', 'borderEndWidth', 'borderStartWidth', 'end', 'start', 'marginEnd', 'marginStart', 'paddingEnd', 'paddingStart', 'overlayColor', 'resizeMode', 'tintColor', ]; rnOnlyProps.forEach(prop => { delete cssStyle[prop]; }); // Ensure color values are strings if (cssStyle.backgroundColor && typeof cssStyle.backgroundColor === 'object') { cssStyle.backgroundColor = String(cssStyle.backgroundColor); } if (cssStyle.color && typeof cssStyle.color === 'object') { cssStyle.color = String(cssStyle.color); } // Ensure numeric values that should be strings with units if (typeof cssStyle.fontSize === 'number') { cssStyle.fontSize = `${cssStyle.fontSize}px`; } if (typeof cssStyle.lineHeight === 'number') { cssStyle.lineHeight = cssStyle.lineHeight; } // Final cleanup - remove any remaining invalid properties const cleanedStyle: any = {}; Object.keys(cssStyle).forEach(key => { const value = cssStyle[key]; // Debug logging for problematic values if (process.env.NODE_ENV === 'development' && (Array.isArray(value) || (typeof value === 'object' && value !== null && typeof value.valueOf === 'function'))) { console.warn(`Skipping invalid CSS property ${key}:`, value); } // Only keep primitive values and valid objects if ( typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value === null ) { cleanedStyle[key] = value; } else if ( typeof value === 'object' && value !== null && !Array.isArray(value) && typeof value.valueOf !== 'function' // Skip Animated.Value objects ) { // Allow plain objects (like shadowOffset) cleanedStyle[key] = value; } // Skip everything else (arrays, functions, Animated.Value, etc.) }); return cleanedStyle as React.CSSProperties; }; // Style processing helper const processStyle = (style: any): React.CSSProperties => { if (!style) return {}; if (Array.isArray(style)) { // Filter out falsy values and process each style return style .filter(s => s) // Remove null, undefined, false values .reduce((acc, s) => ({ ...acc, ...convertRNStyleToCSS(s) }), {}); } return convertRNStyleToCSS(style); }; // Mock Animated for web const MockAnimated = { View: ({ style, children, ...props }: any) => { const processedStyle = processStyle(style); return React.createElement( 'div', { style: processedStyle, ...props, }, children ); }, Text: ({ style, children, ...props }: any) => { const processedStyle = processStyle(style); return React.createElement( 'span', { style: processedStyle, ...props, }, children ); }, Value: class MockValue { constructor(public value: number) {} setValue(value: number) { this.value = value; } addListener() { return 'mock-listener'; } removeListener() {} interpolate(config: any) { // Simple mock interpolation for web return this.value; } // Add valueOf to make it work with CSS transforms valueOf() { return this.value; } toString() { return this.value.toString(); } }, timing: (animatedValue: any, config: any) => ({ start: (callback?: () => void) => { if (animatedValue && animatedValue.setValue) { animatedValue.setValue(config.toValue); } callback?.(); }, }), spring: (animatedValue: any, config: any) => ({ start: (callback?: () => void) => { if (animatedValue && animatedValue.setValue) { animatedValue.setValue(config.toValue); } callback?.(); }, }), loop: (animation: any) => ({ start: () => {} }), sequence: (animations: any[]) => ({ start: (callback?: () => void) => callback?.() }), decay: () => ({ start: (callback?: () => void) => callback?.() }), parallel: (animations: any[]) => ({ start: (callback?: () => void) => callback?.() }), stagger: (time: number, animations: any[]) => ({ start: (callback?: () => void) => callback?.(), }), delay: (time: number) => ({ start: (callback?: () => void) => callback?.() }), }; // Web-only components (no React Native dependencies) const WebComponents = { View: ({ style, children, onPress, ...props }: any) => { const processedStyle = processStyle(style); const webProps: any = { style: processedStyle, ...props }; if (onPress) { webProps.onClick = onPress; webProps.style = { ...processedStyle, cursor: 'pointer', userSelect: 'none', }; return React.createElement('button', webProps, children); } return React.createElement('div', webProps, children); }, Text: ({ style, children, numberOfLines, adjustsFontSizeToFit, minimumFontScale, ...props }: any) => { const processedStyle = processStyle(style); // Filter out React Native-specific props const webProps = { ...props }; delete webProps.numberOfLines; delete webProps.adjustsFontSizeToFit; delete webProps.minimumFontScale; return React.createElement('span', { style: processedStyle, ...webProps }, children); }, TextInput: ({ style, value, onChangeText, placeholder, multiline, numberOfLines, ...props }: any) => { const processedStyle = processStyle(style); const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { onChangeText?.(e.target.value); }; if (multiline) { return React.createElement('textarea', { style: { ...processedStyle, resize: 'vertical' }, value, onChange: handleChange, placeholder, rows: numberOfLines || 4, ...props, }); } return React.createElement('input', { style: processedStyle, type: 'text', value, onChange: handleChange, placeholder, ...props, }); }, TouchableOpacity: ({ style, children, onPress, disabled, onPressIn, onPressOut, activeOpacity, ...props }: any) => { const processedStyle = processStyle(style); // Filter out React Native-specific props const webProps = { ...props }; delete webProps.onPressIn; delete webProps.onPressOut; delete webProps.activeOpacity; return React.createElement( 'button', { style: { // Base button reset styles - be more specific about what we reset outline: 'none', background: 'transparent', padding: 0, userSelect: 'none', cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.6 : 1, // Component styles override base styles ...processedStyle, }, onClick: disabled ? undefined : onPress, disabled, ...webProps, }, children ); }, Pressable: ({ style, onPress, children, disabled, onPressIn, onPressOut, ...props }: any) => { const processedStyle = processStyle(style); // Filter out React Native-specific props const webProps = { ...props }; delete webProps.onPressIn; delete webProps.onPressOut; return React.createElement( 'button', { style: { ...processedStyle, cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.6 : 1, background: 'transparent', border: 'none', }, onClick: disabled ? undefined : onPress, disabled, ...webProps, }, children ); }, ScrollView: ({ style, children, horizontal, ...props }: any) => { const processedStyle = processStyle(style); return React.createElement( 'div', { style: { ...processedStyle, overflow: 'auto', display: horizontal ? 'flex' : 'block', flexDirection: horizontal ? 'row' : 'column', }, ...props, }, children ); }, SafeAreaView: ({ style, children, ...props }: any) => { const processedStyle = processStyle(style); return React.createElement( 'div', { style: { ...processedStyle, paddingTop: 'env(safe-area-inset-top)', paddingBottom: 'env(safe-area-inset-bottom)', paddingLeft: 'env(safe-area-inset-left)', paddingRight: 'env(safe-area-inset-right)', }, ...props, }, children ); }, Image: ({ style, source, ...props }: any) => { const processedStyle = processStyle(style); const src = typeof source === 'object' ? source.uri : source; return React.createElement('img', { style: processedStyle, src, ...props, }); }, ActivityIndicator: ({ color = '#000', size = 'medium', style }: any) => { const processedStyle = processStyle(style); const sizeValue = size === 'small' ? '16px' : size === 'large' ? '32px' : '24px'; return React.createElement('div', { style: { ...processedStyle, width: sizeValue, height: sizeValue, border: `2px solid ${color}30`, borderTop: `2px solid ${color}`, borderRadius: '50%', animation: 'baap-spin 1s linear infinite', }, }); }, Modal: ({ visible, transparent, animationType, onRequestClose, children, ...props }: any) => { // For React Native, this will be replaced by the actual RN Modal at runtime // For web, we use our custom modal implementation if (!visible) return null; return React.createElement( 'div', { style: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: transparent ? 'transparent' : 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, }, onClick: (e: React.MouseEvent) => { if (e.target === e.currentTarget) { onRequestClose?.(); } }, ...props, }, children ); }, Animated: MockAnimated, }; // Web-only APIs const WebAPIs = { StyleSheet: { create: <T extends { [key: string]: any }>(styles: T): T => { // For React Native, this will use the actual StyleSheet.create at runtime // For web, we just return the styles as-is return styles; }, flatten: (style: any) => { if (Array.isArray(style)) { return style.reduce((acc, s) => ({ ...acc, ...s }), {}); } return style || {}; }, }, Platform: { OS: (() => { // For React Native, this will be replaced with actual Platform.OS at runtime // For web, we always return 'web' return 'web'; })(), select: (options: { [key: string]: any }) => { const os = WebAPIs.Platform.OS; return options[os] || options.default; }, }, Dimensions: { get: (dimension: 'window' | 'screen') => { // For React Native, this will be replaced with actual Dimensions.get at runtime // For web, we use window dimensions if (typeof window !== 'undefined') { return { width: window.innerWidth, height: window.innerHeight, }; } return { width: 375, height: 667 }; }, }, }; // Export platform components and APIs (always web components) export const { View, Text, TextInput, TouchableOpacity, Pressable, ScrollView, SafeAreaView, Image, ActivityIndicator, Modal, Animated, } = WebComponents; export const { StyleSheet, Platform, Dimensions } = WebAPIs; // Export types - flexible style types that work with both RN and CSS export type ViewStyle = any; export type TextStyle = any; export type ImageStyle = any; export type TextProps = React.HTMLAttributes<HTMLSpanElement>; export type TextInputProps = React.InputHTMLAttributes<HTMLInputElement>; export type ScrollViewProps = React.HTMLAttributes<HTMLDivElement>; export type DimensionValue = string | number; export type StyleProp<T> = T | T[] | null | undefined; // Export platform info export { PlatformInfo }; // Add CSS animation for spinner if (typeof document !== 'undefined') { const style = document.createElement('style'); style.textContent = ` @keyframes baap-spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } `; document.head.appendChild(style); }