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 (
+
+
+
+
+
+ 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/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 =
+ wrapperMount(
+ ,
+ )
+
+ wrapper.find('button').simulate('click')
+ domEvent.click(document.body)
+
+ setTimeout(() => {
+ onClose.should.have.been.calledOnce()
+ onClose.should.have.been.calledWithMatch(null, { portalOpen: false })
+
+ done()
+ }, 10)
+ })
+
+ it('changes `portalOpen` to false', () => {
+ const trigger =
+ wrapperMount()
+
+ wrapper.find('button').simulate('click')
+ domEvent.click(document.body)
+
+ wrapper.should.have.state('portalOpen', false)
+ })
+ })
+
+ describe('onHide', () => {
+ it('is called with (null, data) when exiting transition finished', (done) => {
+ const onHide = sandbox.spy()
+ const trigger =
+ wrapperMount(
+ ,
+ )
+
+ wrapper.setProps({ open: false })
+ setTimeout(() => {
+ onHide.should.have.been.calledOnce()
+ onHide.should.have.been.calledWithMatch(null, {
+ ...quickTransition,
+ portalOpen: false,
+ transitionVisible: false,
+ })
+
+ done()
+ }, 10)
+ })
+ })
+
+ describe('onOpen', () => {
+ it('is called with (null, data) when Portal opens', () => {
+ const onOpen = sandbox.spy()
+ const trigger =
+
+ wrapperMount()
+ .find('button')
+ .simulate('click')
+
+ onOpen.should.have.been.calledOnce()
+ onOpen.should.have.been.calledWithMatch(null, { portalOpen: true })
+ })
+
+ it('changes `portalOpen` to true', () => {
+ const trigger =
+ wrapperMount()
+
+ wrapper.find('button').simulate('click', event)
+ wrapper.should.have.state('portalOpen', true)
+ })
+ })
+
+ describe('open', () => {
+ it('blocks update of state on Portal close', () => {
+ wrapperMount()
+ wrapper.should.have.state('portalOpen', true)
+
+ domEvent.click(document.body)
+ wrapper.should.have.state('portalOpen', true)
+ })
+ })
+})