diff --git a/src/Transition.js b/src/Transition.js index 981acc26..94190642 100644 --- a/src/Transition.js +++ b/src/Transition.js @@ -364,6 +364,7 @@ class Transition extends React.Component { onExiting: _onExiting, onExited: _onExited, nodeRef: _nodeRef, + appendOnReplace: _appendOnReplace, ...childProps } = this.props @@ -561,6 +562,16 @@ Transition.propTypes = { * @type Function(node: HtmlElement) -> void */ onExited: PropTypes.func, + + /** + * Hint to tell whether the node should be placed before or after the pending items it replaces. + * Everything with appendOnReplace set to true will be placed after the removed keys, + * while everything else goes before the removed keys. + * + * **Note**: applies when replacing one or more consecutive existing items with one or more consecutive new items. + * + */ + appendOnReplace: PropTypes.bool, } // Name the function so it is clearer in the documentation diff --git a/src/utils/ChildMapping.js b/src/utils/ChildMapping.js index 60a360d9..77976f9e 100644 --- a/src/utils/ChildMapping.js +++ b/src/utils/ChildMapping.js @@ -18,6 +18,22 @@ export function getChildMapping(children, mapFn) { return result } +/** + * Given `this.props.children`, return an object mapping key to child's appendOnReplace prop. + * + * @param {*} children `this.props.children` + * @return {object} Mapping of key to placement hint + */ +function getChildHintMapping(children) { + let result = Object.create(null) + if (children) + Children.map(children, c => c).forEach(child => { + // run the map function here instead so that the key is the computed one + if (isValidElement(child)) result[child.key] = child.props && child.props.appendOnReplace + }) + return result +} + /** * When you're adding or removing children some may be added or removed in the * same render pass. We want to show *both* since we want to simultaneously @@ -32,12 +48,14 @@ export function getChildMapping(children, mapFn) { * `ReactTransitionChildMapping.getChildMapping()`. * @param {object} next next children as returned from * `ReactTransitionChildMapping.getChildMapping()`. + * @param {object} appendHints placement hint mapping as returned from getChildHintMapping(). * @return {object} a key set that contains all keys in `prev` and all keys * in `next` in a reasonable order. */ -export function mergeChildMappings(prev, next) { +export function mergeChildMappings(prev, next, appendHints) { prev = prev || {} next = next || {} + appendHints = appendHints || {} function getValueForKey(key) { return key in next ? next[key] : prev[key] @@ -59,23 +77,72 @@ export function mergeChildMappings(prev, next) { } } - let i + let pendingNewKeys = [] + let prependKeys = [] + let appendKeys = [] let childMapping = {} + let i for (let nextKey in next) { - if (nextKeysPending[nextKey]) { - for (i = 0; i < nextKeysPending[nextKey].length; i++) { - let pendingNextKey = nextKeysPending[nextKey][i] - childMapping[nextKeysPending[nextKey][i]] = getValueForKey( - pendingNextKey - ) + if (nextKey in prev) { + if (nextKeysPending[nextKey]) { + prependKeys = [] + appendKeys = [] + if (pendingNewKeys.length) { + // If there were pending new keys that replaced the nextKeysPending, + // place them before or after the prevKeys based on the value of their appendOnReplace prop + for (i = 0; i < pendingNewKeys.length; i++) { + if (!appendHints[pendingNewKeys[i]]) { + prependKeys.push(pendingNewKeys[i]) + } else { + appendKeys.push(pendingNewKeys[i]) + } + } + pendingNewKeys = [] + } + const combinedPendingKeys = prependKeys.concat(nextKeysPending[nextKey]).concat(appendKeys) + for (i = 0; i < combinedPendingKeys.length; i++) { + let pendingNextKey = combinedPendingKeys[i] + childMapping[pendingNextKey] = getValueForKey( + pendingNextKey + ) + } + } else if (pendingNewKeys.length) { + // If there were no pending prevKeys, place the pendingNewKeys before nextKey + for (i = 0; i < pendingNewKeys.length; i++) { + childMapping[pendingNewKeys[i]] = getValueForKey(pendingNewKeys[i]) + } + pendingNewKeys = [] + } + childMapping[nextKey] = getValueForKey(nextKey) + } else { + // If the key is new, wait to see if they go before or after any pending keys + pendingNewKeys.push(nextKey) + } + } + + // For the remaining pending and new keys that didn't appear before any keys in next, + // place the new keys before or after the pending keys based on the value of their appendOnReplace prop + prependKeys = [] + appendKeys = [] + let finalPendingKeys = [] + if (pendingKeys.length && pendingNewKeys.length) { + for (i = 0; i < pendingNewKeys.length; i++) { + if (!appendHints[pendingNewKeys[i]]) { + prependKeys.push(pendingNewKeys[i]) + } else { + appendKeys.push(pendingNewKeys[i]) } } - childMapping[nextKey] = getValueForKey(nextKey) + finalPendingKeys = prependKeys.concat(pendingKeys).concat(appendKeys) + } else if (pendingKeys.length) { + finalPendingKeys = pendingKeys + } else if (pendingNewKeys.length) { + finalPendingKeys = pendingNewKeys } - // Finally, add the keys which didn't appear before any key in `next` - for (i = 0; i < pendingKeys.length; i++) { - childMapping[pendingKeys[i]] = getValueForKey(pendingKeys[i]) + // Finally, add the remaining keys + for (i = 0; i < finalPendingKeys.length; i++) { + childMapping[finalPendingKeys[i]] = getValueForKey(finalPendingKeys[i]) } return childMapping @@ -99,7 +166,8 @@ export function getInitialChildMapping(props, onExited) { export function getNextChildMapping(nextProps, prevChildMapping, onExited) { let nextChildMapping = getChildMapping(nextProps.children) - let children = mergeChildMappings(prevChildMapping, nextChildMapping) + let nextChildHintMapping = getChildHintMapping(nextProps.children) + let children = mergeChildMappings(prevChildMapping, nextChildMapping, nextChildHintMapping) Object.keys(children).forEach(key => { let child = children[key] diff --git a/test/CSSTransitionGroup-test.js b/test/CSSTransitionGroup-test.js index 371981d9..d6579ed1 100644 --- a/test/CSSTransitionGroup-test.js +++ b/test/CSSTransitionGroup-test.js @@ -48,7 +48,7 @@ describe('CSSTransitionGroup', () => { it('should clean-up silently after the timeout elapses', () => { render( - + , container, ); @@ -59,7 +59,7 @@ describe('CSSTransitionGroup', () => { render( - + , container, ); @@ -137,6 +137,111 @@ describe('CSSTransitionGroup', () => { expect(transitionGroupDiv.childNodes[1].id).toBe('two'); }); + it('should place new node after the node it replaces when appendOnReplace is true', () => { + render( + + + , + container, + ); + + const transitionGroupDiv = container.childNodes[0] + + expect(transitionGroupDiv.childNodes.length).toBe(1); + + render( + + + , + container, + ); + + expect(transitionGroupDiv.childNodes.length).toBe(2); + expect(transitionGroupDiv.childNodes[0].id).toBe('one'); + expect(transitionGroupDiv.childNodes[1].id).toBe('two'); + }); + + it('should place new node after last pending node when appendOnReplace is true', () => { + render( + + + + , + container, + ); + + const transitionGroupDiv = container.childNodes[0] + + render( + + + + , + container, + ); + + expect(transitionGroupDiv.childNodes.length).toBe(3); + expect(transitionGroupDiv.childNodes[1].id).toBe('two'); + expect(transitionGroupDiv.childNodes[2].id).toBe('three'); + }); + + it('should place new node after the pending node in the middle of the list when appendOnReplace is true', () => { + render( + + + + + , + container, + ); + + const transitionGroupDiv = container.childNodes[0] + + render( + + + + + , + container, + ); + + expect(transitionGroupDiv.childNodes.length).toBe(4); + expect(transitionGroupDiv.childNodes[0].id).toBe('one'); + expect(transitionGroupDiv.childNodes[1].id).toBe('two'); + expect(transitionGroupDiv.childNodes[2].id).toBe('four'); + expect(transitionGroupDiv.childNodes[3].id).toBe('three'); + }); + + it('should place pending nodes without appendOnReplace first, then new nodes, then pending nodes with appendOnReplace last', () => { + render( + + + + + , + container, + ); + + const transitionGroupDiv = container.childNodes[0] + + render( + + + + + + , + container, + ); + + expect(transitionGroupDiv.childNodes.length).toBe(5); + expect(transitionGroupDiv.childNodes[0].id).toBe('one'); + expect(transitionGroupDiv.childNodes[1].id).toBe('four'); + expect(transitionGroupDiv.childNodes[2].id).toBe('two'); + expect(transitionGroupDiv.childNodes[3].id).toBe('five'); + expect(transitionGroupDiv.childNodes[4].id).toBe('three'); + }); it('should work with a null child', () => { render(