diff --git a/apps/docs/pages/docs/Components/_meta.en-US.json b/apps/docs/pages/docs/Components/_meta.en-US.json index 07e99f23..02a1cc42 100644 --- a/apps/docs/pages/docs/Components/_meta.en-US.json +++ b/apps/docs/pages/docs/Components/_meta.en-US.json @@ -5,6 +5,7 @@ "image": "Image", "divider": "Divider", "icon": "Icon", + "skeleton": "Skeleton", "spinner": "Spinner", "modal": "Modal", "draggable-modal": "DraggableModal" diff --git a/apps/docs/pages/docs/Components/skeleton.en-US.mdx b/apps/docs/pages/docs/Components/skeleton.en-US.mdx new file mode 100644 index 00000000..c48539b8 --- /dev/null +++ b/apps/docs/pages/docs/Components/skeleton.en-US.mdx @@ -0,0 +1,263 @@ +--- +searchable: true +--- + +import { CodeEditor } from '@components/code-editor'; +import PropsTable from "@components/docs/props-table"; + +# Skeleton + +The Skeleton component provides animated placeholders while content is loading. It supports different shapes and automatically adapts to dark mode. + +## Import + +```js +import { Skeleton, SkeletonProvider } from "react-native-ficus-ui"; +``` + +## Usage + +### Basic skeleton + + + + + +`} /> + +### Skeleton Box + + + + + +`} /> + +### Skeleton Text + + + + + + + +`} /> + +### Multi-line text skeleton + + + + + +`} /> + +### Skeleton Circle + + + + + + +`} /> + +### Loading state with content + + { + const timer = setTimeout(() => setIsLoaded(true), 3000); + return () => clearTimeout(timer); + }, []); + + return ( + + + + + This content appears when loaded! + + + + + This is a loaded text with large font size + + + + ); +}`} /> + +### Synchronized animation with SkeletonProvider + + + + + + + + + + + + + + + +`} /> + +### Card layout example + + + + + + + + + + + + + + + + + + + + + +`} /> + +### Custom shimmer settings + + + Default shimmer (enabled) + + + No shimmer animation + + + Custom animation duration + + + +`} /> + +## Props + +### Skeleton + +Extends every `Box` props. + +#### `isLoaded` + + +#### `shimmer` + + +#### `duration` + + +### Skeleton.Text + +Extends every `Skeleton` props. + +#### `fontSize` +", required: false, defaultValue: "'md'" }} +/> + +#### `noOfLines` + + +#### `lineSpacing` +", required: false, defaultValue: "'xs'" }} +/> + +### Skeleton.Circle + +Extends every `Skeleton` props. + +#### `boxSize` +", required: false, defaultValue: "40" }} +/> + +### SkeletonProvider + +#### `duration` + + +#### `paused` + + +## Accessibility + +The Skeleton component includes proper accessibility features: + +- Automatically sets appropriate `accessibilityLabel` when content is loading +- Maintains proper screen reader support +- Respects user's reduced motion preferences + +## Styling + +Skeleton components can be styled using all standard Ficus UI style props: + + + + + + + +`} /> + +## Performance + +The Skeleton component is optimized for performance: + +- Uses `react-native-reanimated` for 60fps animations +- Pure React Native implementation - no external native dependencies +- Lightweight shimmer effect with minimal CPU usage +- Works perfectly in Expo Go and all React Native environments \ No newline at end of file diff --git a/apps/examples/app/components/Skeleton.tsx b/apps/examples/app/components/Skeleton.tsx new file mode 100644 index 00000000..efe9d580 --- /dev/null +++ b/apps/examples/app/components/Skeleton.tsx @@ -0,0 +1,219 @@ +import React, { useState } from 'react'; + +import { + Box, + Button, + HStack, + SafeAreaBox, + ScrollBox, + Skeleton, + SkeletonCircle, + SkeletonProvider, + SkeletonText, + Text, + VStack, + useColorModeValue, +} from 'react-native-ficus-ui'; + +const SkeletonComponent = () => { + const [loading, setLoading] = useState(true); + + const toggleLoading = () => setLoading(!loading); + + return ( + + + + Skeleton Component + + + + + + {/* Basic Skeleton */} + + + Basic Skeleton + + + + + Content loaded! + + + Another line of content + + + + + + {/* Feed Skeleton */} + + + Feed Skeleton + + + + + + User Name + 2 hours ago + + + + + + + + + {/* Text Skeleton */} + + + Text Skeleton + + + + This is the first line of text content that was loaded. + This is the second line showing more content. + And this is the third line with even more information. + + + + + {/* Variants */} + + + Variants + + + pulse + + + + shine + + + + none + + + + + {/* Color Palette */} + + + Color Palettes + + + gray + + + + blue + + + + green + + + + + {/* Async Animations Demo */} + + + Async Animations (Staggered) + + + Notice how these skeletons appear with different delays + + + {Array.from({ length: 5 }, (_, i) => ( + + ))} + + + + {/* Synchronized Animations with Provider */} + + + Synchronized Animations (Provider) + + + All skeletons animate together with SkeletonProvider + + + + + + + + + + + + + + Synchronized content line 1 + Synchronized content line 2 + + + + + + + {/* Different Circle Sizes */} + + + Circle Sizes + + + + + + + + + + + {/* Card Layout Example */} + {/* Card Layout Example */} + + + Card Layout + + + + + + + + John Doe + + + + Software Engineer + + + + Passionate developer with years of experience + in React Native and mobile development. + + + + + + + + + + ); +}; + +export default SkeletonComponent; diff --git a/apps/examples/app/items.ts b/apps/examples/app/items.ts index 12d85cba..5e860754 100644 --- a/apps/examples/app/items.ts +++ b/apps/examples/app/items.ts @@ -30,6 +30,7 @@ import SwitchComponent from './components/Switch'; import CheckboxComponent from './components/Checkbox'; import RadioComponent from './components/Radio'; import SelectComponent from './components/Select'; +import SkeletonComponent from './components/Skeleton'; import TabsExampleComponent from './components/Tabs'; import ToastHook from './components/Toast'; @@ -63,6 +64,7 @@ export const components: ExampleComponentType[] = [ { navigationPath: 'ScrollBox', onScreenName: 'ScrollBox', component: ScrollBoxComponent }, { navigationPath: 'SectionList', onScreenName: 'SectionList', component: SectionListComponent }, { navigationPath: 'Select', onScreenName: 'Select', component: SelectComponent }, + { navigationPath: 'Skeleton', onScreenName: 'Skeleton', component: SkeletonComponent }, { navigationPath: 'Slider', onScreenName: 'Slider', component: SliderComponent }, { navigationPath: 'Spinner', onScreenName: 'Spinner', component: SpinnerComponent }, { navigationPath: 'Stack', onScreenName: 'Stack', component: StackComponent }, diff --git a/packages/react-native-ficus-ui/src/components/index.ts b/packages/react-native-ficus-ui/src/components/index.ts index 74a6def8..268fb529 100644 --- a/packages/react-native-ficus-ui/src/components/index.ts +++ b/packages/react-native-ficus-ui/src/components/index.ts @@ -30,6 +30,7 @@ export * from './input'; export * from './pin-input'; export * from './pin-input/pin-input-field'; export * from './select'; +export * from './skeleton'; export * from './tabs'; export { FicusProvider, type FicusProviderProps, ficus } from './system'; diff --git a/packages/react-native-ficus-ui/src/components/skeleton/index.tsx b/packages/react-native-ficus-ui/src/components/skeleton/index.tsx new file mode 100644 index 00000000..35aed540 --- /dev/null +++ b/packages/react-native-ficus-ui/src/components/skeleton/index.tsx @@ -0,0 +1,377 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from 'react'; + +import { LayoutChangeEvent, StyleSheet } from 'react-native'; +import Animated, { + Easing, + cancelAnimation, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; + +/** + * Skeleton component with pure React Native shimmer animation + * No external dependencies required - works everywhere including Expo Go + */ + +import { useColorMode } from '../../hooks'; +import { omitThemingProps } from '../../style-system'; +import { getColor, useTheme } from '../../theme'; +import { ficus, forwardRef, useStyleConfig } from '../system'; +import { + SkeletonBoxProps, + SkeletonCircleProps, + SkeletonContextValue, + SkeletonProps, + SkeletonProviderProps, + SkeletonTextProps, +} from './skeleton.types'; + +// Skeleton Context +const SkeletonContext = createContext({ + progress: null, + instanceCount: 0, +}); + +const SkeletonProvider = ({ + duration = 1200, + paused = false, + children, +}: SkeletonProviderProps) => { + const progress = useSharedValue(0); + const instanceCountRef = useRef(0); + + useEffect(() => { + if (paused) { + cancelAnimation(progress); + return; + } + progress.value = 0; + progress.value = withRepeat( + withTiming(1, { + duration: Math.max(400, duration), + easing: Easing.inOut(Easing.ease), + }), + -1, + false + ); + return () => cancelAnimation(progress); + }, [duration, paused, progress]); + + return ( + + {children} + + ); +}; +// Base Skeleton Component +const BaseSkeleton = forwardRef( + function BaseSkeleton(props, ref) { + // Extract theming props BEFORE omitThemingProps + const variant = props.variant || 'pulse'; + const colorPalette = props.colorPalette || 'gray'; + + const { + loading = true, + duration = 1200, + children, + ...rest + } = omitThemingProps(props); + + const [size, setSize] = useState({ width: 0, height: 0 }); + const ctx = useContext(SkeletonContext); + const globalProgress = ctx?.progress ?? null; + const { colorMode } = useColorMode(); + const { theme } = useTheme(); + + const localProgress = useSharedValue(0); + const styles = useStyleConfig('Skeleton', { + ...props, + variant, + colorScheme: colorPalette, // Pass colorPalette as colorScheme to theme + }); + + const onLayout = useCallback((e: LayoutChangeEvent) => { + const { width: w, height: h } = e.nativeEvent.layout; + setSize({ width: w, height: h }); + }, []); + + useEffect(() => { + if (variant === 'none' || !loading) { + return; + } + if (globalProgress) { + return; + } + + const startAnimation = () => { + localProgress.value = 0; + localProgress.value = withRepeat( + withTiming(1, { + duration: Math.max(800, duration), + easing: + variant === 'pulse' ? Easing.inOut(Easing.ease) : Easing.linear, + }), + -1, + false + ); + }; + + // Start animation immediately + startAnimation(); + + return () => { + cancelAnimation(localProgress); + }; + }, [variant, loading, globalProgress, duration, localProgress]); + + const pulseAnimationStyle = useAnimatedStyle(() => { + if (variant !== 'pulse') return {}; + const p = (globalProgress?.value ?? localProgress.value) || 0; + + // Smooth sine wave for more natural breathing effect + const sineWave = Math.sin(p * Math.PI * 2); + const normalizedSine = (sineWave + 1) / 2; // Convert -1,1 to 0,1 + + // More pronounced opacity range for better visibility + const opacity = 0.4 + normalizedSine * 0.6; // Animate from 0.4 to 1.0 + + return { opacity }; + }); + + const shineAnimationStyle = useAnimatedStyle(() => { + if (variant !== 'shine') return {}; + const p = (globalProgress?.value ?? localProgress.value) || 0; + + // Smooth easing for more natural shine movement + const easedProgress = p < 0.5 + ? 2 * p * p + : 1 - Math.pow(-2 * p + 2, 2) / 2; // EaseInOutQuad + + const translateX = (easedProgress - 0.5) * size.width * 2.5; // Slightly wider sweep + + return { transform: [{ translateX }] }; + }, [size.width]); + + // Enhanced shimmer styles with better gradients + const shimmerGradientStyle = useAnimatedStyle(() => { + if (variant !== 'shine') return {}; + const p = (globalProgress?.value ?? localProgress.value) || 0; + + // Dynamic opacity for shimmer overlay + const overlayOpacity = 0.6 + Math.sin(p * Math.PI * 2) * 0.2; + + return { opacity: overlayOpacity }; + }); + + // Enhanced pulse animation with color transition + const pulseColorStyle = useAnimatedStyle(() => { + if (variant !== 'pulse') return {}; + const p = (globalProgress?.value ?? localProgress.value) || 0; + + // Smooth sine wave for color transition + const sineWave = Math.sin(p * Math.PI * 2); + const normalizedSine = (sineWave + 1) / 2; + + // Interpolate between base and highlight colors for pulse effect + const colorIntensity = 0.7 + normalizedSine * 0.3; // 0.7 to 1.0 + + return { + backgroundColor: colorMode === 'dark' + ? `rgba(107, 114, 128, ${colorIntensity})` // gray.500 with varying opacity + : `rgba(229, 231, 235, ${colorIntensity})`, // gray.200 with varying opacity + }; + }); + + if (!loading) { + return <>{children}; + } + + // Enhanced color system with better contrast + const baseShimmerColor = getColor( + colorMode === 'dark' ? `${colorPalette}.600` : `${colorPalette}.200`, + theme.colors + ); + + const highlightShimmerColor = getColor( + colorMode === 'dark' ? `${colorPalette}.400` : `${colorPalette}.100`, + theme.colors + ); + + const shimmerBaseStyle = StyleSheet.create({ + shimmer: { + width: '100%', + height: '100%', + backgroundColor: highlightShimmerColor, + }, + }).shimmer; + + // For pulse variant, apply enhanced animation to the main element + if (variant === 'pulse') { + return ( + + + + + + ); + } + + return ( + + {variant === 'shine' && ( + + + + )} + {/* Note: variant 'none' just uses the base styles without any overlay */} + + ); + } +); // Skeleton Box +const SkeletonBox = forwardRef( + function SkeletonBox(props, ref) { + const defaultProps = { + h: 'lg', + borderRadius: 'md', + ...props, + }; + + return ; + } +); + +// Skeleton Text +const SkeletonText = forwardRef( + function SkeletonText(props, ref) { + const { + fontSize = 'md', + noOfLines = 1, + gap = '4', + loading = true, + children, + ...rest + } = props; + + const { theme } = useTheme(); + + const getHeight = (size: string | number): number => { + if (typeof size === 'number') return size; + // Use theme fontSizes with fallback values + const themeSize = theme.fontSizes?.[size] || theme.fontSizes?.md || 16; + return typeof themeSize === 'number' ? themeSize : 16; + }; + + const height = getHeight(fontSize as string); + + if (!loading) { + return <>{children}; + } + + if (noOfLines === 1) { + return ( + + {children} + + ); + } + + const spacingMap: Record = { + '1': 4, + '2': 8, + '3': 12, + '4': 16, + '5': 20, + '6': 24, + }; + + const spacing = + typeof gap === 'number' ? gap : spacingMap[gap as string] || 16; + + return ( + + {Array.from({ length: noOfLines }, (_, index) => ( + + + + ))} + + ); + } +); + +// Skeleton Circle +const SkeletonCircle = forwardRef( + function SkeletonCircle(props, ref) { + const { size = 40, ...rest } = props; + + return ( + + ); + } +); + +// Main Skeleton export +export const Skeleton = SkeletonBox; +export { SkeletonCircle }; +export { SkeletonText }; + +// Provider export +export { SkeletonProvider }; + +// Type exports +export type { + SkeletonProps, + SkeletonBoxProps, + SkeletonTextProps, + SkeletonCircleProps, + SkeletonProviderProps, +}; diff --git a/packages/react-native-ficus-ui/src/components/skeleton/skeleton.spec.tsx b/packages/react-native-ficus-ui/src/components/skeleton/skeleton.spec.tsx new file mode 100644 index 00000000..6f4160ae --- /dev/null +++ b/packages/react-native-ficus-ui/src/components/skeleton/skeleton.spec.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { Skeleton, SkeletonCircle, SkeletonText, SkeletonProvider } from './index'; +import { Text } from '../text'; + +// Mock react-native-reanimated +jest.mock('react-native-reanimated', () => { + const Reanimated = require('react-native-reanimated/mock'); + Reanimated.default.call = () => {}; + return { + ...Reanimated, + useSharedValue: jest.fn(() => ({ value: 0 })), + useAnimatedStyle: jest.fn(() => ({})), + withTiming: jest.fn((value) => value), + withRepeat: jest.fn((value) => value), + cancelAnimation: jest.fn(), + Easing: { + inOut: jest.fn(() => jest.fn()), + ease: jest.fn(), + linear: jest.fn(), + }, + }; +}); + +describe('Skeleton', () => { + it('renders skeleton when loading', () => { + const { queryByText } = render( + + Test content + + ); + + expect(queryByText('Test content')).toBeNull(); + }); + + it('renders content when not loading', () => { + const { getByText } = render( + + Test content + + ); + + expect(getByText('Test content')).toBeDefined(); + }); + + it('renders Skeleton correctly with variants', () => { + const { getByTestId: getByTestId1 } = render( + + ); + const { getByTestId: getByTestId2 } = render( + + ); + const { getByTestId: getByTestId3 } = render( + + ); + + expect(getByTestId1('skeleton-pulse')).toBeDefined(); + expect(getByTestId2('skeleton-shine')).toBeDefined(); + expect(getByTestId3('skeleton-none')).toBeDefined(); + }); + + it('renders SkeletonText with correct height based on fontSize', () => { + const { getByTestId } = render( + + ); + + expect(getByTestId('skeleton-text')).toBeDefined(); + }); + + it('renders multiple lines for SkeletonText when noOfLines > 1', () => { + const { getByTestId } = render( + + ); + + expect(getByTestId('skeleton-text-multiline')).toBeDefined(); + }); + + it('renders SkeletonCircle with correct size', () => { + const { getByTestId } = render( + + ); + + expect(getByTestId('skeleton-circle')).toBeDefined(); + }); + + it('renders SkeletonProvider with children', () => { + const { getByText } = render( + + Provider children + + ); + + expect(getByText('Provider children')).toBeDefined(); + }); + + it('applies colorPalette correctly', () => { + const { getByTestId } = render( + + ); + + expect(getByTestId('skeleton-colored')).toBeDefined(); + }); +}); diff --git a/packages/react-native-ficus-ui/src/components/skeleton/skeleton.types.ts b/packages/react-native-ficus-ui/src/components/skeleton/skeleton.types.ts new file mode 100644 index 00000000..c2cfb8eb --- /dev/null +++ b/packages/react-native-ficus-ui/src/components/skeleton/skeleton.types.ts @@ -0,0 +1,104 @@ +// packages/react-native-ficus-ui/src/components/skeleton/skeleton.types.ts +import { ReactNode } from 'react'; +import { NativeFicusProps } from '../system'; +import { ResponsiveValue } from '../../style-system'; + +export interface SkeletonProviderOptions { + /** + * Duration of the shimmer animation in milliseconds + * @default 1200 + */ + duration?: number; + + /** + * Whether the animation is paused + * @default false + */ + paused?: boolean; + + /** + * Children components + */ + children: ReactNode; +} + +export interface SkeletonOptions { + /** + * The loading state of the skeleton + * @default true + */ + loading?: boolean; + + /** + * The variant of the skeleton animation + * @default "pulse" + */ + variant?: 'pulse' | 'shine' | 'none'; + + /** + * The color palette of the component + * @default "gray" + */ + colorPalette?: 'gray' | 'red' | 'orange' | 'yellow' | 'green' | 'teal' | 'blue' | 'cyan' | 'purple' | 'pink'; + + /** + * Duration of the shimmer animation in milliseconds (overrides provider duration) + */ + duration?: number; + + /** + * Content to show when loaded + */ + children?: ReactNode; +} + +export interface SkeletonProviderProps + extends SkeletonProviderOptions {} + +export interface SkeletonProps + extends NativeFicusProps<'View'>, + SkeletonOptions { + /** + * The variant of the skeleton animation (overrides SkeletonOptions) + * @default "pulse" + */ + variant?: 'pulse' | 'shine' | 'none'; +} + +export interface SkeletonBoxProps extends SkeletonProps {} + +export interface SkeletonTextProps + extends NativeFicusProps<'View'>, + SkeletonOptions { + /** + * Font size to calculate height automatically + */ + fontSize?: ResponsiveValue; + + /** + * Number of lines for multi-line text skeleton + * @default 1 + */ + noOfLines?: number; + + /** + * Spacing between lines when noOfLines > 1 + * @default "4" + */ + gap?: ResponsiveValue; +} + +export interface SkeletonCircleProps + extends NativeFicusProps<'View'>, + SkeletonOptions { + /** + * Size of the circle (both width and height) + * @default "10" + */ + size?: ResponsiveValue; +} + +export interface SkeletonContextValue { + progress: any; // Animated.SharedValue | null + instanceCount: number; +} diff --git a/packages/react-native-ficus-ui/src/theme/components/index.ts b/packages/react-native-ficus-ui/src/theme/components/index.ts index 4f8f073c..79603f9a 100644 --- a/packages/react-native-ficus-ui/src/theme/components/index.ts +++ b/packages/react-native-ficus-ui/src/theme/components/index.ts @@ -9,6 +9,7 @@ import { pinInputFieldTheme, pinInputTheme } from './pin-input'; import { radioTheme } from './radio'; import { radioGroupTheme } from './radio-group'; import { selectTheme } from './select'; +import { Skeleton as skeletonTheme } from './skeleton'; import { sliderTheme } from './slider'; import { switchTheme } from './switch'; import { tabListTheme, tabsTheme } from './tabs'; @@ -24,6 +25,7 @@ export const components = { Radio: radioTheme, RadioGroup: radioGroupTheme, IconButton: iconButtonTheme, + Skeleton: skeletonTheme, Slider: sliderTheme, Switch: switchTheme, Input: inputTheme, diff --git a/packages/react-native-ficus-ui/src/theme/components/skeleton.ts b/packages/react-native-ficus-ui/src/theme/components/skeleton.ts new file mode 100644 index 00000000..82a03cd9 --- /dev/null +++ b/packages/react-native-ficus-ui/src/theme/components/skeleton.ts @@ -0,0 +1,40 @@ +import { defineStyle, defineStyleConfig } from '../../style-system'; + +const baseStyle = defineStyle({ + borderRadius: 'md', + h: 'lg', + w: '100%', +}); + +const variants = { + pulse: defineStyle((props) => { + const { colorScheme = 'gray', colorMode } = props; + return { + bg: colorMode === 'dark' ? `${colorScheme}.600` : `${colorScheme}.200`, + opacity: 1, + }; + }), + shine: defineStyle((props) => { + const { colorScheme = 'gray', colorMode } = props; + return { + bg: colorMode === 'dark' ? `${colorScheme}.600` : `${colorScheme}.200`, + opacity: 1, + }; + }), + none: defineStyle((props) => { + const { colorScheme = 'gray', colorMode } = props; + return { + bg: colorMode === 'dark' ? `${colorScheme}.600` : `${colorScheme}.200`, + opacity: 1, + }; + }), +}; + +export const Skeleton = defineStyleConfig({ + baseStyle, + variants, + defaultProps: { + variant: 'pulse', + colorScheme: 'gray', + }, +}); diff --git a/packages/react-native-ficus-ui/src/theme/foundations/typography.ts b/packages/react-native-ficus-ui/src/theme/foundations/typography.ts index bc651d40..265662be 100644 --- a/packages/react-native-ficus-ui/src/theme/foundations/typography.ts +++ b/packages/react-native-ficus-ui/src/theme/foundations/typography.ts @@ -1,6 +1,6 @@ const typography = { fontSizes: { - xs: 11, + xs: 10, sm: 12, md: 13, lg: 15, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0239e39f..b55b7fe1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5453,6 +5453,7 @@ packages: metro-react-native-babel-preset@0.77.0: resolution: {integrity: sha512-HPPD+bTxADtoE4y/4t1txgTQ1LVR6imOBy7RMHUsqMVTbekoi8Ph5YI9vKX2VMPtVWeFt0w9YnCSLPa76GcXsA==} engines: {node: '>=18'} + deprecated: Use @react-native/babel-preset instead peerDependencies: '@babel/core': '*' @@ -6465,6 +6466,7 @@ packages: react-native-vector-icons@10.2.0: resolution: {integrity: sha512-n5HGcxUuVaTf9QJPs/W22xQpC2Z9u0nb0KgLPnVltP8vdUvOp6+R26gF55kilP/fV4eL4vsAHUqUjewppJMBOQ==} + deprecated: react-native-vector-icons package has moved to a new model of per-icon-family packages. See the https://github.com/oblador/react-native-vector-icons/blob/master/MIGRATION.md on how to migrate hasBin: true react-native-web@0.19.12: