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/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.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) 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" } } },