diff --git a/package-lock.json b/package-lock.json index 797388f..9fa3830 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23406,8 +23406,10 @@ "@storybook/addon-links": "^7.4.6", "@storybook/blocks": "^7.4.6", "@storybook/cli": "^7.4.6", + "@storybook/jest": "^0.2.3", "@storybook/react": "^7.4.6", "@storybook/react-vite": "^7.4.6", + "@storybook/testing-library": "^0.2.2", "@storybook/types": "^7.4.6", "@swc/cli": "^0.1.59", "@swc/core": "^1.3.25", @@ -23415,7 +23417,9 @@ "@testing-library/react": "^13.4.0", "@types/jest": "^29.2.4", "@types/react": "^18.0.26", + "concurrently": "^8.2.1", "gsap": "npm:@gsap/business@^3.12.2", + "http-server": "^14.1.1", "jest": "^29.3.1", "jest-environment-jsdom": "^29.3.1", "npm-run-all": "^4.1.5", @@ -23424,7 +23428,8 @@ "rimraf": "^5.0.5", "storybook": "^7.4.6", "ts-node": "^10.9.1", - "typescript": "^4.9.4" + "typescript": "^4.9.4", + "wait-on": "^7.0.1" }, "peerDependencies": { "@mediamonks/react-hooks": "^1.2.0", @@ -23433,6 +23438,16 @@ "react-dom": ">=17" } }, + "packages/react-animation/node_modules/axios": { + "version": "0.27.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz", + "integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==", + "dev": true, + "dependencies": { + "follow-redirects": "^1.14.9", + "form-data": "^4.0.0" + } + }, "packages/react-animation/node_modules/rimraf": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz", @@ -23451,6 +23466,34 @@ "url": "https://github.com/sponsors/isaacs" } }, + "packages/react-animation/node_modules/rxjs": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", + "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "dev": true, + "dependencies": { + "tslib": "^2.1.0" + } + }, + "packages/react-animation/node_modules/wait-on": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-7.0.1.tgz", + "integrity": "sha512-9AnJE9qTjRQOlTZIldAaf/da2eW0eSRSgcqq85mXQja/DW3MriHxkpODDSUEg+Gri/rKEcXUZHe+cevvYItaog==", + "dev": true, + "dependencies": { + "axios": "^0.27.2", + "joi": "^17.7.0", + "lodash": "^4.17.21", + "minimist": "^1.2.7", + "rxjs": "^7.8.0" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=12.0.0" + } + }, "packages/react-transition-presence": { "name": "@mediamonks/react-transition-presence", "version": "1.1.3", diff --git a/packages/react-animation/package.json b/packages/react-animation/package.json index 75bdd0d..35e4644 100644 --- a/packages/react-animation/package.json +++ b/packages/react-animation/package.json @@ -31,11 +31,13 @@ "fix:eslint": "npm run lint:eslint -- --fix", "test": "npm-run-all -s test:*", "test:jest": "NODE_NO_WARNINGS=1 NODE_OPTIONS=--experimental-vm-modules jest", + "test:storybook": "concurrently -k -s first \"npm run storybook:build --quiet && npx http-server ./.docs/react-transition-presence --port 6006 --silent\" \"wait-on tcp:6006 && npm run storybook:test\"", "build": "npm-run-all -s clean build:*", "build:ts": "tsc -p ./tsconfig.build.json", "clean": "rimraf -rf ./dist", "storybook:dev": "storybook dev -p 6006", - "storybook:build": "storybook build -o ./.docs/react-animation" + "storybook:build": "storybook build -o ./.docs/react-animation", + "storybook:test": "test-storybook" }, "author": "frontend.monks", "license": "MIT", @@ -51,8 +53,10 @@ "@storybook/addon-links": "^7.4.6", "@storybook/blocks": "^7.4.6", "@storybook/cli": "^7.4.6", - "@storybook/react-vite": "^7.4.6", + "@storybook/jest": "^0.2.3", "@storybook/react": "^7.4.6", + "@storybook/react-vite": "^7.4.6", + "@storybook/testing-library": "^0.2.2", "@storybook/types": "^7.4.6", "@swc/cli": "^0.1.59", "@swc/core": "^1.3.25", @@ -60,7 +64,9 @@ "@testing-library/react": "^13.4.0", "@types/jest": "^29.2.4", "@types/react": "^18.0.26", + "concurrently": "^8.2.1", "gsap": "npm:@gsap/business@^3.12.2", + "http-server": "^14.1.1", "jest": "^29.3.1", "jest-environment-jsdom": "^29.3.1", "npm-run-all": "^4.1.5", @@ -69,7 +75,8 @@ "rimraf": "^5.0.5", "storybook": "^7.4.6", "ts-node": "^10.9.1", - "typescript": "^4.9.4" + "typescript": "^4.9.4", + "wait-on": "^7.0.1" }, "peerDependencies": { "@mediamonks/react-hooks": "^1.2.0", diff --git a/packages/react-animation/src/SplitTextWrapper/SplitTextWrapper.mdx b/packages/react-animation/src/SplitTextWrapper/SplitTextWrapper.mdx index 542872f..6463e0c 100644 --- a/packages/react-animation/src/SplitTextWrapper/SplitTextWrapper.mdx +++ b/packages/react-animation/src/SplitTextWrapper/SplitTextWrapper.mdx @@ -1,8 +1,7 @@ -import { Meta } from '@storybook/blocks'; -import { Canvas } from './../../.storybook/Canvas'; -import { Example } from './SplitTextWrapper.stories'; +import { Meta, Canvas, Controls } from '@storybook/blocks'; +import * as stories from './SplitTextWrapper.stories'; - + # SplitTextWrapper @@ -10,55 +9,94 @@ The SplitTextWrapper creates a SplitText instance that can be retrieved using a is available as soon as the just before the components is finished mounting. A new SplitText instance is created when the children or variables change. - - - + -## Usage +## Rendering children -The SplitTextWrapper accepts 1 child, this child is rendered to HTML inside the component to make -sure that compnents from the vDOM are not changed on render making them untargetable in the created -SplitText instance. +The `SplitTextWrapper` renders to HTML inside the component to make sure that compnents from the +vDOM are not changed on render making them untargetable in the created SplitText instance. + +> Warning: state inside the rendered children is lost when the children change. + +```tsx +function Component(): ReactElement { + const splitTextRef = useRef(null); + + useEffect(() => { + // Do something with `splitTextRef.current` + }); + + return ( + + Lorem ipsum dolor sit amet consectetur +
adipisicing elit. Tenetur perspiciatis eius ea, ratione, +
illo molestias, quia sapiente modi quo +
molestiae temporibus. +
+ ); +} +``` + +### Demo + + + +## Using dangerouslySetInnerHTML + +The children are rendered to a string, this is not necessary when the `dangerouslySetInnerHTML` +property of a component is used. ```tsx -function Example(): ReactElement { +function Component(): ReactElement { const splitTextRef = useRef(null); - const animation = useAnimation(() => { - if (!splitTextRef.current) { - return; - } + useEffect(() => { + // Do something with `splitTextRef.current` + }); + + return ( + amet consectetur
adipisicing elit. Tenetur perspiciatis eius ea, ratione,
illo molestias, quia sapiente modi quo
molestiae temporibus.', + }} + /> + ); +} +``` + +### Demo - return gsap.from(splitTextRef.current.words, { - y: 20, - x: 4, - opacity: 0, - duration: 0.2, - stagger: 0.05, - }); - }, []); + - const onReplay = useCallback(() => { - animation.current?.play(0); - }, [animation]); +## The `as` prop (polymorphic component) + +The `SplitTextWrapper` wrapper renders a `div` element by default. The `as` prop can be used to +render the `SplitTextWrapper` as a different element. + +```tsx +function Component(): ReactElement { + const splitTextRef1 = useRef(null); + const splitTextRef2 = useRef(null); + + useEffect(() => { + // Do something with `splitTextRef1.current` or `splitTextRef2.current` + }); return ( <> -

- - <> - Lorem ipsum dolor sit amet consectetur -
adipisicing elit. Tenetur perspiciatis eius ea, ratione, -
illo molestias, quia sapiente modi quo -
molestiae temporibus. - -
-

- - + + I'm an h1 element + + + I'm a code element + ); } ``` + +### Demo + + diff --git a/packages/react-animation/src/SplitTextWrapper/SplitTextWrapper.stories.tsx b/packages/react-animation/src/SplitTextWrapper/SplitTextWrapper.stories.tsx index 85a8e41..fec4226 100644 --- a/packages/react-animation/src/SplitTextWrapper/SplitTextWrapper.stories.tsx +++ b/packages/react-animation/src/SplitTextWrapper/SplitTextWrapper.stories.tsx @@ -1,50 +1,217 @@ /* eslint-disable react/jsx-no-literals */ +import { expect } from '@storybook/jest'; +import type { Meta, StoryObj } from '@storybook/react'; +import { userEvent, within } from '@storybook/testing-library'; import gsap from 'gsap'; import { useCallback, useRef, type ReactElement } from 'react'; import { useAnimation } from '../useAnimation/useAnimation.js'; import { SplitTextWrapper } from './SplitTextWrapper.js'; -export default { +const meta = { title: 'components/SplitTextWrapper', -}; + component: SplitTextWrapper, +} as Meta; + +export default meta; + +type Story = StoryObj; + +export const Children: Story = { + render(): ReactElement { + const splitTextRef = useRef(null); + + const animation = useAnimation(() => { + if (!splitTextRef.current) { + return; + } + + return gsap.from(splitTextRef.current.lines, { + paused: true, + y: 20, + opacity: 0, + duration: 0.2, + stagger: 0.05, + }); + }, []); + + const onReplay = useCallback(() => { + animation.current?.play(0); + }, [animation]); -export function Example(): ReactElement { - const splitTextRef = useRef(null); + return ( + <> + + Lorem ipsum dolor sit amet consectetur +
adipisicing elit. Tenetur perspiciatis eius ea, ratione, +
illo molestias, quia sapiente modi quo +
molestiae temporibus. +
+ + + ); + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const wrapper = canvas.getByTestId('wrapper'); - const animation = useAnimation(() => { - if (!splitTextRef.current) { - return; - } + expect(wrapper).toBeInTheDocument(); + expect(wrapper.childElementCount).toEqual(4); - return gsap.from(splitTextRef.current.words, { - y: 20, - x: 4, - opacity: 0, - duration: 0.2, - stagger: 0.05, + // Wait 2 ticks for styles to be initialized + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + await new Promise((resolve) => { + setTimeout(resolve, 0); }); - }, []); - - const onReplay = useCallback(() => { - animation.current?.play(0); - }, [animation]); - - return ( - <> -

- - <> - Lorem ipsum dolor sit amet consectetur -
adipisicing elit. Tenetur perspiciatis eius ea, ratione, -
illo molestias, quia sapiente modi quo -
molestiae temporibus. - + + expect(wrapper.children[0]).toHaveStyle({ opacity: '0' }); + expect(wrapper.children[3]).toHaveStyle({ opacity: '0' }); + + await userEvent.click(canvas.getByText('Replay')); + await new Promise((resolve) => { + setTimeout(resolve, 200 + wrapper.childElementCount * 50); + }); + + expect(wrapper.children[0]).toHaveStyle({ opacity: '1' }); + expect(wrapper.children[3]).toHaveStyle({ opacity: '1' }); + }, +}; + +export const DangerouslySetInnerHtml: Story = { + render(): ReactElement { + const splitTextRef = useRef(null); + + const animation = useAnimation(() => { + if (!splitTextRef.current) { + return; + } + + return gsap.from(splitTextRef.current.lines, { + y: 20, + opacity: 0, + duration: 0.2, + stagger: 0.05, + }); + }, []); + + const onReplay = useCallback(() => { + animation.current?.play(0); + }, [animation]); + + return ( + <> + amet consectetur
adipisicing elit. Tenetur perspiciatis eius ea, ratione,
illo molestias, quia sapiente modi quo
molestiae temporibus.', + }} + /> + + + ); + }, +}; + +export const AsProp: Story = { + render(): ReactElement { + const splitText1Ref = useRef(null); + const splitText2Ref = useRef(null); + + const animation = useAnimation(() => { + if (!splitText1Ref.current || !splitText2Ref.current) { + return; + } + + return gsap + .timeline() + .from(splitText1Ref.current.lines, { + y: 20, + x: 4, + opacity: 0, + duration: 0.2, + stagger: 0.1, + }) + .from( + splitText2Ref.current.words, + { + opacity: 0, + y: 15, + duration: 0.5, + ease: 'power2.out', + stagger: { + from: 'edges', + amount: 0.5, + }, + }, + 0.15, + ); + }, []); + + const onReplay = useCallback(() => { + animation.current?.play(0); + }, [animation]); + + return ( + <> + + I'm an h1 element.

+
adipisicing elit. Tenetur perspiciatis eius ea, ratione, +
illo molestias, quia sapiente modi quo +
molestiae temporibus.
-

- - - - ); -} + + I'm a label element.

+
adipisicing elit. Tenetur perspiciatis eius ea, ratione, +
illo molestias, quia sapiente modi quo +
molestiae temporibus. +
+ + + ); + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + + expect(canvas.getByTestId('heading').tagName).toEqual('H1'); + expect(canvas.getByTestId('label').tagName).toEqual('LABEL'); + }, +}; diff --git a/packages/react-animation/src/SplitTextWrapper/SplitTextWrapper.tsx b/packages/react-animation/src/SplitTextWrapper/SplitTextWrapper.tsx index 900960c..4d5fc2a 100644 --- a/packages/react-animation/src/SplitTextWrapper/SplitTextWrapper.tsx +++ b/packages/react-animation/src/SplitTextWrapper/SplitTextWrapper.tsx @@ -1,20 +1,49 @@ import { ensuredForwardRef } from '@mediamonks/react-hooks'; import gsap from 'gsap'; import SplitText from 'gsap/SplitText'; -import { type ReactElement } from 'react'; +import { + type ComponentProps, + type ComponentType, + type ReactElement, + type RefAttributes, +} from 'react'; import { renderToString } from 'react-dom/server'; if (typeof window !== 'undefined') { gsap.registerPlugin(SplitText); } -export type SplitTextWrapperProps = { - children: ReactElement; +/** + * Allowed as prop values + */ +type KnownTarget = Exclude; + +type SplitTextWrapperProps = { + /** + * The SplitText variables + * @link https://greensock.com/docs/v3/Plugins/SplitText + */ variables?: SplitText.Vars; + + /** + * The element type to render, the default is `div` + */ + as?: T; }; -export const SplitTextWrapper = ensuredForwardRef( - ({ children, variables = {} }, ref): ReactElement => { +/** + * Polymorphic component type, necessary to get all the attributes/props for the + * as prop component + */ +type SplitTextWrapperComponent = ( + props: SplitTextWrapperProps & + Omit, keyof SplitTextWrapperProps | 'ref'> & + RefAttributes, +) => ReactElement; + +// @ts-expect-error polymorphic type is not compatible with ensuredForwardRef function factory +export const SplitTextWrapper: SplitTextWrapperComponent = ensuredForwardRef( + ({ variables = {}, as, children, ...props }, ref) => { /** * Not using useCallback on purpose so that a new SplitText instance is * created whenever this component rerenders the children @@ -27,15 +56,19 @@ export const SplitTextWrapper = ensuredForwardRef; + return ( -
{children}), + } + } // eslint-disable-next-line react/jsx-no-bind ref={onRef} - // eslint-disable-next-line react/no-danger - dangerouslySetInnerHTML={{ - // eslint-disable-next-line @typescript-eslint/naming-convention - __html: renderToString(children), - }} /> ); }, diff --git a/packages/react-animation/src/useAnimation/useAnimation.mdx b/packages/react-animation/src/useAnimation/useAnimation.mdx index 739aa62..f4906be 100644 --- a/packages/react-animation/src/useAnimation/useAnimation.mdx +++ b/packages/react-animation/src/useAnimation/useAnimation.mdx @@ -1,6 +1,5 @@ -import { Meta } from '@storybook/blocks'; -import { Canvas } from './../../.storybook/Canvas'; -import { Tween, Timeline, OnAction } from './useAnimation.stories'; +import { Meta, Canvas } from '@storybook/blocks'; +import * as stories from './useAnimation.stories'; @@ -39,9 +38,7 @@ function Component(): null { You can create a GSAP timeline in the `useAnimation` hooks callback function. - - - + ```tsx function Timeline(): ReactElement { @@ -103,9 +100,7 @@ function Timeline(): ReactElement { You can create a GSAP tween in the `useAnimation` hooks callback function. - - - + ```tsx function Tween(): ReactElement { @@ -142,9 +137,7 @@ function Tween(): ReactElement { The `useAnimation` hooks returns the animation instance, this can be used to control the animation. Make sure to pause the animation if you don't want it to start on mount. - - - + ```tsx function OnAction(): ReactElement { diff --git a/packages/react-animation/src/useScrollAnimation/useScrollAnimation.mdx b/packages/react-animation/src/useScrollAnimation/useScrollAnimation.mdx index 094f05b..6b94572 100644 --- a/packages/react-animation/src/useScrollAnimation/useScrollAnimation.mdx +++ b/packages/react-animation/src/useScrollAnimation/useScrollAnimation.mdx @@ -1,6 +1,5 @@ -import { Meta } from '@storybook/blocks'; -import { Canvas } from './../../.storybook/Canvas'; -import { UseScrollAnimation } from './useScrollAnimation.stories'; +import { Meta, Canvas } from '@storybook/blocks'; +import * as stories from './useScrollAnimation.stories'; @@ -64,6 +63,4 @@ function ScrollAnimation(): ReactElement { Keep scrolling to see the animation. - - - + diff --git a/packages/react-transition-presence/src/TransitionPresence/TransitionPresence.mdx b/packages/react-transition-presence/src/TransitionPresence/TransitionPresence.mdx index 3b03e58..16ac544 100644 --- a/packages/react-transition-presence/src/TransitionPresence/TransitionPresence.mdx +++ b/packages/react-transition-presence/src/TransitionPresence/TransitionPresence.mdx @@ -1,4 +1,4 @@ -import { Meta, Canvas, Controls } from '@storybook/blocks'; +import { Meta, Canvas } from '@storybook/blocks'; import * as stories from './TransitionPresence.stories';