From 648f9464cbf88147ba312158a0b5675731444a61 Mon Sep 17 00:00:00 2001 From: Matthew Cardarelli Date: Tue, 6 Aug 2024 16:26:30 -0700 Subject: [PATCH] Get a hello world set up --- .gitignore | 1 + AnimatedListDemo.tsx | 5 ++ index.tsx | 21 ++++++++ package-lock.json | 37 +++++++++++++ package.json | 4 ++ public/index.html | 11 ++++ tsconfig.json | 11 ++++ useAnimatedListItems.ts | 113 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 203 insertions(+) create mode 100644 AnimatedListDemo.tsx create mode 100644 index.tsx create mode 100644 public/index.html create mode 100644 tsconfig.json create mode 100644 useAnimatedListItems.ts diff --git a/.gitignore b/.gitignore index 3c3629e..48d3b1f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules +public/scripts diff --git a/AnimatedListDemo.tsx b/AnimatedListDemo.tsx new file mode 100644 index 0000000..42b106e --- /dev/null +++ b/AnimatedListDemo.tsx @@ -0,0 +1,5 @@ +import React from 'react'; + +export const AnimatedListDemo = () => { + return
Hello, world
; +}; diff --git a/index.tsx b/index.tsx new file mode 100644 index 0000000..02549e0 --- /dev/null +++ b/index.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Root, createRoot } from 'react-dom/client'; +import { AnimatedListDemo } from './AnimatedListDemo'; + +class AnimatedListComponent extends HTMLElement { + root: Root; + + constructor() { + super(); + } + + connectedCallback() { + console.log('Hello, world!'); + const rootNode = document.createElement('main'); + this.appendChild(rootNode); + this.root = createRoot(rootNode); + this.root.render(); + } +} + +customElements.define('react-animated-list-demo', AnimatedListComponent); diff --git a/package-lock.json b/package-lock.json index 1bfb253..0ab72e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ "react-dom": "18.3.1" }, "devDependencies": { + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", "esbuild": "0.23.0" } }, @@ -424,6 +426,41 @@ "node": ">=18" } }, + "node_modules/@types/prop-types": { + "version": "15.7.12", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.3", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", + "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.0.tgz", + "integrity": "sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, "node_modules/esbuild": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.0.tgz", diff --git a/package.json b/package.json index 3587545..fdf8e32 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "description": "A demo of an implementation of an animated list in React Typescript.", "main": "index.tsx", "scripts": { + "build": "esbuild index.tsx --bundle", + "dev": "esbuild index.tsx --bundle --watch --serve --outdir=./public/scripts --servedir=./public", "test": "echo \"Error: no test specified\" && exit 1" }, "repository": { @@ -13,6 +15,8 @@ "author": "Matthew Cardarelli", "license": "MIT", "devDependencies": { + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", "esbuild": "0.23.0" }, "dependencies": { diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..6b67d9b --- /dev/null +++ b/public/index.html @@ -0,0 +1,11 @@ + + + + + React animated list demo + + + + + + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4316054 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "jsx": "preserve", + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["DOM", "ESNext"], + "noEmit": true, + "alwaysStrict": true + } +} diff --git a/useAnimatedListItems.ts b/useAnimatedListItems.ts new file mode 100644 index 0000000..f8191a9 --- /dev/null +++ b/useAnimatedListItems.ts @@ -0,0 +1,113 @@ +import { useLayoutEffect, useRef, useState } from 'react'; + +interface ItemTops { + [id: number]: number | undefined; +} + +interface ListItemRefsById { + [id: number]: HTMLLIElement | undefined; +} + +interface ItemOffsets { + [id: number]: number | undefined; +} + +interface ItemStyles { + position: 'relative'; + top: number; + transition: string; +} + +interface UseAnimatedListItemArgs { + /** + * **IMPORTANT - This list must be memoized. The array reference should only change when the + * list of items changes.** + * + * The list of item keys. Each key should uniquely identify a `` component, and + * should be the same value passed to the `ListItem`'s `key` prop. + */ + keys: number[] | undefined; +} + +interface UseAnimatedListItemReturns { + /** + * Item styles that should be passed to the `sx` prop of each `ListItem`. The styles are + * mapped per item key. + */ + itemStyles: { [key: number]: ItemStyles | undefined }; + + /** + * Function that must be called within the `ref` callback prop of each `ListItem`. + * + * @param {number} key - the `ListItem`'s unique key. + * @param {HTMLLIElement} li - the list item element reference. + * @returns {void} + */ + updateItemRef: (key: number, li: HTMLLIElement | null) => void; + + /** + * Function that must be called within each and every callback that causes the list of items + * to refresh or update. This is used to take a "snapshot" of the items current positions prior + * to any change. + * + * @returns {void} + */ + updateItemPositions: () => void; +} + +/** + * This hook can be plugged into a MUI to animate the components whenever + * change their order. + */ +export const useAnimatedListItems = ({ + keys, +}: UseAnimatedListItemArgs): UseAnimatedListItemReturns => { + const itemRefs = useRef({}); + const itemTops = useRef({}); + const [itemOffsets, setItemOffsets] = useState({}); + + useLayoutEffect(() => { + if (!keys) return; + + const newItemOffsets: ItemOffsets = {}; + keys.forEach((key) => { + const itemRef = itemRefs.current[key]; + if (itemRef) { + const currentTop = itemRef.getBoundingClientRect().top; + const prevTop = itemTops.current[key] || 0; + const offset = -(currentTop - prevTop); + newItemOffsets[key] = offset; + } + }); + setItemOffsets(newItemOffsets); + + requestAnimationFrame(() => { + setItemOffsets({}); + }); + }, [keys]); + + let itemStyles: UseAnimatedListItemReturns['itemStyles'] = {}; + keys?.forEach((key) => { + itemStyles[key] = { + position: 'relative', + top: itemOffsets[key] || 0, + transition: + !itemTops.current[key] || itemOffsets[key] ? 'top 0s' : 'top 1s', + }; + }); + + return { + itemStyles, + updateItemRef: (key, li) => { + li === null ? delete itemRefs.current[key] : (itemRefs.current[key] = li); + }, + updateItemPositions: () => { + keys?.forEach((key) => { + const itemRef = itemRefs.current[key]; + if (itemRef) { + itemTops.current[key] = itemRef.getBoundingClientRect().top; + } + }); + }, + }; +};