diff --git a/README.md b/README.md index 9823a7b55e..9e292679b9 100644 --- a/README.md +++ b/README.md @@ -131,7 +131,7 @@ Once you change the flag, you need to refresh your browser to see the changes in | ✓ Rail | | | ✓ Sidebar | | | ✓ Reveal | | | Sticky | | | ✓ Segment | | | ✓ Tab | | -| ✓ Step | | | Transition | | +| ✓ Step | | | ✓ Transition | | ## Our Principles diff --git a/docs/app/Components/ComponentDoc/ComponentExample.js b/docs/app/Components/ComponentDoc/ComponentExample.js index 261e84fa59..cb43b97c77 100644 --- a/docs/app/Components/ComponentDoc/ComponentExample.js +++ b/docs/app/Components/ComponentDoc/ComponentExample.js @@ -19,6 +19,9 @@ const babelConfig = { const titleStyle = { margin: 0, } +const descriptionStyle = { + maxWidth: '50rem', +} const headerColumnStyle = { // provide room for absolutely positions toggle code icons @@ -28,6 +31,7 @@ const headerColumnStyle = { const childrenStyle = { paddingTop: 0, + maxWidth: '50rem', } const errorStyle = { @@ -400,7 +404,7 @@ class ComponentExample extends Component { {title &&
} - {description &&

{description}

} + {description &&

{description}

} diff --git a/docs/app/Examples/modules/Transition/Explorers/TransitionExampleGroupExplorer.js b/docs/app/Examples/modules/Transition/Explorers/TransitionExampleGroupExplorer.js new file mode 100644 index 0000000000..ba77a3a216 --- /dev/null +++ b/docs/app/Examples/modules/Transition/Explorers/TransitionExampleGroupExplorer.js @@ -0,0 +1,57 @@ +import React, { Component } from 'react' +import { Form, Grid, Image, Transition } from 'semantic-ui-react' + +const transitions = [ + 'scale', + 'fade', 'fade up', 'fade down', 'fade left', 'fade right', + 'horizontal flip', 'vertical flip', + 'drop', + 'fly left', 'fly right', 'fly up', 'fly down', + 'swing left', 'swing right', 'swing up', 'swing down', + 'browse', 'browse right', + 'slide down', 'slide up', 'slide right', +] +const options = transitions.map(name => ({ key: name, text: name, value: name })) + +export default class TransitionExampleSingleExplorer extends Component { + state = { animation: transitions[0], duration: 500, visible: true } + + handleChange = (e, { name, value }) => this.setState({ [name]: value }) + + handleVisibility = () => this.setState({ visible: !this.state.visible }) + + render() { + const { animation, duration, visible } = this.state + + return ( + + + + + + + + + + {visible && } + + + + ) + } +} diff --git a/docs/app/Examples/modules/Transition/Explorers/TransitionExampleTransitionExplorer.js b/docs/app/Examples/modules/Transition/Explorers/TransitionExampleTransitionExplorer.js new file mode 100644 index 0000000000..1c5afa3a51 --- /dev/null +++ b/docs/app/Examples/modules/Transition/Explorers/TransitionExampleTransitionExplorer.js @@ -0,0 +1,49 @@ +import React, { Component } from 'react' +import { Form, Grid, Image, Transition } from 'semantic-ui-react' + +const transitions = ['jiggle', 'flash', 'shake', 'pulse', 'tada', 'bounce'] + +const options = transitions.map(name => ({ key: name, text: name, value: name })) + +export default class TransitionExampleStaticExplorer extends Component { + state = { animation: transitions[0], duration: 500, visible: true } + + handleChange = (e, { name, value }) => this.setState({ [name]: value }) + + toggleVisibility = () => this.setState({ visible: !this.state.visible }) + + render() { + const { animation, duration, visible } = this.state + + return ( + + + + + + + + + + + + + + ) + } +} diff --git a/docs/app/Examples/modules/Transition/Explorers/index.js b/docs/app/Examples/modules/Transition/Explorers/index.js new file mode 100644 index 0000000000..22fad79918 --- /dev/null +++ b/docs/app/Examples/modules/Transition/Explorers/index.js @@ -0,0 +1,35 @@ +import React from 'react' + +import ComponentExample from 'docs/app/Components/ComponentDoc/ComponentExample' +import ExampleSection from 'docs/app/Components/ComponentDoc/ExampleSection' + +import { Message } from 'semantic-ui-react' + +const TransitionTypesExamples = () => ( + + + + + Trigger static animations just as you trigger directional animations, + by toggling the visible prop. The value is not significant since + static animations are unidirectional. + + + +) + +export default TransitionTypesExamples diff --git a/docs/app/Examples/modules/Transition/Types/TransitionExampleGroup.js b/docs/app/Examples/modules/Transition/Types/TransitionExampleGroup.js new file mode 100644 index 0000000000..aa401875df --- /dev/null +++ b/docs/app/Examples/modules/Transition/Types/TransitionExampleGroup.js @@ -0,0 +1,41 @@ +import _ from 'lodash' +import React, { Component } from 'react' +import { Button, Image, List, Transition } from 'semantic-ui-react' + +const users = ['ade', 'chris', 'christian', 'daniel', 'elliot', 'helen'] + +export default class TransitionExampleGroup extends Component { + state = { items: users.slice(0, 3) } + + handleAdd = () => this.setState({ items: users.slice(0, this.state.items.length + 1) }) + + handleRemove = () => this.setState({ items: this.state.items.slice(0, -1) }) + + render() { + const { items } = this.state + + return ( +
+ +
+ ) + } +} diff --git a/docs/app/Examples/modules/Transition/Types/TransitionExampleTransition.js b/docs/app/Examples/modules/Transition/Types/TransitionExampleTransition.js new file mode 100644 index 0000000000..31d8ee2e28 --- /dev/null +++ b/docs/app/Examples/modules/Transition/Types/TransitionExampleTransition.js @@ -0,0 +1,22 @@ +import React, { Component } from 'react' +import { Button, Divider, Image, Transition } from 'semantic-ui-react' + +export default class TransitionExampleTransition extends Component { + state = { visible: true } + + toggleVisibility = () => this.setState({ visible: !this.state.visible }) + + render() { + const { visible } = this.state + + return ( +
+
+ ) + } +} diff --git a/docs/app/Examples/modules/Transition/Types/index.js b/docs/app/Examples/modules/Transition/Types/index.js new file mode 100644 index 0000000000..6c4915bab3 --- /dev/null +++ b/docs/app/Examples/modules/Transition/Types/index.js @@ -0,0 +1,34 @@ +import React from 'react' + +import ComponentExample from 'docs/app/Components/ComponentDoc/ComponentExample' +import ExampleSection from 'docs/app/Components/ComponentDoc/ExampleSection' +import { Message } from 'semantic-ui-react' + +const TransitionTypesExamples = () => ( + + + +

Do not unmount a Transition child or else it cannot be animated.

+ + + Use the unmountOnHide prop to unmount the child after the animation exits. + + + Use a Transition.Group to animate children as they mount and unmount. + + +
+
+ +
+) + +export default TransitionTypesExamples diff --git a/docs/app/Examples/modules/Transition/index.js b/docs/app/Examples/modules/Transition/index.js new file mode 100644 index 0000000000..67ee743c24 --- /dev/null +++ b/docs/app/Examples/modules/Transition/index.js @@ -0,0 +1,13 @@ +import React from 'react' + +import Explorers from './Explorers' +import Types from './Types' + +const TransitionExamples = () => ( +
+ + +
+) + +export default TransitionExamples diff --git a/docs/app/assets/images/leaves/1.png b/docs/app/assets/images/leaves/1.png new file mode 100644 index 0000000000..8d6250ea68 Binary files /dev/null and b/docs/app/assets/images/leaves/1.png differ diff --git a/docs/app/assets/images/leaves/4.png b/docs/app/assets/images/leaves/4.png new file mode 100644 index 0000000000..35db2f5e21 Binary files /dev/null and b/docs/app/assets/images/leaves/4.png differ diff --git a/docs/app/assets/images/leaves/5.png b/docs/app/assets/images/leaves/5.png new file mode 100644 index 0000000000..0651469a99 Binary files /dev/null and b/docs/app/assets/images/leaves/5.png differ diff --git a/index.d.ts b/index.d.ts index 348ff8b064..733d3adcd6 100644 --- a/index.d.ts +++ b/index.d.ts @@ -163,6 +163,9 @@ export { default as SidebarPusher, SidebarPusherProps } from './dist/commonjs/mo export { default as Tab, TabProps } from './dist/commonjs/modules/Tab'; export { default as TabPane, TabPaneProps } from './dist/commonjs/modules/Tab/TabPane'; +export { default as Transition, TransitionProps, TRANSITION_STATUSES } from './dist/commonjs/modules/Transition'; +export { default as TransitionGroup, TransitionGroupProps } from './dist/commonjs/modules/Transition/TransitionGroup'; + // Views export { default as Advertisement, AdvertisementProps } from './dist/commonjs/views/Advertisement'; diff --git a/src/index.d.ts b/src/index.d.ts index 75e0b27fb3..9b3f095c11 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -48,6 +48,17 @@ export type SemanticCOLORS = 'red' | 'orange' | 'yellow' | 'olive' |'green' | 't 'pink' | 'brown' | 'grey' | 'black'; export type SemanticSIZES = 'mini' | 'tiny' | 'small' | 'medium' | 'large' | 'big' | 'huge' | 'massive'; +// ====================================================== +// Transitions +// ====================================================== + +type SemanticDIRECTIONALTRANSITIONS = 'scale' | 'fade' | 'fade up' | 'fade down' | 'fade left' | 'fade right' | + 'horizontal flip' | 'vertical flip' | 'drop' | 'fly left' | 'fly right' | 'fly up' | 'fly down' | 'swing left' | + 'swing right' | 'swing up' | 'swing down' | 'browse' | 'browse right' | 'slide down' | 'slide up' | 'slide right'; +type SemanticSTATICTRANSITIONS = 'jiggle' | 'flash' | 'shake' | 'pulse' | 'tada' | 'bounce'; + +export type SemanticTRANSITIONS = SemanticDIRECTIONALTRANSITIONS | SemanticSTATICTRANSITIONS; + // ====================================================== // Widths // ====================================================== diff --git a/src/index.js b/src/index.js index e6875c7bbe..115333d5be 100644 --- a/src/index.js +++ b/src/index.js @@ -145,6 +145,9 @@ export { default as SidebarPusher } from './modules/Sidebar/SidebarPusher' export { default as Tab } from './modules/Tab' export { default as TabPane } from './modules/Tab/TabPane' +export { default as Transition } from './modules/Transition' +export { default as TransitionGroup } from './modules/Transition/TransitionGroup' + // Views export { default as Advertisement } from './views/Advertisement' diff --git a/src/lib/SUI.js b/src/lib/SUI.js index 076beaa0fc..6c875737e3 100644 --- a/src/lib/SUI.js +++ b/src/lib/SUI.js @@ -29,6 +29,19 @@ export const WIDTHS = [ ..._.values(numberToWordMap), ] +export const DIRECTIONAL_TRANSITIONS = [ + 'scale', + 'fade', 'fade up', 'fade down', 'fade left', 'fade right', + 'horizontal flip', 'vertical flip', + 'drop', + 'fly left', 'fly right', 'fly up', 'fly down', + 'swing left', 'swing right', 'swing up', 'swing down', + 'browse', 'browse right', + 'slide down', 'slide up', 'slide right', +] +export const STATIC_TRANSITIONS = ['jiggle', 'flash', 'shake', 'pulse', 'tada', 'bounce'] +export const TRANSITIONS = [...DIRECTIONAL_TRANSITIONS, ...STATIC_TRANSITIONS] + // Generated from: // https://github.com/Semantic-Org/Semantic-UI/blob/master/dist/components/icon.css export const WEB_CONTENT_ICONS = [ diff --git a/src/lib/childMapping.js b/src/lib/childMapping.js new file mode 100644 index 0000000000..6be71daac3 --- /dev/null +++ b/src/lib/childMapping.js @@ -0,0 +1,61 @@ +import _ from 'lodash' +import { Children, isValidElement } from 'react' + +/** + * Given `this.props.children`, return an object mapping key to child. + * + * @param {object} children Element's children + * @return {object} Mapping of key to child + */ +export const getChildMapping = children => _.keyBy(_.filter(Children.toArray(children), isValidElement), 'key') + +const getPendingKeys = (prev, next) => { + const nextKeysPending = {} + let pendingKeys = [] + + _.forEach(_.keys(prev), prevKey => { + if (!_.has(next, prevKey)) { + pendingKeys.push(prevKey) + return + } + + if (pendingKeys.length) { + nextKeysPending[prevKey] = pendingKeys + pendingKeys = [] + } + }) + + return [nextKeysPending, pendingKeys] +} + +const getValue = (key, prev, next) => _.has(next, key) ? next[key] : prev[key] + +/** + * 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 animate elements in and out. This function takes a previous set of keys and a new set + * of keys and merges them with its best guess of the correct ordering. + * + * @param {object} prev Prev children as returned from `getChildMapping()` + * @param {object} next Next children as returned from `getChildMapping()` + * @return {object} A key set that contains all keys in `prev` and all keys in `next` in a reasonable order + */ +export const mergeChildMappings = (prev = {}, next = {}) => { + const childMapping = {} + const [nextKeysPending, pendingKeys] = getPendingKeys(prev, next) + + _.forEach(_.keys(next), nextKey => { + if (_.has(nextKeysPending, nextKey)) { + _.forEach(nextKeysPending[nextKey], pendingKey => { + childMapping[pendingKey] = getValue(pendingKey, next, prev) + }) + } + + childMapping[nextKey] = getValue(nextKey, next, prev) + }) + + _.forEach(pendingKeys, pendingKey => { + childMapping[pendingKey] = getValue(pendingKey, next, prev) + }) + + return childMapping +} diff --git a/src/lib/index.js b/src/lib/index.js index 810a913c06..7815dbcbd7 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -1,4 +1,5 @@ export { default as AutoControlledComponent } from './AutoControlledComponent' +export { getChildMapping, mergeChildMappings } from './childMapping' export * as childrenUtils from './childrenUtils' export { diff --git a/src/modules/Transition/Transition.d.ts b/src/modules/Transition/Transition.d.ts new file mode 100644 index 0000000000..ddbc68e567 --- /dev/null +++ b/src/modules/Transition/Transition.d.ts @@ -0,0 +1,84 @@ +import * as React from 'react'; + +import { SemanticTRANSITIONS } from '../../'; +import { default as TransitionGroup } from './TransitionGroup'; + +export type TRANSITION_STATUSES = 'ENTERED' | 'ENTERING' | 'EXITED' | 'EXITING' | 'UNMOUNTED'; + +export interface TransitionProps { + [key: string]: any; + + /** Named animation event to used. Must be defined in CSS. */ + animation?: SemanticTRANSITIONS; + + /** Primary content. */ + children?: React.ReactNode; + + /** Duration of the CSS transition animation in milliseconds. */ + duration?: number; + + /** Show the component; triggers the enter or exit animation. */ + visible?: boolean; + + /** Wait until the first "enter" transition to mount the component (add it to the DOM). */ + mountOnShow?: boolean; + + /** + * Callback on each transition that changes visibility to shown. + * + * @param {null} + * @param {object} data - All props with status. + */ + onComplete?: (nothing: null, data: TransitionEventData) => void; + + /** + * Callback on each transition that changes visibility to hidden. + * + * @param {null} + * @param {object} data - All props with status. + */ + onHide?: (nothing: null, data: TransitionEventData) => void; + + /** + * Callback on each transition that changes visibility to shown. + * + * @param {null} + * @param {object} data - All props with status. + */ + onShow?: (nothing: null, data: TransitionEventData) => void; + + /** + * Callback on animation start. + * + * @param {null} + * @param {object} data - All props with status. + */ + onStart?: (nothing: null, data: TransitionEventData) => void; + + /** React's key of the element. */ + reactKey?: string; + + /** Run the enter animation when the component mounts, if it is initially shown. */ + transitionOnMount?: boolean; + + /** Unmount the component (remove it from the DOM) when it is not shown. */ + unmountOnHide?: boolean; +} + +export interface TransitionEventData extends TransitionProps { + status: TRANSITION_STATUSES; +} + +interface TransitionComponent extends React.ComponentClass { + Group: typeof TransitionGroup; + + ENTERED: 'ENTERED'; + ENTERING :'ENTERING'; + EXITED: 'EXITED'; + EXITING: 'EXITING'; + UNMOUNTED: 'UNMOUNTED'; +} + +declare const Transition: TransitionComponent; + +export default Transition; diff --git a/src/modules/Transition/Transition.js b/src/modules/Transition/Transition.js new file mode 100644 index 0000000000..6ceefd08ac --- /dev/null +++ b/src/modules/Transition/Transition.js @@ -0,0 +1,288 @@ +import cx from 'classnames' +import _ from 'lodash' +import PropTypes from 'prop-types' +import { cloneElement, Component } from 'react' + +import { + makeDebugger, + META, + SUI, + useKeyOnly, +} from '../../lib' +import TransitionGroup from './TransitionGroup' + +const debug = makeDebugger('transition') + +/** + * A transition is an animation usually used to move content in or out of view. + */ +export default class Transition extends Component { + static propTypes = { + /** Named animation event to used. Must be defined in CSS. */ + animation: PropTypes.oneOf(SUI.TRANSITIONS), + + /** Primary content. */ + children: PropTypes.element.isRequired, + + /** Duration of the CSS transition animation in milliseconds. */ + duration: PropTypes.number, + + /** Show the component; triggers the enter or exit animation. */ + visible: PropTypes.bool, + + /** Wait until the first "enter" transition to mount the component (add it to the DOM). */ + mountOnShow: PropTypes.bool, + + /** + * Callback on each transition that changes visibility to shown. + * + * @param {null} + * @param {object} data - All props with status. + */ + onComplete: PropTypes.func, + + /** + * Callback on each transition that changes visibility to hidden. + * + * @param {null} + * @param {object} data - All props with status. + */ + onHide: PropTypes.func, + + /** + * Callback on each transition that changes visibility to shown. + * + * @param {null} + * @param {object} data - All props with status. + */ + onShow: PropTypes.func, + + /** + * Callback on animation start. + * + * @param {null} + * @param {object} data - All props with status. + */ + onStart: PropTypes.func, + + /** React's key of the element. */ + reactKey: PropTypes.string, + + /** Run the enter animation when the component mounts, if it is initially shown. */ + transitionOnMount: PropTypes.bool, + + /** Unmount the component (remove it from the DOM) when it is not shown. */ + unmountOnHide: PropTypes.bool, + } + + static defaultProps = { + animation: 'fade', + duration: 500, + visible: true, + mountOnShow: true, + transitionOnMount: false, + unmountOnHide: false, + } + + static _meta = { + name: 'Transition', + type: META.TYPES.MODULE, + } + + static ENTERED = 'ENTERED' + static ENTERING = 'ENTERING' + static EXITED = 'EXITED' + static EXITING = 'EXITING' + static UNMOUNTED = 'UNMOUNTED' + + static Group = TransitionGroup + + constructor(...args) { + super(...args) + + const { initial: status, next } = this.computeInitialStatuses() + this.nextStatus = next + this.state = { status } + } + + // ---------------------------------------- + // Lifecycle + // ---------------------------------------- + + componentDidMount() { + debug('componentDidMount()') + + this.updateStatus() + } + + componentWillReceiveProps(nextProps) { + debug('componentWillReceiveProps()') + + const { current: status, next } = this.computeStatuses(nextProps) + + this.nextStatus = next + if (status) this.setState({ status }) + } + + componentDidUpdate() { + debug('componentDidUpdate()') + + this.updateStatus() + } + + componentWillUnmount() { + debug('componentWillUnmount()') + } + + // ---------------------------------------- + // Callback handling + // ---------------------------------------- + + handleStart = () => { + const { duration } = this.props + const status = this.nextStatus + + this.nextStatus = null + this.setState({ status, animating: true }, () => { + _.invoke(this.props, 'onStart', null, { ...this.props, status }) + setTimeout(this.handleComplete, duration) + }) + } + + handleComplete = () => { + const { status: current } = this.state + + _.invoke(this.props, 'onComplete', null, { ...this.props, status: current }) + + if (this.nextStatus) { + this.handleStart() + return + } + + const status = this.computeCompletedStatus() + const callback = current === Transition.ENTERING ? 'onShow' : 'onHide' + + this.setState({ status, animating: false }, () => { + _.invoke(this.props, callback, null, { ...this.props, status }) + }) + } + + updateStatus = () => { + const { animating } = this.state + + if (this.nextStatus) { + this.nextStatus = this.computeNextStatus() + if (!animating) this.handleStart() + } + } + + // ---------------------------------------- + // Helpers + // ---------------------------------------- + + computeClasses = () => { + const { animation, children } = this.props + const { animating, status } = this.state + + const childClasses = _.get(children, 'props.className') + const directional = _.includes(SUI.DIRECTIONAL_TRANSITIONS, animation) + + if (directional) { + return cx( + animation, + childClasses, + useKeyOnly(animating, 'animating'), + useKeyOnly(status === Transition.ENTERING, 'in'), + useKeyOnly(status === Transition.EXITING, 'out'), + useKeyOnly(status === Transition.EXITED, 'hidden'), + useKeyOnly(status !== Transition.EXITED, 'visible'), + 'transition', + ) + } + + return cx( + animation, + childClasses, + useKeyOnly(animating, 'animating transition'), + ) + } + + computeCompletedStatus = () => { + const { unmountOnHide } = this.props + const { status } = this.state + + if (status === Transition.ENTERING) return Transition.ENTERED + return unmountOnHide ? Transition.UNMOUNTED : Transition.EXITED + } + + computeInitialStatuses = () => { + const { + visible, + mountOnShow, + transitionOnMount, + unmountOnHide, + } = this.props + + if (visible) { + if (transitionOnMount) { + return { + initial: Transition.EXITED, + next: Transition.ENTERING, + } + } + return { initial: Transition.ENTERED } + } + + if (mountOnShow || unmountOnHide) return { initial: Transition.UNMOUNTED } + return { initial: Transition.EXITED } + } + + computeNextStatus = () => { + const { animating, status } = this.state + + if (animating) return status === Transition.ENTERING ? Transition.EXITING : Transition.ENTERING + return status === Transition.ENTERED ? Transition.EXITING : Transition.ENTERING + } + + computeStatuses = props => { + const { status } = this.state + const { visible } = props + + if (visible) { + return { + current: status === Transition.UNMOUNTED && Transition.EXITED, + next: (status !== Transition.ENTERING && status !== Transition.ENTERED) && Transition.ENTERING, + } + } + + return { + next: (status === Transition.ENTERING || status === Transition.ENTERED) && Transition.EXITING, + } + } + + computeStyle = () => { + const { children, duration } = this.props + const childStyle = _.get(children, 'props.style') + + return { ...childStyle, animationDuration: `${duration}ms` } + } + + // ---------------------------------------- + // Render + // ---------------------------------------- + + render() { + debug('render()') + debug('props', this.props) + debug('state', this.state) + + const { children } = this.props + const { status } = this.state + + if (status === Transition.UNMOUNTED) return null + return cloneElement(children, { + className: this.computeClasses(), + style: this.computeStyle(), + }) + } +} diff --git a/src/modules/Transition/TransitionGroup.d.ts b/src/modules/Transition/TransitionGroup.d.ts new file mode 100644 index 0000000000..713a494d0c --- /dev/null +++ b/src/modules/Transition/TransitionGroup.d.ts @@ -0,0 +1,25 @@ +import * as React from 'react'; +import { SemanticTRANSITIONS } from '../../'; + +export interface TransitionGroupProps { + [key: string]: any; + + /** An element type to render as (string or function). */ + as?: any; + + /** Named animation event to used. Must be defined in CSS. */ + animation?: SemanticTRANSITIONS; + + /** Primary content. */ + children?: React.ReactNode; + + /** Duration of the CSS transition animation in milliseconds. */ + duration?: number; +} + +interface TransitionGroupComponent extends React.ComponentClass { +} + +declare const TransitionGroup: TransitionGroupComponent; + +export default TransitionGroup; diff --git a/src/modules/Transition/TransitionGroup.js b/src/modules/Transition/TransitionGroup.js new file mode 100644 index 0000000000..b6109747bc --- /dev/null +++ b/src/modules/Transition/TransitionGroup.js @@ -0,0 +1,129 @@ +import _ from 'lodash' +import PropTypes from 'prop-types' +import React, { cloneElement } from 'react' + +import { + customPropTypes, + getChildMapping, + getElementType, + getUnhandledProps, + makeDebugger, + mergeChildMappings, + META, + SUI, +} from '../../lib' +import Transition from './Transition' + +const debug = makeDebugger('transition_group') + +/** + * A Transition.Group animates children as they mount and unmount. + */ +export default class TransitionGroup extends React.Component { + static propTypes = { + /** An element type to render as (string or function). */ + as: customPropTypes.as, + + /** Named animation event to used. Must be defined in CSS. */ + animation: PropTypes.oneOf(SUI.TRANSITIONS), + + /** Primary content. */ + children: PropTypes.node, + + /** Duration of the CSS transition animation in milliseconds. */ + duration: PropTypes.number, + } + + static defaultProps = { + animation: 'fade', + duration: 500, + } + + static _meta = { + name: 'TransitionGroup', + parent: 'Transition', + type: META.TYPES.MODULE, + } + + constructor(...args) { + super(...args) + + const { children } = this.props + this.state = { children: _.mapValues(getChildMapping(children), child => this.wrapChild(child)) } + } + + componentWillReceiveProps(nextProps) { + debug('componentWillReceiveProps()') + + const { children: prevMapping } = this.state + const nextMapping = getChildMapping(nextProps.children) + const children = mergeChildMappings(prevMapping, nextMapping) + + _.forEach(children, (child, key) => { + const hasPrev = _.has(prevMapping, key) + const hasNext = _.has(nextMapping, key) + const { [key]: prevChild } = prevMapping + const isLeaving = !_.get(prevChild, 'props.visible') + + // item is new (entering), should be wrapped + if (hasNext && (!hasPrev || isLeaving)) { + children[key] = this.wrapChild(child, true) + return + } + + // item is old (exiting), should be updated + if (!hasNext && hasPrev && !isLeaving) { + children[key] = cloneElement(prevChild, { visible: false }) + return + } + + // item hasn't changed transition states, copy over the last transition props; + const { props: { visible, transitionOnMount } } = prevChild + + children[key] = cloneElement(child, { visible, transitionOnMount }) + }) + + this.setState({ children }) + } + + handleOnHide = (nothing, childProps) => { + debug('handleOnHide', childProps) + const { reactKey } = childProps + + this.setState(state => { + const children = { ...state.children } + delete children[reactKey] + + return { children } + }) + } + + wrapChild = (child, transitionOnMount = false) => { + const { animation, duration } = this.props + const { key } = child + + return ( + + ) + } + + render() { + debug('render') + debug('props', this.props) + debug('state', this.state) + + const { children } = this.state + const ElementType = getElementType(TransitionGroup, this.props) + const rest = getUnhandledProps(TransitionGroup, this.props) + + return {_.values(children)} + } +} diff --git a/src/modules/Transition/index.d.ts b/src/modules/Transition/index.d.ts new file mode 100644 index 0000000000..65c437859d --- /dev/null +++ b/src/modules/Transition/index.d.ts @@ -0,0 +1 @@ +export { default, TransitionProps, TRANSITION_STATUSES } from './Transition'; diff --git a/src/modules/Transition/index.js b/src/modules/Transition/index.js new file mode 100644 index 0000000000..4e5bebecc2 --- /dev/null +++ b/src/modules/Transition/index.js @@ -0,0 +1 @@ +export default from './Transition' diff --git a/test/specs/lib/childMapping-test.js b/test/specs/lib/childMapping-test.js new file mode 100644 index 0000000000..51fd99ecf9 --- /dev/null +++ b/test/specs/lib/childMapping-test.js @@ -0,0 +1,98 @@ +import React from 'react' +import { getChildMapping, mergeChildMappings } from 'src/lib' + +describe('childMapping', () => { + describe('childMapping', () => { + it('should support getChildMapping', () => { + const component = ( +
+
+
+
+ ) + + getChildMapping(component.props.children).should.have.deep.keys(['.$one', '.$two']) + }) + + it('skips invalid elements', () => { + const component = ( +
+ string +
+
+
+ ) + + getChildMapping(component.props.children).should.have.deep.keys(['.$one', '.$two']) + }) + }) + + describe('mergeChildMappings', () => { + it('should support mergeChildMappings for adding keys', () => { + const prev = { one: true, two: true } + const next = { one: true, two: true, three: true } + + mergeChildMappings(prev, next).should.deep.equal({ + one: true, + two: true, + three: true, + }) + }) + + it('should support mergeChildMappings for removing keys', () => { + const prev = { one: true, two: true, three: true } + const next = { one: true, two: true } + + mergeChildMappings(prev, next).should.deep.equal({ + one: true, + two: true, + three: true, + }) + }) + + it('should support mergeChildMappings for adding and removing', () => { + const prev = { one: true, two: true, three: true } + const next = { one: true, two: true, four: true } + + mergeChildMappings(prev, next).should.deep.equal({ + one: true, + two: true, + three: true, + four: true, + }) + }) + + it('should reconcile overlapping insertions and deletions', () => { + const prev = { one: true, two: true, four: true, five: true } + const next = { one: true, two: true, three: true, five: true } + + mergeChildMappings(prev, next).should.deep.equal({ + one: true, + two: true, + three: true, + four: true, + five: true, + }) + }) + + it('should support mergeChildMappings with undefined next input', () => { + const prev = { one: true, two: true } + const next = undefined + + mergeChildMappings(prev, next).should.deep.equal({ + one: true, + two: true, + }) + }) + + it('should support mergeChildMappings with undefined prev input', () => { + const prev = undefined + const next = { three: true, four: true } + + mergeChildMappings(prev, next).should.deep.equal({ + three: true, + four: true, + }) + }) + }) +}) diff --git a/test/specs/modules/Transition/Transition-test.js b/test/specs/modules/Transition/Transition-test.js new file mode 100644 index 0000000000..ee4674a036 --- /dev/null +++ b/test/specs/modules/Transition/Transition-test.js @@ -0,0 +1,412 @@ +import React from 'react' + +import { SUI } from 'src/lib' +import Transition from 'src/modules/Transition/Transition' +import TransitionGroup from 'src/modules/Transition/TransitionGroup' +import * as common from 'test/specs/commonTests' +import { sandbox } from 'test/utils' + +let wrapper + +const wrapperMount = (...args) => (wrapper = mount(...args)) +const wrapperShallow = (...args) => (wrapper = shallow(...args)) + +describe('Transition', () => { + common.hasSubComponents(Transition, [TransitionGroup]) + common.hasValidTypings(Transition) + + beforeEach(() => { + wrapper = undefined + }) + + afterEach(() => { + if (wrapper && wrapper.unmount) wrapper.unmount() + }) + + describe('animation', () => { + SUI.DIRECTIONAL_TRANSITIONS.forEach(animation => { + it(`directional ${animation}`, () => { + wrapperShallow( + +

+ + ) + + wrapper.setState({ status: Transition.ENTERING }) + wrapper.should.have.className(animation) + wrapper.should.have.className('in') + + wrapper.setState({ status: Transition.EXITING }) + wrapper.should.have.className(animation) + wrapper.should.have.className('out') + }) + }) + + SUI.STATIC_TRANSITIONS.forEach(animation => { + it(`static ${animation}`, () => { + wrapperShallow( + +

+ + ) + + wrapper.setState({ status: Transition.ENTERING }) + wrapper.should.have.className(animation) + wrapper.should.not.have.className('in') + + wrapper.setState({ status: Transition.EXITING }) + wrapper.should.have.className(animation) + wrapper.should.not.have.className('out') + }) + }) + }) + + describe('className', () => { + it("passes element's className", () => { + wrapperShallow( + +

+ + ) + + wrapper.should.have.className('foo') + wrapper.should.have.className('bar') + }) + + it('adds classes when ENTERED', () => { + wrapperShallow(

) + + wrapper.should.have.className('visible') + wrapper.should.have.className('transition') + }) + + it('adds classes when ENTERING', () => { + wrapperShallow(

) + wrapper.setState({ animating: true, status: Transition.ENTERING }) + + wrapper.should.have.className('animating') + wrapper.should.have.className('visible') + wrapper.should.have.className('transition') + }) + + it('adds classes when EXITED', () => { + wrapperShallow(

) + wrapper.setState({ status: Transition.EXITED }) + + wrapper.should.have.className('hidden') + wrapper.should.have.className('transition') + }) + + it('adds classes when EXITING', () => { + wrapperShallow(

) + wrapper.setState({ animating: true, status: Transition.EXITING }) + + wrapper.should.have.className('animating') + wrapper.should.have.className('visible') + wrapper.should.have.className('transition') + }) + }) + + describe('children', () => { + it('clones element', () => { + wrapperShallow( + +

+ + ).should.have.descendants('p.foo') + }) + + it('returns null when UNMOUNTED', () => { + wrapperShallow( + +

+ + ) + + wrapper.setState({ status: Transition.UNMOUNTED }) + wrapper.should.be.blank() + }) + }) + + describe('constructor', () => { + it('has default statuses', () => { + wrapperShallow(

) + + wrapper.should.have.state('status', Transition.ENTERED) + wrapper.instance().should.include({ nextStatus: undefined }) + }) + + it('sets statuses when `visible` is false', () => { + wrapperShallow(

) + + wrapper.should.have.state('status', Transition.UNMOUNTED) + wrapper.instance().should.include({ nextStatus: undefined }) + }) + + it('sets statuses when mount is disabled', () => { + wrapperShallow( + +

+ + ) + + wrapper.should.have.state('status', Transition.EXITED) + wrapper.instance().should.include({ nextStatus: undefined }) + }) + }) + + describe('duration', () => { + it('applies default value to style', () => { + wrapperShallow( + +

+ + ).should.have.style('animation-duration', '500ms') + }) + + it('applies value to style', () => { + wrapperShallow( + +

+ + ).should.have.style('animation-duration', '1000ms') + }) + }) + + describe('visible', () => { + it('updates status when set to false while ENTERING', () => { + wrapperShallow(

) + wrapper.setState({ status: Transition.ENTERING }) + wrapper.setProps({ visible: false }) + + wrapper.instance().should.include({ nextStatus: Transition.EXITING }) + }) + + it('updates status when set to false while ENTERED', () => { + wrapperShallow( + +

+ + ) + wrapper.setProps({ visible: false }) + + wrapper.instance().should.include({ nextStatus: Transition.EXITING }) + }) + + it('updates status when set to true while UNMOUNTED', () => { + wrapperShallow( + +

+ + ) + wrapper.setProps({ visible: true }) + + wrapper.should.have.state('status', Transition.EXITED) + wrapper.instance().should.include({ nextStatus: Transition.ENTERING }) + }) + + it('updates next status when set to true while performs an ENTERING transition', done => { + wrapperMount( + +

+ + ) + wrapper.setProps({ visible: false }) + + wrapper.should.have.state('status', Transition.ENTERING) + wrapper.instance().should.include({ nextStatus: Transition.EXITING }) + }) + + it('updates next status when set to true while performs an EXITING transition', done => { + wrapperMount( + +

+ + ) + wrapper.setProps({ visible: false }) + wrapper.setProps({ visible: true }) + + wrapper.should.have.state('status', Transition.EXITING) + wrapper.instance().should.include({ nextStatus: Transition.ENTERING }) + }) + }) + + describe('onComplete', () => { + it('is called with (null, props) when transition completed', done => { + const onComplete = sandbox.spy() + const handleComplete = (...args) => { + onComplete(...args) + + onComplete.should.have.been.calledOnce() + onComplete.should.have.been.calledWithMatch(null, { duration: 0, status: Transition.ENTERING }) + + done() + } + + wrapperMount( + +

+ + ) + }) + }) + + describe('onHide', () => { + it('is called with (null, props) when hidden', done => { + const onHide = sandbox.spy() + const handleHide = (...args) => { + onHide(...args) + + onHide.should.have.been.calledOnce() + onHide.should.have.been.calledWithMatch(null, { duration: 0, status: Transition.EXITED }) + + done() + } + + wrapperMount( + +

+ + ) + wrapper.setProps({ visible: false }) + }) + }) + + describe('onShow', () => { + it('is called with (null, props) when shown', done => { + const onShow = sandbox.spy() + const handleShow = (...args) => { + onShow(...args) + + onShow.should.have.been.calledOnce() + onShow.should.have.been.calledWithMatch(null, { duration: 0, status: Transition.ENTERED }) + + done() + } + + wrapperMount( + +

+ + ) + }) + }) + + describe('onStart', () => { + it('is called with (null, props) when transition started', done => { + const onStart = sandbox.spy() + const handleStart = (...args) => { + onStart(...args) + + onStart.should.have.been.calledOnce() + onStart.should.have.been.calledWithMatch(null, { duration: 0, status: Transition.ENTERING }) + + done() + } + + wrapperMount( + +

+ + ) + }) + }) + + describe('style', () => { + it("passes element's style", () => { + wrapperShallow( + +

+ + ) + + wrapper.should.have.style('bottom', '5px') + wrapper.should.have.style('top', '10px') + }) + }) + + describe('transitionOnMount', () => { + it('sets statuses when is true', () => { + wrapperShallow( + +

+ + ) + + wrapper.should.have.state('status', Transition.EXITED) + wrapper.instance().should.include({ nextStatus: Transition.ENTERING }) + }) + + it('updates status after mount when is true', () => { + wrapperMount( + +

+ + ).should.have.state('status', Transition.ENTERING) + }) + }) + + describe('unmountOnHide', () => { + it('unmounts child when true', done => { + const onHide = () => { + wrapper.should.have.state('status', Transition.UNMOUNTED) + done() + } + + wrapperMount( + +

+ + ) + wrapper.setProps({ visible: false }) + }) + + it('lefts mounted when false', done => { + const onHide = () => { + wrapper.should.have.state('status', Transition.EXITED) + done() + } + + wrapperMount( + +

+ + ) + wrapper.setProps({ visible: false }) + }) + }) +}) diff --git a/test/specs/modules/Transition/TransitionGroup-test.js b/test/specs/modules/Transition/TransitionGroup-test.js new file mode 100644 index 0000000000..6c3e82a192 --- /dev/null +++ b/test/specs/modules/Transition/TransitionGroup-test.js @@ -0,0 +1,108 @@ +import React from 'react' + +import Transition from 'src/modules/Transition/Transition' +import TransitionGroup from 'src/modules/Transition/TransitionGroup' +import * as common from 'test/specs/commonTests' + +let wrapper + +const wrapperMount = (...args) => (wrapper = mount(...args)) +const wrapperShallow = (...args) => (wrapper = shallow(...args)) + +describe('TransitionGroup', () => { + common.isConformant(TransitionGroup) + + beforeEach(() => { + wrapper = undefined + }) + + afterEach(() => { + if (wrapper && wrapper.unmount) wrapper.unmount() + }) + + describe('children', () => { + it('wraps all children to Transition', () => { + shallow( + +

+
+
+ + ) + .children() + .everyWhere(item => item.type().should.equal(Transition)) + }) + + it('passes props to children', () => { + shallow( + +
+
+
+ + ) + .children() + .everyWhere(item => { + item.should.have.prop('animation', 'scale') + item.should.have.prop('duration', 1500) + }) + }) + + it('wraps new child to Transition and sets transitionOnMount to true', () => { + wrapperShallow( + +
+ + ) + wrapper.setProps({ children: [
,
] }) + + const child = wrapper.childAt(1) + child.key().should.equal('.$second') + child.type().should.equal(Transition) + child.should.have.prop('transitionOnMount', true) + }) + + it('skips invalid children', () => { + wrapperShallow( + +
+ + ) + wrapper.setProps({ children: [
, '',
] }) + + wrapper.children().should.have.length(2) + wrapper.childAt(0).key().should.equal('.$first') + wrapper.childAt(1).key().should.equal('.$second') + }) + + it('sets visible to false when child was removed', () => { + wrapperShallow( + +
+
+ + ) + wrapper.setProps({ children: [
] }) + + wrapper.children().should.have.length(2) + wrapper.childAt(1).should.have.prop('visible', false) + }) + + it('removes child after transition', done => { + wrapperMount( + +
+
+ + ) + wrapper.setProps({ children: [
] }) + + setTimeout(() => { + wrapper.children().should.have.length(1) + wrapper.childAt(0).key().should.equal('.$first') + + done() + }, 10) + }) + }) +})