├── .gitignore ├── .npmignore ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── demos ├── basicFollow │ ├── basicFollow.html │ └── basicFollow.tsx ├── expander │ ├── expander.html │ └── expander.tsx ├── fadeFollow │ ├── fadeFollow.html │ └── fadeFollow.tsx ├── gridFollow │ ├── gridFollow.html │ └── gridFollow.tsx ├── lazyExpander │ ├── lazyExpander.html │ └── lazyExpander.tsx ├── list │ ├── list.html │ └── list.tsx ├── multipleFollow │ ├── multipleFollow.html │ └── multipleFollow.tsx └── repeat │ ├── repeat.html │ └── repeat.tsx ├── index.tsx ├── jest.config.js ├── package.json ├── rollup.config.js ├── src ├── SpringyDOMElement.tsx ├── getSpringyDOMElement.test.tsx ├── getSpringyDOMElement.tsx ├── helpers │ ├── domStyleProperties.ts │ ├── getConfig.ts │ ├── getUnits.ts │ ├── handleForwardedRef.ts │ ├── reconciler.ts │ └── testHelpers.ts ├── springyGroups │ ├── SpringyFollowGroup.tsx │ ├── SpringyRepeater.tsx │ ├── SpringyRepositionGroup.tsx │ └── childRegisterContext.tsx └── types.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | dist 4 | *.log 5 | .rts2_* 6 | .rpt2_* -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .cache 3 | .rts2_* 4 | demos -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Jest All", 8 | "program": "${workspaceFolder}/node_modules/.bin/jest", 9 | "args": ["--runInBand", "--verbose"], 10 | "console": "integratedTerminal", 11 | "internalConsoleOptions": "neverOpen", 12 | "disableOptimisticBPs": true, 13 | "windows": { 14 | "program": "${workspaceFolder}/node_modules/jest/bin/jest", 15 | } 16 | }, 17 | { 18 | "type": "node", 19 | "request": "launch", 20 | "name": "Jest Current File", 21 | "program": "${workspaceFolder}/node_modules/.bin/jest", 22 | "args": [ 23 | "${fileBasenameNoExtension}", 24 | "--config", 25 | "jest.config.js" 26 | ], 27 | "console": "integratedTerminal", 28 | "internalConsoleOptions": "neverOpen", 29 | "disableOptimisticBPs": true, 30 | "windows": { 31 | "program": "${workspaceFolder}/node_modules/jest/bin/jest", 32 | } 33 | } 34 | ] 35 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | 'Software'), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React SPHO 2 | 3 | The (hopefully) easiest to use animation library for React for interactive applications. 4 | 5 | Installation: 6 | 7 | ```bash 8 | npm install @ismailman/react-spho 9 | ``` 10 | 11 | or 12 | 13 | ```bash 14 | yarn add @ismailman/react-spho 15 | ``` 16 | 17 | Quick example of how it works: 18 | 19 | ```typescript 20 | // import the key function from the library 21 | import {getSpringyDOMElement} from '@ismailman/react-spho'; 22 | 23 | // create a "Springy" version of a div 24 | const SpringyDiv = getSpringyDOMElement('div'); 25 | 26 | 27 | // Use the Springy version of the div inside of your component 28 | // whenever the left prop is changed the SpringyDiv will automatically 29 | // transition to the new left value over time in a springy/organic feeling way 30 | function ExampleComponent({left, children}){ 31 | 32 | return ( 33 | 34 | {children} 35 | 36 | ); 37 | 38 | } 39 | ``` 40 | 41 | The main idea is that you create _Springy_ versions of regular DOM elements, and then any style property values you want to animate put those values in the `springyStyle` prop instead of the `style` prop. That's the basic usage. 42 | 43 | ## Important information about the springyStyle prop 44 | 45 | The `springyStyle` prop is very similar to the standard `style` prop with some key differences, that should hopefully make springyStyle easier to use than regular style. Specifically: 46 | 47 | - the values are only *numbers*, no *strings* (except for `auto` for certain style properties) 48 | ```typescript 49 | // this is good 50 | springyStyle={{ 51 | left: 10, 52 | top: 20 53 | }} 54 | 55 | // this is bad 56 | springyStyle={{ 57 | left: '10px', 58 | top: '20px' 59 | }} 60 | ``` 61 | - you don't set the `transform` property directly, instead the transform commands (`translate`, `translateX`, `scale`, `rotate`, ect) need to be set 62 | ```typescript 63 | // this is good 64 | springyStyle={{ 65 | translateX: 10, 66 | rotateY: 30 67 | }} 68 | 69 | // this is bad 70 | springyStyle={{ 71 | transform: 'translateX(10px) rotateY(30deg)' 72 | }} 73 | ``` 74 | - `auto` should "Just Work" for: `width`, `height`, `margin`, `top`, `right`, `bottom`, `left` - if in one render you specify one of those properties as a number, and then in anothe render you specify the property value as `'auto'` **React SPHO** will make sure that the value animates between the original value and the new auto value. This also works if you go from `'auto'` to an explicit value. 75 | 76 | ## Configuring Spring "Feel" 77 | 78 | You configure the spring dynamics when you create the Springy version of your DOM element. You can't change the dynamics once they've been set. 79 | 80 | ```typescript 81 | // create a new SpringyDiv where the translateX spring 82 | // will move a bit slower but have more bounce, and the 83 | // translateY spring will move faster but have no bounce 84 | const SpringyDiv = getSpringyDOMElement('div', { 85 | translateX: { 86 | speed: 0.8, 87 | bounciness: 1.2 88 | }, 89 | translateY: { 90 | speed: 1.5, 91 | bounciness: 0.5 92 | } 93 | }); 94 | ``` 95 | 96 | For the spring config specifically, other spring libraries will have you choose things like `mass`, `dampening`, `friction`, etc. I found that too confusing and so I tried to simplify to just `speed` and `bounciness` which should hopefully be self-describing. 97 | 98 | ## Animating component entering, and component exiting 99 | 100 | This is always a huge pain in the butt with React, so **React SPHO** makes this easier by letting you set enter and exit values on properties for when components are entering and exiting. 101 | ```typescript 102 | // creates a SpringyDiv component that when mounted 103 | // will grow from zero height to the auto "natural" 104 | // height, and also will fade in when entering, and 105 | // fade out when exiting 106 | const SpringyDiv = getSpringyDOMElement('div', { 107 | height: { 108 | onEnterFromValue: 0, 109 | onEnterToValue: 'auto' 110 | }, 111 | opacity: { 112 | onEnterFromValue: 0, 113 | onEnterToValue: 1, 114 | onExitFromValue: 1, 115 | onExitToValue: 0 116 | } 117 | }); 118 | ``` 119 | 120 | ## Animating multiple elements together 121 | 122 | There are times when you want the animation of multiple elements to be tied together in some way, **React SPHO** lets you do this for a few cases. 123 | 124 | ### `SpringyRepeater` 125 | 126 | ```typescript 127 | import {SpringyRepeater, getSpringyDOMElement} from '@ismailman/react-spho'; 128 | 129 | const SpringyDiv = getSpringyDOMElement('div'); 130 | 131 | function ExampleComponent() { 132 | 133 | // will constantly change the scale of the two 134 | // SpringyDivs from 0 to 1 and back to 0 and back 135 | // to 1 forever and ever 136 | return ( 137 | 145 | Hello 146 | World 147 | 148 | ); 149 | 150 | } 151 | ``` 152 | 153 | ### `SpringyFollowGroup` 154 | ```typescript 155 | import {SpringyFollowGroup, getSpringyDOMElement} from '@ismailman/react-spho'; 156 | 157 | const SpringyDiv = getSpringyDOMElement('div'); 158 | 159 | function ExampleComponent({mousePosition}) { 160 | 161 | // we don't need to specify the translateX and translateY 162 | // values of the second SpringyDiv element since they're 163 | // part of the same SpringyFollowGroup and the 2nd element 164 | // will have the translateX, translateY properties 165 | // "follow" the leader's values (the 166 | // SpringyDiv with springyOrderedIndex=0) 167 | return ( 168 | 171 | 178 | Hello 179 | 180 | 181 | World 182 | 183 | 184 | ); 185 | 186 | } 187 | ``` 188 | 189 | ### `SpringyRepositionGroup` 190 | ```typescript 191 | import {SpringyRepositionGroup, getSpringyDOMElement} from '@ismailman/react-spho'; 192 | 193 | const SpringyDiv = getSpringyDOMElement('div'); 194 | 195 | function ExampleComponent({list}) { 196 | 197 | // when items are added/removed/repositioned 198 | // in the list the SpringyDivs will animate to 199 | // their new positions automatically 200 | return ( 201 | 202 | { 203 | list.map(item => ( 204 | 205 | {item.text} 206 | 207 | )) 208 | } 209 | 210 | ); 211 | } 212 | ``` 213 | --- 214 | # Examples on Code Sandbox 215 | 216 | - [Follow group](https://codesandbox.io/s/reactspho-follow-example-5oqqk) 217 | - [2-dimensional following in a grid](https://codesandbox.io/s/grid-follow-reactspho-example-gjtlu) 218 | - [Repeating animations](https://codesandbox.io/s/reactspho-repeating-dcyk6) 219 | - [Elements transitioning in and out and animated repositioning](https://codesandbox.io/s/reactspho-list-example-wr8ct) 220 | 221 | --- 222 | # API Reference 223 | 224 | #### `getSpringyDOMElement(domElementName: string, propertyConfigs?: ConfigMap, styleOnExit?: StyleOnExitObject | (DOMNode) => StyleOnExitObject): SpringyComponent` 225 | 226 | Creates a new React Component that is a "Springy" version of the DOM element you passed in. Only native DOM elements (`div`, `a`, `p`, `img`, etc are supported). You can't pass in your own custom components. Use the SpringyComponent where you would usually use a `
` or whatever, and get organic springy animations for free. 227 | 228 | ```typescript 229 | const SpringyDiv = getSpringyDOMElement('div'); 230 | const SpringyAnchor = getSpringyDOMElement('a'); 231 | const SpringyImg = getSpringyDOMElement('img'); 232 | ``` 233 | 234 | ## ConfigMap 235 | 236 | A map of style property names (`left`, `translateX`, `margin`, `opacity`, etc) to a configuration that specifies how they behave as their values change for a particular element. More specifically: 237 | ```typescript 238 | type ConfigMap = { 239 | [key: string]: Config 240 | }; 241 | 242 | type Config = { 243 | // the default speed of the spring for this property 244 | speed?: number; 245 | 246 | // the default bounciness of the spring for this property 247 | bounciness?: number; 248 | 249 | // when the new "target" value is larger than 250 | // the old "target" value you can update the spring's properties 251 | configWhenGettingBigger?: { 252 | speed?: number; 253 | bounciness?: number; 254 | }; 255 | 256 | // when the new "target" value is smaller than the old 257 | // "target" value you can update the spring's properties 258 | configWhenGettingSmaller?: { 259 | speed?: number; 260 | bounciness?: number; 261 | }; 262 | 263 | // if you don't want an absolute starting number, but 264 | // instead want to make the number relative to the initial 265 | // target (i.e. the height targets to auto, but you want 266 | // to start from 20px less) 267 | onEnterFromValueOffset?: number; 268 | 269 | // when you want to animate in from an explicit 270 | // starting value (think 0 for opacity) 271 | onEnterFromValue?: number; 272 | 273 | // when the component is mounted what should 274 | // the spring animate to 275 | onEnterToValue?: number | 'auto'; 276 | 277 | // when unmounting what should the spring start from. 278 | onExitFromValue?: number | 'auto'; 279 | 280 | // when unmounting what should the spring animate to 281 | // before being fully removed from the DOM. When using 282 | // onExitToValue an element is cloned and is placed in 283 | // the same position as the element that is being removed. 284 | // This clone gets a couple of styles, specifically 285 | // `pointer events: none` and the `width` and `height` are 286 | // explicitly set to the computed value when the clone is created. 287 | onExitToValue?: number; 288 | 289 | // what units should be used for this property? 290 | // You can't change/mix units for a specific property. 291 | units?: string; 292 | } 293 | ``` 294 | 295 | Example 296 | ```typescript 297 | const SpringyDiv = getSpringyDOMElement('div', { 298 | scale: { 299 | speed: 1.2, 300 | bounciness: 1.2, 301 | onEnterFromValue: 0.1, 302 | onEnterToValue: 1, 303 | onExitToValue: 2 304 | }, 305 | opacity: { 306 | onExitToValue: 0 307 | }, 308 | height: { 309 | configWhenGettingBigger: { 310 | bounciness: 1.2 311 | }, 312 | configWhenGettingSmaller: { 313 | bounciness: 0.5 314 | }, 315 | units: 'vh' 316 | } 317 | }); 318 | ``` 319 | 320 | ## StyleOnExitObject 321 | 322 | If you supply an onExitToValue then what **React SPHO** does under the hood is that when the element gets unmounted a clone of the element is created and placed in the same DOM tree position and the style elements are animated on that cloned DOM node. Sometimes you'll want to add extra styles to that cloned DOM node, and this is where you specify that (i.e. position absolute, a background color, whatever). 323 | 324 | ```typescript 325 | const SpringyDiv = getSpringyDOMElement('div', null, { 326 | position: 'absolute', 327 | zIndex: 10, 328 | backgroundColor: 'red' 329 | }); 330 | 331 | // or with a function, where node is the newly cloned node 332 | const SpringyDiv = getSpringyDOMElement('div', null, (node) => { 333 | return { 334 | position: 'absolute', 335 | zIndex: 10, 336 | backgroundColor: 'red' 337 | }; 338 | }); 339 | 340 | ``` 341 | 342 | ## SpringyComponent Props 343 | 344 | When you have a springy version of a DOM element and you render that new component, all the normal props you can pass into a native DOM element work the same (tabIndex, src, title, etc). The SpringyComponent version also handles some extra props around the spring functionality. 345 | 346 | ```typescript 347 | type SpringyComponentProps = { 348 | // map of style properties to values 349 | springyStyle: Object; 350 | 351 | // Allows you to register a listener for 352 | // spring value updates. For advanced usage where 353 | // you want to take action when a certain spring 354 | // property reaches a certain value. 355 | onSpringyPropertyValueUpdate: (property: string, value: number) => void; 356 | 357 | // Allows you to register a listener for when a 358 | // spring comes to a stop. You can think of this 359 | // like the animation ending. 360 | onSpringPropertyValueAtRest: (property: string, value: number) => void 361 | 362 | // When the SpringyComponent is a child of a SpringyGroup 363 | // component (SpringyRepeater or SpringyFollowGroup) then 364 | // the springyOrderedIndex specifies the ordering of the 365 | // element in that group. Within a group there needs to 366 | // be exactly ONE element with a springyOrderedIndex of 0 367 | springyOrderedIndex: number; 368 | 369 | // This is for a pretty advanced use case - essentially when 370 | // the you define an onExitToValue and an element is 371 | // animating out, if you then do another render pass 372 | // and want to render that same "logical" element again 373 | // (think of a list that gets filtered, and then you take off the filter) 374 | // if you use the same globalUniqueIDForSpringReuse the 375 | // new element will "take over" the springs for that old 376 | // element and things will transition nicely. 377 | globalUniqueIDForSpringReuse: string; 378 | 379 | // when you use a ref on a SpringyComponent you'll just get 380 | // access to the DOM node as if you had rendered a normal 381 | // "div". If you want to get access to the SpringyComponent 382 | // instance directly, then you need to pass in an "instanceRef" prop. 383 | // This most likely will be used very very little and is here 384 | // more as an "escape" hatch if you need to do something 385 | // very sophisticated. 386 | instanceRef: (ref: SpringyComponent) => void; 387 | }; 388 | ``` 389 | 390 | Example 391 | ```typescript 392 | const SpringyDiv = getSpringyDOMElement('div'); 393 | 394 | const uniqueID = String(Math.random()); 395 | 396 | function ExampleComponent() { 397 | return ( 398 | { 406 | console.log(property, 'has a new value:', value); 407 | }) 408 | onSpringyPropertyValueAtRest((property, value) => { 409 | console.log(property, 'has stopped moving with value:', value); 410 | }) 411 | globalUniqueIDForSpringyReuse={uniqueID} 412 | > 413 | Hello World 414 | 415 | ); 416 | } 417 | ``` 418 | 419 | 420 | --- 421 | # Springy Group APIs 422 | These components let you define how springs for multiple elements should be animated together. 423 | 424 | ## `SpringyRepeater Props` 425 | 426 | ```typescript 427 | type SpringyRepeaterProps = { 428 | // a map of properties to repeat over and what values repeat to and from 429 | springyRepeaterStyles: { 430 | [key: string]: { 431 | from: number; 432 | to: number; 433 | } 434 | }; 435 | 436 | // determines how the animation repeats. Does it 437 | // go back-and-from from "from" to "to" to "from" to "to", etc. 438 | // Or does it always start from the beginning each time 439 | // from "from" to "to" and then from "from" to "to" again. 440 | // The default is "back-and-forth". 441 | direction: 'from-beginning-each-time' | 'back-and-forth'; 442 | 443 | // if multiple SpringyComponents are achild of a SpringyRepeater 444 | // you can stagger their start times with this number in milliseconds. 445 | // The springyOrderedIndex is used for ordering. 446 | delayStartBetweenChildren: number; 447 | 448 | // if you are repeating the values for multiple different 449 | // properties (think rotation and opacity) and you want 450 | // those properties to be synchronized, i.e. they will always 451 | // start and end at the same time, then set this to true. 452 | // Defaults to false if Object.keys(springyRepeaterStyles).length=1 453 | // and defaults to true if Object.keys(springyRepeaterStyles).length > 1. 454 | normalizeToZeroAndOn: boolean; 455 | 456 | // by default this is "infinite" which means the values 457 | // will animate forever, but if you only want to do a 458 | // finite number of animations then specify a number here 459 | numberOfTimesToRepeat: number | 'infinite'; 460 | }; 461 | ``` 462 | 463 | Example 464 | ```typescript 465 | 466 | import {SpringyRepeater} from '@ismailman/react-spho'; 467 | 468 | function Example() { 469 | 470 | return ( 471 | 486 | 487 | Hello 488 | 489 | 490 | World 491 | 492 | 493 | ); 494 | 495 | } 496 | ``` 497 | 498 | ## `SpringyFollowGroup Props` 499 | 500 | ```typescript 501 | type SpringyFollowGroupProps = { 502 | // specify which springy properties should be followed. 503 | // You can specify just a string for the name, or use an 504 | // object where you specify a name and an offset 505 | properties: Array; 509 | } 510 | ``` 511 | 512 | Example 513 | ```typescript 514 | import {SpringyFollowGroup} from '@ismailman/react-spho'; 515 | 516 | function ExampleComponent({mousePosition}) { 517 | return ( 518 | 527 | 534 | Hello 535 | 536 | 537 | World 538 | 539 | 540 | ); 541 | } 542 | ``` 543 | 544 | ## `SpringyRepositionGroup Props` 545 | 546 | There actually are no props to the SpringyRepositionGroup element itself, just the children. But it's very important to note: SpringyRepositionGroup only works with SpringyDOMElements. The reposition group uses `translateX` and `translateY` to *flip* from the old position to new. For translate properties to have a visual effect the element needs to be a "block level element". Most commonly this will be display: block or display:inline-block. display: flex also works. 547 | 548 | Example 549 | ```typescript 550 | import {SpringyRepositionGroup} from '@ismailman/react-spho'; 551 | 552 | function ExampleComponent({list}) { 553 | return ( 554 | 555 | { 556 | list.map(item => ( 557 | 558 | {item.text} 559 | 560 | )) 561 | } 562 | 563 | ); 564 | } 565 | 566 | ``` -------------------------------------------------------------------------------- /demos/basicFollow/basicFollow.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 18 | 19 | -------------------------------------------------------------------------------- /demos/basicFollow/basicFollow.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import {getSpringyDOMElement} from '../../index'; 5 | 6 | const SDiv = getSpringyDOMElement('div'); 7 | 8 | 9 | function Trail() { 10 | const [left, setLeft] = useState(0); 11 | const [top, setTop] = useState(0); 12 | 13 | return ( 14 |
{ 16 | setLeft(e.pageX); 17 | setTop(e.pageY); 18 | }} 19 | > 20 | 21 |
22 |
23 | ); 24 | } 25 | 26 | ReactDOM.render( 27 | , 28 | document.getElementById('app') 29 | ); -------------------------------------------------------------------------------- /demos/expander/expander.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /demos/expander/expander.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import {getSpringyDOMElement} from '../../index'; 5 | 6 | const SDiv = getSpringyDOMElement( 7 | 'div', 8 | { 9 | height: { 10 | configWhenGettingBigger: { 11 | bounciness: 0.9, 12 | speed: 1.2 13 | }, 14 | configWhenGettingSmaller: { 15 | bounciness: 0.5, 16 | speed: 3 17 | } 18 | } 19 | } 20 | ); 21 | 22 | const LOREM = 'Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of "de Finibus Bonorum et Malorum" (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, "Lorem ipsum dolor sit amet..", comes from a line in section 1.10.32. The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from "de Finibus Bonorum et Malorum" by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham.'; 23 | 24 | 25 | function Expander() { 26 | const [expanded, setExpanded] = useState(false) 27 | const [text, setText] = useState(LOREM); 28 | 29 | return ( 30 |
31 | 36 | 41 | 42 | 47 |
48 | 52 | {text} 53 | 54 |
55 | 56 |
57 | ); 58 | } 59 | 60 | ReactDOM.render( 61 | , 62 | document.getElementById('app') 63 | ); -------------------------------------------------------------------------------- /demos/fadeFollow/fadeFollow.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /demos/fadeFollow/fadeFollow.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import {getSpringyDOMElement, SpringyFollowGroup, SpringyRepositionGroup} from '../../index'; 5 | 6 | const SDiv = getSpringyDOMElement('div', { 7 | opacity: { 8 | onEnterFromValue: 0, 9 | onEnterToValue: 1, 10 | onExitToValue: 0, 11 | bounciness: 0.5 12 | }, 13 | translateY: { 14 | onEnterFromValue: -10, 15 | onEnterToValue: 0, 16 | onExitToValue: -10, 17 | bounciness: 0.5 18 | }, 19 | scale: { 20 | onEnterFromValue: 2, 21 | onEnterToValue: 1, 22 | onExitToValue: 2, 23 | configWhenGettingSmaller: { 24 | bounciness: 0.5, 25 | speed: 3 26 | } 27 | } 28 | }); 29 | 30 | const arr = [1, 2, 3, 4, 5]; 31 | function Trail() { 32 | const [show, setShow] = useState(false); 33 | 34 | return ( 35 |
36 |
37 | 40 |
41 | 42 | { 43 | show && arr.map((_, index) => ( 44 | 50 | )) 51 | } 52 | 53 |
54 | ); 55 | } 56 | 57 | ReactDOM.render( 58 | , 59 | document.getElementById('app') 60 | ); -------------------------------------------------------------------------------- /demos/gridFollow/gridFollow.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 19 | 20 | -------------------------------------------------------------------------------- /demos/gridFollow/gridFollow.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import {getSpringyDOMElement, SpringyFollowGroup, SpringyRepeater} from '../../index'; 5 | 6 | const SDiv = getSpringyDOMElement('div'); 7 | 8 | function Trail() { 9 | const [length, setLength] = useState(5); 10 | const [width, setWidth] = useState(5); 11 | const [centerRow, setCenterRow] = useState(-1); 12 | const [centerColumn, setCenterColumn] = useState(-1); 13 | const [small, setSmall] = useState(false); 14 | 15 | return ( 16 |
17 |
18 |
19 | Length: setLength(parseInt(e.target.value)) }/> 20 |
21 |
22 | Width: setWidth(parseInt(e.target.value)) }/> 23 |
24 |
25 | 26 | { 27 | [...Array(length)].map((_, row) => ( 28 |
29 | { 30 | [...Array(width)].map((_, column) => { 31 | if(row === centerRow && column === centerColumn) { 32 | return ( 33 | 44 | { 48 | setCenterRow(row); 49 | setCenterColumn(column); 50 | setSmall(!small); 51 | }} 52 | /> 53 | 54 | ); 55 | } 56 | else { 57 | return ( 58 | { 63 | setCenterRow(row); 64 | setCenterColumn(column); 65 | setSmall(!small); 66 | }} 67 | /> 68 | ); 69 | } 70 | }) 71 | } 72 |
73 | )) 74 | } 75 |
76 |
77 | ); 78 | } 79 | 80 | ReactDOM.render( 81 | , 82 | document.getElementById('app') 83 | ); -------------------------------------------------------------------------------- /demos/lazyExpander/lazyExpander.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /demos/lazyExpander/lazyExpander.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import {getSpringyDOMElement} from '../../index'; 5 | 6 | const SDiv = getSpringyDOMElement( 7 | 'div', 8 | { 9 | height: { 10 | bounciness: 0.5, 11 | speed: 1.5, 12 | onEnterFromValue: 200 13 | } 14 | } 15 | ); 16 | 17 | 18 | function LazyExpander() { 19 | const [expanded, setExpanded] = useState(false); 20 | const [isElementAdded, setIsElementAdded] = useState(false); 21 | 22 | return ( 23 |
24 | 33 | { 34 | isElementAdded && ( 35 | { 39 | if(property !== 'height' || value !== 0) return; 40 | setIsElementAdded(false); 41 | }} 42 | > 43 | Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lorem Ipsum passage, and going through the cites of the word in classical literature, discovered the undoubtable source. Lorem Ipsum comes from sections 1.10.32 and 1.10.33 of "de Finibus Bonorum et Malorum" (The Extremes of Good and Evil) by Cicero, written in 45 BC. This book is a treatise on the theory of ethics, very popular during the Renaissance. The first line of Lorem Ipsum, "Lorem ipsum dolor sit amet..", comes from a line in section 1.10.32. 44 | 45 | The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for those interested. Sections 1.10.32 and 1.10.33 from "de Finibus Bonorum et Malorum" by Cicero are also reproduced in their exact original form, accompanied by English versions from the 1914 translation by H. Rackham. 46 | 47 | ) 48 | } 49 | 50 |
51 | ); 52 | } 53 | 54 | ReactDOM.render( 55 | , 56 | document.getElementById('app') 57 | ); -------------------------------------------------------------------------------- /demos/list/list.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /demos/list/list.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import {getSpringyDOMElement, SpringyRepositionGroup} from '../../index'; 5 | 6 | const SDiv = getSpringyDOMElement( 7 | 'div', 8 | { 9 | translateX: { 10 | onEnterFromValue: -110, 11 | onEnterToValue: 0, 12 | onExitFromValue: 0, 13 | onExitToValue: -110, 14 | units: '%' 15 | } 16 | }, 17 | (node) => { 18 | return { 19 | position: 'absolute', 20 | zIndex: 1, 21 | top: node.offsetTop, 22 | left: node.offsetLeft 23 | } as React.CSSProperties; 24 | } 25 | ); 26 | 27 | 28 | function List() { 29 | const [itemList, setItemList] = useState([]); 30 | 31 | return ( 32 |
33 | 38 | 43 | 48 | 49 | { 50 | itemList.map((item, index) => ( 51 | 55 | {new Date(item).toISOString()} 56 | 61 | 62 | )) 63 | } 64 | 65 | 66 |
67 | ); 68 | } 69 | 70 | ReactDOM.render( 71 | , 72 | document.getElementById('app') 73 | ); 74 | 75 | // taken from https://www.w3resource.com/javascript-exercises/javascript-array-exercise-17.php 76 | function shuffle(arra1) { 77 | var ctr = arra1.length, index; 78 | const newArray = [...arra1]; 79 | 80 | // While there are elements in the array 81 | while (ctr > 0) { 82 | // Pick a random index 83 | index = Math.floor(Math.random() * ctr); 84 | // Decrease ctr by 1 85 | ctr--; 86 | // And swap the last element with it 87 | const temp = newArray[ctr]; 88 | newArray[ctr] = newArray[index]; 89 | newArray[index] = temp; 90 | } 91 | return newArray; 92 | } -------------------------------------------------------------------------------- /demos/multipleFollow/multipleFollow.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 18 | 19 | -------------------------------------------------------------------------------- /demos/multipleFollow/multipleFollow.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import {getSpringyDOMElement, SpringyFollowGroup} from '../../index'; 5 | 6 | const SDiv = getSpringyDOMElement('div'); 7 | 8 | const arr = [1, 2, 3, 4, 5]; 9 | function Trail() { 10 | const [left, setLeft] = useState(0); 11 | const [top, setTop] = useState(0); 12 | 13 | return ( 14 |
{ 16 | setLeft(e.pageX); 17 | setTop(e.pageY); 18 | }} 19 | > 20 | 21 | 22 | { 23 | arr.map((_, index) => ( 24 | 25 | )) 26 | } 27 | 28 |
29 |
30 | ); 31 | } 32 | 33 | ReactDOM.render( 34 | , 35 | document.getElementById('app') 36 | ); -------------------------------------------------------------------------------- /demos/repeat/repeat.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 | 6 | 7 | 54 | 55 | -------------------------------------------------------------------------------- /demos/repeat/repeat.tsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import {getSpringyDOMElement, SpringyRepeater, SpringyFollowGroup} from '../../index'; 5 | 6 | const BouncyDiv = getSpringyDOMElement('div', { 7 | translateY: {speed: 0.6}, 8 | rotate: {speed: 0.2} 9 | }); 10 | 11 | const PulseDiv = getSpringyDOMElement('div', { 12 | scale: {speed: 0.3, bounciness: 0.5}, 13 | opacity: {speed: 0.3, bounciness: 0.5} 14 | }); 15 | 16 | 17 | function Repeater() { 18 | return ( 19 |
20 |
21 |

Back and Forth with Follow

22 |
23 | 24 | 33 | 34 | 35 | 36 | 37 | 38 | 39 |
40 |
41 |
42 |

Pulsing

43 |
44 | 59 | 63 | 67 | 71 | 72 |
73 |
74 |
75 |

Spinning

76 |
77 | 86 | 87 |
88 | 89 | 90 |
91 |
92 |
93 | ); 94 | } 95 | 96 | ReactDOM.render( 97 | , 98 | document.getElementById('app') 99 | ); -------------------------------------------------------------------------------- /index.tsx: -------------------------------------------------------------------------------- 1 | export {default as getSpringyDOMElement} from './src/getSpringyDOMElement'; 2 | export {default as SpringyRepositionGroup} from './src/springyGroups/SpringyRepositionGroup'; 3 | export {default as SpringyFollowGroup} from './src/springyGroups/SpringyFollowGroup'; 4 | export {default as SpringyRepeater} from './src/springyGroups/SpringyRepeater'; 5 | export {default as Spring, SpringConfig, SpringValueListener, InitialPositionConfig} from 'simple-performant-harmonic-oscillator'; -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest' 3 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ismailman/simple-react-spho", 3 | "version": "1.0.0", 4 | "description": "The (hopefully) easiest to use animation library for React for interactive applications.", 5 | "repository": "https://github.com/ismailman/react-spho.git", 6 | "author": "", 7 | "source": "index.tsx", 8 | "main": "dist/index.js", 9 | "module": "dist/index.es.js", 10 | "jsnext:main": "dist/index.es.js", 11 | "types": "dist/index.d.ts", 12 | "license": "MIT", 13 | "browserslist": [ 14 | "Chrome 72" 15 | ], 16 | "scripts": { 17 | "build": "rollup -c", 18 | "prepublish": "yarn run build" 19 | }, 20 | "peerDependencies": { 21 | "react": "^16.8.5", 22 | "react-dom": "^16.8.5" 23 | }, 24 | "dependencies": { 25 | "simple-performant-harmonic-oscillator": "^4.0.0" 26 | }, 27 | "devDependencies": { 28 | "@types/jest": "^24.0.13", 29 | "@types/lolex": "^3.1.1", 30 | "@types/react": "^16.8.8", 31 | "@types/react-dom": "^16.8.4", 32 | "jest": "^24.8.0", 33 | "jest-dom": "^3.1.4", 34 | "lolex": "^4.0.1", 35 | "parcel": "^1.12.3", 36 | "react": "^16.8.5", 37 | "react-dom": "^16.8.5", 38 | "rollup": "^1.7.4", 39 | "rollup-plugin-commonjs": "^9.2.2", 40 | "rollup-plugin-node-resolve": "^4.0.1", 41 | "rollup-plugin-replace": "^2.1.1", 42 | "rollup-plugin-typescript2": "^0.20.1", 43 | "ts-jest": "^24.0.2", 44 | "ts-loader": "^7.0.5", 45 | "typescript": "^3.4.3", 46 | "webpack": "^4.30.0", 47 | "webpack-cli": "^3.3.2" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import typescript from 'rollup-plugin-typescript2'; 2 | import resolve from 'rollup-plugin-node-resolve'; 3 | import replace from 'rollup-plugin-replace'; 4 | import commonjs from 'rollup-plugin-commonjs'; 5 | 6 | export default [ 7 | { 8 | input: './index.tsx', 9 | output: [{file: './dist/index.js', format: 'cjs'}, {file: './dist/index.es.js', format: 'es'}], 10 | external: ['react', 'react-dom'], 11 | plugins: [ 12 | resolve({}), 13 | typescript({ 14 | typescript: require('typescript'), 15 | check: false, 16 | }), 17 | ], 18 | }, 19 | ]; -------------------------------------------------------------------------------- /src/SpringyDOMElement.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Spring from 'simple-performant-harmonic-oscillator'; 3 | 4 | import {InternalSpringyProps} from './types'; 5 | 6 | import {AUTO_PROPERTIES, RESIZE_PROPERTIES} from './helpers/domStyleProperties'; 7 | import getConfig from './helpers/getConfig'; 8 | import getUnits from './helpers/getUnits'; 9 | import handleForwardedRef from './helpers/handleForwardedRef'; 10 | import reconciler from './helpers/reconciler'; 11 | 12 | import {ChildRegisterContext} from './springyGroups/childRegisterContext'; 13 | 14 | type ReconcileScheduler = { 15 | values: {[key: string]: string}; 16 | scheduled?: Promise | null; 17 | }; 18 | 19 | type CustomValueMapper = (value: number) => number; 20 | 21 | const springyDOMMap: Map = new Map(); 22 | 23 | export default class SpringyDOMElement extends React.PureComponent { 24 | 25 | static contextType = ChildRegisterContext; 26 | 27 | _reconcileUpdate: ReconcileScheduler = {values: {}}; 28 | _ref: HTMLElement | null = null; 29 | _resizeObserver: ResizeObserver | null = null; 30 | _isSecondRender: boolean = false; 31 | _needsSecondRender: boolean = false; 32 | _springMap: Map = new Map(); 33 | _removalBlocked: boolean = false; 34 | _transitionOutCloneElement: HTMLElement | null = null; 35 | 36 | render() { 37 | const cleanProps = {...this.props} as any; 38 | delete (cleanProps as any).forwardedRef; 39 | delete (cleanProps as any).ComponentToWrap; 40 | delete (cleanProps as any).configMap; 41 | delete (cleanProps as any).styleOnExit; 42 | delete (cleanProps as any).globalUniqueIDForSpringReuse; 43 | delete (cleanProps as any).onSpringyPropertyValueAtRest; 44 | delete (cleanProps as any).onSpringyPropertyValueUpdate; 45 | delete (cleanProps as any).springyOrderedIndex; 46 | delete (cleanProps as any).springyStyle; 47 | delete (cleanProps as any).instanceRef; 48 | 49 | if(this.props.globalUniqueIDForSpringReuse){ 50 | this._checkAndTakeOverExistingSpringyDOM(this.props.globalUniqueIDForSpringReuse); 51 | } 52 | 53 | this._processSpringyStyle(); 54 | 55 | const ComponentToWrap = this.props.ComponentToWrap; 56 | 57 | return ( 58 | { 61 | this._ref = ref; 62 | handleForwardedRef(ref, this.props.forwardedRef) 63 | if(this.props.instanceRef){ 64 | handleForwardedRef(this, this.props.instanceRef); 65 | } 66 | }} 67 | > { 68 | this.props.children && ( 69 | 70 | {this.props.children} 71 | 72 | ) 73 | } 74 | 75 | 76 | ); 77 | } 78 | 79 | componentDidMount(){ 80 | if(this.context) { 81 | this.context.registerChild(this); 82 | if(this.props.springyOrderedIndex != null){ 83 | this.context.registerChildIndex(this, this.props.springyOrderedIndex); 84 | } 85 | } 86 | if(!this._isSecondRender && this._needsSecondRender){ 87 | this._rerenderToUseTrueSize(); 88 | } 89 | } 90 | 91 | componentDidUpdate(prevProps: InternalSpringyProps){ 92 | if(this.context){ 93 | if(prevProps.springyOrderedIndex !== null && prevProps.springyOrderedIndex != this.props.springyOrderedIndex){ 94 | this.context.unregisterChildIndex(this, prevProps.springyOrderedIndex); 95 | 96 | if(this.props.springyOrderedIndex != null) { 97 | this.context.registerChildIndex(this, this.props.springyOrderedIndex); 98 | } 99 | } 100 | } 101 | 102 | if(!this._isSecondRender && this._needsSecondRender){ 103 | this._rerenderToUseTrueSize(); 104 | } 105 | } 106 | 107 | componentWillUnmount() { 108 | this._killResizeObserver(); 109 | 110 | this._handleOnExitIfExists(); 111 | } 112 | 113 | blockRemoval() { 114 | this._removalBlocked = true; 115 | return () => { 116 | this._removalBlocked = false; 117 | if(this._springMap.size === 0 && this._transitionOutCloneElement){ 118 | this._transitionOutCloneElement.remove(); 119 | if(this.props.globalUniqueIDForSpringReuse && springyDOMMap.get(this.props.globalUniqueIDForSpringReuse) === this) { 120 | springyDOMMap.delete(this.props.globalUniqueIDForSpringReuse); 121 | } 122 | } 123 | } 124 | } 125 | 126 | getSpringForProperty(property: string): Spring | null { 127 | return this._springMap.get(property); 128 | } 129 | 130 | isUnmounting(): boolean { 131 | return Boolean(this._transitionOutCloneElement); 132 | } 133 | 134 | setSpringToValueForProperty(property: string, toValue: number | 'auto', overridingFromValue?: number, customValueMapper?: CustomValueMapper) { 135 | this._setupOrUpdateSpringForProperty(property, toValue, overridingFromValue); 136 | 137 | if(customValueMapper) { 138 | const spring = this.getSpringForProperty(property); 139 | if(!spring) throw new Error('spring should have been created'); 140 | spring.setValueMapper(customValueMapper); 141 | } 142 | } 143 | 144 | getDOMNode(): HTMLElement | null { 145 | return this._ref; 146 | } 147 | 148 | _checkAndTakeOverExistingSpringyDOM(globalUniqueIDForSpringReuse) { 149 | const existingSpringyDOM = springyDOMMap.get(globalUniqueIDForSpringReuse); 150 | if(existingSpringyDOM) { 151 | const springMap = existingSpringyDOM._springMap; 152 | if(springMap) { 153 | for(let [property, spring] of springMap) { 154 | const springClone = spring.clone(); 155 | spring.end(); 156 | 157 | this._listenToSpring(springClone, property); 158 | } 159 | } 160 | 161 | if(existingSpringyDOM._transitionOutCloneElement) existingSpringyDOM._transitionOutCloneElement.remove(); 162 | } 163 | 164 | springyDOMMap.set(globalUniqueIDForSpringReuse, this); 165 | } 166 | 167 | _processSpringyStyle() { 168 | let springyStyle = this.props.springyStyle; 169 | const configMap = this.props.configMap; 170 | if(!springyStyle && !configMap) { 171 | this._killResizeObserver(); 172 | return; 173 | } 174 | 175 | if(!springyStyle) { 176 | springyStyle = {}; // we have a configMap, so we'll have an artificial springyStyle object 177 | 178 | //put the springyStyle value for this property to the onEnterToValue 179 | // we do this BEFORE th flipAutoPropsIfNecessary so that allows the config map 180 | // to have "auto" as a onEnterToValue value 181 | for(let property in configMap){ 182 | if(configMap[property].onEnterToValue != null){ 183 | springyStyle[property] = configMap[property].onEnterToValue; 184 | } 185 | else if( 186 | (configMap[property].onEnterFromValue != null || configMap[property].onEnterFromValueOffset != null) && 187 | AUTO_PROPERTIES.includes(property) 188 | ){ 189 | springyStyle[property] = 'auto'; //default to auto 190 | } 191 | } 192 | } 193 | 194 | this._dealWithPotentialResizeObserver(springyStyle); 195 | this._flipAutoPropsIfNecessary(springyStyle); 196 | 197 | for(let property in springyStyle){ 198 | this._setupOrUpdateSpringForProperty(property, springyStyle[property]); 199 | } 200 | } 201 | 202 | _flipAutoPropsIfNecessary(springyStyle: {[key: string]: number | 'auto'}) { 203 | const propsThatAreSpringy = Object.keys(springyStyle); 204 | const springyPropsThatCanBeAuto = propsThatAreSpringy.filter(property => AUTO_PROPERTIES.includes(property)); 205 | if (springyPropsThatCanBeAuto.length === 0) return; 206 | 207 | const propsThatAreAuto = 208 | Object.keys(springyStyle) 209 | .filter( 210 | property => 211 | springyStyle[property] === 'auto' && 212 | springyPropsThatCanBeAuto.includes(property) 213 | ); 214 | 215 | if(propsThatAreAuto.length === 0) return; 216 | if(!this._isSecondRender) { 217 | this._needsSecondRender = true; 218 | return; 219 | } 220 | 221 | if(!this._ref) return; 222 | 223 | // apply latest styles 224 | reconciler(this._ref, {...(this.props as any).style}, springyStyle); 225 | 226 | // get the computed styles and clean up 227 | const computedStyle = getComputedStyle(this._ref); 228 | propsThatAreAuto.forEach(property => { 229 | springyStyle[property] = parseFloat(computedStyle.getPropertyValue(property)); //use target value for mutable prop 230 | }); 231 | } 232 | 233 | _setupOrUpdateSpringForProperty(property: string, propValue: number | 'auto', overridingFromValue?: number) { 234 | if(propValue == 'auto') return; 235 | 236 | const configMap = this.props.configMap; 237 | let spring = this._springMap.get(property); 238 | 239 | const toValue = 240 | propValue != null ? 241 | propValue : 242 | configMap && configMap[property] && configMap[property].onEnterToValue; 243 | 244 | // we don't have a target toValue, then don't do anything 245 | if(toValue == null || typeof toValue === 'string') return null; 246 | 247 | // spring has already been initialized and we're just updating values 248 | if(spring != null){ 249 | if(configMap){ 250 | const config = getConfig(configMap[property], spring.getToValue(), toValue); 251 | spring.setBounciness(config.bounciness); 252 | spring.setSpeed(config.speed); 253 | } 254 | 255 | spring.setToValue(propValue); 256 | if(overridingFromValue != null) spring.setCurrentValue(overridingFromValue); 257 | } 258 | else { 259 | const fromValue = 260 | overridingFromValue != null ? overridingFromValue : //if we have an overridingFromValue use that 261 | configMap == null || configMap[property] == null ? propValue : // if we don't have a then use propValue 262 | configMap[property].onEnterFromValue != null ? configMap[property].onEnterFromValue : // if we have an onEnterFromValue use that 263 | configMap[property].onEnterFromValueOffset != null ? // if have an onEnterFromValueOffset use that 264 | propValue + configMap[property].onEnterFromValueOffset : propValue; // use propValue 265 | 266 | spring = new Spring( 267 | configMap ? getConfig(configMap[property], fromValue, toValue) : {}, 268 | {fromValue, toValue} 269 | ); 270 | 271 | this._listenToSpring(spring, property); 272 | 273 | if(fromValue === toValue) { 274 | this._updateValueForProperty(property, toValue); 275 | } 276 | } 277 | } 278 | 279 | _listenToSpring(spring: Spring, property: string) { 280 | spring.onUpdate((value: number) => { 281 | this._updateValueForProperty(property, value); 282 | if(this.props.onSpringyPropertyValueUpdate) this.props.onSpringyPropertyValueUpdate(property, value); 283 | }); 284 | 285 | spring.onAtRest((value: number) => { 286 | if(this.props.onSpringyPropertyValueAtRest) this.props.onSpringyPropertyValueAtRest(property, value); 287 | }); 288 | 289 | this._springMap.set(property, spring); 290 | } 291 | 292 | _updateValueForProperty(property: string, value: number) { 293 | this._reconcileUpdate.values[property] = `${value}${getUnits(this.props.configMap, property)}`; 294 | 295 | if(!this._reconcileUpdate.scheduled){ 296 | this._reconcileUpdate.scheduled = Promise.resolve().then(() => { 297 | reconciler(this._ref, {...(this.props as any).style}, this._reconcileUpdate.values); 298 | this._reconcileUpdate.scheduled = null; 299 | }) 300 | } 301 | } 302 | 303 | _dealWithPotentialResizeObserver(springyStyle: {[key: string]: number | 'auto'}){ 304 | if(!this._ref) return; 305 | const propsThatAreSpringy = Object.keys(springyStyle); 306 | const springyPropsThatCanBeAuto = propsThatAreSpringy.filter(property => AUTO_PROPERTIES.includes(property)); 307 | const resizableSpringyAutoProperties = springyPropsThatCanBeAuto.filter(property => RESIZE_PROPERTIES.includes(property)); 308 | if(resizableSpringyAutoProperties.length === 0) { 309 | this._killResizeObserver(); 310 | return; 311 | } 312 | 313 | if(this._resizeObserver){ 314 | //we already have one 315 | return; 316 | } 317 | 318 | if((window as any).ResizeObserver){ 319 | this._resizeObserver = new (window as any).ResizeObserver(entries => { 320 | const springyStyle = this.props.springyStyle; 321 | const propsThatAreAutoAndResizable = 322 | Object.keys(springyStyle) 323 | .filter( 324 | property => 325 | springyStyle[property] === 'auto' && 326 | RESIZE_PROPERTIES.includes(property) 327 | ); 328 | 329 | if(propsThatAreAutoAndResizable.length > 0 && !this._isSecondRender && !this._needsSecondRender){ 330 | this._rerenderToUseTrueSize(); 331 | } 332 | }); 333 | 334 | this._resizeObserver.observe(this._ref); 335 | } 336 | } 337 | 338 | _rerenderToUseTrueSize() { 339 | this._isSecondRender = true; 340 | this.forceUpdate(() => { 341 | this._needsSecondRender = false; 342 | this._isSecondRender = false; 343 | }); 344 | } 345 | 346 | _killResizeObserver(){ 347 | if(this._resizeObserver){ 348 | this._resizeObserver.disconnect(); 349 | this._resizeObserver = null; 350 | } 351 | } 352 | 353 | _handleOnExitIfExists() { 354 | const configMap = this.props.configMap; 355 | if(!configMap || !this._ref) return; 356 | const propertiesWithOnExitToValue = Object.keys(configMap).filter(property => configMap[property].onExitToValue != null); 357 | 358 | if(propertiesWithOnExitToValue.length === 0) { 359 | this._cleanUpSprings(); 360 | return; 361 | } 362 | 363 | const lastStyle = {...(this.props as any).style}; 364 | 365 | const clone = this._transitionOutCloneElement = this._ref.cloneNode(true) as HTMLElement; //true = deep clone 366 | clone.style.pointerEvents = 'none'; 367 | this._ref.insertAdjacentElement('beforebegin', clone); 368 | this._ref.remove(); 369 | 370 | const fromValues = {}; 371 | const propertiesWithOnExitFromValue = propertiesWithOnExitToValue.filter(property => configMap[property].onExitFromValue != null); 372 | const propertiesWithoutOnExitFromValue = propertiesWithOnExitToValue.filter(property => configMap[property].onExitFromValue == null); 373 | 374 | propertiesWithOnExitFromValue.forEach(property => { 375 | fromValues[property] = configMap[property].onExitFromValue; 376 | }); 377 | 378 | const computedStyle = getComputedStyle(clone); 379 | //we set width and height to computed value because we're make make the element 380 | // position absolute which may change height and width 381 | clone.style.width = computedStyle.getPropertyValue('width'); 382 | clone.style.height = computedStyle.getPropertyValue('height'); 383 | 384 | if(this.props.styleOnExit) { 385 | let styleOnExit = this.props.styleOnExit; 386 | if(typeof styleOnExit === 'function') { 387 | styleOnExit = (styleOnExit as any)(clone); 388 | } 389 | 390 | reconciler(clone, lastStyle, styleOnExit); 391 | } 392 | 393 | propertiesWithoutOnExitFromValue.forEach(property => { 394 | fromValues[property] = parseFloat(computedStyle.getPropertyValue(property)); //use target value for mutable prop 395 | }); 396 | 397 | clone.insertAdjacentElement('beforebegin', this._ref); 398 | 399 | let existingUpdate: ReconcileScheduler = {values: {}}; 400 | for(let property of propertiesWithOnExitToValue) { 401 | const config = configMap[property]; 402 | const springConfig = getConfig(config, fromValues[property], config.onExitToValue); 403 | const spring = new Spring(springConfig, {fromValue: fromValues[property], toValue: config.onExitToValue}); 404 | this._springMap.set(property, spring); 405 | 406 | spring.onUpdate((value) => { 407 | existingUpdate.values[property] = `${value}${getUnits(configMap, property)}`; 408 | 409 | if(!existingUpdate.scheduled){ 410 | existingUpdate.scheduled = Promise.resolve().then(() => { 411 | reconciler(clone, lastStyle, existingUpdate.values); 412 | existingUpdate.scheduled = null; 413 | }); 414 | } 415 | }); 416 | 417 | const cleanUp = () => { 418 | this._springMap.delete(property); 419 | spring.end(); 420 | if(this._springMap.size === 0) { 421 | if(!this._removalBlocked){ 422 | clone.remove(); 423 | if(this.props.globalUniqueIDForSpringReuse && springyDOMMap.get(this.props.globalUniqueIDForSpringReuse) === this) { 424 | springyDOMMap.delete(this.props.globalUniqueIDForSpringReuse); 425 | } 426 | } 427 | 428 | this._cleanUpSprings(); 429 | } 430 | }; 431 | 432 | spring.onAtRest(cleanUp); 433 | spring.onEnd(cleanUp); 434 | } 435 | } 436 | 437 | _cleanUpSprings() { 438 | for(let spring of this._springMap.values()) { 439 | spring.end(); 440 | } 441 | this._springMap.clear(); 442 | 443 | if(this.context) { 444 | this.context.unregisterChild(this); 445 | if(this.props.springyOrderedIndex != null){ 446 | this.context.unregisterChildIndex(this, this.props.springyOrderedIndex); 447 | } 448 | } 449 | } 450 | } 451 | 452 | /* 453 | only render children on the first render 454 | */ 455 | class SecondRenderGuard extends React.Component<{isSecondRender: boolean}> { 456 | shouldComponentUpdate(nextProps){ 457 | return !nextProps.isSecondRender; 458 | } 459 | 460 | render() { 461 | return this.props.children; 462 | } 463 | 464 | } -------------------------------------------------------------------------------- /src/getSpringyDOMElement.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import 'jest-dom/extend-expect'; 5 | 6 | import { 7 | resetBodyAndGetAppDiv, 8 | runNumberOfFramesForward 9 | } from './helpers/testHelpers'; 10 | 11 | import getSpringyDOMElement from './getSpringyDOMElement'; 12 | import SpringyDOMElement from './SpringyDOMElement'; 13 | 14 | describe('basics', () => { 15 | 16 | it('can render a div', async () => { 17 | 18 | const div = resetBodyAndGetAppDiv(); 19 | const SpringyDiv = getSpringyDOMElement('div'); 20 | 21 | ReactDOM.render( 22 | 23 | Hello World 24 | , 25 | div 26 | ); 27 | 28 | expect(div).toHaveTextContent('Hello World'); 29 | 30 | }); 31 | 32 | it('value changes over time', async () => { 33 | const div = resetBodyAndGetAppDiv(); 34 | const SpringyDiv = getSpringyDOMElement('div'); 35 | 36 | ReactDOM.render( 37 | , 38 | div 39 | ); 40 | 41 | const testDiv = div.querySelector('div'); 42 | 43 | await runNumberOfFramesForward(1); 44 | 45 | expect(testDiv.style.left).toBe('10px'); 46 | 47 | ReactDOM.render( 48 | , 49 | div 50 | ); 51 | 52 | await runNumberOfFramesForward(2); 53 | expect(testDiv.style.left).not.toBe('10px'); 54 | 55 | await runNumberOfFramesForward(100); 56 | expect(testDiv.style.left).toBe('20px'); 57 | }); 58 | 59 | it('multiple values are updated', async () => { 60 | const div = resetBodyAndGetAppDiv(); 61 | const SpringyDiv = getSpringyDOMElement('div'); 62 | 63 | ReactDOM.render( 64 | , 65 | div 66 | ); 67 | 68 | const testDiv = div.querySelector('div'); 69 | 70 | await runNumberOfFramesForward(1); 71 | 72 | expect(testDiv.style.left).toBe('10px'); 73 | expect(testDiv.style.top).toBe('10px'); 74 | 75 | ReactDOM.render( 76 | , 77 | div 78 | ); 79 | 80 | await runNumberOfFramesForward(2); 81 | expect(testDiv.style.left).not.toBe('10px'); 82 | expect(testDiv.style.top).not.toBe('10px'); 83 | 84 | await runNumberOfFramesForward(100); 85 | expect(testDiv.style.left).toBe('20px'); 86 | expect(testDiv.style.top).toBe('20px'); 87 | }); 88 | 89 | it('can have springy styles and regular styles and other props', async () => { 90 | const div = resetBodyAndGetAppDiv(); 91 | const SpringyDiv = getSpringyDOMElement('div'); 92 | 93 | ReactDOM.render( 94 | , 99 | div 100 | ); 101 | 102 | const testDiv = div.querySelector('div'); 103 | await runNumberOfFramesForward(1); 104 | 105 | expect(testDiv.style.left).toBe('10px'); 106 | expect(testDiv.style.width).toBe('10px'); 107 | expect(testDiv.tabIndex).toBe(1); 108 | }); 109 | 110 | it('ref is properly forwarded', async () => { 111 | const div = resetBodyAndGetAppDiv(); 112 | const SpringyDiv = getSpringyDOMElement('div'); 113 | 114 | let refDiv; 115 | ReactDOM.render( 116 | refDiv = ref} 118 | />, 119 | div 120 | ); 121 | 122 | const testDiv = div.querySelector('div'); 123 | expect(testDiv).toBe(refDiv); 124 | }); 125 | 126 | it('instanceRef is a SpringyDOMElement instance', async () => { 127 | const div = resetBodyAndGetAppDiv(); 128 | const SpringyDiv = getSpringyDOMElement('div'); 129 | 130 | let instanceRef; 131 | ReactDOM.render( 132 | instanceRef = ref} 134 | />, 135 | div 136 | ); 137 | 138 | expect(instanceRef).toBeInstanceOf(SpringyDOMElement); 139 | }); 140 | 141 | it('calls property update listener', async () => { 142 | 143 | const div = resetBodyAndGetAppDiv(); 144 | const SpringyDiv = getSpringyDOMElement('div'); 145 | const spy = jest.fn(); 146 | 147 | ReactDOM.render( 148 | , 152 | div 153 | ); 154 | 155 | await runNumberOfFramesForward(1); 156 | 157 | ReactDOM.render( 158 | , 162 | div 163 | ); 164 | 165 | await runNumberOfFramesForward(1); 166 | expect(spy).toHaveBeenCalled(); 167 | expect(spy.mock.calls[0][0]).toBe('left'); 168 | 169 | }); 170 | 171 | it('calls onRest listener', async () => { 172 | 173 | const div = resetBodyAndGetAppDiv(); 174 | const SpringyDiv = getSpringyDOMElement('div'); 175 | const spy = jest.fn(); 176 | 177 | ReactDOM.render( 178 | , 182 | div 183 | ); 184 | 185 | await runNumberOfFramesForward(1); 186 | 187 | ReactDOM.render( 188 | , 192 | div 193 | ); 194 | 195 | await runNumberOfFramesForward(100); 196 | expect(spy).toHaveBeenCalled(); 197 | expect(spy.mock.calls[0][0]).toBe('left'); 198 | expect(spy.mock.calls[0][1]).toBe(20); 199 | 200 | }); 201 | 202 | }); -------------------------------------------------------------------------------- /src/getSpringyDOMElement.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {SpringyDOMWrapper, DOMSpringConfigMap} from './types'; 4 | import SpringyDOMElement from './SpringyDOMElement'; 5 | 6 | function getSpringyDOMElement(ComponentToWrap: string, configMap?: DOMSpringConfigMap | null, styleOnExit?: any) { 7 | return React.forwardRef((props, ref) => ( 8 | 15 | )); 16 | } 17 | 18 | export default (getSpringyDOMElement as any) as SpringyDOMWrapper; -------------------------------------------------------------------------------- /src/helpers/domStyleProperties.ts: -------------------------------------------------------------------------------- 1 | export const AUTO_PROPERTIES = [ 2 | 'width', 3 | 'height', 4 | 'margin', 5 | 'top', 6 | 'right', 7 | 'bottom', 8 | 'left' 9 | ]; 10 | 11 | export const RESIZE_PROPERTIES = [ 12 | 'width', 'height' 13 | ]; 14 | 15 | export const DEFAULT_UNIT_SUFFIXES = { 16 | 'width': 'px', 17 | 'height': 'px', 18 | 'margin': 'px', 19 | 'top': 'px', 20 | 'right': 'px', 21 | 'left': 'px', 22 | 'bottom': 'px', 23 | 'padding': 'px', 24 | 'scaleX': '', 25 | 'scaleY': '', 26 | 'scaleZ': '', 27 | 'scale': '', 28 | 'translate': 'px', 29 | 'translateX': 'px', 30 | 'translateY': 'px', 31 | 'translateZ': 'px', 32 | 'rotate': 'deg', 33 | 'rotateX': 'deg', 34 | 'rotateY': 'deg', 35 | 'rotateZ': 'deg', 36 | 'skew': 'deg', 37 | 'skewX': '', 38 | 'skewY': '', 39 | 'backgroundPosition': 'px', 40 | 'borderWidth': 'px', 41 | 'borderBottom-width': 'px', 42 | 'borderTopWidth': 'px', 43 | 'borderLeftWidth': 'px', 44 | 'borderRightWidth': 'px', 45 | 'lineHeight': 'px', 46 | 'maxHeight': 'px', 47 | 'maxHidth': 'px', 48 | 'minHeight': 'px', 49 | 'minHidth': 'px', 50 | 'fontSize': 'px', 51 | 'fontWeight': '', 52 | 'markerOffset': 'px', 53 | 'marginTop': 'px', 54 | 'marginRight': 'px', 55 | 'marginBottom': 'px', 56 | 'marginLeft': 'px', 57 | 'paddingTop': 'px', 58 | 'paddingRight': 'px', 59 | 'paddingBottom': 'px', 60 | 'paddingLeft': 'px', 61 | 'outlineWidth': 'px', 62 | 'letterSpacing': 'px', 63 | 'textIndent': 'px', 64 | 'wordSpacing': 'px', 65 | 'opacity': '', 66 | 'perspective': 'px' 67 | }; -------------------------------------------------------------------------------- /src/helpers/getConfig.ts: -------------------------------------------------------------------------------- 1 | import {SpringConfig} from 'simple-performant-harmonic-oscillator'; 2 | import {SpringPropertyConfig} from '../types'; 3 | 4 | export default function getConfig(config: SpringPropertyConfig | null | undefined, oldToValue: number, newToValue: number): SpringConfig | null { 5 | let springConfig: SpringConfig = {}; 6 | if(config == null) return springConfig; 7 | 8 | springConfig.speed = config.speed; 9 | springConfig.bounciness = config.bounciness; 10 | 11 | if(oldToValue <= newToValue) { 12 | const configWhenGettingBigger = config.configWhenGettingBigger; 13 | if(configWhenGettingBigger){ 14 | springConfig.speed = configWhenGettingBigger.speed != null ? configWhenGettingBigger.speed : springConfig.speed; 15 | springConfig.bounciness = configWhenGettingBigger.bounciness != null ? configWhenGettingBigger.bounciness : springConfig.bounciness; 16 | } 17 | } 18 | else if(oldToValue > newToValue){ 19 | const configWhenGettingSmaller = config.configWhenGettingSmaller; 20 | if(configWhenGettingSmaller){ 21 | springConfig.speed = configWhenGettingSmaller.speed != null ? configWhenGettingSmaller.speed : springConfig.speed; 22 | springConfig.bounciness = configWhenGettingSmaller.bounciness != null ? configWhenGettingSmaller.bounciness : springConfig.bounciness; 23 | } 24 | } 25 | 26 | return springConfig; 27 | } -------------------------------------------------------------------------------- /src/helpers/getUnits.ts: -------------------------------------------------------------------------------- 1 | import {DOMSpringConfigMap} from '../types'; 2 | import {DEFAULT_UNIT_SUFFIXES} from './domStyleProperties'; 3 | 4 | export default function getUnits(configMap: DOMSpringConfigMap, property: string): string { 5 | 6 | return configMap && configMap[property] && configMap[property].units ? 7 | configMap[property].units : 8 | DEFAULT_UNIT_SUFFIXES[property] != null ? 9 | DEFAULT_UNIT_SUFFIXES[property] : 10 | ''; 11 | 12 | 13 | 14 | 15 | } -------------------------------------------------------------------------------- /src/helpers/handleForwardedRef.ts: -------------------------------------------------------------------------------- 1 | import {MutableRefObject} from 'react'; 2 | type Ref = { bivarianceHack(instance: T | null): void }["bivarianceHack"] | MutableRefObject | null; 3 | 4 | export default function handleForwardedRef(ref: T, forwardedRef: Ref) { 5 | if(forwardedRef){ 6 | if(typeof forwardedRef === 'function') forwardedRef(ref); 7 | else if(typeof forwardedRef === 'object' && forwardedRef.hasOwnProperty('current')) forwardedRef.current = ref; 8 | } 9 | } -------------------------------------------------------------------------------- /src/helpers/reconciler.ts: -------------------------------------------------------------------------------- 1 | import {StyleObject} from '../types'; 2 | 3 | export default function reconciler(refElement: null | HTMLElement, currentStyle: StyleObject, values: any) { 4 | if(refElement == null) return; 5 | 6 | for(let property in values){ 7 | refElement.style[property] = values[property]; 8 | } 9 | } -------------------------------------------------------------------------------- /src/helpers/testHelpers.ts: -------------------------------------------------------------------------------- 1 | import lolex from 'lolex'; 2 | import { number } from 'prop-types'; 3 | 4 | 5 | const clock = lolex.install(); 6 | 7 | export function resetBodyAndGetAppDiv(): HTMLDivElement { 8 | 9 | document.body.innerHTML = ''; 10 | const div = document.createElement('div'); 11 | document.body.appendChild(div); 12 | 13 | return div; 14 | 15 | } 16 | 17 | export async function runNumberOfFramesForward(numberOfFrames: number) { 18 | 19 | for(let ii=0; ii { 7 | if(hasBeenCalled) return; 8 | hasBeenCalled = true; 9 | fn(); 10 | }; 11 | } 12 | 13 | type PropertyConfig = { 14 | property: string; 15 | offset: number; 16 | }; 17 | 18 | type Props = { 19 | properties: Array 20 | }; 21 | 22 | export default class SpringyFollowGroup extends AbstractChildRegisterProviderClass { 23 | 24 | _unregisterFunctions = []; 25 | 26 | componentDidMount() { 27 | this._setupFollows(); 28 | } 29 | 30 | componentDidUpdate() { 31 | this._setupFollows(); 32 | } 33 | 34 | componentWillUnmount() { 35 | this._unregisterListeners(); 36 | } 37 | 38 | _setupFollows() { 39 | this._unregisterListeners(); 40 | 41 | let numActive = 0; 42 | let removalQueue = []; 43 | for(let propertyConfig of this.props.properties) { 44 | const offset = 45 | typeof propertyConfig === 'string' ? 46 | 0 : propertyConfig.offset; 47 | 48 | const property = 49 | typeof propertyConfig === 'string' ? 50 | propertyConfig : propertyConfig.property; 51 | 52 | let lastGroupChild: SpringyDOMElement | null = null; 53 | 54 | this._orderedChildrenGroups.filter(Boolean).forEach(childGroup => { 55 | childGroup.forEach((child, index) => { 56 | const isUnmounting = child.isUnmounting(); 57 | if(lastGroupChild != null){ 58 | const parentSpring = lastGroupChild.getSpringForProperty(property); 59 | if(parentSpring) { 60 | // if the child already has a spring for the property then we don't 61 | // override the from value 62 | let childSpring = child.getSpringForProperty(property); 63 | const overridingFrom = childSpring != null ? null : parentSpring.getCurrentValue() + offset; 64 | child.setSpringToValueForProperty(property, parentSpring.getCurrentValue() + offset, overridingFrom); 65 | 66 | this._unregisterFunctions.push( 67 | parentSpring.onUpdate(value => { 68 | child.setSpringToValueForProperty(property, value + offset); 69 | }) 70 | ); 71 | 72 | if(isUnmounting) { 73 | const childSpring = child.getSpringForProperty(property); 74 | if(childSpring) { 75 | const unblock = childSpring.blockSpringFromResting(); 76 | this._unregisterFunctions.push(unblock); 77 | 78 | this._unregisterFunctions.push( 79 | parentSpring.onAtRest(() => unblock()) 80 | ); 81 | } 82 | } 83 | } 84 | } 85 | 86 | if(index === childGroup.length - 1) lastGroupChild = child; 87 | 88 | if(isUnmounting) { 89 | const childSpring = child.getSpringForProperty(property); 90 | if(childSpring) { 91 | const unblockRemoval = child.blockRemoval(); 92 | this._unregisterFunctions.push(unblockRemoval); 93 | numActive++; 94 | const remove = once(() => { 95 | removalQueue.push(unblockRemoval); 96 | numActive--; 97 | if(numActive === 0) { 98 | removalQueue.forEach(fn => fn()); 99 | } 100 | }); 101 | 102 | this._unregisterFunctions.push(childSpring.onAtRest(remove)); 103 | this._unregisterFunctions.push(childSpring.onEnd(remove)); 104 | } 105 | } 106 | }); 107 | }); 108 | } 109 | } 110 | 111 | _unregisterListeners(){ 112 | this._unregisterFunctions.forEach(fn => fn()); 113 | this._unregisterFunctions = []; 114 | } 115 | 116 | } -------------------------------------------------------------------------------- /src/springyGroups/SpringyRepeater.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import {AbstractChildRegisterProviderClass} from './childRegisterContext'; 4 | import SpringyDOMElement from '../SpringyDOMElement'; 5 | import { arrayOf } from 'prop-types'; 6 | 7 | type RepeaterConfig = { 8 | from: number; 9 | to: number; 10 | }; 11 | 12 | type Props = { 13 | springyRepeaterStyles: {[key: string]: RepeaterConfig} 14 | direction: 'from-beginning-each-time' | 'back-and-forth'; 15 | delayStartBetweenChildren?: number; 16 | normalizeToZeroAndOne?: boolean; 17 | numberOfTimesToRepeat?: number | 'infinite'; 18 | }; 19 | 20 | function wait(time: number) { 21 | return new Promise(resolve => setTimeout(resolve, time)); 22 | } 23 | 24 | export default class SpringyRepeater extends AbstractChildRegisterProviderClass { 25 | static defaultProps = { 26 | direction: 'back-and-forth', 27 | numberOfTimesToRepeat: 'infinite' 28 | }; 29 | 30 | _lastRenderTime: number = 0; 31 | _unregisterFunctions: Map> = new Map(); 32 | 33 | componentDidMount() { 34 | this._setupRepeaters(); 35 | } 36 | 37 | componentDidUpdate() { 38 | this._setupRepeaters(); 39 | } 40 | 41 | componentWillUnmount() { 42 | this._unregisterFunctions.forEach((functions) => functions.forEach(fn => fn())); 43 | } 44 | 45 | unregisterChild(child: SpringyDOMElement) { 46 | super.unregisterChild(child); 47 | this._unregisterListeners(child); 48 | } 49 | 50 | async _setupRepeaters() { 51 | const renderTime = this._lastRenderTime = Date.now(); 52 | if(this._orderedChildrenGroups.length > 0) { 53 | for(let group of this._orderedChildrenGroups) { 54 | if(this.props.delayStartBetweenChildren != null) { 55 | await wait(this.props.delayStartBetweenChildren); 56 | if(renderTime !== this._lastRenderTime) return; // we had a render while waiting 57 | } 58 | 59 | for(let child of group) { 60 | this._setupRepeaterForChild(child); 61 | } 62 | } 63 | } 64 | else { 65 | for(let child of this._registeredChildren) { 66 | if(this.props.delayStartBetweenChildren != null) { 67 | await wait(this.props.delayStartBetweenChildren); 68 | if(renderTime !== this._lastRenderTime) return; 69 | } 70 | this._setupRepeaterForChild(child); 71 | } 72 | } 73 | } 74 | 75 | _setupRepeaterForChild(child: SpringyDOMElement) { 76 | this._unregisterListeners(child); 77 | for(let property in this.props.springyRepeaterStyles) { 78 | const config = this.props.springyRepeaterStyles[property]; 79 | this._setupRepeaterSpring(property, config, child); 80 | } 81 | } 82 | 83 | _setupRepeaterSpring(property: string, config: RepeaterConfig, child: SpringyDOMElement) { 84 | const unregisterFunctions = this._unregisterFunctions.get(child) || []; 85 | this._unregisterFunctions.set(child, unregisterFunctions); 86 | 87 | if( 88 | (Object.keys(this.props.springyRepeaterStyles).length > 1 && this.props.normalizeToZeroAndOne !== false) 89 | || this.props.normalizeToZeroAndOne === true 90 | ) { 91 | unregisterFunctions.push(this._setupNormalizedRepeaterSpring(property, config, child)); 92 | } 93 | else { 94 | unregisterFunctions.push(this._setupRegularRepeaterSpring(property, config, child)); 95 | } 96 | } 97 | 98 | _setupNormalizedRepeaterSpring(property: string, config: RepeaterConfig, child: SpringyDOMElement) { 99 | let configOrigin = config.from; 100 | let configTarget = config.to; 101 | let origin = 0; 102 | let target = 1; 103 | let isTargetBiggerThanOrigin = configTarget - configOrigin > 0; 104 | 105 | const mapper = (value: number) => { 106 | return (configTarget-configOrigin) * value + configOrigin; 107 | }; 108 | 109 | let spring = child.getSpringForProperty(property); 110 | // if there's already a spring for this property then we don't reset the from 111 | // value so the spring will go from its current position and go towards the 112 | // new target 113 | let initialOrigin = spring != null ? null : origin; 114 | child.setSpringToValueForProperty(property, target, initialOrigin, mapper); 115 | spring = child.getSpringForProperty(property); 116 | if(!spring) throw new Error('spring should have been created'); 117 | 118 | let numberOfRepeats = 0; 119 | return spring.onUpdate((value) => { 120 | if(isTargetBiggerThanOrigin ? value >= configTarget : value <= configTarget) { 121 | numberOfRepeats++; 122 | if(this.props.numberOfTimesToRepeat !== 'infinite' && numberOfRepeats > this.props.numberOfTimesToRepeat) { 123 | return; //we are done 124 | } 125 | if(this.props.direction === 'from-beginning-each-time') { 126 | child.setSpringToValueForProperty(property, target, origin); 127 | } 128 | else { 129 | //swap target and origin 130 | const temp = configTarget; 131 | configTarget = configOrigin; 132 | configOrigin = temp; 133 | 134 | isTargetBiggerThanOrigin = configTarget - configOrigin > 0; 135 | child.setSpringToValueForProperty(property, target, origin); 136 | } 137 | } 138 | }); 139 | } 140 | 141 | _setupRegularRepeaterSpring(property: string, config: RepeaterConfig, child: SpringyDOMElement) { 142 | let origin = config.from; 143 | let target = config.to; 144 | let isTargetBiggerThanOrigin = target - origin > 0; 145 | 146 | let spring = child.getSpringForProperty(property); 147 | // if there's already a spring for this property then we don't reset the from 148 | // value so the spring will go from its current position and go towards the 149 | // new target 150 | let initialOrigin = spring != null ? null : origin; 151 | child.setSpringToValueForProperty(property, target, initialOrigin); 152 | 153 | spring = child.getSpringForProperty(property); 154 | if(!spring) throw new Error('spring should have been created'); 155 | spring.unsetValueMapper(); 156 | 157 | let numberOfRepeats = 0; 158 | return spring.onUpdate((value) => { 159 | if(isTargetBiggerThanOrigin ? value >= target : value <= target) { 160 | numberOfRepeats++; 161 | if(this.props.numberOfTimesToRepeat !== 'infinite' && numberOfRepeats > this.props.numberOfTimesToRepeat) { 162 | return; //we are done 163 | } 164 | if(this.props.direction === 'from-beginning-each-time') { 165 | child.setSpringToValueForProperty(property, target, origin); 166 | } 167 | else { 168 | //swap target and origin 169 | const temp = target; 170 | target = origin; 171 | origin = temp; 172 | isTargetBiggerThanOrigin = target - origin > 0; 173 | child.setSpringToValueForProperty(property, target); 174 | } 175 | } 176 | }); 177 | } 178 | 179 | _unregisterListeners(child: SpringyDOMElement) { 180 | const unregisterFunctions = this._unregisterFunctions.get(child); 181 | if(unregisterFunctions){ 182 | unregisterFunctions.forEach(fn => fn()); 183 | this._unregisterFunctions.delete(child); 184 | } 185 | } 186 | } -------------------------------------------------------------------------------- /src/springyGroups/SpringyRepositionGroup.tsx: -------------------------------------------------------------------------------- 1 | import {AbstractChildRegisterProviderClass} from './childRegisterContext'; 2 | import SpringyDOMElement from '../SpringyDOMElement'; 3 | 4 | export default class SpringyRepositionGroup extends AbstractChildRegisterProviderClass<{}> { 5 | getSnapshotBeforeUpdate() { 6 | const offsetValues = new Map(); 7 | this._registeredChildren.forEach((node: any) => { 8 | const domNode = node.getDOMNode(); 9 | if(!domNode) return; 10 | offsetValues.set(node, { 11 | top: domNode.offsetTop, 12 | left: domNode.offsetLeft 13 | }); 14 | }); 15 | 16 | return offsetValues; 17 | } 18 | 19 | componentDidUpdate(prevProps, prevState, offsetValues: Map) { 20 | if(!offsetValues) return; 21 | this._registeredChildren.forEach((node: SpringyDOMElement) => { 22 | const domNode = node.getDOMNode(); 23 | if(!domNode || !offsetValues.get(node)) return; 24 | 25 | const newOffsetTop = domNode.offsetTop; 26 | const newOffsetLeft = domNode.offsetLeft; 27 | 28 | const translateXSpring = node._springMap.get('translateX'); 29 | const existingTranslateXTarget = 30 | translateXSpring ? translateXSpring.getToValue() : 0; 31 | const currentTranslateXValue = 32 | translateXSpring ? translateXSpring.getCurrentValue() : 0; 33 | 34 | const translateYSpring = node._springMap.get('translateY'); 35 | const existingTranslateYTarget = 36 | translateYSpring ? translateYSpring.getToValue() : 0; 37 | const currentTranslateYValue = 38 | translateYSpring ? translateYSpring.getCurrentValue() : 0; 39 | 40 | node.setSpringToValueForProperty('translateX', existingTranslateXTarget, currentTranslateXValue + offsetValues.get(node).left - newOffsetLeft); 41 | node.setSpringToValueForProperty('translateY', existingTranslateYTarget, currentTranslateYValue + offsetValues.get(node).top - newOffsetTop); 42 | }); 43 | } 44 | 45 | } -------------------------------------------------------------------------------- /src/springyGroups/childRegisterContext.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import SpringyDOMElement from '../SpringyDOMElement'; 3 | 4 | export const ChildRegisterContext = React.createContext({ 5 | registerChild: (child: SpringyDOMElement) => void 0, 6 | unregisterChild: (child: SpringyDOMElement) => void 0, 7 | registerChildIndex: (child: SpringyDOMElement, index: number) => void 0, 8 | unregisterChildIndex: (child: SpringyDOMElement, index: number) => void 0 9 | }); 10 | 11 | export class AbstractChildRegisterProviderClass extends React.PureComponent { 12 | 13 | static contextType = ChildRegisterContext; 14 | _registeredChildren: Array = []; 15 | _orderedChildrenGroups: Array> = []; 16 | 17 | registerChild(child: SpringyDOMElement) { 18 | if(this._registeredChildren.indexOf(child) === -1) this._registeredChildren.push(child); 19 | if(this.context) this.context.registerChild(child); 20 | } 21 | 22 | unregisterChild(child: SpringyDOMElement) { 23 | const index = this._registeredChildren.indexOf(child); 24 | if(index > -1){ 25 | this._registeredChildren[index] = this._registeredChildren[this._registeredChildren.length - 1]; 26 | this._registeredChildren.pop(); 27 | } 28 | 29 | if(this.context) this.context.unregisterChild(child); 30 | } 31 | 32 | registerChildIndex(child: SpringyDOMElement, index: number) { 33 | const childrenAtIndex = this._orderedChildrenGroups[index] || []; 34 | childrenAtIndex.push(child); 35 | this._orderedChildrenGroups[index] = childrenAtIndex; 36 | if(this.context) this.context.registerChildIndex(child, index); 37 | } 38 | 39 | unregisterChildIndex(child: SpringyDOMElement, index: number) { 40 | const childrenAtIndex = this._orderedChildrenGroups[index]; 41 | if(childrenAtIndex) { 42 | const childIndex = childrenAtIndex.indexOf(child); 43 | if(childIndex > -1){ 44 | childrenAtIndex[childIndex] = childrenAtIndex[childrenAtIndex.length - 1]; 45 | childrenAtIndex.pop(); 46 | } 47 | } 48 | 49 | if(this.context) this.context.unregisterChildIndex(child, index); 50 | } 51 | 52 | render(): React.ReactNode { 53 | return ( 54 | 55 | {this.props.children} 56 | 57 | ); 58 | } 59 | 60 | } -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import {SpringConfig} from 'simple-performant-harmonic-oscillator'; 2 | import SpringyDOMElement from './SpringyDOMElement'; 3 | 4 | export type SpringyStyleValue = number | 'auto'; 5 | 6 | export type SpringPropertyConfig = SpringConfig&{ 7 | configWhenGettingBigger?: SpringConfig; 8 | configWhenGettingSmaller?: SpringConfig; 9 | onEnterFromValueOffset?: number; 10 | onEnterFromValue?: number; 11 | onEnterToValue?: SpringyStyleValue; 12 | onExitFromValue?: SpringyStyleValue; 13 | onExitToValue?: number; 14 | units?: string; 15 | }; 16 | 17 | export type DOMSpringConfigMap = { 18 | [key:string]: SpringPropertyConfig; 19 | } 20 | 21 | export type InternalSpringyProps = { 22 | forwardedRef: any; 23 | ComponentToWrap: string; 24 | configMap?: DOMSpringConfigMap; 25 | instanceRef?: (ref: SpringyDOMElement) => void; 26 | styleOnExit?: {[key: string]: string | number}; 27 | globalUniqueIDForSpringReuse?: string; 28 | onSpringyPropertyValueUpdate?: (property: string, value: number) => void; 29 | onSpringyPropertyValueAtRest?: (property: string, value: number) => void; 30 | springyOrderedIndex?: number; 31 | springyStyle?: {[key:string]: SpringyStyleValue}; 32 | }; 33 | 34 | type SpringyProps = Pick>; 35 | type StyleOnExitFunction = (node: HTMLElement) => JSX.IntrinsicElements[T]; 36 | export type StyleOnExit = JSX.IntrinsicElements[T] | StyleOnExitFunction; 37 | 38 | export type SpringyDOMWrapper = 39 | (ComponentToWrap: T, configMap?: DOMSpringConfigMap, styleOnExit?: StyleOnExit) => React.ComponentClass; 40 | 41 | export type StyleObject = { 42 | [key: string]: number | string 43 | }; 44 | 45 | /* 46 | resize observer type from https://github.com/que-etc/resize-observer-polyfill/blob/master/src/index.d.ts 47 | */ 48 | interface DOMRectReadOnly { 49 | readonly x: number; 50 | readonly y: number; 51 | readonly width: number; 52 | readonly height: number; 53 | readonly top: number; 54 | readonly right: number; 55 | readonly bottom: number; 56 | readonly left: number; 57 | } 58 | 59 | declare global { 60 | interface ResizeObserverCallback { 61 | (entries: ResizeObserverEntry[], observer: ResizeObserver): void 62 | } 63 | 64 | interface ResizeObserverEntry { 65 | readonly target: Element; 66 | readonly contentRect: DOMRectReadOnly; 67 | } 68 | 69 | interface ResizeObserver { 70 | observe(target: Element): void; 71 | unobserve(target: Element): void; 72 | disconnect(): void; 73 | } 74 | } 75 | 76 | declare var ResizeObserver: { 77 | prototype: ResizeObserver; 78 | new(callback: ResizeObserverCallback): ResizeObserver; 79 | } 80 | 81 | interface ResizeObserver { 82 | observe(target: Element): void; 83 | unobserve(target: Element): void; 84 | disconnect(): void; 85 | } 86 | 87 | export default ResizeObserver; 88 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Basic Options */ 4 | "target": "ES5", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */ 5 | "module": "ES2015", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */ 6 | "lib": ["dom", "es2017"], /* Specify library files to be included in the compilation. */ 7 | // "allowJs": true, /* Allow javascript files to be compiled. */ 8 | // "checkJs": true, /* Report errors in .js files. */ 9 | "jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 10 | "declaration": true, /* Generates corresponding '.d.ts' file. */ 11 | //"declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 12 | "sourceMap": true, /* Generates corresponding '.map' file. */ 13 | //"outFile": "./", /* Concatenate and emit output to single file. */ 14 | "outDir": "./dist", /* Redirect output structure to the directory. */ 15 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 16 | // "composite": true, /* Enable project compilation */ 17 | // "removeComments": true, /* Do not emit comments to output. */ 18 | // "noEmit": true, /* Do not emit outputs. */ 19 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 20 | "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 21 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 22 | 23 | /* Strict Type-Checking Options */ 24 | // "strict": true, /* Enable all strict type-checking options. */ 25 | "noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */ 26 | // "strictNullChecks": true, /* Enable strict null checks. */ 27 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 28 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 29 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 30 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 31 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 32 | 33 | /* Additional Checks */ 34 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 35 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 36 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 37 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 38 | 39 | /* Module Resolution Options */ 40 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 41 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 42 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 43 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 44 | // "typeRoots": [], /* List of folders to include type definitions from. */ 45 | // "types": [], /* Type declaration files to be included in compilation. */ 46 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 47 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 48 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 49 | 50 | /* Source Map Options */ 51 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 52 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 53 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 54 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 55 | 56 | /* Experimental Options */ 57 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 58 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 59 | 60 | "suppressImplicitAnyIndexErrors": true, 61 | } 62 | } 63 | --------------------------------------------------------------------------------