From b23a5ddcb02e920207ec6a68bbde9783a1759486 Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Mon, 19 Oct 2026 15:19:08 +0300 Subject: [PATCH 1/2] =?UTF-8?q?fix(skeleton):=20=D0=BE=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D1=8B=20API,=20=D0=B4=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=83=D0=BF=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=B8=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- design-tokens/input/tokens.json | 6 +- src/components/Skeleton/Skeleton.stories.tsx | 7 +- src/components/Skeleton/Skeleton.tsx | 11 +- .../__tests__/Skeleton.animation.test.tsx | 57 +++ .../Skeleton/__tests__/Skeleton.test.tsx | 106 ++--- .../__snapshots__/Skeleton.test.tsx.snap | 412 ------------------ src/components/Skeleton/index.ts | 2 +- src/theme/tokens/components/dark.json | 4 +- src/theme/tokens/components/light.json | 4 +- 9 files changed, 119 insertions(+), 490 deletions(-) create mode 100644 src/components/Skeleton/__tests__/Skeleton.animation.test.tsx delete mode 100644 src/components/Skeleton/__tests__/__snapshots__/Skeleton.test.tsx.snap diff --git a/design-tokens/input/tokens.json b/design-tokens/input/tokens.json index 2fd926ba..52924de5 100644 --- a/design-tokens/input/tokens.json +++ b/design-tokens/input/tokens.json @@ -4269,8 +4269,8 @@ }, "skeleton": { "extend": { - "minWidth": "{dimension.size.900}", - "height": "{dimension.size.900}" + "minWidth": "{dimension.size.400}", + "minHeight": "{dimension.size.400}" }, "colorScheme": { "light": { @@ -4286,7 +4286,7 @@ } } }, - "root": { "borderRadius": "{dimension.borderRadius.400}" } + "root": { "borderRadius": "{dimension.borderRadius.350}" } }, "slider": { "colorScheme": { diff --git a/src/components/Skeleton/Skeleton.stories.tsx b/src/components/Skeleton/Skeleton.stories.tsx index ef1ade57..accee6bc 100644 --- a/src/components/Skeleton/Skeleton.stories.tsx +++ b/src/components/Skeleton/Skeleton.stories.tsx @@ -13,7 +13,12 @@ const meta: Meta = { title: 'Misc/Skeleton', component: Skeleton, render: () => ( - + diff --git a/src/components/Skeleton/Skeleton.tsx b/src/components/Skeleton/Skeleton.tsx index fe9036ea..cc29c4dc 100644 --- a/src/components/Skeleton/Skeleton.tsx +++ b/src/components/Skeleton/Skeleton.tsx @@ -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 {} /** * Используется для отображения контента в момент загрузки - * @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(({ style, testID, ...rest }) => { const { @@ -48,6 +46,7 @@ export const Skeleton = memo(({ style, testID, ...rest }) => { return ( (({ style, testID, ...rest }) => { ]} testID={SkeletonTestId.animatedView} > - + { + 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( + + + + ) + 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 }] }) + }) + }) +}) diff --git a/src/components/Skeleton/__tests__/Skeleton.test.tsx b/src/components/Skeleton/__tests__/Skeleton.test.tsx index b681fa2e..330ce81f 100644 --- a/src/components/Skeleton/__tests__/Skeleton.test.tsx +++ b/src/components/Skeleton/__tests__/Skeleton.test.tsx @@ -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 => - ({ value }) as SharedValue - -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 ( - - {children} - - ) -} +import { SkeletonTestId } from '../Skeleton' +import { Skeleton } from '../index' describe('Skeleton', () => { - test('before animation started', () => { - const { toJSON } = render(, { - wrapper: ({ children }) => ( - {children} - ), - }) - - expect(toJSON()).toMatchSnapshot('before animation started') + test('передаёт свойства корневому View и сохраняет пользовательский testID', () => { + const { getByTestId } = render( + + ) + + expect(getByTestId('CustomSkeleton')).toHaveProp( + 'nativeID', + 'SkeletonPlaceholder' + ) }) - test('50% animation', () => { - const { toJSON } = render(, { - wrapper: ({ children }) => ( - {children} - ), - }) + test('остаётся декоративным даже при переданном accessible', () => { + const { getByTestId } = render() - expect(toJSON()).toMatchSnapshot() - }) - - test('100% animation', () => { - const { toJSON } = render(, { - wrapper: ({ children }) => ( - {children} - ), - }) + 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, + registerSkeleton, + unregisterSkeleton, + skeletonWidth: 500, + } + const { rerender, unmount } = render( + + + + ) + + expect(registerSkeleton).toHaveBeenCalledOnce() + expect(unregisterSkeleton).not.toHaveBeenCalled() - const { rerender } = render(, { - wrapper: ({ children }) => ( - {children} - ), - }) + rerender( + + + + ) expect(registerSkeleton).toHaveBeenCalledOnce() expect(unregisterSkeleton).not.toHaveBeenCalled() - rerender() + unmount() expect(unregisterSkeleton).toHaveBeenCalledOnce() }) diff --git a/src/components/Skeleton/__tests__/__snapshots__/Skeleton.test.tsx.snap b/src/components/Skeleton/__tests__/__snapshots__/Skeleton.test.tsx.snap deleted file mode 100644 index 14b2bb9d..00000000 --- a/src/components/Skeleton/__tests__/__snapshots__/Skeleton.test.tsx.snap +++ /dev/null @@ -1,412 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Skeleton 50% animation 1`] = ` - - - - - - - - - - - - -`; - -exports[`Skeleton 100% animation 1`] = ` - - - - - - - - - - - - -`; - -exports[`Skeleton before animation started: before animation started 1`] = ` - - - - - - - - - - - - -`; diff --git a/src/components/Skeleton/index.ts b/src/components/Skeleton/index.ts index e42e3ebe..d58593a8 100644 --- a/src/components/Skeleton/index.ts +++ b/src/components/Skeleton/index.ts @@ -1 +1 @@ -export { Skeleton } from './Skeleton' +export { Skeleton, type SkeletonProps } from './Skeleton' diff --git a/src/theme/tokens/components/dark.json b/src/theme/tokens/components/dark.json index 4d27a39b..74af7db0 100644 --- a/src/theme/tokens/components/dark.json +++ b/src/theme/tokens/components/dark.json @@ -2241,11 +2241,11 @@ "root": { "borderRadius": 12 } }, "skeleton": { - "extend": { "minWidth": 32, "height": 32 }, + "extend": { "minWidth": 14, "minHeight": 14 }, "colorScheme": { "root": { "background": "#56595f", "animationBackground": "#6d7076" } }, - "root": { "borderRadius": 14 } + "root": { "borderRadius": 12 } }, "slider": { "colorScheme": { "handle": { "content": { "background": "#2b2e33" } } }, diff --git a/src/theme/tokens/components/light.json b/src/theme/tokens/components/light.json index 8c63f0dc..4faa2ed6 100644 --- a/src/theme/tokens/components/light.json +++ b/src/theme/tokens/components/light.json @@ -2241,11 +2241,11 @@ "root": { "borderRadius": 12 } }, "skeleton": { - "extend": { "minWidth": 32, "height": 32 }, + "extend": { "minWidth": 14, "minHeight": 14 }, "colorScheme": { "root": { "background": "#e2e2e4", "animationBackground": "#f0f0f1" } }, - "root": { "borderRadius": 14 } + "root": { "borderRadius": 12 } }, "slider": { "colorScheme": { "handle": { "content": { "background": "#ffffff" } } }, From c94ba8776b501e2e424cca24b6f6501919cfe390 Mon Sep 17 00:00:00 2001 From: Alesya Volosach Date: Tue, 20 Oct 2026 13:43:44 +0300 Subject: [PATCH 2/2] =?UTF-8?q?docs(skeleton):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B0=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=82=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=BF=D0=BE=D0=BD=D0=B5=D0=BD=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +++ src/components/Skeleton/Skeleton.md | 44 +++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) create mode 100644 src/components/Skeleton/Skeleton.md diff --git a/README.md b/README.md index bf4be666..c7e4c615 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Репозиторий UI kit на основе Prime Faces, Prime Flex для React Native +Документация компонентов: + +- [Skeleton](src/components/Skeleton/Skeleton.md) + ## Installation **Yarn** diff --git a/src/components/Skeleton/Skeleton.md b/src/components/Skeleton/Skeleton.md new file mode 100644 index 00000000..03627b7d --- /dev/null +++ b/src/components/Skeleton/Skeleton.md @@ -0,0 +1,44 @@ +# Skeleton + +Декоративный плейсхолдер с анимацией градиента для загружаемого контента. + +## Использование + +```tsx +import { View } from 'react-native' +import { Skeleton } from '@cdek-it/react-native-ui-kit' + +export function ProfileLoading() { + return ( + + + + + ) +} +``` + +Используйте внутри `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)