diff --git a/docs/app/Examples/addons/TransitionablePortal/Types/TransitionablePortalExampleControlled.js b/docs/app/Examples/addons/TransitionablePortal/Types/TransitionablePortalExampleControlled.js new file mode 100644 index 0000000000..d0f7e33cc5 --- /dev/null +++ b/docs/app/Examples/addons/TransitionablePortal/Types/TransitionablePortalExampleControlled.js @@ -0,0 +1,33 @@ +import React, { Component } from 'react' +import { Button, Header, Segment, TransitionablePortal } from 'semantic-ui-react' + +export default class TransitionablePortalExampleControlled extends Component { + state = { open: false } + + handleClick = () => this.setState({ open: !this.state.open }) + + handleClose = () => this.setState({ open: false }) + + render() { + const { open } = this.state + + return ( +
+
+ ) + } +} diff --git a/docs/app/Examples/addons/TransitionablePortal/Types/TransitionablePortalExamplePortal.js b/docs/app/Examples/addons/TransitionablePortal/Types/TransitionablePortalExamplePortal.js new file mode 100644 index 0000000000..8e8187f733 --- /dev/null +++ b/docs/app/Examples/addons/TransitionablePortal/Types/TransitionablePortalExamplePortal.js @@ -0,0 +1,36 @@ +import React, { Component } from 'react' +import { Button, Header, Segment, TransitionablePortal } from 'semantic-ui-react' + +export default class TransitionablePortalExamplePortal extends Component { + state = { open: false } + + handleOpen = () => this.setState({ open: true }) + + handleClose = () => this.setState({ open: false }) + + render() { + const { open } = this.state + + return ( + + )} + > + +
This is an example portal
+

Portals have tons of great callback functions to hook into.

+

To close, simply click the close button or click away

+
+
+ ) + } +} diff --git a/docs/app/Examples/addons/TransitionablePortal/Types/index.js b/docs/app/Examples/addons/TransitionablePortal/Types/index.js new file mode 100644 index 0000000000..c240d23299 --- /dev/null +++ b/docs/app/Examples/addons/TransitionablePortal/Types/index.js @@ -0,0 +1,21 @@ +import React from 'react' + +import ComponentExample from 'docs/app/Components/ComponentDoc/ComponentExample' +import ExampleSection from 'docs/app/Components/ComponentDoc/ExampleSection' + +const TransitionablePortalTypesExamples = () => ( + + + + +) + +export default TransitionablePortalTypesExamples diff --git a/docs/app/Examples/addons/TransitionablePortal/Usage/TransitionablePortalExampleTransition.js b/docs/app/Examples/addons/TransitionablePortal/Usage/TransitionablePortalExampleTransition.js new file mode 100644 index 0000000000..2463f017a6 --- /dev/null +++ b/docs/app/Examples/addons/TransitionablePortal/Usage/TransitionablePortalExampleTransition.js @@ -0,0 +1,66 @@ +import React, { Component } from 'react' +import { Form, Grid, Header, Segment, TransitionablePortal } 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 TransitionablePortalExampleTransition extends Component { + state = { animation: transitions[0], duration: 500, open: false } + + handleChange = (e, { name, value }) => this.setState({ [name]: value }) + + handleClick = () => this.setState({ open: !this.state.open }) + + render() { + const { animation, duration, open } = this.state + + return ( + + +
+ + + + + + + +
This is a controlled portal
+

Portals have tons of great callback functions to hook into.

+

To close, simply click the close button or click away

+
+
+
+
+ ) + } +} diff --git a/docs/app/Examples/addons/TransitionablePortal/Usage/index.js b/docs/app/Examples/addons/TransitionablePortal/Usage/index.js new file mode 100644 index 0000000000..53340a46b4 --- /dev/null +++ b/docs/app/Examples/addons/TransitionablePortal/Usage/index.js @@ -0,0 +1,22 @@ +import React from 'react' +import { Link } from 'react-router-dom' +import { Message } from 'semantic-ui-react' + +import ComponentExample from 'docs/app/Components/ComponentDoc/ComponentExample' +import ExampleSection from 'docs/app/Components/ComponentDoc/ExampleSection' + +const TransitionablePortalUsageExamples = () => ( + + + + See Transition for more examples of usage. + + + +) + +export default TransitionablePortalUsageExamples diff --git a/docs/app/Examples/addons/TransitionablePortal/index.js b/docs/app/Examples/addons/TransitionablePortal/index.js new file mode 100644 index 0000000000..7a387a57a9 --- /dev/null +++ b/docs/app/Examples/addons/TransitionablePortal/index.js @@ -0,0 +1,13 @@ +import React from 'react' + +import Types from './Types' +import Usage from './Usage' + +const TransitionablePortalExamples = () => ( +
+ + +
+) + +export default TransitionablePortalExamples diff --git a/index.d.ts b/index.d.ts index a153527599..c17237711d 100644 --- a/index.d.ts +++ b/index.d.ts @@ -10,6 +10,11 @@ export { default as Radio, RadioProps } from './dist/commonjs/addons/Radio'; export { default as Ref, RefProps } from './dist/commonjs/addons/Ref'; export { default as Select, SelectProps } from './dist/commonjs/addons/Select'; export { default as TextArea, TextAreaProps } from './dist/commonjs/addons/TextArea'; +export { + default as TransitionablePortal, + TransitionablePortalProps, + TransitionablePortalState +} from './dist/commonjs/addons/TransitionablePortal'; // Behaviors export { diff --git a/src/addons/TransitionablePortal/TransitionablePortal.d.ts b/src/addons/TransitionablePortal/TransitionablePortal.d.ts new file mode 100644 index 0000000000..6bd6d10e54 --- /dev/null +++ b/src/addons/TransitionablePortal/TransitionablePortal.d.ts @@ -0,0 +1,58 @@ +import * as React from 'react'; + +import { TransitionEventData, TransitionProps } from '../../modules/Transition/Transition'; +import { PortalProps } from '../Portal/Portal'; + +export interface TransitionablePortalProps { + [key: string]: any; + + /** Primary content. */ + children: React.ReactNode; + + /** + * Called when a close event happens. + * + * @param {SyntheticEvent} event - React's original SyntheticEvent. + * @param {object} data - All props and internal state. + */ + onClose?: (nothing: null, data: PortalProps & TransitionablePortalState) => void; + + /** + * Callback on each transition that changes visibility to hidden. + * + * @param {null} + * @param {object} data - All props with status. + */ + onHide?: (nothing: null, data: TransitionEventData & TransitionablePortalState) => void; + + /** + * Called when an open event happens. + * + * @param {SyntheticEvent} event - React's original SyntheticEvent. + * @param {object} data - All props and internal state. + */ + onOpen?: (nothing: null, data: PortalProps & TransitionablePortalState) => void; + + /** + * Callback on animation start. + * + * @param {null} + * @param {object} data - All props with status. + */ + onStart?: (nothing: null, data: TransitionEventData & TransitionablePortalState) => void; + + /** Controls whether or not the portal is displayed. */ + open?: boolean; + + /** Transition props. */ + transition?: TransitionProps; +} + +export interface TransitionablePortalState { + portalOpen: boolean; + transitionVisible: boolean; +} + +declare const TransitionablePortal: React.ComponentClass; + +export default TransitionablePortal; diff --git a/src/addons/TransitionablePortal/TransitionablePortal.js b/src/addons/TransitionablePortal/TransitionablePortal.js new file mode 100644 index 0000000000..543d3d5394 --- /dev/null +++ b/src/addons/TransitionablePortal/TransitionablePortal.js @@ -0,0 +1,173 @@ +import _ from 'lodash' +import PropTypes from 'prop-types' +import React, { Component } from 'react' + +import Portal from '../Portal' +import Transition from '../../modules/Transition' +import { + getUnhandledProps, + makeDebugger, + META, +} from '../../lib' + +const debug = makeDebugger('transitionable_portal') + +/** + * A sugar for `Portal` and `Transition`. + * @see Portal + * @see Transition + */ +export default class TransitionablePortal extends Component { + static propTypes = { + /** Primary content. */ + children: PropTypes.node.isRequired, + + /** + * Called when a close event happens. + * + * @param {SyntheticEvent} event - React's original SyntheticEvent. + * @param {object} data - All props and internal state. + */ + onClose: PropTypes.func, + + /** + * Callback on each transition that changes visibility to hidden. + * + * @param {null} + * @param {object} data - All props with transition status and internal state. + */ + onHide: PropTypes.func, + + /** + * Called when an open event happens. + * + * @param {SyntheticEvent} event - React's original SyntheticEvent. + * @param {object} data - All props and internal state. + */ + onOpen: PropTypes.func, + + /** + * Callback on animation start. + * + * @param {null} + * @param {object} data - All props with transition status and internal state. + */ + onStart: PropTypes.func, + + /** Controls whether or not the portal is displayed. */ + open: PropTypes.bool, + + /** Transition props. */ + transition: PropTypes.object, + } + + static _meta = { + name: 'TransitionablePortal', + type: META.TYPES.ADDON, + } + + static defaultProps = { + transition: { + animation: 'scale', + duration: 400, + }, + } + + constructor(props) { + super(props) + + this.state = { + portalOpen: props.open, + } + } + + // ---------------------------------------- + // Lifecycle + // ---------------------------------------- + + componentWillReceiveProps({ open }) { + debug('componentWillReceiveProps()', { open }) + + // Heads up! We apply `open` prop only when it's defined, otherwise it will break + // autocontrolled Portal + if (!_.isNil(open)) this.setState({ portalOpen: open }) + } + + // ---------------------------------------- + // Callback handling + // ---------------------------------------- + + handlePortalClose = () => { + debug('handlePortalClose()') + const { open } = this.props + const { portalOpen } = this.state + + // Heads up! We simply call `onClose` when component is controlled with `open` prop. + // But, when it's autocontrolled we should change the state to opposite to keep the transition + // queue + if (_.isNil(open)) this.setState({ portalOpen: !portalOpen }) + } + + handlePortalOpen = () => { + debug('handlePortalOpen()') + + this.setState({ portalOpen: true }) + } + + handleTransitionHide = (nothing, data) => { + debug('handleTransitionHide()') + const { portalOpen } = this.state + + this.setState({ transitionVisible: false }) + _.invoke(this.props, 'onClose', null, { ...data, portalOpen: false, transitionVisible: false }) + _.invoke(this.props, 'onHide', null, { ...data, portalOpen, transitionVisible: false }) + } + + handleTransitionStart = (nothing, data) => { + debug('handleTransitionStart()') + const { portalOpen } = this.state + const { status } = data + const transitionVisible = status === Transition.ENTERING + + _.invoke(this.props, 'onStart', null, { ...data, portalOpen, transitionVisible }) + + // Heads up! TransitionablePortal fires onOpen callback on the start of transition animation + if (!transitionVisible) return + + this.setState({ transitionVisible }) + _.invoke(this.props, 'onOpen', null, { ...data, transitionVisible, portalOpen: true }) + } + + // ---------------------------------------- + // Render + // ---------------------------------------- + + render() { + debug('render()', this.state) + + const { children, transition } = this.props + const { portalOpen, transitionVisible } = this.state + + const open = portalOpen || transitionVisible + const rest = getUnhandledProps(TransitionablePortal, this.props) + + return ( + + + {children} + + + ) + } +} diff --git a/src/addons/TransitionablePortal/index.d.ts b/src/addons/TransitionablePortal/index.d.ts new file mode 100644 index 0000000000..ba2c4032aa --- /dev/null +++ b/src/addons/TransitionablePortal/index.d.ts @@ -0,0 +1 @@ +export { default, TransitionablePortalProps, TransitionablePortalState } from './TransitionablePortal'; diff --git a/src/addons/TransitionablePortal/index.js b/src/addons/TransitionablePortal/index.js new file mode 100644 index 0000000000..07a316357d --- /dev/null +++ b/src/addons/TransitionablePortal/index.js @@ -0,0 +1 @@ +export default from './TransitionablePortal' diff --git a/src/index.js b/src/index.js index 51faf9ddc6..b6c27c97cb 100644 --- a/src/index.js +++ b/src/index.js @@ -6,6 +6,7 @@ export { default as Radio } from './addons/Radio' export { default as Ref } from './addons/Ref' export { default as Select } from './addons/Select' export { default as TextArea } from './addons/TextArea' +export { default as TransitionablePortal } from './addons/TransitionablePortal' // Behaviors export { default as Visibility } from './behaviors/Visibility' diff --git a/test/specs/addons/TransitionablePortal/TransitionablePortal-test.js b/test/specs/addons/TransitionablePortal/TransitionablePortal-test.js new file mode 100644 index 0000000000..0e4ec1382e --- /dev/null +++ b/test/specs/addons/TransitionablePortal/TransitionablePortal-test.js @@ -0,0 +1,158 @@ +import React from 'react' + +import TransitionablePortal from 'src/addons/TransitionablePortal/TransitionablePortal' +import * as common from 'test/specs/commonTests' +import { domEvent, sandbox } from 'test/utils' + +// ---------------------------------------- +// Wrapper +// ---------------------------------------- +let wrapper + +// we need to unmount the modal after every test to remove it from the document +// wrap the render methods to update a global wrapper that is unmounted after each test +const wrapperMount = (...args) => (wrapper = mount(...args)) +const wrapperShallow = (...args) => (wrapper = shallow(...args)) + +const quickTransition = { duration: 0 } +const requiredProps = { + children:
, +} + +describe('TransitionablePortal', () => { + beforeEach(() => { + wrapper = undefined + document.body.innerHTML = '' + }) + + afterEach(() => { + if (wrapper && wrapper.unmount) wrapper.unmount() + }) + + common.isConformant(TransitionablePortal, { requiredProps }) + + describe('children', () => { + it('renders a Portal', () => { + wrapperShallow() + .should.have.descendants('Portal') + }) + + it('renders a Transition', () => { + wrapperShallow() + .should.have.descendants('Transition') + }) + }) + + describe('componentWillReceiveProps', () => { + it('passes `open` prop to `portalOpen` when defined', () => { + wrapperMount() + + wrapper.setProps({ open: true }) + wrapper.should.have.state('portalOpen', true) + wrapper.setProps({ open: false }) + wrapper.should.have.state('portalOpen', false) + }) + + it('does not pass `open` prop to `portalOpen` when not defined', () => { + wrapperMount() + + wrapper.setProps({ transition: {} }) + wrapper.should.have.not.state('portalOpen') + }) + }) + + describe('onClose', () => { + it('is called with (null, data) when Portal closes', (done) => { + const onClose = sandbox.spy() + const trigger =