diff --git a/client/.eslintrc.json b/client/.eslintrc.json index 19101bb20..fc801afc8 100644 --- a/client/.eslintrc.json +++ b/client/.eslintrc.json @@ -1,4 +1,9 @@ { + "env": { + "browser": true, + "es2021": true, + "jest": true + }, "parser": "@typescript-eslint/parser", "plugins": ["@typescript-eslint"], "extends": [ @@ -21,6 +26,7 @@ } ], "rules": { + "react/display-name": 0, // I suggest you add those two rules: "@typescript-eslint/no-unused-vars": "error", "@typescript-eslint/no-explicit-any": "error", diff --git a/client/src/shared/lib/utils/get-elapsed-time/get-elapsed-time.test.ts b/client/src/shared/lib/utils/get-elapsed-time/get-elapsed-time.test.ts index abca6a7b4..025180f34 100644 --- a/client/src/shared/lib/utils/get-elapsed-time/get-elapsed-time.test.ts +++ b/client/src/shared/lib/utils/get-elapsed-time/get-elapsed-time.test.ts @@ -46,7 +46,7 @@ describe('getElapsedTime', () => { expect(getElapsedTime(pastTime)).toBe('0s ago'); const pastTime2 = new Date(currentTime.getTime() - 100); // 100 millisecond ago expect(getElapsedTime(pastTime2)).toBe('0s ago'); - const pastTime3 = new Date(currentTime.getTime() - 999); // 999 millisecond ago + const pastTime3 = new Date(currentTime.getTime() - 997); // 997 millisecond ago expect(getElapsedTime(pastTime3)).toBe('0s ago'); }); diff --git a/client/src/shared/ui/skeleton/skeleton.module.scss b/client/src/shared/ui/skeleton/skeleton.module.scss new file mode 100644 index 000000000..d6237b1bf --- /dev/null +++ b/client/src/shared/ui/skeleton/skeleton.module.scss @@ -0,0 +1,55 @@ +@keyframes react-loading-skeleton { + 100% { + transform: translateX(100%); + } +} + +.reactLoadingSkeleton { + --base-color: #313131; + --highlight-color: #525252; + --animation-duration: 1.5s; + --animation-direction: normal; + --pseudo-element-display: block; /* Enable animation */ + + background-color: var(--base-color); + + width: 100%; + border-radius: 0.25rem; + display: inline-flex; + line-height: 1; + + position: relative; + user-select: none; + overflow: hidden; + z-index: 1; /* Necessary for overflow: hidden to work correctly in Safari */ +} + +.reactLoadingSkeleton::after { + content: ' '; + display: var(--pseudo-element-display); + position: absolute; + top: 0; + left: 0; + right: 0; + height: 100%; + background-repeat: no-repeat; + background-image: linear-gradient( + 90deg, + var(--base-color), + var(--highlight-color), + var(--base-color) + ); + transform: translateX(-100%); + + animation-name: react-loading-skeleton; + animation-direction: var(--animation-direction); + animation-duration: var(--animation-duration); + animation-timing-function: ease-in-out; + animation-iteration-count: infinite; +} + +/*@media (prefers-reduced-motion) {*/ +/* .reactLoadingSkeleton {*/ +/* --pseudo-element-display: none; !* Disable animation *!*/ +/* }*/ +/*}*/ diff --git a/client/src/shared/ui/skeleton/skeleton.stories.tsx b/client/src/shared/ui/skeleton/skeleton.stories.tsx new file mode 100644 index 000000000..4e751bdc1 --- /dev/null +++ b/client/src/shared/ui/skeleton/skeleton.stories.tsx @@ -0,0 +1,218 @@ +import React from 'react'; +import { Meta, StoryObj } from '@storybook/react'; +import { Skeleton, SkeletonProps } from './skeleton'; + +// Default props for Skeleton +const skeletonProps: SkeletonProps = { + count: 1, + inline: false, + height: '100px', + width: '100%', + borderRadius: '4px', +}; + +// Defining meta information for Storybook +type Story = StoryObj; +const SkeletonTemplate: Story = { + render: args => ( +
+ +
+ ), +}; + +export const Playground = { ...SkeletonTemplate }; +Playground.args = skeletonProps; + +// Skeleton with multiple elements +export const MultipleElements = { ...SkeletonTemplate }; +MultipleElements.args = { + ...skeletonProps, + count: 5, +}; + +// Inline Skeleton elements +export const InlineElements = { ...SkeletonTemplate }; +InlineElements.args = { + ...skeletonProps, + inline: true, +}; + +// Skeleton with custom dimensions +export const CustomDimensions = { ...SkeletonTemplate }; +CustomDimensions.args = { + ...skeletonProps, + height: '50px', + width: '50px', +}; + +// Skeleton with rounded corners +export const RoundedCorners = { ...SkeletonTemplate }; +RoundedCorners.args = { + ...skeletonProps, + borderRadius: '50%', +}; + +// Skeleton with custom style +export const CustomStyle = { ...SkeletonTemplate }; +CustomStyle.args = { + ...skeletonProps, + style: { backgroundColor: 'lightgray' }, +}; + +// Skeleton with custom wrapper +export const CustomWrapper = { ...SkeletonTemplate }; +CustomWrapper.args = { + ...skeletonProps, + wrapper: ({ children }) => ( +
{children}
+ ), +}; + +// Skeleton representing a loading text line +export const TextLine = { ...SkeletonTemplate }; +TextLine.args = { + ...skeletonProps, + width: '80%', + height: '16px', +}; + +// Skeleton representing a loading avatar +export const Avatar = { ...SkeletonTemplate }; +Avatar.args = { + ...skeletonProps, + width: '48px', + height: '48px', + borderRadius: '50%', +}; + +// Skeleton representing a loading card +export const Card = { ...SkeletonTemplate }; +Card.args = { + ...skeletonProps, + width: '300px', + height: '400px', + borderRadius: '8px', +}; + +// Skeleton representing a loading button +export const Button = { ...SkeletonTemplate }; +Button.args = { + ...skeletonProps, + width: '120px', + height: '36px', + borderRadius: '4px', +}; + +// Skeleton representing a loading image +export const Image = { ...SkeletonTemplate }; +Image.args = { + ...skeletonProps, + width: '200px', + height: '200px', + borderRadius: '8px', +}; + +// Skeleton representing loading list items +export const ListItems = { ...SkeletonTemplate }; +ListItems.args = { + ...skeletonProps, + count: 5, +}; + +const skeletonArgTypes = { + count: { + control: 'number', + description: 'The number of skeleton elements to render.', + defaultValue: 1, + table: { + type: { summary: 'number' }, + defaultValue: { summary: 1 }, + }, + }, + inline: { + control: 'boolean', + description: 'Whether the skeleton elements should be rendered inline.', + defaultValue: false, + table: { + type: { summary: 'boolean' }, + defaultValue: { summary: false }, + }, + }, + wrapper: { + control: 'object', + description: 'An optional wrapper component for the skeleton elements.', + table: { + type: { summary: 'React.FunctionComponent' }, + }, + }, + className: { + control: 'text', + description: 'A custom class name for the skeleton elements.', + table: { + type: { summary: 'string' }, + }, + }, + height: { + control: 'text', + description: 'The height of the skeleton elements.', + defaultValue: '20px', + table: { + type: { summary: 'string | number' }, + defaultValue: { summary: '20px' }, + }, + }, + width: { + control: 'text', + description: 'The width of the skeleton elements.', + defaultValue: '100%', + table: { + type: { summary: 'string | number' }, + defaultValue: { summary: '100%' }, + }, + }, + borderRadius: { + control: 'text', + description: 'The border radius of the skeleton elements.', + defaultValue: '4px', + table: { + type: { summary: 'string' }, + defaultValue: { summary: '4px' }, + }, + }, + containerTestId: { + control: 'text', + description: 'The data-testid attribute for the container element.', + table: { + type: { summary: 'string' }, + }, + }, + skeletonTestId: { + control: 'text', + description: 'The data-testid attribute for the skeleton elements.', + table: { + type: { summary: 'string' }, + }, + }, + containerClassName: { + control: 'text', + description: 'A custom class name for the container element.', + table: { + type: { summary: 'string' }, + }, + }, + style: { + control: 'object', + description: 'Custom styles for the skeleton elements.', + table: { + type: { summary: 'CSSProperties' }, + }, + }, +}; + +export default { + title: 'shared/Skeleton', + component: Skeleton, + tags: ['autodocs'], + argTypes: skeletonArgTypes, +} as Meta; diff --git a/client/src/shared/ui/skeleton/skeleton.test.tsx b/client/src/shared/ui/skeleton/skeleton.test.tsx new file mode 100644 index 000000000..f8b792797 --- /dev/null +++ b/client/src/shared/ui/skeleton/skeleton.test.tsx @@ -0,0 +1,31 @@ +import { render, screen } from '@testing-library/react'; + +import { Skeleton } from './skeleton'; + +describe('Skeleton component', () => { + it('should render with provided height and width', () => { + const height = 100; + const width = 200; + render(); + const skeletonContainer = screen.getByTestId('skeleton123-test'); + expect(skeletonContainer).toHaveStyle(`height: ${height}px`); + expect(skeletonContainer).toHaveStyle(`width: ${width}px`); + }); + it('should render with provided border radius', () => { + const borderRadius = '50%'; + render(); + const skeletonContainer = screen.getByTestId('skeleton1234-test'); + expect(skeletonContainer).toHaveStyle(`border-radius: ${borderRadius}`); + }); + it('should render with the provided className', () => { + const className = 'custom-class'; + render(); + const skeletonContainer = screen.getByTestId('skeleton12345-test'); + expect(skeletonContainer).toHaveClass(className); + }); + it('should render without any provided props', () => { + render(); + const skeletonContainer = screen.getByTestId('skeleton123456-test'); + expect(skeletonContainer).toBeInTheDocument(); + }); +}); diff --git a/client/src/shared/ui/skeleton/skeleton.tsx b/client/src/shared/ui/skeleton/skeleton.tsx new file mode 100644 index 000000000..1529e31ee --- /dev/null +++ b/client/src/shared/ui/skeleton/skeleton.tsx @@ -0,0 +1,140 @@ +import type { CSSProperties, PropsWithChildren } from 'react'; +import React, { memo } from 'react'; + +import cls from './skeleton.module.scss'; +import { clsx } from 'clsx'; + +export interface SkeletonProps { + /** + * The number of skeleton elements to render + */ + count?: number; + /** + * [props.inline=false] - Whether the skeleton elements should be rendered inline + */ + inline?: boolean; + /** + * [props.wrapper] - An optional wrapper component for the skeleton elements + */ + wrapper?: React.FunctionComponent>; + /** + * [props.className] - A custom class name for the skeleton elements + */ + className?: string; + /** + * [props.height] - The height of the skeleton elements + */ + height?: string | number; + /** + * [props.width] - The width of the skeleton elements + */ + width?: string | number; + /** + * [props.borderRadius] - The border radius of the skeleton elements + */ + borderRadius?: string; + /** + * [props.containerTestId] - The data-testid attribute for the container element + */ + containerTestId?: string; + /** + * [props.skeletonTestId] - The data-testid attribute for the skeleton elements + */ + skeletonTestId?: string; + /** + * [props.containerClassName] - A custom class name for the container element + */ + containerClassName?: string; + /** + * [props.style] - Custom styles for the skeleton elements + */ + style?: CSSProperties; +} + +/** + * `Skeleton` is a presentational component that renders a skeleton screen placeholder UI. + * This is typically used to indicate that content is being loaded and provides a better user experience by reducing the perceived loading time. + * + * The skeleton elements are customizable in terms of dimensions, appearance, and behavior. + * + * Example: + * + * ```tsx + * // Basic usage + * + * ``` + * + * ```tsx + * // Inline skeleton elements with custom dimensions + * + * ``` + * + * ```tsx + * // Skeleton elements with custom wrapper component + * const Wrapper = ({ children }) =>
{children}
; + * + * ``` + * + * ```tsx + * // Skeleton elements with custom styles and class names + * + * ``` + */ +export const Skeleton = memo((props: SkeletonProps) => { + const { + skeletonTestId, + containerClassName, + count = 1, + containerTestId, + wrapper: Wrapper, + inline, + className, + borderRadius, + height, + width, + style, + } = props; + + const styles: CSSProperties = { + width, + height, + borderRadius, + }; + + const elements: React.ReactElement[] = []; + + const countCeil = Math.ceil(count); + + for (let i = 0; i < countCeil; i++) { + const thisStyle = { ...styles, ...style }; + const skeletonSpan = ( + + ‌ + + ); + + if (inline) { + elements.push(skeletonSpan); + } else { + elements.push( + + {skeletonSpan} +
+
+ ); + } + } + + return ( + + {Wrapper + ? elements.map((element, index) => {element}) + : elements} + + ); +}); diff --git a/client/tsconfig.json b/client/tsconfig.json index 86fa2d1f7..1286fc546 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "types": ["node", "jest", "@testing-library/jest-dom"], "target": "es5", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, @@ -26,8 +27,8 @@ }, "include": [ "next-env.d.ts", - "**/*.ts", - "**/*.tsx", + "*/**/*.ts", + "*/**/*.tsx", ".next/types/**/*.ts", "config/storybook/preview.tsx" ],