├── .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 |
",
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 |
--------------------------------------------------------------------------------