diff --git a/.github/workflows/build-and-test.yml b/.github/workflows/build-and-test.yml index 465e50c..c5d54f1 100644 --- a/.github/workflows/build-and-test.yml +++ b/.github/workflows/build-and-test.yml @@ -13,14 +13,28 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - name: Use Node.js ${{ matrix.node-version }} + - name: Check out source + uses: actions/checkout@v3 + + - name: Use Node.js uses: actions/setup-node@v3 with: - node-version: lts/* - cache: 'npm' - - run: npm ci - - run: npx playwright install --with-deps - - run: npm run lint - - run: npm run build - - run: npm run test + node-version: '20.x' + + - name: Install dependencies + env: + GSAP_TOKEN: ${{ secrets.GSAP_TOKEN }} + run: | + npm config set //npm.greensock.com/:_authToken=$GSAP_TOKEN + npm config set @gsap:registry=https://npm.greensock.com + npm ci + npx playwright install --with-deps + + - name: Lint + run: npm run lint + + - name: Build + run: npm run build + + - name: Test + run: npm run test diff --git a/.github/workflows/bump-version-and-publish.yml b/.github/workflows/bump-version-and-publish.yml index 575974a..dfa0a5d 100644 --- a/.github/workflows/bump-version-and-publish.yml +++ b/.github/workflows/bump-version-and-publish.yml @@ -32,14 +32,22 @@ jobs: with: ssh-key: ${{secrets.DEPLOY_KEY}} - - name: Setup Node.js + - name: Use Node.js uses: actions/setup-node@v3 with: - node-version: '18' - cache: 'npm' + node-version: '20.x' - - name: Install npm packages - run: npm ci + - name: Install dependencies + env: + GSAP_TOKEN: ${{ secrets.GSAP_TOKEN }} + run: | + npm config set //npm.greensock.com/:_authToken=$GSAP_TOKEN + npm config set @gsap:registry=https://npm.greensock.com + npm ci + + - name: Install dependencies + run: | + npm run build - name: Setup Git run: | @@ -58,5 +66,6 @@ jobs: with: token: ${{ secrets.NPM_TOKEN }} ignore-scripts: false + - name: Push latest version run: git push origin main --follow-tags diff --git a/.github/workflows/update-docs.yml b/.github/workflows/update-docs.yml index 87f0f3c..fb5ecc9 100644 --- a/.github/workflows/update-docs.yml +++ b/.github/workflows/update-docs.yml @@ -23,14 +23,17 @@ jobs: with: fetch-depth: 0 - - name: Setup Node.js + - name: Use Node.js uses: actions/setup-node@v3 with: - node-version: 16 - cache: 'npm' + node-version: '20.x' - name: Build Storybook + env: + GSAP_TOKEN: ${{ secrets.GSAP_TOKEN }} run: | + npm config set //npm.greensock.com/:_authToken=$GSAP_TOKEN + npm config set @gsap:registry=https://npm.greensock.com npm ci npm run storybook:build diff --git a/package-lock.json b/package-lock.json index 9b8f623..3a318cb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,6 @@ "version": "1.4.0", "license": "MIT", "dependencies": { - "@psimk/typed-object": "^1.0.4", - "@types/lodash-es": "^4.17.6", - "@vitest/expect": "^0.34.2", "lodash-es": "^4.17.21" }, "devDependencies": { @@ -32,6 +29,7 @@ "@storybook/testing-library": "^0.2.2", "@storybook/types": "^7.5.2", "@testing-library/react": "^14.0.0", + "@types/lodash-es": "^4.17.11", "@types/react": "^18.0.25", "@vitejs/plugin-react": "^4.0.0", "concurrently": "^8.2.2", @@ -52,6 +50,9 @@ "vitest": "^0.34.3", "wait-on": "^7.1.0" }, + "optionalDependencies": { + "gsap": "npm:@gsap/business@3.12.2" + }, "peerDependencies": { "react": ">= 17" } @@ -4004,6 +4005,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, "dependencies": { "@sinclair/typebox": "^0.27.8" }, @@ -4711,14 +4713,6 @@ "node": ">=14" } }, - "node_modules/@psimk/typed-object": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@psimk/typed-object/-/typed-object-1.0.4.tgz", - "integrity": "sha512-UBqfjpmvqmfvLxdCqBwPsGRiU3FMge9NZlyDgeqhfQPtheeJiwfwC9mFJ+H9mZ0h0LxN1NP5UNLzpv9jUnk1TA==", - "peerDependencies": { - "typescript": ">=4" - } - }, "node_modules/@radix-ui/number": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.0.1.tgz", @@ -5434,7 +5428,8 @@ "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==" + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true }, "node_modules/@sinonjs/commons": { "version": "1.8.6", @@ -7885,12 +7880,14 @@ "node_modules/@types/lodash": { "version": "4.14.194", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.194.tgz", - "integrity": "sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g==" + "integrity": "sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g==", + "dev": true }, "node_modules/@types/lodash-es": { - "version": "4.17.9", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.9.tgz", - "integrity": "sha512-ZTcmhiI3NNU7dEvWLZJkzG6ao49zOIjEgIE0RgV7wbPxU0f2xT3VSAHw2gmst8swH6V0YkLRGp4qPlX/6I90MQ==", + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.11.tgz", + "integrity": "sha512-eCw8FYAWHt2DDl77s+AMLLzPn310LKohruumpucZI4oOFJkIgnlaJcy23OKMJxx4r9PeTF13Gv6w+jqjWQaYUg==", + "dev": true, "dependencies": { "@types/lodash": "*" } @@ -8390,6 +8387,7 @@ "version": "0.34.6", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.34.6.tgz", "integrity": "sha512-QUzKpUQRc1qC7qdGo7rMK3AkETI7w18gTCUrsNnyjjJKYiuUB9+TQK3QnR1unhCnWRC0AbKv2omLGQDF/mIjOw==", + "dev": true, "dependencies": { "@vitest/spy": "0.34.6", "@vitest/utils": "0.34.6", @@ -8502,6 +8500,7 @@ "version": "0.34.6", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.34.6.tgz", "integrity": "sha512-xaCvneSaeBw/cz8ySmF7ZwGvL0lBjfvqc1LpQ/vcdHEvpLn3Ff1vAvjw+CoGn0802l++5L/pxb7whwcWAw+DUQ==", + "dev": true, "dependencies": { "tinyspy": "^2.1.1" }, @@ -8513,6 +8512,7 @@ "version": "0.34.6", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.34.6.tgz", "integrity": "sha512-IG5aDD8S6zlvloDsnzHw0Ut5xczlF+kv2BOTo+iXfPr54Yhi5qbVOgGB1hZaVq4iJ4C/MZ2J0y15IlsV/ZcI0A==", + "dev": true, "dependencies": { "diff-sequences": "^29.4.3", "loupe": "^2.3.6", @@ -8526,6 +8526,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, "engines": { "node": ">=10" }, @@ -8537,6 +8538,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, "engines": { "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } @@ -8545,6 +8547,7 @@ "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, "dependencies": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", @@ -8557,7 +8560,8 @@ "node_modules/@vitest/utils/node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true }, "node_modules/@yarnpkg/esbuild-plugin-pnp": { "version": "3.0.0-rc.15", @@ -8978,6 +8982,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, "engines": { "node": "*" } @@ -9818,6 +9823,7 @@ "version": "4.3.10", "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz", "integrity": "sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g==", + "dev": true, "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.3", @@ -9884,6 +9890,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, "dependencies": { "get-func-name": "^2.0.2" }, @@ -10662,6 +10669,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "dev": true, "dependencies": { "type-detect": "^4.0.0" }, @@ -13205,6 +13213,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, "engines": { "node": "*" } @@ -13495,6 +13504,14 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/gsap": { + "name": "@gsap/business", + "version": "3.12.2", + "resolved": "https://npm.greensock.com/@gsap%2fbusiness/-/business-3.12.2.tgz", + "integrity": "sha512-AkkhzjaOOJ5Jk9Qyo5PB4C0sL+uUj3SsAN+F+HvM71QVs/jISXgwAyrap2CjK8CzTXrC3T74sHkoOVlqU0Fbfg==", + "license": "This package should only be used by individuals/companies with an active Business Green Club GreenSock membership. See https://greensock.com/club/. Licensing: https://greensock.com/licensing/", + "optional": true + }, "node_modules/gunzip-maybe": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz", @@ -19618,6 +19635,7 @@ "version": "2.3.6", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz", "integrity": "sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==", + "dev": true, "dependencies": { "get-func-name": "^2.0.0" } @@ -21347,6 +21365,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, "engines": { "node": "*" } @@ -24039,6 +24058,7 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.0.tgz", "integrity": "sha512-d2eda04AN/cPOR89F7Xv5bK/jrQEhmcLFe6HFldoeO9AJtps+fqEnh486vnT/8y4bw38pSyxDcTCAq+Ks2aJTg==", + "dev": true, "engines": { "node": ">=14.0.0" } @@ -24285,6 +24305,7 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, "engines": { "node": ">=4" } @@ -24347,6 +24368,7 @@ "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "dev": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -28175,6 +28197,7 @@ "version": "29.6.3", "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, "requires": { "@sinclair/typebox": "^0.27.8" } @@ -28713,12 +28736,6 @@ "dev": true, "optional": true }, - "@psimk/typed-object": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@psimk/typed-object/-/typed-object-1.0.4.tgz", - "integrity": "sha512-UBqfjpmvqmfvLxdCqBwPsGRiU3FMge9NZlyDgeqhfQPtheeJiwfwC9mFJ+H9mZ0h0LxN1NP5UNLzpv9jUnk1TA==", - "requires": {} - }, "@radix-ui/number": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.0.1.tgz", @@ -29112,7 +29129,8 @@ "@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==" + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true }, "@sinonjs/commons": { "version": "1.8.6", @@ -30886,12 +30904,14 @@ "@types/lodash": { "version": "4.14.194", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.194.tgz", - "integrity": "sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g==" + "integrity": "sha512-r22s9tAS7imvBt2lyHC9B8AGwWnXaYb1tY09oyLkXDs4vArpYJzw09nj8MLx5VfciBPGIb+ZwG0ssYnEPJxn/g==", + "dev": true }, "@types/lodash-es": { - "version": "4.17.9", - "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.9.tgz", - "integrity": "sha512-ZTcmhiI3NNU7dEvWLZJkzG6ao49zOIjEgIE0RgV7wbPxU0f2xT3VSAHw2gmst8swH6V0YkLRGp4qPlX/6I90MQ==", + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.11.tgz", + "integrity": "sha512-eCw8FYAWHt2DDl77s+AMLLzPn310LKohruumpucZI4oOFJkIgnlaJcy23OKMJxx4r9PeTF13Gv6w+jqjWQaYUg==", + "dev": true, "requires": { "@types/lodash": "*" } @@ -31275,6 +31295,7 @@ "version": "0.34.6", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-0.34.6.tgz", "integrity": "sha512-QUzKpUQRc1qC7qdGo7rMK3AkETI7w18gTCUrsNnyjjJKYiuUB9+TQK3QnR1unhCnWRC0AbKv2omLGQDF/mIjOw==", + "dev": true, "requires": { "@vitest/spy": "0.34.6", "@vitest/utils": "0.34.6", @@ -31358,6 +31379,7 @@ "version": "0.34.6", "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-0.34.6.tgz", "integrity": "sha512-xaCvneSaeBw/cz8ySmF7ZwGvL0lBjfvqc1LpQ/vcdHEvpLn3Ff1vAvjw+CoGn0802l++5L/pxb7whwcWAw+DUQ==", + "dev": true, "requires": { "tinyspy": "^2.1.1" } @@ -31366,6 +31388,7 @@ "version": "0.34.6", "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-0.34.6.tgz", "integrity": "sha512-IG5aDD8S6zlvloDsnzHw0Ut5xczlF+kv2BOTo+iXfPr54Yhi5qbVOgGB1hZaVq4iJ4C/MZ2J0y15IlsV/ZcI0A==", + "dev": true, "requires": { "diff-sequences": "^29.4.3", "loupe": "^2.3.6", @@ -31375,17 +31398,20 @@ "ansi-styles": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==" + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true }, "diff-sequences": { "version": "29.6.3", "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==" + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true }, "pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, "requires": { "@jest/schemas": "^29.6.3", "ansi-styles": "^5.0.0", @@ -31395,7 +31421,8 @@ "react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==" + "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", + "dev": true } } }, @@ -31729,7 +31756,8 @@ "assertion-error": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==" + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true }, "ast-types": { "version": "0.16.1", @@ -32358,6 +32386,7 @@ "version": "4.3.10", "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.10.tgz", "integrity": "sha512-0UXG04VuVbruMUYbJ6JctvH0YnC/4q3/AkT18q4NaITo91CUm0liMS9VqzT9vZhVQ/1eqPanMWjBM+Juhfb/9g==", + "dev": true, "requires": { "assertion-error": "^1.1.0", "check-error": "^1.0.3", @@ -32415,6 +32444,7 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, "requires": { "get-func-name": "^2.0.2" } @@ -32996,6 +33026,7 @@ "version": "4.1.3", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "dev": true, "requires": { "type-detect": "^4.0.0" } @@ -34947,7 +34978,8 @@ "get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==" + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true }, "get-intrinsic": { "version": "1.2.1", @@ -35161,6 +35193,12 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "gsap": { + "version": "npm:@gsap/business@3.12.2", + "resolved": "https://npm.greensock.com/@gsap%2fbusiness/-/business-3.12.2.tgz", + "integrity": "sha512-AkkhzjaOOJ5Jk9Qyo5PB4C0sL+uUj3SsAN+F+HvM71QVs/jISXgwAyrap2CjK8CzTXrC3T74sHkoOVlqU0Fbfg==", + "optional": true + }, "gunzip-maybe": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/gunzip-maybe/-/gunzip-maybe-1.4.2.tgz", @@ -39676,6 +39714,7 @@ "version": "2.3.6", "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz", "integrity": "sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==", + "dev": true, "requires": { "get-func-name": "^2.0.0" } @@ -40941,7 +40980,8 @@ "pathval": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==" + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true }, "peek-stream": { "version": "1.1.3", @@ -43005,7 +43045,8 @@ "tinyspy": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.0.tgz", - "integrity": "sha512-d2eda04AN/cPOR89F7Xv5bK/jrQEhmcLFe6HFldoeO9AJtps+fqEnh486vnT/8y4bw38pSyxDcTCAq+Ks2aJTg==" + "integrity": "sha512-d2eda04AN/cPOR89F7Xv5bK/jrQEhmcLFe6HFldoeO9AJtps+fqEnh486vnT/8y4bw38pSyxDcTCAq+Ks2aJTg==", + "dev": true }, "title-case": { "version": "3.0.3", @@ -43189,7 +43230,8 @@ "type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", - "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==" + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true }, "type-fest": { "version": "2.19.0", @@ -43236,7 +43278,8 @@ "typescript": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.2.2.tgz", - "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==" + "integrity": "sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==", + "dev": true }, "ufo": { "version": "1.3.1", diff --git a/package.json b/package.json index 26699db..12de2c2 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "types": "./dist/index.d.ts", "module": "./dist/index.js", "exports": { - "import": "./dist/index.js" + ".": "./dist/index.js" }, "type": "module", "files": [ @@ -98,6 +98,7 @@ "@storybook/testing-library": "^0.2.2", "@storybook/types": "^7.5.2", "@testing-library/react": "^14.0.0", + "@types/lodash-es": "^4.17.11", "@types/react": "^18.0.25", "@vitejs/plugin-react": "^4.0.0", "concurrently": "^8.2.2", @@ -118,13 +119,13 @@ "vitest": "^0.34.3", "wait-on": "^7.1.0" }, + "dependencies": { + "lodash-es": "^4.17.21" + }, "peerDependencies": { "react": ">= 17" }, - "dependencies": { - "@psimk/typed-object": "^1.0.4", - "@types/lodash-es": "^4.17.6", - "@vitest/expect": "^0.34.2", - "lodash-es": "^4.17.21" + "optionalDependencies": { + "gsap": "npm:@gsap/business@3.12.2" } } diff --git a/src/gsap/animations.ts b/src/gsap/animations.ts new file mode 100644 index 0000000..6c685cf --- /dev/null +++ b/src/gsap/animations.ts @@ -0,0 +1,49 @@ +class AnimationsMap extends Map { + private readonly callbacks = new Set<() => void>(); + + private updateQueued = false; + + public set(key: unknown, value: gsap.core.Animation): this { + if (key === null || key === undefined) { + throw new Error( + 'Cannot set animation with null or undefined key. Make sure the ref you pass correctly has its value set.', + ); + } + + // skip if value is the same, mostly for optimisation + if (value === this.get(key)) { + return this; + } + + const result = super.set(key, value); + + if (this.updateQueued) { + return result; + } + + this.updateQueued = true; + + queueMicrotask(() => { + for (const callback of this.callbacks) { + callback(); + } + + this.updateQueued = false; + }); + + return result; + } + + public listen(callback: () => void): () => void { + this.callbacks.add(callback); + + return () => { + this.callbacks.delete(callback); + }; + } +} + +/** + * Global map of animations that can be accessed by reference + */ +export const animations = new AnimationsMap(); diff --git a/src/gsap/components/SplitTextWrapper/SplitTextWrapper.mdx b/src/gsap/components/SplitTextWrapper/SplitTextWrapper.mdx new file mode 100644 index 0000000..6463e0c --- /dev/null +++ b/src/gsap/components/SplitTextWrapper/SplitTextWrapper.mdx @@ -0,0 +1,102 @@ +import { Meta, Canvas, Controls } from '@storybook/blocks'; +import * as stories from './SplitTextWrapper.stories'; + + + +# SplitTextWrapper + +The SplitTextWrapper creates a SplitText instance that can be retrieved using a ref. The SplitText +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. + + + +## Rendering children + +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 Component(): ReactElement { + const splitTextRef = useRef(null); + + 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 + + + +## 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 ( + <> + + I'm an h1 element + + + I'm a code element + + + ); +} +``` + +### Demo + + diff --git a/src/gsap/components/SplitTextWrapper/SplitTextWrapper.stories.tsx b/src/gsap/components/SplitTextWrapper/SplitTextWrapper.stories.tsx new file mode 100644 index 0000000..77aaf07 --- /dev/null +++ b/src/gsap/components/SplitTextWrapper/SplitTextWrapper.stories.tsx @@ -0,0 +1,217 @@ +/* eslint-disable react-hooks/rules-of-hooks, 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 '../../hooks/useAnimation/useAnimation.js'; +import { SplitTextWrapper } from './SplitTextWrapper.js'; + +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 onPlay = 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. +
+ + + ); + }, + async play({ canvasElement }) { + const canvas = within(canvasElement); + const wrapper = canvas.getByTestId('wrapper'); + + expect(wrapper).toBeInTheDocument(); + expect(wrapper.childElementCount).toEqual(4); + + // Wait 2 ticks for styles to be initialized + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + + expect(wrapper.children[0]).toHaveStyle({ opacity: '0' }); + expect(wrapper.children[3]).toHaveStyle({ opacity: '0' }); + + await userEvent.click(canvas.getByText('Play')); + 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 onPlay = 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 onPlay = 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/src/gsap/components/SplitTextWrapper/SplitTextWrapper.tsx b/src/gsap/components/SplitTextWrapper/SplitTextWrapper.tsx new file mode 100644 index 0000000..9e140bd --- /dev/null +++ b/src/gsap/components/SplitTextWrapper/SplitTextWrapper.tsx @@ -0,0 +1,75 @@ +import gsap from 'gsap'; +import SplitText from 'gsap/SplitText'; +import { + type ComponentProps, + type ComponentType, + type ReactElement, + type RefAttributes, +} from 'react'; +import { renderToString } from 'react-dom/server'; +import { ensuredForwardRef } from '../../../hocs/ensuredForwardRef/ensuredForwardRef.js'; + +if (typeof window !== 'undefined') { + gsap.registerPlugin(SplitText); +} + +/** + * 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; +}; + +/** + * 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 + */ + const onRef = async (element: HTMLDivElement): Promise => { + if (ref.current && 'isSplit' in ref.current && ref.current.isSplit) { + return; + } + + ref.current = new SplitText(element, variables); + }; + + const Component = (as ?? 'div') as unknown as ComponentType; + + return ( + {children}), + } + } + // eslint-disable-next-line react/jsx-no-bind + ref={onRef} + /> + ); + }, +); diff --git a/src/gsap/hooks/useAnimation/useAnimation.mdx b/src/gsap/hooks/useAnimation/useAnimation.mdx new file mode 100644 index 0000000..f4906be --- /dev/null +++ b/src/gsap/hooks/useAnimation/useAnimation.mdx @@ -0,0 +1,205 @@ +import { Meta, Canvas } from '@storybook/blocks'; +import * as stories from './useAnimation.stories'; + + + +# useAnimation + +The useAnimation hook is used to created an animation with GSAP, the animation is automatically +killed when the component unmounts. The animation is updated when one of the dependencies changes. + +## Usage + +An animation is created in the callback function of the useAnimation hook. The callback hook should +return an animation instance. The `useAnimation` hook returns a RefObject, the animation is availble +on mount. + +The callback accepts a `gsap.core.Animation` instance as a return type. You can use tweens +(`gsap.to`, `gsap.from`, `gsap.fromTo`) and `gsap.timelines` to create an animation. + +```tsx +import { useAnimation } from '@mediamonks/react-animation'; +import gsap from 'gsap'; + +function Component(): null { + const animation1 = useAnimation(() => { + return gsap.to({ value: 0 }, { value: 100 }); + }); + + const animation2 = useAnimation(() => { + return gsap.to({ value: 0 }, { value: 100 }); + }); + + return null; +} +``` + +### Timeline + +You can create a GSAP timeline in the `useAnimation` hooks callback function. + + + +```tsx +function Timeline(): ReactElement { + const ref = useRef(null); + + useAnimation(() => { + if (ref.current === null) { + return; + } + + gsap.set(ref.current, { + scaleX: 0, + scaleY: 0, + }); + + return gsap + .timeline() + .to(ref.current, { + scaleX: 0.25, + scaleY: 0.25, + ease: 'power3.inOut', + }) + .to(ref.current, { + scaleX: 1, + scaleY: 0.25, + ease: 'power3.inOut', + }) + .to(ref.current, { + scaleX: 0.25, + scaleY: 1, + ease: 'power3.inOut', + }) + .to(ref.current, { + scaleX: 0.25, + scaleY: 0.25, + ease: 'power3.inOut', + }) + .to(ref.current, { + scale: 1, + delay: 0.5, + ease: Bounce.easeOut, + }); + }, []); + + return ( +
+ ); +} +``` + +### Tween + +You can create a GSAP tween in the `useAnimation` hooks callback function. + + + +```tsx +function Tween(): ReactElement { + const ref = useRef(null); + + useAnimation(() => { + if (ref.current === null) { + return; + } + + return gsap.from(ref.current, { + scale: 0, + rotate: 120, + duration: 0.6, + ease: 'power3.out', + }); + }, []); + + return ( +
+ ); +} +``` + +### On Action + +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 { + const ref = useRef(null); + + const animation = useAnimation(() => { + if (ref.current === null) { + return; + } + + return gsap.from(ref.current, { + paused: true, + scale: 0, + rotate: 120, + duration: 0.6, + ease: 'power3.out', + }); + }, []); + + const onPlay = useCallback(() => { + if (animation.current?.progress() === 1) { + animation.current.play(0); + return; + } + + animation.current?.play(); + }, [animation]); + + const onPause = useCallback(() => { + animation.current?.pause(); + }, [animation]); + + const onReset = useCallback(() => { + animation.current?.pause(); + animation.current?.progress(0); + }, [animation]); + + return ( + <> +
+ +
+ + + + + + + + ); +} +``` diff --git a/src/gsap/hooks/useAnimation/useAnimation.stories.tsx b/src/gsap/hooks/useAnimation/useAnimation.stories.tsx new file mode 100644 index 0000000..3acb106 --- /dev/null +++ b/src/gsap/hooks/useAnimation/useAnimation.stories.tsx @@ -0,0 +1,180 @@ +/* eslint-disable react/jsx-no-literals, react/no-multi-comp */ +import gsap, { Bounce } from 'gsap'; +import { useCallback, useRef, type ReactElement } from 'react'; +import { useAnimation } from './useAnimation.js'; + +export default { + title: 'hooks/useAnimation', + component: Timeline, +}; + +export function Timeline(): ReactElement { + const ref = useRef(null); + + const animation = useAnimation(() => { + if (ref.current === null) { + return; + } + + gsap.set(ref.current, { + scaleX: 0, + scaleY: 0, + }); + + return gsap + .timeline() + .to(ref.current, { + scaleX: 0.25, + scaleY: 0.25, + ease: 'power3.inOut', + }) + .to(ref.current, { + scaleX: 1, + scaleY: 0.25, + ease: 'power3.inOut', + }) + .to(ref.current, { + scaleX: 0.25, + scaleY: 1, + ease: 'power3.inOut', + }) + .to(ref.current, { + scaleX: 0.25, + scaleY: 0.25, + ease: 'power3.inOut', + }) + .to(ref.current, { + scale: 1, + delay: 0.5, + ease: Bounce.easeOut, + }); + }, []); + + const onReplay = useCallback(() => { + animation.current?.play(0); + }, [animation]); + + return ( + <> +
+ +
+
+ + + + ); +} + +export function Tween(): ReactElement { + const ref = useRef(null); + + const animation = useAnimation(() => { + if (ref.current === null) { + return; + } + + return gsap.from(ref.current, { + scale: 0, + rotate: 120, + duration: 0.6, + ease: 'power3.out', + }); + }, []); + + const onReplay = useCallback(() => { + animation.current?.play(0); + }, [animation]); + + return ( + <> +
+ +
+
+ + + + ); +} + +export function OnAction(): ReactElement { + const ref = useRef(null); + + const animation = useAnimation(() => { + if (ref.current === null) { + return; + } + + return gsap.from(ref.current, { + paused: true, + scale: 0, + rotate: 120, + duration: 0.6, + ease: 'power3.out', + }); + }, []); + + const onPlay = useCallback(() => { + if (animation.current?.progress() === 1) { + animation.current.play(0); + return; + } + + animation.current?.play(); + }, [animation]); + + const onPause = useCallback(() => { + animation.current?.pause(); + }, [animation]); + + const onReset = useCallback(() => { + animation.current?.pause(); + animation.current?.progress(0); + }, [animation]); + + return ( + <> +
+ +
+ + + + + + + + ); +} diff --git a/src/gsap/hooks/useAnimation/useAnimation.test.ts b/src/gsap/hooks/useAnimation/useAnimation.test.ts new file mode 100644 index 0000000..f347da9 --- /dev/null +++ b/src/gsap/hooks/useAnimation/useAnimation.test.ts @@ -0,0 +1,33 @@ +import { renderHook } from '@testing-library/react'; +import gsap from 'gsap'; +import { type RefObject } from 'react'; +import { describe, expect, it } from 'vitest'; +import { useAnimation } from './useAnimation.js'; + +describe('useAnimation', () => { + it('should not crash', () => { + renderHook(() => useAnimation(() => gsap.to({ value: 0 }, { value: 1 }), [])); + }); + + it('should return animation and update it when dependencies change', () => { + const ref = { value: 0 }; + + const hook = renderHook, { value: number }>( + ({ value = 1 }) => useAnimation(() => gsap.to(ref, { value }), [value]), + { + initialProps: { + value: 1, + }, + }, + ); + + hook.result.current.current?.progress(1); + + expect(ref.value).toBe(1); + + hook.rerender({ value: 2 }); + hook.result.current.current?.progress(1); + + expect(ref.value).toBe(2); + }); +}); diff --git a/src/gsap/hooks/useAnimation/useAnimation.ts b/src/gsap/hooks/useAnimation/useAnimation.ts new file mode 100644 index 0000000..290d481 --- /dev/null +++ b/src/gsap/hooks/useAnimation/useAnimation.ts @@ -0,0 +1,27 @@ +import { type RefObject, useCallback, useEffect, useRef } from 'react'; + +/** + * Create gsap animation via a callback, animation is killed when component is + * unmounted or when a new callback function is provided. + */ +export function useAnimation( + callback: () => T, + dependencies: ReadonlyArray, +): RefObject { + const animation = useRef(); + + // eslint-disable-next-line react-hooks/exhaustive-deps, no-underscore-dangle + const _callback = useCallback(callback, dependencies); + + useEffect(() => { + // eslint-disable-next-line no-underscore-dangle + const _animation = _callback(); + animation.current = _animation; + + return () => { + _animation?.kill(); + }; + }, [_callback]); + + return animation; +} diff --git a/src/gsap/hooks/useExposeAnimation/useExposeAnimation.mdx b/src/gsap/hooks/useExposeAnimation/useExposeAnimation.mdx new file mode 100644 index 0000000..61f2665 --- /dev/null +++ b/src/gsap/hooks/useExposeAnimation/useExposeAnimation.mdx @@ -0,0 +1,63 @@ +import { Meta } from '@storybook/blocks'; +import { UseExposeAnimation } from './useExposeAnimation.stories'; + + + +# useExposeAnimation + +The `useExposeAnimation` hook is used to expose an animation in the global animation Map for a given +reference (not necessarily an HTMLElement). + +## Using a ref + +```tsx +const UseExposeAnimation = (): ReactElement => { + const ref = useRef(null); + + const animation = useAnimation(() => gsap.from({ value: 0 }, { value: 1 }), []); + useExposeAnimation(animation, ref); + + useEffect(() => { + console.log(getAnimation(ref.current)); + }, []); + + return
; +}; +``` + +## Using a forward ref + +```tsx +import { ensuredForwardRef } from '@mediamonks/react-hooks'; + +const UseExposeAnimationForwardRef = ensuredForwardRef( + (_, ref): ReactElement => { + const animation = useAnimation(() => gsap.from({ value: 0 }, { value: 1 }), []); + useExposeAnimation(animation, ref); + + useEffect(() => { + console.log(getAnimation(ref.current)); + }, []); + + return
; + }, +); +``` + +## Using a value as reference + +```tsx +const value = Symbol("myRef") + +const UseExposeAnimationAnyValue = (): ReactElement => { + const ref = useRef(value); + + const animation = useAnimation(() => gsap.from({ value: 0 }, { value: 1 }), []); + useExposeAnimation(animation, ref); + + useEffect(() => { + console.log(getAnimation(value)); + }, []); + + ... +``` diff --git a/src/gsap/hooks/useExposeAnimation/useExposeAnimation.stories.tsx b/src/gsap/hooks/useExposeAnimation/useExposeAnimation.stories.tsx new file mode 100644 index 0000000..9234c9e --- /dev/null +++ b/src/gsap/hooks/useExposeAnimation/useExposeAnimation.stories.tsx @@ -0,0 +1,66 @@ +/* eslint-disable react/jsx-no-literals, react/no-multi-comp, react-hooks/rules-of-hooks */ +import type { Meta, StoryObj } from '@storybook/react'; +import gsap from 'gsap'; +import { useEffect, useRef, type ReactElement } from 'react'; +import { ensuredForwardRef } from '../../../hocs/ensuredForwardRef/ensuredForwardRef.js'; +import { getAnimation } from '../../utils/getAnimation/getAnimation.js'; +import { useAnimation } from '../useAnimation/useAnimation.js'; +import { useExposeAnimation } from '../useExposeAnimation/useExposeAnimation.js'; + +const meta = { + title: 'hooks/useExposeAnimation', +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const UseExposeAnimation: Story = { + render(): ReactElement { + const ref = useRef(null); + + const animation = useAnimation(() => gsap.from({ value: 0 }, { value: 1 }), []); + useExposeAnimation(animation, ref); + + useEffect(() => { + // eslint-disable-next-line no-console + console.log(getAnimation(ref.current)); + }, []); + + return
Check the console to see the result
; + }, +}; + +export const UseExposeAnimationForwardRef = { + render(): ReactElement { + const Component = ensuredForwardRef((_, ref): ReactElement => { + const animation = useAnimation(() => gsap.from({ value: 0 }, { value: 1 }), []); + useExposeAnimation(animation, ref); + + useEffect(() => { + // eslint-disable-next-line no-console + console.log(getAnimation(ref.current)); + }, [ref]); + + return
Check the console to see the result
; + }); + + return ; + }, +}; + +export const UseExposeAnimationStringReference = { + render(): ReactElement { + const ref = useRef('myRef'); + + const animation = useAnimation(() => gsap.from({ value: 0 }, { value: 1 }), []); + useExposeAnimation(animation, ref); + + useEffect(() => { + // eslint-disable-next-line no-console + console.log(getAnimation('myRef')); + }, []); + + return
Check the console to see the result
; + }, +}; diff --git a/src/gsap/hooks/useExposeAnimation/useExposeAnimation.test.ts b/src/gsap/hooks/useExposeAnimation/useExposeAnimation.test.ts new file mode 100644 index 0000000..a14ff31 --- /dev/null +++ b/src/gsap/hooks/useExposeAnimation/useExposeAnimation.test.ts @@ -0,0 +1,57 @@ +import { renderHook } from '@testing-library/react'; +import gsap from 'gsap'; +import { useRef } from 'react'; +import { describe, expect, it } from 'vitest'; +import { getAnimation } from '../../utils/getAnimation/getAnimation.js'; +import { useAnimation } from '../useAnimation/useAnimation.js'; +import { useExposeAnimation } from './useExposeAnimation.js'; + +describe('useExposeAnimation', () => { + it('should not crash', () => { + const hook = renderHook(() => { + const ref = useRef(Symbol('reference')); + + useExposeAnimation(useRef(), ref); + + return { + ref, + }; + }); + + expect(getAnimation(hook.result.current.ref.current)).toBeUndefined(); + }); + + it('should return animation when animation is exposed for reference', () => { + const hook = renderHook(() => { + const ref = useRef(Symbol('reference')); + const animation = useAnimation(() => gsap.to({ value: 0 }, { value: 1 }), []); + + useExposeAnimation(animation, ref); + + return { + ref, + animation, + }; + }); + + expect(getAnimation(hook.result.current.ref.current)).not.toBeUndefined(); + }); + + it('should return undefined when unmounted', () => { + const hook = renderHook(() => { + const ref = useRef(Symbol('reference')); + const animation = useAnimation(() => gsap.to({ value: 0 }, { value: 1 }), []); + + useExposeAnimation(animation, ref); + + return { + ref, + animation, + }; + }); + + hook.unmount(); + + expect(getAnimation(hook.result.current.ref.current)).toBeUndefined(); + }); +}); diff --git a/src/gsap/hooks/useExposeAnimation/useExposeAnimation.ts b/src/gsap/hooks/useExposeAnimation/useExposeAnimation.ts new file mode 100644 index 0000000..b8fb1e1 --- /dev/null +++ b/src/gsap/hooks/useExposeAnimation/useExposeAnimation.ts @@ -0,0 +1,35 @@ +import { useEffect, type RefObject } from 'react'; +import { unref, type Unreffable } from '../../../utils/unref/unref.js'; +import { animations } from '../../animations.js'; + +/** + * Hook to store animation using a reference in global animations map + */ +export function useExposeAnimation( + animation: RefObject, + reference: Unreffable, +): void { + useEffect(() => { + // eslint-disable-next-line no-underscore-dangle + const _reference = unref(reference); + // eslint-disable-next-line no-underscore-dangle + const _animation = animation.current; + + if (_animation && _reference) { + animations.set(_reference, _animation); + } + + return () => { + animations.delete(_reference); + }; + + // TODO: We currently rely on the Component where this hook is used, + // and we know that animation will get a new ref.current assigned + // as part of useAnimation, if that has dependencies. + // If that updates (due to a re-render), we should also update + // the animation in the global map. + // This feels a bit flaky, but I can't think of a better way to do this currently. + // We should probably have these hooks more integrated with each other. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [animation, animation.current, reference]); +} diff --git a/src/gsap/hooks/useExposedAnimation/useExposedAnimation.mdx b/src/gsap/hooks/useExposedAnimation/useExposedAnimation.mdx new file mode 100644 index 0000000..2137126 --- /dev/null +++ b/src/gsap/hooks/useExposedAnimation/useExposedAnimation.mdx @@ -0,0 +1,131 @@ +import { Meta, Canvas } from '@storybook/blocks'; +import * as stories from './useExposedAnimation.stories'; + + + +# useExposedAnimation + +The `useExposedAnimation` hook is used to get an animation that is exposed in the global animation +map for a given reference (a reference isn't necessarily an `HTMLElement`). The hook will trigger a +rerender when the animation for the reference value changes. + +> Note: The return value will most likely be empty during the first render pass because animations +> are created after components are "mounted". A rerender is necessary to get an animation from a +> child component. + +```tsx +const Child = ensuredForwardRef((_, ref): ReactElement => { + const animation = useAnimation(() => gsap.from({ value: 0 }, { value: 1 }), []); + + useExposeAnimation(animation, ref); + + return
Check the console to see the result
; +}); + +function Parent(): ReactElement { + const ref = useRef(null); + const animation = useExposedAnimation(ref); + + console.log(animation); + + return ; +} +``` + +## Retrieving multiple animations from a child component + +If you want to retrieve multiple animations from a child component, you can use multiple unique +references to retrieve animations. + + + +Create multiple `RefObject`s in the parent components using `useAnimationRef`, use the ref in the +child component in `useExposeAnimation`, you can then retrieve the animations using +`useExposedAnimation` in the parent. + +```tsx +type ChildProps = { + animation1Ref: RefObject; + animation2Ref: RefObject; +}; + +const Child = ensuredForwardRef( + ({ animation1Ref, animation2Ref }, ref) => { + const ref = useRef(null); + + const animation = useAnimation(() => gsap.from(ref.current, { y: 100, paused: true }), []); + useExposeAnimation(animation, ref); + + const animation1 = useAnimation(() => gsap.from(ref.current, { opacity: 0, paused: true }), []); + useExposeAnimation(animation1, animation1Ref); + + const animation2 = useAnimation(() => gsap.from(ref.current, { scale: 0, paused: true }), []); + useExposeAnimation(animation2, animation2Ref); + + return ( +
+ ); + }, +); + +function Parent(): ReactElement { + const ref = useRef(null); + const animation = useExposedAnimation(ref); + + const animation1Ref = useAnimationRef(); + const animation1 = useExposedAnimation(animation1Ref); + + const animation2Ref = useAnimationRef(); + const animation2 = useExposedAnimation(animation2Ref); + + useAnimation( + () => gsap.timeline().add(animation).add(animation1).add(animation2), + [animation, animation1, animation2], + ); + + return ; +} +``` + +#### Making the animation ref optional + +The ref parameter in `useExposeAnimation` is mandatory. You can make the ref property of the child +component optional using the `useAnimationRef` hook. The hook will use the value from the provided +`RefObject` if it's defined, the fallback value is used when no `RefObject` is provided. + +```tsx +type ChildProps = { + animationRef?: RefObject; +}; + +function Child({ animationRef }: ChildProps): ReactElement { + const animation = useAnimation( + () => gsap.from({ value: 0 }, { value: 1, onUpdate: console.log }), + [], + ); + + useExposeAnimation(animation, useAnimationRef(animationRef)); + + return
Check the console to see the result
; +} + +function Parent(): ReactElement { + const animationRef = useAnimationRef(); + const childAnimation = useExposedAnimation(animationRef); + + useAnimation(() => { + return gsap.timeline().add(childAnimation); + }, [childAnimation]); + + return ; +} +``` + + diff --git a/src/gsap/hooks/useExposedAnimation/useExposedAnimation.stories.tsx b/src/gsap/hooks/useExposedAnimation/useExposedAnimation.stories.tsx new file mode 100644 index 0000000..6b279e9 --- /dev/null +++ b/src/gsap/hooks/useExposedAnimation/useExposedAnimation.stories.tsx @@ -0,0 +1,204 @@ +/* eslint-disable react-hooks/rules-of-hooks, react/jsx-no-literals */ +import type { Meta, StoryFn, StoryObj } from '@storybook/react'; +import gsap from 'gsap'; +import { useRef, type ReactElement } from 'react'; +import { ensuredForwardRef } from '../../../hocs/ensuredForwardRef/ensuredForwardRef.js'; +import { useEventListener } from '../../../hooks/useEventListener/useEventListener.js'; +import { useStaticValue } from '../../../hooks/useStaticValue/useStaticValue.js'; +import { useAnimation } from '../useAnimation/useAnimation.js'; +import { useExposeAnimation } from '../useExposeAnimation/useExposeAnimation.js'; +import { useExposedAnimation } from './useExposedAnimation.js'; + +const meta = { + title: 'hooks/useExposedAnimation', +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +type ChildProps = { + rotateAnimationReference?: symbol; + scaleAnimationReference?: symbol; +}; + +const Child = ensuredForwardRef( + ( + { rotateAnimationReference: rotateAnimationRef, scaleAnimationReference: scaleAnimationRef }, + ref, + ): ReactElement => { + const animation = useAnimation( + () => + gsap.from( + { value: 0 }, + { + value: 1, + // eslint-disable-next-line no-console + onUpdate: console.log, + }, + ), + [], + ); + + const rotateAnimation = useAnimation( + () => + gsap + .timeline({ paused: true }) + .to(ref.current, { + x: 20, + y: 20, + duration: 0.4, + ease: 'power2.inOut', + }) + .to(ref.current, { + x: -20, + y: -20, + duration: 0.4, + ease: 'power2.out', + }) + .to(ref.current, { + x: 0, + y: 0, + duration: 0.6, + ease: 'power2.out', + }), + [], + ); + + const scaleAnimation = useAnimation( + () => + gsap + .timeline({ paused: true }) + .fromTo(ref.current, { scale: 1 }, { scale: 0.75, ease: 'power2.out' }) + .to(ref.current, { scale: 1, ease: 'power2.out' }), + [], + ); + + useExposeAnimation(animation, ref); + useExposeAnimation(rotateAnimation, rotateAnimationRef); + useExposeAnimation(scaleAnimation, scaleAnimationRef); + + return ( +
+ ); + }, +); + +export const UseExposedAnimation: Story = { + render(): ReactElement { + const ref = useRef(null); + const animation = useExposedAnimation(ref); + + // eslint-disable-next-line no-console + console.log('useExposedAnimation', animation); + + return ; + }, +}; + +export const MultipleChildComponentAnimations = { + decorators: [ + // eslint-disable-next-line @typescript-eslint/naming-convention + (Story: StoryFn): ReactElement => ( +
+ +
+ ), + ], + render(): ReactElement { + const ref = useRef(null); + + const rotateAnimationReference = useStaticValue(() => Symbol('rotateAnimation')); + const scaleAnimationReference = useStaticValue(() => Symbol('scaleAnimation')); + + const rotateAnimation = useExposedAnimation(rotateAnimationReference); + const scaleAnimation = useExposedAnimation(scaleAnimationReference); + + useEventListener(globalThis.document, 'keydown', (event) => { + if (event instanceof KeyboardEvent) { + switch (event.key) { + case 'z': { + rotateAnimation?.play(0); + break; + } + case 'x': { + scaleAnimation?.play(0); + break; + } + default: { + break; + } + } + } + }); + + const animation = useExposedAnimation(ref); + + // eslint-disable-next-line no-console + console.log('useExposedAnimation', animation); + + return ( + <> + +

+ Press `z` to wiggle, press `x` to scale +

+ + ); + }, +}; + +export const OptionalMultipleChildComponentAnimations: Story = { + decorators: [ + // eslint-disable-next-line @typescript-eslint/naming-convention + (Story: StoryFn): ReactElement => ( +
+ +
+ ), + ], + render(): ReactElement { + const ref = useRef(null); + const rotateAnimationReference = useStaticValue(() => Symbol('rotateAnimation')); + const rotateAnimation = useExposedAnimation(rotateAnimationReference); + + useEventListener(globalThis.document, 'keydown', (event) => { + if (event instanceof KeyboardEvent) { + switch (event.key) { + case 'z': { + rotateAnimation?.play(0); + break; + } + default: { + break; + } + } + } + }); + + const animation = useExposedAnimation(ref); + + // eslint-disable-next-line no-console + console.log('useExposedAnimation', animation); + + return ( + <> + +

+ Press `z` to wiggle, the scale animation is not enabled +

+ + ); + }, +}; diff --git a/src/gsap/hooks/useExposedAnimation/useExposedAnimation.test.ts b/src/gsap/hooks/useExposedAnimation/useExposedAnimation.test.ts new file mode 100644 index 0000000..71cc65c --- /dev/null +++ b/src/gsap/hooks/useExposedAnimation/useExposedAnimation.test.ts @@ -0,0 +1,41 @@ +import { renderHook } from '@testing-library/react'; +import gsap from 'gsap'; +import { useRef } from 'react'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { useAnimation } from '../useAnimation/useAnimation.js'; +import { useExposeAnimation } from '../useExposeAnimation/useExposeAnimation.js'; +import { useExposedAnimation } from './useExposedAnimation.js'; + +describe('useExposedAnimation', () => { + beforeAll(() => { + vi.useFakeTimers(); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it('should return undefined', () => { + const hook = renderHook(() => { + const ref = useRef(Symbol('reference')); + + return useExposedAnimation(ref); + }); + + expect(hook.result.current).toBeUndefined(); + }); + + it('should not return undefined', async () => { + const hook = renderHook(() => { + const ref = useRef(Symbol('reference')); + + const animation = useAnimation(() => gsap.to({ value: 0 }, { value: 1 }), []); + + useExposeAnimation(animation, ref); + + return useExposedAnimation(ref); + }); + + expect(hook.result.current).toBeUndefined(); + }); +}); diff --git a/src/gsap/hooks/useExposedAnimation/useExposedAnimation.ts b/src/gsap/hooks/useExposedAnimation/useExposedAnimation.ts new file mode 100644 index 0000000..a107d96 --- /dev/null +++ b/src/gsap/hooks/useExposedAnimation/useExposedAnimation.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from 'react'; +import { unref, type Unreffable } from '../../../index.js'; +import { animations } from '../../animations.js'; + +/** + * Hook to get animation from global animations map using given reference + */ +export function useExposedAnimation( + target: Unreffable, +): T | undefined { + const [animation, setAnimation] = useState(); + + useEffect( + () => + animations.listen(() => { + setAnimation(animations.get(unref(target))); + }), + [target], + ); + + return animation; +} diff --git a/src/gsap/hooks/useExposedAnimations/useExposedAnimations.mdx b/src/gsap/hooks/useExposedAnimations/useExposedAnimations.mdx new file mode 100644 index 0000000..2afe595 --- /dev/null +++ b/src/gsap/hooks/useExposedAnimations/useExposedAnimations.mdx @@ -0,0 +1,38 @@ +import { Meta } from '@storybook/blocks'; + + + +# useExposedAnimations + +The `useExposedAnimations` hook is used to get a collection of animations which are exposed in the +global animation Map for the given references (not necessarily an HTMLElement). The hook will +trigger a rerender when the animation for any of the references in the collection changes, is added, +or is removed. + +> Note: The return value will most likely be an empty array during the first render pass + +```tsx +const ChildItem = ensuredForwardRef((_, ref): ReactElement => { + const animation = useAnimation(() => gsap.from({ value: 0 }, { value: 1 }), []); + + useExposeAnimation(animation, ref); + + return
Child item content
; +}); + +function Parent(): ReactElement { + const ref = useRef>([]); + const animations = useExposedAnimations(ref); + + console.log(animations); // array + + return ( + <> + {Array.from({ length: 3 }).map((_, index) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + + ); +} +``` diff --git a/src/gsap/hooks/useExposedAnimations/useExposedAnimations.stories.tsx b/src/gsap/hooks/useExposedAnimations/useExposedAnimations.stories.tsx new file mode 100644 index 0000000..183e530 --- /dev/null +++ b/src/gsap/hooks/useExposedAnimations/useExposedAnimations.stories.tsx @@ -0,0 +1,146 @@ +/* eslint-disable react/jsx-no-literals, react/no-multi-comp */ +import gsap from 'gsap'; +import { useCallback, useEffect, useRef, useState, type ReactElement } from 'react'; +import { ensuredForwardRef } from '../../../hocs/ensuredForwardRef/ensuredForwardRef.js'; +import { arrayRef } from '../../../utils/arrayRef/arrayRef.js'; +import { useAnimation } from '../useAnimation/useAnimation.js'; +import { useExposeAnimation } from '../useExposeAnimation/useExposeAnimation.js'; +import { useScrollAnimation } from '../useScrollAnimation/useScrollAnimation.js'; +import { useExposedAnimations } from './useExposedAnimations.js'; + +export default { + title: 'hooks/useExposedAnimations', +}; + +// Forces a re-render, useful to test for unwanted side-effects +function useRerender(): () => void { + // eslint-disable-next-line react/hook-use-state + const [count, setCount] = useState(0); + // eslint-disable-next-line no-console + console.log('rerender', count); + return () => { + setCount((previous) => previous + 1); + }; +} + +const ChildItem = ensuredForwardRef((_, ref): ReactElement => { + const animation = useAnimation( + () => gsap.fromTo(ref.current, { opacity: 0, duration: 2 }, { opacity: 1 }), + [], + ); + useExposeAnimation(animation, ref); + return
Check the console to see the result
; +}); + +export function PassingChildRefs(): ReactElement { + const ref = useRef>([]); + const animations = useExposedAnimations(ref); + + // eslint-disable-next-line no-console + console.log('useExposedAnimations', animations); + + const restartAnimations = useCallback(() => { + for (const animation of animations) { + animation.restart(); + } + }, [animations]); + + return ( +
+ {Array.from({ length: 3 }).map((_, index) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + +
+ ); +} + +const Child = ensuredForwardRef, unknown>( + (props, ref): ReactElement => ( + <> + {Array.from({ length: 3 }).map((_, index) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + + ), +); + +export function PassingArrayRefs(): ReactElement { + const ref = useRef>([]); + const animations = useExposedAnimations(ref); + + // eslint-disable-next-line no-console + console.log('useExposedAnimations', animations); + + const restartAnimations = useCallback(() => { + for (const animation of animations) { + animation.restart(); + } + }, [animations]); + + return ( +
+ + +
+ ); +} + +export function RerenderTesting(): ReactElement { + const rerender = useRerender(); + const ref = useRef>([]); + const animations = useExposedAnimations(ref); + + // eslint-disable-next-line no-console + console.log('useExposedAnimations', animations); + + // if there is something wrong in the useExposedAnimations, this will keep re-rendering + useEffect(() => { + // eslint-disable-next-line no-console + console.log('animations updated', animations); + }, [animations]); + + // if there is something wrong in the useExposedAnimations, this will cause a new animation + // to trigger, which will cause 'animations' to update, which will cause a re-render, etc + useScrollAnimation(() => gsap.timeline(), [animations]); + + return ( +
+

+ If this is working as intended, there shouldn't be an endless streams of logs in the + console +

+ {Array.from({ length: 3 }).map((_, index) => ( + // eslint-disable-next-line react/no-array-index-key + + ))} + +
+ ); +} diff --git a/src/gsap/hooks/useExposedAnimations/useExposedAnimations.test.ts b/src/gsap/hooks/useExposedAnimations/useExposedAnimations.test.ts new file mode 100644 index 0000000..5d23ef0 --- /dev/null +++ b/src/gsap/hooks/useExposedAnimations/useExposedAnimations.test.ts @@ -0,0 +1,102 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import gsap from 'gsap'; +import { useRef } from 'react'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { arrayRef } from '../../../utils/arrayRef/arrayRef.js'; +import { useAnimation } from '../useAnimation/useAnimation.js'; +import { useExposeAnimation } from '../useExposeAnimation/useExposeAnimation.js'; +import { useExposedAnimations } from './useExposedAnimations.js'; + +describe('useExposedAnimation', () => { + beforeAll(() => { + vi.useFakeTimers(); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it('should return an empty array', () => { + const hook = renderHook(() => { + const ref = useRef([]); + + return useExposedAnimations(ref); + }); + + hook.rerender(); + expect(hook.result.current).toEqual([]); + }); + + it.skip('should not return undefined', async () => { + const parentHook = renderHook(() => { + // used in place of html elements that are normally used as ref values + const symbol0 = Symbol('ref0'); + const symbol1 = Symbol('ref1'); + + // ref array in the parent that is used in the loop to collect the ref values of the children + const ref = useRef>([]); + // this happens in the loop, the refFn is passed to children + // normally there is a ensuredForwardRef HoC in between, that we manually do below + const refFn0 = arrayRef(ref, 0); + const refFn1 = arrayRef(ref, 1); + + return { symbol0, symbol1, ref, refFn0, refFn1 }; + }); + + const hook = renderHook((ref) => useExposedAnimations(ref), { + initialProps: parentHook.result.current.ref, + }); + + // child 0 + renderHook( + ({ symbol, refFn }) => { + // assigning the ref values to the ref objects + const ref = useRef(symbol); + // doing what the ensuredForwardRef HoC does, by passing the ref value to the arrayRef callback function + refFn(symbol); + + const animation = useAnimation(() => gsap.to({ value: 0 }, { value: 1 }), []); + + useExposeAnimation(animation, ref); + }, + { + initialProps: { + symbol: parentHook.result.current.symbol0, + refFn: parentHook.result.current.refFn0, + }, + }, + ); + + // child 1 + renderHook( + ({ symbol, refFn }) => { + // assigning the ref values to the ref objects + const ref = useRef(symbol); + // doing what the ensuredForwardRef HoC does, by passing the ref value to the arrayRef callback function + refFn(symbol); + + const animation = useAnimation(() => gsap.to({ value: 0 }, { value: 1 }), []); + + useExposeAnimation(animation, ref); + }, + { + initialProps: { + symbol: parentHook.result.current.symbol1, + refFn: parentHook.result.current.refFn1, + }, + }, + ); + + // makes sure the queueMicrotask in the animations can run and trigger the listeners + await waitFor( + () => { + expect(hook.result.current).toHaveLength(2); + expect(typeof hook.result.current[0]).toBe('object'); + expect(hook.result.current[0]).toMatchObject({ vars: { delay: 0 } }); + }, + { + interval: 100, + }, + ); + }); +}); diff --git a/src/gsap/hooks/useExposedAnimations/useExposedAnimations.ts b/src/gsap/hooks/useExposedAnimations/useExposedAnimations.ts new file mode 100644 index 0000000..674b072 --- /dev/null +++ b/src/gsap/hooks/useExposedAnimations/useExposedAnimations.ts @@ -0,0 +1,48 @@ +import { useEffect, useState } from 'react'; +import { unref, type Unreffable } from '../../../index.js'; +import { animations } from '../../animations.js'; + +/** + * Hook to get animation from global animations map using given reference + */ +export function useExposedAnimations( + target: Unreffable>, +): ReadonlyArray { + const [exposedAnimations, setExposedAnimations] = useState>( + [], + ); + + useEffect( + () => + animations.listen(() => { + const array = unref(target); + + if (array) { + const newAnimations = array.map((ref) => animations.get(ref)); + // this should only be done when the refs have been updated, otherwise we're returning + // a new array ref with the same values, which will cause a re-render + setExposedAnimations((currentAnimations) => + areArraysEqual(newAnimations, currentAnimations) ? currentAnimations : newAnimations, + ); + } + }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [target], + ); + + return exposedAnimations; +} + +function areArraysEqual(a: ReadonlyArray, b: ReadonlyArray): boolean { + if (a.length !== b.length) { + return false; + } + + for (const [index, element] of a.entries()) { + if (element !== b[index]) { + return false; + } + } + + return true; +} diff --git a/src/gsap/hooks/useFlip/useFlip.mdx b/src/gsap/hooks/useFlip/useFlip.mdx new file mode 100644 index 0000000..5f2a6d0 --- /dev/null +++ b/src/gsap/hooks/useFlip/useFlip.mdx @@ -0,0 +1,46 @@ +import { Meta, Canvas } from '@storybook/blocks'; +import * as stories from './useFlip.stories'; + + + +# useFlip + +Use to flip a components state from one value to another. + +```tsx +function MyComponent(): ReactElement { + const [isFlipped, toggle] = useToggle(false); + + const divRef = useRef(null); + useFlip(divRef, flipOptions); + + useEventListener(globalThis.document, 'click', () => { + toggle(); + }); + + return ( +
+ ); +} +``` + +### Demo + + diff --git a/src/gsap/hooks/useFlip/useFlip.stories.tsx b/src/gsap/hooks/useFlip/useFlip.stories.tsx new file mode 100644 index 0000000..3c27896 --- /dev/null +++ b/src/gsap/hooks/useFlip/useFlip.stories.tsx @@ -0,0 +1,64 @@ +/* eslint-disable react-hooks/rules-of-hooks, react/jsx-no-literals */ +import type { Meta, StoryObj } from '@storybook/react'; +import { useRef, type CSSProperties, type ReactElement } from 'react'; +import { useEventListener } from '../../../hooks/useEventListener/useEventListener.js'; +import { useToggle } from '../../../hooks/useToggle/useToggle.js'; +import { useFlip } from './useFlip.js'; + +const meta = { + title: 'hooks/useFlip', +} as Meta; + +export default meta; + +type Story = StoryObj; + +const flipOptions = { + ease: 'power2.inOut', +} satisfies Flip.FromToVars; + +const styles = { + backgroundColor: 'royalblue', +} satisfies CSSProperties; + +export const Default: Story = { + render(): ReactElement { + const [isFlipped, toggle] = useToggle(false); + const div1Ref = useRef(null); + const div2Ref = useRef(null); + + useFlip(div1Ref, flipOptions); + useFlip(div2Ref, flipOptions); + + useEventListener(globalThis.document, 'click', () => { + toggle(); + }); + + return ( + <> +

+ Click to rerender, the box will fill the right side of the screen using position absolute. +

+
+

The element renders inline

+ + ); + }, +}; diff --git a/src/gsap/hooks/useFlip/useFlip.ts b/src/gsap/hooks/useFlip/useFlip.ts new file mode 100644 index 0000000..69f14a3 --- /dev/null +++ b/src/gsap/hooks/useFlip/useFlip.ts @@ -0,0 +1,26 @@ +import gsap from 'gsap'; +import Flip from 'gsap/Flip'; +import { useEffect, useRef, type MutableRefObject } from 'react'; +import { unref, type Unreffable } from '../../../utils/unref/unref.js'; + +gsap.registerPlugin(Flip); + +export function useFlip( + ref: Unreffable, + flipStateVariables: Flip.FromToVars = {}, +): MutableRefObject { + const flipStateRef = useRef(); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (globalThis.window !== undefined) { + flipStateRef.current = Flip.getState(unref(ref)); + } + + useEffect(() => { + if (flipStateRef.current) { + Flip.from(flipStateRef.current, flipStateVariables); + } + }); + + return flipStateRef; +} diff --git a/src/gsap/hooks/useScrollAnimation/useScrollAnimation.mdx b/src/gsap/hooks/useScrollAnimation/useScrollAnimation.mdx new file mode 100644 index 0000000..6b94572 --- /dev/null +++ b/src/gsap/hooks/useScrollAnimation/useScrollAnimation.mdx @@ -0,0 +1,66 @@ +import { Meta, Canvas } from '@storybook/blocks'; +import * as stories from './useScrollAnimation.stories'; + + + +# useScrollAnimation + +The `useScrollAnimation` hook is used to created an animation with GSAP that uses ScrollTrigger. The +animation is killed when the component unmounts, the animation is updated when one of the +dependencies change. ScrollTrigger is refreshed when an animation instance created with +`useScrollTrigger` is updated. + +## Usage + +The function signature for `useScrollAnimation` is exactly the same as `useAnimation`. + +An animation is created in the callback function of the useAnimation hook. The callback hook should +return an animation instance. The `useScrollAnimation` hook returns a RefObject with the animation +instance, the animation instance is availble on mount. + +The callback accepts a `gsap.core.Animation` instance as a return type. You can use tweens +(`gsap.to`, `gsap.from`, `gsap.fromTo`) and `gsap.timelines` to create an animation. + +```tsx +function ScrollAnimation(): ReactElement { + const ref = useRef(null); + + useScrollAnimation( + () => + gsap.to(ref.current, { + scale: 0.5, + ease: 'power3.out', + scrollTrigger: { + pin: ref.current, + scrub: true, + markers: true, + end: '+=150%', + }, + }), + [], + ); + + return ( +
+
+
+ ); +} +``` + +> Note: do not scrub a React component's root. GSAP modifies the DOM, React won't know what element +> to unmount when the component with a pinned animation must removed from the vDOM. + +## Example + +Keep scrolling to see the animation. + + diff --git a/src/gsap/hooks/useScrollAnimation/useScrollAnimation.stories.tsx b/src/gsap/hooks/useScrollAnimation/useScrollAnimation.stories.tsx new file mode 100644 index 0000000..6ae8e41 --- /dev/null +++ b/src/gsap/hooks/useScrollAnimation/useScrollAnimation.stories.tsx @@ -0,0 +1,44 @@ +/* eslint-disable react/jsx-no-literals, react/no-multi-comp */ +import gsap from 'gsap'; +import { useRef, type ReactElement } from 'react'; +import { useScrollAnimation } from './useScrollAnimation.js'; + +export default { + title: 'hooks/useScrollAnimation', + parameters: { + layout: 'fullscreen', + }, +}; + +export function UseScrollAnimation(): ReactElement { + const ref = useRef(null); + + useScrollAnimation( + () => + gsap.to(ref.current, { + scale: 0.5, + ease: 'power3.out', + scrollTrigger: { + pin: ref.current, + scrub: true, + markers: true, + end: '+=100%', + }, + }), + [], + ); + + return ( +
+
+
+ ); +} diff --git a/src/gsap/hooks/useScrollAnimation/useScrollAnimation.ts b/src/gsap/hooks/useScrollAnimation/useScrollAnimation.ts new file mode 100644 index 0000000..e2f3f13 --- /dev/null +++ b/src/gsap/hooks/useScrollAnimation/useScrollAnimation.ts @@ -0,0 +1,38 @@ +import gsap from 'gsap'; +import ScrollTrigger from 'gsap/ScrollTrigger'; +import { useEffect, useRef, type RefObject } from 'react'; +import { animations } from '../../animations.js'; +import { useAnimation } from '../useAnimation/useAnimation.js'; +import { useExposeAnimation } from '../useExposeAnimation/useExposeAnimation.js'; + +if (typeof window !== 'undefined') { + gsap.registerPlugin(ScrollTrigger); +} + +/** + * Hook to create animation that make use of ScrollTrigger, ScrollTrigger is refreshed + * when the global animations map is updated. + * + * Note: do not scrub a React component's root because React won't know what element to + * unmount when the component with a pinned animation is must removed from the vDOM. + */ +export function useScrollAnimation( + callback: () => T | undefined, + dependencies: ReadonlyArray, +): RefObject { + const animation = useAnimation(callback, dependencies); + + // Expose animation so that we can leverage the listener from the global animations map + const ref = useRef(Symbol('useScrollAnimation')); + useExposeAnimation(animation, ref); + + useEffect( + () => + animations.listen(() => { + ScrollTrigger.refresh(); + }), + [], + ); + + return animation; +} diff --git a/src/gsap/utils/getAnimation/getAnimation.mdx b/src/gsap/utils/getAnimation/getAnimation.mdx new file mode 100644 index 0000000..8302520 --- /dev/null +++ b/src/gsap/utils/getAnimation/getAnimation.mdx @@ -0,0 +1,22 @@ +import { Meta } from '@storybook/blocks'; + + + +# useExposeAnimation + +The `useExposeAnimation` hook is used to expose an animation in the global animation Map for a given +reference (not necessarily an HTMLElement). + +```tsx +const Component = ensuredForwardRef((_, ref): ReactElement => { + const animation = useAnimation(() => gsap.from(...), []); + + useEffect(() => { + const animation = getAnimation(ref.current); + // eslint-disable-next-line no-console + console.log('getAnimation', animation); + }, [ref]); + + return
; +}); +``` diff --git a/src/gsap/utils/getAnimation/getAnimation.stories.tsx b/src/gsap/utils/getAnimation/getAnimation.stories.tsx new file mode 100644 index 0000000..58e6e79 --- /dev/null +++ b/src/gsap/utils/getAnimation/getAnimation.stories.tsx @@ -0,0 +1,42 @@ +/* eslint-disable react/jsx-no-literals, react/no-multi-comp */ +import gsap from 'gsap'; +import { useEffect, useRef, type ReactElement } from 'react'; +import { ensuredForwardRef } from '../../../hocs/ensuredForwardRef/ensuredForwardRef.js'; +import { useAnimation } from '../../hooks/useAnimation/useAnimation.js'; +import { useExposeAnimation } from '../../hooks/useExposeAnimation/useExposeAnimation.js'; +import { getAnimation } from './getAnimation.js'; + +export default { + title: 'hooks/getAnimation', +}; + +const Child = ensuredForwardRef((_, ref): ReactElement => { + const animation = useAnimation( + () => + gsap.from( + { + value: 0, + }, + { + value: 1, + }, + ), + [], + ); + + useExposeAnimation(animation, ref); + + return
Check the console to see the result
; +}); + +export function GetAnimation(): ReactElement { + const ref = useRef(null); + + useEffect(() => { + const animation = getAnimation(ref.current); + // eslint-disable-next-line no-console + console.log('getAnimation', animation); + }, [ref]); + + return ; +} diff --git a/src/gsap/utils/getAnimation/getAnimation.ts b/src/gsap/utils/getAnimation/getAnimation.ts new file mode 100644 index 0000000..e068175 --- /dev/null +++ b/src/gsap/utils/getAnimation/getAnimation.ts @@ -0,0 +1,8 @@ +import { animations } from '../../animations.js'; + +/** + * Tries to get animation from global animations map for given reference + */ +export function getAnimation(reference: unknown): gsap.core.Animation | undefined { + return animations.get(reference); +} diff --git a/src/hooks/useRegisterRef/useRegisterRef.mdx b/src/hooks/useRegisterRef/useRegisterRef.mdx deleted file mode 100644 index e4f6cd7..0000000 --- a/src/hooks/useRegisterRef/useRegisterRef.mdx +++ /dev/null @@ -1,67 +0,0 @@ -import { Meta } from '@storybook/blocks'; - - - -# useRegisterRef - -A helper hook that allows you to easily register and access refs by "name", including ref -collections. - -Due to the dynamic nature of collecting refs, it's tricky to know how many `useRef` calls you need -to consistently do without explicitly setting (and updating when your refs change) the amount. This -is why this hook doesn't use `useRef` at all, but stores the actual DOM elements directly. - -This means that other hooks that rely on `ref.current` will not work. In that case you should change -the hook (if you can), or wrap your element inside a ref (TODO: this can be easily done with the -`todo` hook). - -## Reference - -```ts -function useRegisterRef>(): readonly [ - T, - (name: string, index?: number) => (ref: Element | null) => void, -]; -``` - -### Returns - -- `[refs, registerRef]` - Define the return value here. - - `refs` – An object with the registered DOM Elements, matching the structure and type of the - passed `Refs` in the generics. - - `registerRef` – A factory to create a scoped function to pass to the JSX `ref` attribute. You - pass the name of the ref you want to capture (matching the `Refs` generics) and the index if you - are registering a collection of items, and it returns a function that collects the React element - under that name in the `refs` object. When using arrays (and your `Refs` defines something as an - array), you have to append `[]` to the name of the ref, so we can turn on the array capture - behaviour. - -## Usage - -```tsx -const [refs, registerRef] = useRegisterRef(); -``` - -```tsx -type Refs = { - element: HTMLDivElement; - elements: ReadonlyArray; -}; - -function DemoComponent() { - const [refs, registerRef] = useRegisterRef(); - - console.log(refs.element); // HTMLDivElement - console.log(refs.elements); // Array - - return ( -
- {items.map((item, index) => ( - - {item} - - ))} -
- ); -} -``` diff --git a/src/hooks/useRegisterRef/useRegisterRef.stories.tsx b/src/hooks/useRegisterRef/useRegisterRef.stories.tsx deleted file mode 100644 index 8610d95..0000000 --- a/src/hooks/useRegisterRef/useRegisterRef.stories.tsx +++ /dev/null @@ -1,143 +0,0 @@ -/* eslint-disable react/jsx-no-bind, react/no-multi-comp, react/jsx-no-literals */ -import type { Meta, StoryObj } from '@storybook/react'; -import { shuffle } from 'lodash-es'; -import { useState, type ReactElement } from 'react'; -import { useRegisterRef } from './useRegisterRef.js'; - -const meta = { - title: 'Hooks / useRegisterRef', -} satisfies Meta; - -export default meta; - -type Story = StoryObj; - -type Refs = { - item1: HTMLDivElement | null; - item2: HTMLParagraphElement | null; - btnMore: HTMLButtonElement | null; - listItems: ReadonlyArray | null; - keyList: ReadonlyArray | null; -}; - -function DemoComponent(): ReactElement { - const [refs, registerRef] = useRegisterRef(); - const [count, setCount] = useState(3); - const [keyList, setKeyList] = useState(['key A', 'key B', 'key C']); - - return ( -
-
-

Instructions!

-

See the collected refs from the Test Area below.

-

If you add more or less items, you can test how array capturing works.

-

- If you shuffle the key list, you can test what happens when using stable keys that update - in the array. -

-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Ref nameResolved Element
item1 - {refs.item1?.outerHTML ?? null} -
item2 - {refs.item2?.outerHTML ?? null} -
btnMore - {refs.btnMore?.outerHTML ?? null} -
itemList - -
{refs.listItems?.map((element) => element.outerHTML).join('\n')}
-
-
keyList - -
{refs.keyList?.map((element) => element.outerHTML).join('\n')}
-
-
-
-
-
Test Area
-
-
container 1
-

paragraph 2

- {' '} - -
    - {Array.from({ length: count }, (_, index) => ( -
  • - list item {index} -
  • - ))} -
- -
    - {keyList.map((item, index) => ( -
  • - {item} -
  • - ))} -
-
-
-
- ); -} - -export const Demo: Story = { - name: 'Demo', - render() { - return ; - }, -}; diff --git a/src/hooks/useRegisterRef/useRegisterRef.test.tsx b/src/hooks/useRegisterRef/useRegisterRef.test.tsx deleted file mode 100644 index a001b61..0000000 --- a/src/hooks/useRegisterRef/useRegisterRef.test.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { act, renderHook } from '@testing-library/react'; -import { describe, expect, it } from 'vitest'; -import { useRegisterRef } from './useRegisterRef.js'; - -describe('useRegisterRef', () => { - it('should not crash', () => { - renderHook(useRegisterRef); - }); - - it('should have correct initial state', () => { - const { result } = renderHook(useRegisterRef); - - const [refs, registerRef] = result.current; - expect(refs).toEqual({}); - expect(typeof registerRef).toBe('function'); - }); - - it('should be able to register a ref', () => { - const { result } = renderHook(useRegisterRef); - const [refs, registerRef] = result.current; - - act(() => { - registerRef('item')('A'); - }); - - expect(refs).toEqual({ item: 'A' }); - }); - - it('should be able to register Array refs', () => { - const { result } = renderHook(useRegisterRef); - const [refs, registerRef] = result.current; - - act(() => { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - registerRef('items[]', 0)('A'); - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error - registerRef('items[]', 1)('B'); - }); - - expect(refs).toEqual({ items: ['A', 'B'] }); - }); -}); diff --git a/src/hooks/useRegisterRef/useRegisterRef.ts b/src/hooks/useRegisterRef/useRegisterRef.ts deleted file mode 100644 index bcb4090..0000000 --- a/src/hooks/useRegisterRef/useRegisterRef.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { memoize } from 'lodash-es'; -import { useCallback, useState } from 'react'; - -// appends the `[]` after the existing key when the type is an Array -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type RefKey = T extends ReadonlyArray ? `${K}[]` : K; -// only returns non-array keys -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type RefKeyPlain = T extends ReadonlyArray ? never : K; -// only returns array keys -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export type RefKeyList = T extends ReadonlyArray ? `${K}[]` : never; - -// get modified and optionally filtered object keys -export type RefKeys< - // eslint-disable-next-line @typescript-eslint/no-explicit-any - T extends Record, - M extends 'all' | 'list' | 'plain' = 'all', -> = Exclude< - keyof { - [P in Exclude as M extends 'all' - ? RefKey - : M extends 'plain' - ? RefKeyPlain - : RefKeyList]: T[P]; - }, - number | symbol ->; - -function arrayTrimEnd>(array: T): void { - const existingElementIndex = [...array].reverse().findIndex(Boolean); - const lastExistingItemIndex = - existingElementIndex === -1 ? 0 : array.length - existingElementIndex; - - array.splice(lastExistingItemIndex); -} - -/** - * A helper hook that allows you to easily register and access refs by "name", including ref collections. - * - * Usage: - * ``` - * // this gives auto-completion when registering or accessing the refs - * type RefMap = { - * element: HTMLDivElement; - * elements: ReadonlyArray; - * }; - * - * // put this hook in your component - * const [refs, registerRef] = useRegisterRef(); - * - * // use the object itself - * doSomethingWithRefs(refs); // the complete object of all registered refs as HTML Elements - * - * // or individual refs - * console.log(refs.elements); // the 3
  • elements collected in the array - * - * // use `registerRef(...)` in your JSX to collect the ref - *
    - * {Array.from({ length: 3 }).map((_, index) => ( - * // array-type refs are required to append `[]` when registering - *
  • {index}
  • - * ))} - *
    - * ``` - */ -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types, @typescript-eslint/explicit-function-return-type -export function useRegisterRef>() { - const [refs, setRefs] = useState({} as T); - - // use method overloading to make the 2nd `index` parameter only there and required for array-like - // refs, and leave it out for plain single element refs; - function registerRefInternal>( - name: N, - ): (ref: Exclude) => void; - function registerRefInternal>( - name: N, - index: number, - ): (ref: Exclude) => void; - function registerRefInternal>( - name: N, - index?: number, - ): (ref: Exclude) => void { - return (ref: Exclude) => { - setRefs((oldRefs) => { - // array mode - if (name.endsWith('[]')) { - const cleanName = name.replace('[]', '') as N; - if (!oldRefs[cleanName]) { - oldRefs[cleanName] = [] as T[N]; - } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const slot = oldRefs[cleanName] as Array; - - if (index !== undefined) { - slot[index] = ref; - } - - // if we set something to `null`, try to clean up the array - if (!ref) { - arrayTrimEnd(slot); - } - } - // plain mode - else { - oldRefs[name] = ref; - } - // force re-render - return { ...oldRefs }; - }); - }; - } - - // eslint-disable-next-line react-hooks/exhaustive-deps - const registerRef = useCallback( - // memoize the function creation so it doesn't cause re-renders when they are passed to the `ref` attribute - memoize(registerRefInternal, (..._arguments) => `${_arguments[0]}-${_arguments[1]}`), - [], - ); - - return [refs, registerRef] as const; -} diff --git a/src/index.ts b/src/index.ts index 45634b8..63b73c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,13 @@ /* PLOP_ADD_EXPORT */ export * from './components/AutoFill/AutoFill.js'; +export * from './gsap/components/SplitTextWrapper/SplitTextWrapper.js'; +export * from './gsap/hooks/useAnimation/useAnimation.js'; +export * from './gsap/hooks/useExposeAnimation/useExposeAnimation.js'; +export * from './gsap/hooks/useExposedAnimation/useExposedAnimation.js'; +export * from './gsap/hooks/useExposedAnimations/useExposedAnimations.js'; +export * from './gsap/hooks/useFlip/useFlip.js'; +export * from './gsap/hooks/useScrollAnimation/useScrollAnimation.js'; +export * from './gsap/utils/getAnimation/getAnimation.js'; export * from './hocs/ensuredForwardRef/ensuredForwardRef.js'; export * from './hooks/useClientSideValue/useClientSideValue.js'; export * from './hooks/useEventListener/useEventListener.js'; @@ -18,7 +26,6 @@ export * from './hooks/useRefs/utils/assertAndUnwrapRefs/assertAndUnwrapRefs.js' export * from './hooks/useRefs/utils/unwrapRefs/unwrapRefs.js'; export * from './hooks/useRefs/utils/unwrapRefs/unwrapRefs.types.js'; export * from './hooks/useRefs/utils/validateAndUnwrapRefs/validateAndUnwrapRefs.js'; -export * from './hooks/useRegisterRef/useRegisterRef.js'; export * from './hooks/useResizeObserver/useResizeObserver.js'; export * from './hooks/useStaticValue/useStaticValue.js'; export * from './hooks/useToggle/useToggle.js';