From 8db0511c05f0e51b363171d7c1180e1c7995fc13 Mon Sep 17 00:00:00 2001 From: omar-bear Date: Tue, 14 Oct 2025 14:22:45 +0100 Subject: [PATCH 1/6] feat: add Skeleton component with documentation and examples --- .../pages/docs/Components/_meta.en-US.json | 1 + .../pages/docs/Components/skeleton.en-US.mdx | 263 +++++++++++++++++ apps/examples/app/components/Skeleton.tsx | 165 +++++++++++ apps/examples/app/items.ts | 2 + .../src/components/index.ts | 1 + .../src/components/skeleton/index.tsx | 272 ++++++++++++++++++ .../src/components/skeleton/skeleton.spec.tsx | 117 ++++++++ .../src/components/skeleton/skeleton.types.ts | 92 ++++++ .../src/theme/components/index.ts | 2 + .../src/theme/components/skeleton.ts | 48 ++++ .../src/theme/foundations/typography.ts | 2 +- pnpm-lock.yaml | 2 + 12 files changed, 966 insertions(+), 1 deletion(-) create mode 100644 apps/docs/pages/docs/Components/skeleton.en-US.mdx create mode 100644 apps/examples/app/components/Skeleton.tsx create mode 100644 packages/react-native-ficus-ui/src/components/skeleton/index.tsx create mode 100644 packages/react-native-ficus-ui/src/components/skeleton/skeleton.spec.tsx create mode 100644 packages/react-native-ficus-ui/src/components/skeleton/skeleton.types.ts create mode 100644 packages/react-native-ficus-ui/src/theme/components/skeleton.ts 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..eb69f08f --- /dev/null +++ b/apps/examples/app/components/Skeleton.tsx @@ -0,0 +1,165 @@ +import React, { useState } from 'react'; + +import { + Box, + Button, + HStack, + SafeAreaBox, + ScrollBox, + Skeleton, + SkeletonProvider, + Text, + VStack, + useColorModeValue, +} from 'react-native-ficus-ui'; + +const SkeletonComponent = () => { + const [isLoaded, setIsLoaded] = useState(false); + + const toggleLoading = () => setIsLoaded(!isLoaded); + + return ( + + + + Skeleton Component + + + + + + + Basic Usage + + + + This is some content that will be loaded + + + + + + + + + Skeleton.Text Examples + + + + Small text content + + + + Large text content + + + + + This is a multi-line text + that spans across multiple lines + to demonstrate the skeleton effect + + + + + + Skeleton.Circle Examples + + + + + + + + + + + + + + + + + With SkeletonProvider (Synchronized Animation) + + + + + + + + + + + + User Name + + + + + + @username + + + + + + + + This is a user post content that + demonstrates synchronized skeleton loading. + + + + + + + Card Layout Example + + + + + + + + + + + Article Title + + + + + 2 hours ago + + + + + + + + + + + + This is the article content preview. + It shows how skeleton loading works + with complex card layouts. + + + + + + + + ); +}; + +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..ac7b11d7 --- /dev/null +++ b/packages/react-native-ficus-ui/src/components/skeleton/index.tsx @@ -0,0 +1,272 @@ +import React, { + createContext, + useCallback, + useContext, + useEffect, + 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 }); + +const SkeletonProvider = ({ + duration = 1200, + paused = false, + children, +}: SkeletonProviderProps) => { + const progress = useSharedValue(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) { + const { + shimmer = true, + duration = 1200, + isLoaded = false, + 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); + + const onLayout = useCallback((e: LayoutChangeEvent) => { + const { width: w, height: h } = e.nativeEvent.layout; + setSize({ width: w, height: h }); + }, []); + + useEffect(() => { + if (!shimmer) { + return; + } + if (globalProgress) { + return; + } + + localProgress.value = 0; + localProgress.value = withRepeat( + withTiming(1, { + duration: Math.max(400, duration), + easing: Easing.inOut(Easing.ease), + }), + -1, + false + ); + return () => cancelAnimation(localProgress); + }, [shimmer, globalProgress, duration, localProgress]); + + const shimmerStyle = useAnimatedStyle(() => { + const p = (globalProgress?.value ?? localProgress.value) || 0; + const start = -size.width - 60; + const end = size.width + 60; + const x = start + (end - start) * p; + return { transform: [{ translateX: x }] }; + }, [size.width]); + + if (isLoaded) { + return <>{children}; + } + + const backgroundColor = getColor( + colorMode === 'dark' ? 'gray.600' : 'gray.300', + theme.colors + ); + const shimmerColor = getColor( + colorMode === 'dark' ? 'gray.500' : 'gray.100', + theme.colors + ); + + const shimmerBaseStyle = StyleSheet.create({ + shimmer: { + width: Math.max(80, size.width * 0.35), + backgroundColor: shimmerColor, + opacity: 0.8, + }, + }).shimmer; + + return ( + + {shimmer && size.width > 0 && ( + + )} + + ); + } +); + +// Skeleton Box +const SkeletonBox = forwardRef( + function SkeletonBox(props, ref) { + const defaultProps = { + h: 20, + borderRadius: 'md', + ...props, + }; + + return ; + } +); + +// Skeleton Text +const SkeletonText = forwardRef( + function SkeletonText(props, ref) { + const { + fontSize = 'md', + noOfLines = 1, + lineSpacing = 'xs', + ...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 (noOfLines === 1) { + return ; + } + + const spacingMap: Record = { + xs: 4, + sm: 8, + md: 12, + lg: 16, + }; + + const spacing = + typeof lineSpacing === 'number' + ? lineSpacing + : spacingMap[lineSpacing as string] || 4; + + return ( + + {Array.from({ length: noOfLines }, (_, index) => ( + + + + ))} + + ); + } +); + +// Skeleton Circle +const SkeletonCircle = forwardRef( + function SkeletonCircle(props, ref) { + const { boxSize = 40, ...rest } = props; + + return ( + + ); + } +); + +// Main Skeleton export with attached components +export const Skeleton = Object.assign(SkeletonBox, { + Box: SkeletonBox, + Text: SkeletonText, + Circle: SkeletonCircle, +}); + +// Individual exports +export { SkeletonProvider }; +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..7de8a0c0 --- /dev/null +++ b/packages/react-native-ficus-ui/src/components/skeleton/skeleton.spec.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { Skeleton, 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(), + }, + }; +}); + +// Mock react-native-linear-gradient +jest.mock('react-native-linear-gradient', () => 'LinearGradient'); + +describe('Skeleton', () => { + it('renders skeleton when not loaded', () => { + const { queryByText } = render( + + Test content + + ); + + expect(queryByText('Test content')).toBeNull(); + }); + + it('renders content when loaded', () => { + const { getByText } = render( + + Test content + + ); + + expect(getByText('Test content')).toBeDefined(); + }); + + it('renders SkeletonBox correctly', () => { + const { getByTestId } = render( + + ); + + expect(getByTestId('skeleton-box')).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 shimmer animation by default', () => { + const { getByTestId } = render( + + ); + + expect(getByTestId('skeleton-shimmer')).toBeDefined(); + }); + + it('disables shimmer animation when shimmer is false', () => { + const { getByTestId } = render( + + ); + + expect(getByTestId('skeleton-no-shimmer')).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..3c8124b8 --- /dev/null +++ b/packages/react-native-ficus-ui/src/components/skeleton/skeleton.types.ts @@ -0,0 +1,92 @@ +// packages/react-native-ficus-ui/src/components/skeleton/skeleton.types.ts +import { ReactNode } from 'react'; +import { NativeFicusProps } from '../system'; +import { ThemingProps, 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 { + /** + * Whether to show shimmer animation + * @default true + */ + shimmer?: boolean; + + /** + * Duration of the shimmer animation in milliseconds (overrides provider duration) + */ + duration?: number; + + /** + * Whether the skeleton is loaded (shows content instead of skeleton) + * @default false + */ + isLoaded?: boolean; + + /** + * Content to show when loaded + */ + children?: ReactNode; +} + +export interface SkeletonProviderProps + extends SkeletonProviderOptions {} + +export interface SkeletonProps + extends NativeFicusProps<'View'>, + SkeletonOptions, + ThemingProps<'Skeleton'> {} + +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 "xs" + */ + lineSpacing?: ResponsiveValue; +} + +export interface SkeletonCircleProps + extends NativeFicusProps<'View'>, + SkeletonOptions { + /** + * Size of the circle (both width and height) + * @default 40 + */ + boxSize?: ResponsiveValue; +} + +export interface SkeletonContextValue { + progress: any; // Animated.SharedValue | null +} 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..4f998133 --- /dev/null +++ b/packages/react-native-ficus-ui/src/theme/components/skeleton.ts @@ -0,0 +1,48 @@ +import { defineStyle, defineStyleConfig } from '../../style-system'; + +const baseStyle = defineStyle({ + borderRadius: 'md', + h: 20, + w: '100%', +}); + +const variants = { + subtle: defineStyle((props) => { + return { + bg: props.colorMode === 'dark' ? 'gray.600' : 'gray.300', + }; + }), + solid: defineStyle((props) => { + return { + bg: props.colorMode === 'dark' ? 'gray.500' : 'gray.400', + }; + }), +}; + +const sizes = { + xs: defineStyle({ + h: 12, + }), + sm: defineStyle({ + h: 16, + }), + md: defineStyle({ + h: 20, + }), + lg: defineStyle({ + h: 24, + }), + xl: defineStyle({ + h: 28, + }), +}; + +export const Skeleton = defineStyleConfig({ + baseStyle, + variants, + sizes, + defaultProps: { + variant: 'subtle', + size: 'md', + }, +}); 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: From 038b65b99cf70a80cb3cfd0ab0c051b9c44914ba Mon Sep 17 00:00:00 2001 From: omar-bear Date: Tue, 14 Oct 2025 14:42:20 +0100 Subject: [PATCH 2/6] feat: enhance Skeleton.Text component with noOfLines and isLoaded props --- apps/examples/app/components/Skeleton.tsx | 2 +- .../src/components/skeleton/index.tsx | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/examples/app/components/Skeleton.tsx b/apps/examples/app/components/Skeleton.tsx index eb69f08f..568fa48d 100644 --- a/apps/examples/app/components/Skeleton.tsx +++ b/apps/examples/app/components/Skeleton.tsx @@ -51,7 +51,7 @@ const SkeletonComponent = () => { Small text content - + Large text content diff --git a/packages/react-native-ficus-ui/src/components/skeleton/index.tsx b/packages/react-native-ficus-ui/src/components/skeleton/index.tsx index ac7b11d7..a2492679 100644 --- a/packages/react-native-ficus-ui/src/components/skeleton/index.tsx +++ b/packages/react-native-ficus-ui/src/components/skeleton/index.tsx @@ -186,6 +186,8 @@ const SkeletonText = forwardRef( fontSize = 'md', noOfLines = 1, lineSpacing = 'xs', + isLoaded = false, + children, ...rest } = props; @@ -200,8 +202,21 @@ const SkeletonText = forwardRef( const height = getHeight(fontSize as string); + if (isLoaded) { + return <>{children}; + } + if (noOfLines === 1) { - return ; + return ( + + {children} + + ); } const spacingMap: Record = { From 1451cf1ef33f4b7f17a99e9ce404b7d85c512233 Mon Sep 17 00:00:00 2001 From: omar-bear Date: Tue, 14 Oct 2025 14:45:25 +0100 Subject: [PATCH 3/6] refactor: remove mock for react-native-linear-gradient from tests --- .../src/components/skeleton/skeleton.spec.tsx | 3 --- 1 file changed, 3 deletions(-) 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 index 7de8a0c0..6cd00767 100644 --- a/packages/react-native-ficus-ui/src/components/skeleton/skeleton.spec.tsx +++ b/packages/react-native-ficus-ui/src/components/skeleton/skeleton.spec.tsx @@ -21,9 +21,6 @@ jest.mock('react-native-reanimated', () => { }; }); -// Mock react-native-linear-gradient -jest.mock('react-native-linear-gradient', () => 'LinearGradient'); - describe('Skeleton', () => { it('renders skeleton when not loaded', () => { const { queryByText } = render( From f73e3290aab7e6af4f1a48b9e1cc9ce5ef90ec88 Mon Sep 17 00:00:00 2001 From: omar-bear Date: Tue, 14 Oct 2025 14:47:38 +0100 Subject: [PATCH 4/6] fix: add missing key prop to SkeletonText component's child elements --- .../react-native-ficus-ui/src/components/skeleton/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-ficus-ui/src/components/skeleton/index.tsx b/packages/react-native-ficus-ui/src/components/skeleton/index.tsx index a2492679..7daa64a2 100644 --- a/packages/react-native-ficus-ui/src/components/skeleton/index.tsx +++ b/packages/react-native-ficus-ui/src/components/skeleton/index.tsx @@ -235,12 +235,12 @@ const SkeletonText = forwardRef( {Array.from({ length: noOfLines }, (_, index) => ( Date: Tue, 14 Oct 2025 15:42:02 +0100 Subject: [PATCH 5/6] refactor: remove unused background color and size definitions from Skeleton component --- .../src/components/skeleton/index.tsx | 5 ----- .../src/theme/components/skeleton.ts | 20 ------------------- 2 files changed, 25 deletions(-) diff --git a/packages/react-native-ficus-ui/src/components/skeleton/index.tsx b/packages/react-native-ficus-ui/src/components/skeleton/index.tsx index 7daa64a2..162d366b 100644 --- a/packages/react-native-ficus-ui/src/components/skeleton/index.tsx +++ b/packages/react-native-ficus-ui/src/components/skeleton/index.tsx @@ -125,10 +125,6 @@ const BaseSkeleton = forwardRef( return <>{children}; } - const backgroundColor = getColor( - colorMode === 'dark' ? 'gray.600' : 'gray.300', - theme.colors - ); const shimmerColor = getColor( colorMode === 'dark' ? 'gray.500' : 'gray.100', theme.colors @@ -147,7 +143,6 @@ const BaseSkeleton = forwardRef( ref={ref} onLayout={onLayout} overflow="hidden" - backgroundColor={backgroundColor} __styles={styles} {...rest} > diff --git a/packages/react-native-ficus-ui/src/theme/components/skeleton.ts b/packages/react-native-ficus-ui/src/theme/components/skeleton.ts index 4f998133..13ea9df5 100644 --- a/packages/react-native-ficus-ui/src/theme/components/skeleton.ts +++ b/packages/react-native-ficus-ui/src/theme/components/skeleton.ts @@ -19,30 +19,10 @@ const variants = { }), }; -const sizes = { - xs: defineStyle({ - h: 12, - }), - sm: defineStyle({ - h: 16, - }), - md: defineStyle({ - h: 20, - }), - lg: defineStyle({ - h: 24, - }), - xl: defineStyle({ - h: 28, - }), -}; - export const Skeleton = defineStyleConfig({ baseStyle, variants, - sizes, defaultProps: { variant: 'subtle', - size: 'md', }, }); From 115a95261f4c0a6714714dd773af4d58efbc7806 Mon Sep 17 00:00:00 2001 From: omar-bear Date: Wed, 29 Oct 2025 12:54:36 +0100 Subject: [PATCH 6/6] feat: enhance Skeleton component with loading state, variants, and improved animations --- apps/examples/app/components/Skeleton.tsx | 282 +++++++++++------- .../src/components/skeleton/index.tsx | 221 ++++++++++---- .../src/components/skeleton/skeleton.spec.tsx | 55 ++-- .../src/components/skeleton/skeleton.types.ts | 40 ++- .../src/theme/components/skeleton.ts | 24 +- 5 files changed, 398 insertions(+), 224 deletions(-) diff --git a/apps/examples/app/components/Skeleton.tsx b/apps/examples/app/components/Skeleton.tsx index 568fa48d..efe9d580 100644 --- a/apps/examples/app/components/Skeleton.tsx +++ b/apps/examples/app/components/Skeleton.tsx @@ -7,16 +7,18 @@ import { SafeAreaBox, ScrollBox, Skeleton, + SkeletonCircle, SkeletonProvider, + SkeletonText, Text, VStack, useColorModeValue, } from 'react-native-ficus-ui'; const SkeletonComponent = () => { - const [isLoaded, setIsLoaded] = useState(false); + const [loading, setLoading] = useState(true); - const toggleLoading = () => setIsLoaded(!isLoaded); + const toggleLoading = () => setLoading(!loading); return ( @@ -27,135 +29,187 @@ const SkeletonComponent = () => { - - Basic Usage - - - - This is some content that will be loaded - - - - - + {/* Basic Skeleton */} + + + Basic Skeleton + + + + + Content loaded! + + + Another line of content + + + - - Skeleton.Text Examples - - - - Small text content - - - - Large text content - + {/* Feed Skeleton */} + + + Feed Skeleton + + + + + + User Name + 2 hours ago + + + + + + + - - - This is a multi-line text - that spans across multiple lines - to demonstrate the skeleton effect + {/* 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. - + - - Skeleton.Circle Examples - - - - - - - - - - - - - - - - - With SkeletonProvider (Synchronized Animation) - - - - - - - - - - - - User Name - - + {/* Variants */} + + + Variants + + + pulse + + + + shine + + + + none + + + - - - @username - - - - + {/* Color Palette */} + + + Color Palettes + + + gray + + + + blue + + + + green + + + - - - This is a user post content that - demonstrates synchronized skeleton loading. - - + {/* Async Animations Demo */} + + + Async Animations (Staggered) + + + Notice how these skeletons appear with different delays + + + {Array.from({ length: 5 }, (_, i) => ( + + ))} - + - - Card Layout Example - - - - - - - + {/* Synchronized Animations with Provider */} + + + Synchronized Animations (Provider) + + + All skeletons animate together with SkeletonProvider + + + + + + + + + + + + + + Synchronized content line 1 + Synchronized content line 2 + + + + + - - - Article Title - + {/* Different Circle Sizes */} + + + Circle Sizes + + + + + + + + + - - - 2 hours ago + {/* Card Layout Example */} + {/* Card Layout Example */} + + + Card Layout + + + + + + + + John Doe - + + + Software Engineer + + + + Passionate developer with years of experience + in React Native and mobile development. + + - - - - - - - - This is the article content preview. - It shows how skeleton loading works - with complex card layouts. - - - - + + diff --git a/packages/react-native-ficus-ui/src/components/skeleton/index.tsx b/packages/react-native-ficus-ui/src/components/skeleton/index.tsx index 162d366b..35aed540 100644 --- a/packages/react-native-ficus-ui/src/components/skeleton/index.tsx +++ b/packages/react-native-ficus-ui/src/components/skeleton/index.tsx @@ -3,6 +3,7 @@ import React, { useCallback, useContext, useEffect, + useRef, useState, } from 'react'; @@ -35,7 +36,10 @@ import { } from './skeleton.types'; // Skeleton Context -const SkeletonContext = createContext({ progress: null }); +const SkeletonContext = createContext({ + progress: null, + instanceCount: 0, +}); const SkeletonProvider = ({ duration = 1200, @@ -43,6 +47,7 @@ const SkeletonProvider = ({ children, }: SkeletonProviderProps) => { const progress = useSharedValue(0); + const instanceCountRef = useRef(0); useEffect(() => { if (paused) { @@ -62,19 +67,26 @@ const SkeletonProvider = ({ }, [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 { - shimmer = true, + loading = true, duration = 1200, - isLoaded = false, children, ...rest } = omitThemingProps(props); @@ -86,7 +98,11 @@ const BaseSkeleton = forwardRef( const { theme } = useTheme(); const localProgress = useSharedValue(0); - const styles = useStyleConfig('Skeleton', props); + 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; @@ -94,50 +110,132 @@ const BaseSkeleton = forwardRef( }, []); useEffect(() => { - if (!shimmer) { + if (variant === 'none' || !loading) { return; } if (globalProgress) { return; } - localProgress.value = 0; - localProgress.value = withRepeat( - withTiming(1, { - duration: Math.max(400, duration), - easing: Easing.inOut(Easing.ease), - }), - -1, - false - ); - return () => cancelAnimation(localProgress); - }, [shimmer, globalProgress, duration, localProgress]); - - const shimmerStyle = useAnimatedStyle(() => { + 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; - const start = -size.width - 60; - const end = size.width + 60; - const x = start + (end - start) * p; - return { transform: [{ translateX: x }] }; + + // 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]); - if (isLoaded) { + // 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}; } - const shimmerColor = getColor( - colorMode === 'dark' ? 'gray.500' : 'gray.100', + // 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: Math.max(80, size.width * 0.35), - backgroundColor: shimmerColor, - opacity: 0.8, + width: '100%', + height: '100%', + backgroundColor: highlightShimmerColor, }, }).shimmer; + // For pulse variant, apply enhanced animation to the main element + if (variant === 'pulse') { + return ( + + + + + + ); + } + return ( ( __styles={styles} {...rest} > - {shimmer && size.width > 0 && ( + {variant === 'shine' && ( + > + + )} + {/* Note: variant 'none' just uses the base styles without any overlay */} ); } -); - -// Skeleton Box +); // Skeleton Box const SkeletonBox = forwardRef( function SkeletonBox(props, ref) { const defaultProps = { - h: 20, + h: 'lg', borderRadius: 'md', ...props, }; @@ -180,8 +279,8 @@ const SkeletonText = forwardRef( const { fontSize = 'md', noOfLines = 1, - lineSpacing = 'xs', - isLoaded = false, + gap = '4', + loading = true, children, ...rest } = props; @@ -197,7 +296,7 @@ const SkeletonText = forwardRef( const height = getHeight(fontSize as string); - if (isLoaded) { + if (!loading) { return <>{children}; } @@ -207,6 +306,7 @@ const SkeletonText = forwardRef( ref={ref} h={height} borderRadius="sm" + loading={loading} {...rest} > {children} @@ -215,16 +315,16 @@ const SkeletonText = forwardRef( } const spacingMap: Record = { - xs: 4, - sm: 8, - md: 12, - lg: 16, + '1': 4, + '2': 8, + '3': 12, + '4': 16, + '5': 20, + '6': 24, }; const spacing = - typeof lineSpacing === 'number' - ? lineSpacing - : spacingMap[lineSpacing as string] || 4; + typeof gap === 'number' ? gap : spacingMap[gap as string] || 16; return ( @@ -239,6 +339,7 @@ const SkeletonText = forwardRef( h={height} borderRadius="sm" flex={1} + loading={loading} /> ))} @@ -250,29 +351,23 @@ const SkeletonText = forwardRef( // Skeleton Circle const SkeletonCircle = forwardRef( function SkeletonCircle(props, ref) { - const { boxSize = 40, ...rest } = props; + const { size = 40, ...rest } = props; return ( - + ); } ); -// Main Skeleton export with attached components -export const Skeleton = Object.assign(SkeletonBox, { - Box: SkeletonBox, - Text: SkeletonText, - Circle: SkeletonCircle, -}); +// Main Skeleton export +export const Skeleton = SkeletonBox; +export { SkeletonCircle }; +export { SkeletonText }; -// Individual exports +// Provider export export { SkeletonProvider }; + +// Type exports export type { SkeletonProps, SkeletonBoxProps, 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 index 6cd00767..6f4160ae 100644 --- a/packages/react-native-ficus-ui/src/components/skeleton/skeleton.spec.tsx +++ b/packages/react-native-ficus-ui/src/components/skeleton/skeleton.spec.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { render } from '@testing-library/react-native'; -import { Skeleton, SkeletonProvider } from './index'; +import { Skeleton, SkeletonCircle, SkeletonText, SkeletonProvider } from './index'; import { Text } from '../text'; // Mock react-native-reanimated @@ -17,14 +17,15 @@ jest.mock('react-native-reanimated', () => { Easing: { inOut: jest.fn(() => jest.fn()), ease: jest.fn(), + linear: jest.fn(), }, }; }); describe('Skeleton', () => { - it('renders skeleton when not loaded', () => { + it('renders skeleton when loading', () => { const { queryByText } = render( - + Test content ); @@ -32,9 +33,9 @@ describe('Skeleton', () => { expect(queryByText('Test content')).toBeNull(); }); - it('renders content when loaded', () => { + it('renders content when not loading', () => { const { getByText } = render( - + Test content ); @@ -42,20 +43,28 @@ describe('Skeleton', () => { expect(getByText('Test content')).toBeDefined(); }); - it('renders SkeletonBox correctly', () => { - const { getByTestId } = render( - + it('renders Skeleton correctly with variants', () => { + const { getByTestId: getByTestId1 } = render( + + ); + const { getByTestId: getByTestId2 } = render( + + ); + const { getByTestId: getByTestId3 } = render( + ); - expect(getByTestId('skeleton-box')).toBeDefined(); + 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( - ); @@ -64,10 +73,10 @@ describe('Skeleton', () => { it('renders multiple lines for SkeletonText when noOfLines > 1', () => { const { getByTestId } = render( - ); @@ -76,10 +85,10 @@ describe('Skeleton', () => { it('renders SkeletonCircle with correct size', () => { const { getByTestId } = render( - ); @@ -96,19 +105,11 @@ describe('Skeleton', () => { expect(getByText('Provider children')).toBeDefined(); }); - it('applies shimmer animation by default', () => { - const { getByTestId } = render( - - ); - - expect(getByTestId('skeleton-shimmer')).toBeDefined(); - }); - - it('disables shimmer animation when shimmer is false', () => { + it('applies colorPalette correctly', () => { const { getByTestId } = render( - + ); - expect(getByTestId('skeleton-no-shimmer')).toBeDefined(); + 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 index 3c8124b8..c2cfb8eb 100644 --- a/packages/react-native-ficus-ui/src/components/skeleton/skeleton.types.ts +++ b/packages/react-native-ficus-ui/src/components/skeleton/skeleton.types.ts @@ -1,7 +1,7 @@ // packages/react-native-ficus-ui/src/components/skeleton/skeleton.types.ts import { ReactNode } from 'react'; import { NativeFicusProps } from '../system'; -import { ThemingProps, ResponsiveValue } from '../../style-system'; +import { ResponsiveValue } from '../../style-system'; export interface SkeletonProviderOptions { /** @@ -24,21 +24,27 @@ export interface SkeletonProviderOptions { export interface SkeletonOptions { /** - * Whether to show shimmer animation + * The loading state of the skeleton * @default true */ - shimmer?: boolean; + loading?: boolean; /** - * Duration of the shimmer animation in milliseconds (overrides provider duration) + * The variant of the skeleton animation + * @default "pulse" */ - duration?: number; + variant?: 'pulse' | 'shine' | 'none'; /** - * Whether the skeleton is loaded (shows content instead of skeleton) - * @default false + * 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) */ - isLoaded?: boolean; + duration?: number; /** * Content to show when loaded @@ -51,8 +57,13 @@ export interface SkeletonProviderProps export interface SkeletonProps extends NativeFicusProps<'View'>, - SkeletonOptions, - ThemingProps<'Skeleton'> {} + SkeletonOptions { + /** + * The variant of the skeleton animation (overrides SkeletonOptions) + * @default "pulse" + */ + variant?: 'pulse' | 'shine' | 'none'; +} export interface SkeletonBoxProps extends SkeletonProps {} @@ -72,9 +83,9 @@ export interface SkeletonTextProps /** * Spacing between lines when noOfLines > 1 - * @default "xs" + * @default "4" */ - lineSpacing?: ResponsiveValue; + gap?: ResponsiveValue; } export interface SkeletonCircleProps @@ -82,11 +93,12 @@ export interface SkeletonCircleProps SkeletonOptions { /** * Size of the circle (both width and height) - * @default 40 + * @default "10" */ - boxSize?: ResponsiveValue; + size?: ResponsiveValue; } export interface SkeletonContextValue { progress: any; // Animated.SharedValue | null + instanceCount: number; } diff --git a/packages/react-native-ficus-ui/src/theme/components/skeleton.ts b/packages/react-native-ficus-ui/src/theme/components/skeleton.ts index 13ea9df5..82a03cd9 100644 --- a/packages/react-native-ficus-ui/src/theme/components/skeleton.ts +++ b/packages/react-native-ficus-ui/src/theme/components/skeleton.ts @@ -2,19 +2,30 @@ import { defineStyle, defineStyleConfig } from '../../style-system'; const baseStyle = defineStyle({ borderRadius: 'md', - h: 20, + h: 'lg', w: '100%', }); const variants = { - subtle: defineStyle((props) => { + pulse: defineStyle((props) => { + const { colorScheme = 'gray', colorMode } = props; return { - bg: props.colorMode === 'dark' ? 'gray.600' : 'gray.300', + bg: colorMode === 'dark' ? `${colorScheme}.600` : `${colorScheme}.200`, + opacity: 1, }; }), - solid: defineStyle((props) => { + shine: defineStyle((props) => { + const { colorScheme = 'gray', colorMode } = props; return { - bg: props.colorMode === 'dark' ? 'gray.500' : 'gray.400', + 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, }; }), }; @@ -23,6 +34,7 @@ export const Skeleton = defineStyleConfig({ baseStyle, variants, defaultProps: { - variant: 'subtle', + variant: 'pulse', + colorScheme: 'gray', }, });