Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

Репозиторий UI kit на основе Prime Faces, Prime Flex для React Native

Документация компонентов:

- [Skeleton](src/components/Skeleton/Skeleton.md)

## Installation

**Yarn**
Expand Down
6 changes: 3 additions & 3 deletions design-tokens/input/tokens.json
Original file line number Diff line number Diff line change
Expand Up @@ -4269,8 +4269,8 @@
},
"skeleton": {
"extend": {
"minWidth": "{dimension.size.900}",
"height": "{dimension.size.900}"
"minWidth": "{dimension.size.400}",
"minHeight": "{dimension.size.400}"
},
"colorScheme": {
"light": {
Expand All @@ -4286,7 +4286,7 @@
}
}
},
"root": { "borderRadius": "{dimension.borderRadius.400}" }
"root": { "borderRadius": "{dimension.borderRadius.350}" }
},
"slider": {
"colorScheme": {
Expand Down
44 changes: 44 additions & 0 deletions src/components/Skeleton/Skeleton.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Skeleton

Декоративный плейсхолдер с анимацией градиента для загружаемого контента.

## Использование

```tsx
import { View } from 'react-native'
import { Skeleton } from '@cdek-it/react-native-ui-kit'

export function ProfileLoading() {
return (
<View
accessible
accessibilityLabel='Загрузка профиля'
accessibilityState={{ busy: true }}
style={{ gap: 8 }}
>
<Skeleton style={{ width: 48, height: 48, borderRadius: 24 }} />
<Skeleton style={{ width: 160, height: 16 }} />
</View>
)
}
```

Используйте внутри `ThemeContextProvider` UI Kit: Skeleton в нём анимируются
синхронно. Размер и форму задавайте через `style`. Размеры по умолчанию не
заданы.

## Props

Собственных props нет. Поддерживаются нативные props View, кроме `onLayout`.

Полный контракт — [SkeletonProps](./Skeleton.tsx).

## Доступность

Skeleton декоративный: `accessible` всегда `false`. Описание, роль и состояние
загрузки задавайте общему контейнеру. После загрузки замените плейсхолдеры
контентом, обновите описание контейнера и сбросьте `accessibilityState.busy`.

## Макет

[Skeleton в Figma](https://www.figma.com/design/Q1BWgZ7zoV5UzlBOnjW0cM/UI-Kit--DS--v2.1?node-id=5221-12465&m=dev)
7 changes: 6 additions & 1 deletion src/components/Skeleton/Skeleton.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@ const meta: Meta<typeof Skeleton> = {
title: 'Misc/Skeleton',
component: Skeleton,
render: () => (
<View style={{ gap: 10 }}>
<View
accessible
accessibilityLabel='Загрузка контента'
accessibilityState={{ busy: true }}
style={{ gap: 10 }}
>
<Skeleton style={{ height: 50 }} />
<View style={styles.row}>
<Skeleton style={{ height: 50, width: 100 }} />
Expand Down
11 changes: 5 additions & 6 deletions src/components/Skeleton/Skeleton.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,20 @@
import { memo, useCallback, useContext, useEffect, useRef } from 'react'
import { View } from 'react-native'
import { View, type ViewProps } from 'react-native'
import Animated, {
useAnimatedStyle,
useSharedValue,
} from 'react-native-reanimated'
import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg'
import type { ViewProps } from 'react-native-svg/lib/typescript/fabric/utils'

import { StyleSheet } from 'react-native-unistyles'

import { SkeletonContext } from '../../utils/SkeletonContext'

interface SkeletonProps extends ViewProps {}
export interface SkeletonProps extends Omit<ViewProps, 'onLayout'> {}

/**
* Используется для отображения контента в момент загрузки
* @see https://www.figma.com/design/4TYeki0MDLhfPGJstbIicf/UI-kit-PrimeFace-(DS)?node-id=5241-3731
* @see https://www.figma.com/design/Q1BWgZ7zoV5UzlBOnjW0cM/UI-Kit--DS--v2.1?node-id=24043-15545
* @see https://www.figma.com/design/Q1BWgZ7zoV5UzlBOnjW0cM/UI-Kit--DS--v2.1?node-id=5221-12465
*/
export const Skeleton = memo<SkeletonProps>(({ style, testID, ...rest }) => {
const {
Expand Down Expand Up @@ -48,6 +46,7 @@ export const Skeleton = memo<SkeletonProps>(({ style, testID, ...rest }) => {
return (
<View
{...rest}
accessible={false}
ref={skeletonRef}
style={[styles.container, style]}
testID={testID ?? SkeletonTestId.root}
Expand All @@ -61,7 +60,7 @@ export const Skeleton = memo<SkeletonProps>(({ style, testID, ...rest }) => {
]}
testID={SkeletonTestId.animatedView}
>
<Svg testID={SkeletonTestId.svg}>
<Svg accessible={false} testID={SkeletonTestId.svg}>
<Defs>
<LinearGradient id='gradient' x1='0' x2='1' y1='1' y2='1'>
<Stop
Expand Down
57 changes: 57 additions & 0 deletions src/components/Skeleton/__tests__/Skeleton.animation.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { act, fireEvent, render, waitFor } from '@testing-library/react-native'
import { View } from 'react-native'
import { makeMutable } from 'react-native-reanimated'

import { SkeletonContext } from '../../../utils/SkeletonContext'
import { Skeleton, SkeletonTestId } from '../Skeleton'

describe('Skeleton animation', () => {
afterEach(() => {
jest.restoreAllMocks()
})

test('сдвигает градиент с учётом общей координаты и изменений позиции Skeleton', async () => {
const globalTranslateX = makeMutable(100)
jest
.spyOn(View.prototype, 'measure')
.mockImplementationOnce((callback) => callback(0, 0, 100, 20, 40, 0))
.mockImplementationOnce((callback) => callback(0, 0, 100, 20, 80, 0))

const { getByTestId } = render(
<SkeletonContext.Provider
value={{
globalTranslateX,
registerSkeleton: jest.fn(),
unregisterSkeleton: jest.fn(),
skeletonWidth: 400,
}}
>
<Skeleton />
</SkeletonContext.Provider>
)
const gradient = getByTestId(SkeletonTestId.animatedView)
const root = getByTestId(SkeletonTestId.root)

expect(gradient).toHaveAnimatedStyle({ transform: [{ translateX: 100 }] })

act(() => {
globalTranslateX.value = 200
})

await waitFor(() => {
expect(gradient).toHaveAnimatedStyle({ transform: [{ translateX: 200 }] })
})

fireEvent(root, 'layout')

await waitFor(() => {
expect(gradient).toHaveAnimatedStyle({ transform: [{ translateX: 160 }] })
})

fireEvent(root, 'layout')

await waitFor(() => {
expect(gradient).toHaveAnimatedStyle({ transform: [{ translateX: 120 }] })
})
})
})
106 changes: 43 additions & 63 deletions src/components/Skeleton/__tests__/Skeleton.test.tsx
Original file line number Diff line number Diff line change
@@ -1,83 +1,63 @@
import { render } from '@testing-library/react-native'
import { useMemo, type FC, type ReactNode } from 'react'
import { View } from 'react-native'
import type { SharedValue } from 'react-native-reanimated'

import { SkeletonContext } from '../../../utils/SkeletonContext'
import { Skeleton } from '../Skeleton'

const createTestSharedValue = (value: number): SharedValue<number> =>
({ value }) as SharedValue<number>

const registerSkeleton = jest.fn()
const unregisterSkeleton = jest.fn()

const TestWrapper: FC<{
readonly children: ReactNode
readonly globalTranslateXValue: number
}> = ({ children, globalTranslateXValue }) => {
const contextValue = useMemo(
() => ({
globalTranslateX: createTestSharedValue(globalTranslateXValue),
registerSkeleton,
unregisterSkeleton,
skeletonWidth: 500,
}),
[globalTranslateXValue]
)

return (
<SkeletonContext.Provider value={contextValue}>
{children}
</SkeletonContext.Provider>
)
}
import { SkeletonTestId } from '../Skeleton'
import { Skeleton } from '../index'

describe('Skeleton', () => {
test('before animation started', () => {
const { toJSON } = render(<Skeleton style={{ height: 50 }} />, {
wrapper: ({ children }) => (
<TestWrapper globalTranslateXValue={-200}>{children}</TestWrapper>
),
})

expect(toJSON()).toMatchSnapshot('before animation started')
test('передаёт свойства корневому View и сохраняет пользовательский testID', () => {
const { getByTestId } = render(
<Skeleton nativeID='SkeletonPlaceholder' testID='CustomSkeleton' />
)

expect(getByTestId('CustomSkeleton')).toHaveProp(
'nativeID',
'SkeletonPlaceholder'
)
})

test('50% animation', () => {
const { toJSON } = render(<Skeleton style={{ height: 50 }} />, {
wrapper: ({ children }) => (
<TestWrapper globalTranslateXValue={100}>{children}</TestWrapper>
),
})
test('остаётся декоративным даже при переданном accessible', () => {
const { getByTestId } = render(<Skeleton accessible />)

expect(toJSON()).toMatchSnapshot()
})

test('100% animation', () => {
const { toJSON } = render(<Skeleton style={{ height: 50 }} />, {
wrapper: ({ children }) => (
<TestWrapper globalTranslateXValue={400}>{children}</TestWrapper>
),
})
const root = getByTestId(SkeletonTestId.root)
const svg = getByTestId(SkeletonTestId.svg)

expect(toJSON()).toMatchSnapshot()
expect(root.props.accessible).toBeFalse()
expect(svg.props.accessible).toBeFalse()
expect(root.props.accessibilityRole).toBeUndefined()
expect(root.props.accessibilityLabel).toBeUndefined()
expect(root.props.accessibilityState).toBeUndefined()
})

test('skeleton registration', () => {
registerSkeleton.mockReset()
unregisterSkeleton.mockClear()
test('регистрируется при монтировании и снимает регистрацию при удалении', () => {
const registerSkeleton = jest.fn()
const unregisterSkeleton = jest.fn()
const contextValue = {
globalTranslateX: { value: 0 } as SharedValue<number>,
registerSkeleton,
unregisterSkeleton,
skeletonWidth: 500,
}
const { rerender, unmount } = render(
<SkeletonContext.Provider value={contextValue}>
<Skeleton />
</SkeletonContext.Provider>
)

expect(registerSkeleton).toHaveBeenCalledOnce()
expect(unregisterSkeleton).not.toHaveBeenCalled()

const { rerender } = render(<Skeleton style={{ height: 50 }} />, {
wrapper: ({ children }) => (
<TestWrapper globalTranslateXValue={0}>{children}</TestWrapper>
),
})
rerender(
<SkeletonContext.Provider value={contextValue}>
<Skeleton nativeID='UpdatedSkeleton' />
</SkeletonContext.Provider>
)

expect(registerSkeleton).toHaveBeenCalledOnce()
expect(unregisterSkeleton).not.toHaveBeenCalled()

rerender(<View />)
unmount()

expect(unregisterSkeleton).toHaveBeenCalledOnce()
})
Expand Down
Loading
Loading