From 50bda74a4814fec05fb25a71e75b11066dc7133a Mon Sep 17 00:00:00 2001 From: Alexandru Buliga Date: Thu, 23 Aug 2018 12:28:48 +0200 Subject: [PATCH 1/2] feat(portal): simple base implementation --- CHANGELOG.md | 1 + .../Portal/Types/PortalExample.shorthand.tsx | 62 +++++++ .../components/Portal/Types/PortalExample.tsx | 61 +++++++ .../PortalExampleControlled.shorthand.tsx | 65 ++++++++ .../Portal/Types/PortalExampleControlled.tsx | 65 ++++++++ .../components/Portal/Types/index.tsx | 21 +++ docs/src/examples/components/Portal/index.tsx | 10 ++ src/components/Portal/Portal.tsx | 151 ++++++++++++++++++ src/components/Portal/PortalInner.tsx | 48 ++++++ src/components/Portal/index.ts | 1 + src/components/Ref/Ref.tsx | 35 ++++ src/components/Ref/index.ts | 1 + src/index.ts | 1 + 13 files changed, 522 insertions(+) create mode 100644 docs/src/examples/components/Portal/Types/PortalExample.shorthand.tsx create mode 100644 docs/src/examples/components/Portal/Types/PortalExample.tsx create mode 100644 docs/src/examples/components/Portal/Types/PortalExampleControlled.shorthand.tsx create mode 100644 docs/src/examples/components/Portal/Types/PortalExampleControlled.tsx create mode 100644 docs/src/examples/components/Portal/Types/index.tsx create mode 100644 docs/src/examples/components/Portal/index.tsx create mode 100644 src/components/Portal/Portal.tsx create mode 100644 src/components/Portal/PortalInner.tsx create mode 100644 src/components/Portal/index.ts create mode 100644 src/components/Ref/Ref.tsx create mode 100644 src/components/Ref/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fbedfb51..b5362e518 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm - Add Button `text` prop @mnajdova ([#177](https://github.com/stardust-ui/react/pull/177)) - Add accessibility keyboard action handlers @sophieH29 ([#121](https://github.com/stardust-ui/react/pull/121)) - Add accessibility description for `Text` component @codepretty ([#205](https://github.com/stardust-ui/react/pull/205)) +- Add `Portal`, `PortalInner` and `Ref` components base implementation @Bugaa92 ([#144](https://github.com/stardust-ui/react/pull/144)) ## [v0.5.0](https://github.com/stardust-ui/react/tree/v0.5.0) (2018-08-30) diff --git a/docs/src/examples/components/Portal/Types/PortalExample.shorthand.tsx b/docs/src/examples/components/Portal/Types/PortalExample.shorthand.tsx new file mode 100644 index 000000000..3d81f70ba --- /dev/null +++ b/docs/src/examples/components/Portal/Types/PortalExample.shorthand.tsx @@ -0,0 +1,62 @@ +import React from 'react' +import { Button, Divider, Header, Label, Portal } from '@stardust-ui/react' + +class PortalExamplePortal extends React.Component { + public state = { + log: [], + logCount: 0, + } + + public render() { + const { log, logCount } = this.state + + const controls = ( +
+
+ ) + + const portalContent = ( +
+
This is a basic portal
+

Portals have tons of great callback functions to hook into.

+

To close, simply click the close button or click away

+
+ ) + + return ( +
+ { + } + /> + } + + {controls} +
+ ) + } + + private handleClick = () => + this.setState({ + log: [`${new Date().toLocaleTimeString()}: handleClick`, ...this.state.log].slice(0, 20), + logCount: this.state.logCount + 1, + }) + + private clearLog = () => this.setState({ log: [], logCount: 0 }) +} + +export default PortalExamplePortal diff --git a/docs/src/examples/components/Portal/Types/PortalExample.tsx b/docs/src/examples/components/Portal/Types/PortalExample.tsx new file mode 100644 index 000000000..a319f5cde --- /dev/null +++ b/docs/src/examples/components/Portal/Types/PortalExample.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import { Button, Divider, Header, Label, Portal } from '@stardust-ui/react' + +class PortalExamplePortal extends React.Component { + public state = { + log: [], + logCount: 0, + } + + public render() { + const { log, logCount } = this.state + + const controls = ( +
+
+ ) + + const portalContent = ( +
+
This is a basic portal
+

Portals have tons of great callback functions to hook into.

+

To close, simply click the close button or click away

+
+ ) + + return ( +
+ { + }> + {portalContent} + + } + + {controls} +
+ ) + } + + private handleClick = () => + this.setState({ + log: [`${new Date().toLocaleTimeString()}: handleClick`, ...this.state.log].slice(0, 20), + logCount: this.state.logCount + 1, + }) + + private clearLog = () => this.setState({ log: [], logCount: 0 }) +} + +export default PortalExamplePortal diff --git a/docs/src/examples/components/Portal/Types/PortalExampleControlled.shorthand.tsx b/docs/src/examples/components/Portal/Types/PortalExampleControlled.shorthand.tsx new file mode 100644 index 000000000..6abaa1a38 --- /dev/null +++ b/docs/src/examples/components/Portal/Types/PortalExampleControlled.shorthand.tsx @@ -0,0 +1,65 @@ +import * as React from 'react' +import { Button, Divider, Header, Label, Portal } from '@stardust-ui/react' + +class PortalExampleControlled extends React.Component { + public state = { + log: [], + logCount: 0, + open: false, + } + + public render() { + const { log, logCount, open } = this.state + const content = open ? 'Close Portal' : 'Open Portal' + + const controls = ( +
+
+ ) + + const portalContent = ( +
+
This is a controlled portal
+

Portals have tons of great callback functions to hook into.

+

To close, simply click the close button

+
+ ) + + return ( +
+ {controls} + {} +
+ ) + } + + private handleClick = (logContent: string) => { + this.setState({ open: !this.state.open }) + this.writeLog(logContent) + } + + private clearLog = () => this.setState({ log: [], logCount: 0 }) + + private writeLog = eventName => + this.setState({ + log: [`${new Date().toLocaleTimeString()}: ${eventName}`, ...this.state.log].slice(0, 20), + logCount: this.state.logCount + 1, + }) +} + +export default PortalExampleControlled diff --git a/docs/src/examples/components/Portal/Types/PortalExampleControlled.tsx b/docs/src/examples/components/Portal/Types/PortalExampleControlled.tsx new file mode 100644 index 000000000..74d2d2da5 --- /dev/null +++ b/docs/src/examples/components/Portal/Types/PortalExampleControlled.tsx @@ -0,0 +1,65 @@ +import * as React from 'react' +import { Button, Divider, Header, Label, Portal } from '@stardust-ui/react' + +class PortalExampleControlled extends React.Component { + public state = { + log: [], + logCount: 0, + open: false, + } + + public render() { + const { log, logCount, open } = this.state + const content = open ? 'Close Portal' : 'Open Portal' + + const controls = ( +
+
+ ) + + const portalContent = ( +
+
This is a controlled portal
+

Portals have tons of great callback functions to hook into.

+

To close, simply click the close button

+
+ ) + + return ( +
+ {controls} + {{portalContent}} +
+ ) + } + + private handleClick = (logContent: string) => { + this.setState({ open: !this.state.open }) + this.writeLog(logContent) + } + + private clearLog = () => this.setState({ log: [], logCount: 0 }) + + private writeLog = eventName => + this.setState({ + log: [`${new Date().toLocaleTimeString()}: ${eventName}`, ...this.state.log].slice(0, 20), + logCount: this.state.logCount + 1, + }) +} + +export default PortalExampleControlled diff --git a/docs/src/examples/components/Portal/Types/index.tsx b/docs/src/examples/components/Portal/Types/index.tsx new file mode 100644 index 000000000..f13c543c8 --- /dev/null +++ b/docs/src/examples/components/Portal/Types/index.tsx @@ -0,0 +1,21 @@ +import React from 'react' + +import ComponentExample from 'docs/src/components/ComponentDoc/ComponentExample' +import ExampleSection from 'docs/src/components/ComponentDoc/ExampleSection' + +const PortalTypesExamples = () => ( + + + + +) + +export default PortalTypesExamples diff --git a/docs/src/examples/components/Portal/index.tsx b/docs/src/examples/components/Portal/index.tsx new file mode 100644 index 000000000..c3921d2e0 --- /dev/null +++ b/docs/src/examples/components/Portal/index.tsx @@ -0,0 +1,10 @@ +import React from 'react' +import Types from './Types' + +const PortalExamples = () => ( +
+ +
+) + +export default PortalExamples diff --git a/src/components/Portal/Portal.tsx b/src/components/Portal/Portal.tsx new file mode 100644 index 000000000..8b2a4a269 --- /dev/null +++ b/src/components/Portal/Portal.tsx @@ -0,0 +1,151 @@ +import * as PropTypes from 'prop-types' +import * as React from 'react' +import { invoke } from 'lodash' + +import { + childrenExist, + customPropTypes, + AutoControlledComponent, + eventStack, + doesNodeContainClick, +} from '../../lib' +import { ItemShorthand, Extendable } from '../../../types/utils' +import Ref from '../Ref' +import PortalInner from './PortalInner' + +type ReactMouseEvent = React.MouseEvent + +export interface IPortalProps { + content?: ItemShorthand | ItemShorthand[] + defaultOpen?: boolean + onMount?: (props: IPortalProps) => void + onUnmount?: (props: IPortalProps) => void + open?: boolean + trigger?: JSX.Element + triggerRef?: (node: HTMLElement) => void +} + +/** + * A component that allows you to render children outside their parent. + */ +class Portal extends AutoControlledComponent, any> { + private portalNode: HTMLElement + private triggerNode: HTMLElement + + public static autoControlledProps = ['open'] + + public static propTypes = { + /** Primary content. */ + children: PropTypes.node, + + /** Shorthand for primary content. */ + content: customPropTypes.contentShorthand, + + /** Initial value of open. */ + defaultOpen: PropTypes.bool, + + /** + * Called when the portal is mounted on the DOM. + * + * @param {object} data - All props. + */ + onMount: PropTypes.func, + + /** + * Called when the portal is unmounted from the DOM. + * + * @param {object} data - All props. + */ + onUnmount: PropTypes.func, + + /** Controls whether or not the portal is displayed. */ + open: PropTypes.bool, + + /** Element to be rendered in-place where the portal is defined. */ + trigger: PropTypes.node, + + /** + * Called with a ref to the trigger node. + * + * @param {JSX.Element} node - Referred node. + */ + triggerRef: PropTypes.func, + } + + public render() { + return ( + + {this.renderPortal()} + {this.renderTrigger()} + + ) + } + + private renderPortal(): JSX.Element | undefined { + const { children, content } = this.props + const { open } = this.state + + return ( + open && ( + + + {childrenExist(children) ? children : content} + + + ) + ) + } + + private renderTrigger(): JSX.Element | undefined { + const { trigger } = this.props + + return ( + trigger && ( + + {React.cloneElement(trigger, { onClick: this.handleTriggerClick })} + + ) + ) + } + + private handleMount = () => { + eventStack.sub('click', this.handleDocumentClick) + invoke(this.props, 'onMount', this.props) + } + + private handleUnmount = () => { + eventStack.unsub('click', this.handleDocumentClick) + invoke(this.props, 'onUnmount', this.props) + } + + private handlePortalRef = (portalNode: HTMLElement) => { + this.portalNode = portalNode + } + + private handleTriggerRef = (triggerNode: HTMLElement) => { + this.triggerNode = triggerNode + + invoke(this.props, 'triggerRef', triggerNode) + } + + private handleTriggerClick = (e: ReactMouseEvent, ...rest) => { + const { trigger } = this.props + + invoke(trigger, 'props.onClick', e, ...rest) // Call original event handler + this.trySetState({ open: !this.state.open }) + } + + private handleDocumentClick = (e: ReactMouseEvent) => { + if ( + !this.portalNode || // no portal + doesNodeContainClick(this.triggerNode, e) || // event happened in trigger (delegate to trigger handlers) + doesNodeContainClick(this.portalNode, e) // event happened in the portal + ) { + return // ignore the click + } + + this.trySetState({ open: false }) + } +} + +export default Portal diff --git a/src/components/Portal/PortalInner.tsx b/src/components/Portal/PortalInner.tsx new file mode 100644 index 000000000..b507f9aac --- /dev/null +++ b/src/components/Portal/PortalInner.tsx @@ -0,0 +1,48 @@ +import * as PropTypes from 'prop-types' +import { invoke } from 'lodash' +import { Component } from 'react' +import { createPortal } from 'react-dom' +import { Extendable } from '../../../types/utils' + +export interface IPortalProps { + onMount?: (props: IPortalProps) => void + onUnmount?: (props: IPortalProps) => void +} + +/** + * An inner component that allows you to render children outside their parent. + */ +class PortalInner extends Component, any> { + public static propTypes = { + /** Primary content. */ + children: PropTypes.node, + + /** + * Called when the portal is mounted on the DOM + * + * @param {object} data - All props. + */ + onMount: PropTypes.func, + + /** + * Called when the portal is unmounted from the DOM + * + * @param {object} data - All props. + */ + onUnmount: PropTypes.func, + } + + public componentDidMount() { + invoke(this.props, 'onMount', { ...this.props }) + } + + public componentWillUnmount() { + invoke(this.props, 'onUnmount', { ...this.props }) + } + + public render() { + return createPortal(this.props.children, document.body) + } +} + +export default PortalInner diff --git a/src/components/Portal/index.ts b/src/components/Portal/index.ts new file mode 100644 index 000000000..0ec1d5618 --- /dev/null +++ b/src/components/Portal/index.ts @@ -0,0 +1 @@ +export { default } from './Portal' diff --git a/src/components/Ref/Ref.tsx b/src/components/Ref/Ref.tsx new file mode 100644 index 000000000..d79604624 --- /dev/null +++ b/src/components/Ref/Ref.tsx @@ -0,0 +1,35 @@ +import * as PropTypes from 'prop-types' +import { invoke } from 'lodash' +import { Children, Component } from 'react' +import { findDOMNode } from 'react-dom' +import { Extendable } from '../../../types/utils' + +export interface IRefProps { + innerRef?: (ref: HTMLElement) => void +} + +/** + * This component exposes a callback prop that always returns the DOM node of both functional and class component + * children. + */ +export default class Ref extends Component, any> { + static propTypes = { + /** Primary content. */ + children: PropTypes.element, + + /** + * Called when componentDidMount. + * + * @param {HTMLElement} node - Referred node. + */ + innerRef: PropTypes.func, + } + + componentDidMount() { + invoke(this.props, 'innerRef', findDOMNode(this)) + } + + render() { + return Children.only(this.props.children) + } +} diff --git a/src/components/Ref/index.ts b/src/components/Ref/index.ts new file mode 100644 index 000000000..008c6e84b --- /dev/null +++ b/src/components/Ref/index.ts @@ -0,0 +1 @@ +export { default } from './Ref' diff --git a/src/index.ts b/src/index.ts index 4ef2d0f46..e397e1c6e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,3 +32,4 @@ export { default as ToolbarBehavior } from './lib/accessibility/Behaviors/Toolba export { default as ToolbarButtonBehavior, } from './lib/accessibility/Behaviors/Toolbar/ToolbarButtonBehavior' +export { default as Portal } from './components/Portal' From d0df7ed21ad0d4cd92993bbcd9c9c422f3323248 Mon Sep 17 00:00:00 2001 From: Alexandru Buliga Date: Tue, 28 Aug 2018 14:49:22 +0200 Subject: [PATCH 2/2] addressed PR comments --- .../Portal/Types/PortalExample.shorthand.tsx | 81 ++++----- .../components/Portal/Types/PortalExample.tsx | 77 ++++----- .../PortalExampleControlled.shorthand.tsx | 93 +++++------ .../Portal/Types/PortalExampleControlled.tsx | 90 +++++----- src/components/Portal/Portal.tsx | 32 +++- src/components/Portal/PortalInner.tsx | 34 ++-- src/components/Ref/Ref.tsx | 9 +- test/specs/components/Portal/Portal-test.tsx | 156 ++++++++++++++++++ .../PortalInner/PortalInner-test.tsx | 41 +++++ test/specs/components/Ref/Ref-test.tsx | 46 ++++++ test/specs/components/Ref/fixtures.tsx | 18 ++ 11 files changed, 463 insertions(+), 214 deletions(-) create mode 100644 test/specs/components/Portal/Portal-test.tsx create mode 100644 test/specs/components/PortalInner/PortalInner-test.tsx create mode 100644 test/specs/components/Ref/Ref-test.tsx create mode 100644 test/specs/components/Ref/fixtures.tsx diff --git a/docs/src/examples/components/Portal/Types/PortalExample.shorthand.tsx b/docs/src/examples/components/Portal/Types/PortalExample.shorthand.tsx index 3d81f70ba..48e37842a 100644 --- a/docs/src/examples/components/Portal/Types/PortalExample.shorthand.tsx +++ b/docs/src/examples/components/Portal/Types/PortalExample.shorthand.tsx @@ -1,62 +1,51 @@ import React from 'react' import { Button, Divider, Header, Label, Portal } from '@stardust-ui/react' -class PortalExamplePortal extends React.Component { - public state = { - log: [], - logCount: 0, - } +class PortalExamplePortal extends React.Component { + state = { log: [], logCount: 0 } - public render() { - const { log, logCount } = this.state + handleClick = () => + this.setState({ + log: [`${new Date().toLocaleTimeString()}: handleClick`, ...this.state.log].slice(0, 20), + logCount: this.state.logCount + 1, + }) - const controls = ( -
-
- ) + clearLog = () => this.setState({ log: [], logCount: 0 }) - const portalContent = ( -
-
This is a basic portal
-

Portals have tons of great callback functions to hook into.

-

To close, simply click the close button or click away

-
- ) + render() { + const { log, logCount } = this.state return (
- { - } - /> - } + +
This is a basic portal
+

Portals have tons of great callback functions to hook into.

+

To close, simply click the close button or click away

+
+ } + trigger={ }) + testPortalInnerIsOpen(false) + + wrapper.find('button').simulate('click') + testPortalInnerIsOpen(true) + }) + + it('closes the portal on click when set', () => { + mountPortal({ trigger: + mountPortal({ trigger }) + + expect(wrapper.find('button').length).toBe(1) + expect(wrapper.text()).toEqual(text) + }) + }) +}) diff --git a/test/specs/components/PortalInner/PortalInner-test.tsx b/test/specs/components/PortalInner/PortalInner-test.tsx new file mode 100644 index 000000000..c1f70f8f7 --- /dev/null +++ b/test/specs/components/PortalInner/PortalInner-test.tsx @@ -0,0 +1,41 @@ +import * as React from 'react' +import { mount } from 'enzyme' + +import PortalInner, { IPortalInnerProps } from 'src/components/Portal/PortalInner' + +const mountPortalInner = (props: IPortalInnerProps) => + mount( + +

+ , + ) + +describe('PortalInner', () => { + describe('render', () => { + it('calls react createPortal', () => { + const context = document.createElement('div') + const comp = mountPortalInner({ context }) + + expect(context.contains(comp.getDOMNode())).toBeTruthy() + }) + }) + + describe('onMount', () => { + it('called when mounting', () => { + const handlerSpy = jest.fn() + mountPortalInner({ onMount: handlerSpy }) + + expect(handlerSpy).toHaveBeenCalledTimes(1) + }) + }) + + describe('onUnmount', () => { + it('is called only once when unmounting', () => { + const handlerSpy = jest.fn() + const wrapper = mountPortalInner({ onUnmount: handlerSpy }) + wrapper.unmount() + + expect(handlerSpy).toHaveBeenCalledTimes(1) + }) + }) +}) diff --git a/test/specs/components/Ref/Ref-test.tsx b/test/specs/components/Ref/Ref-test.tsx new file mode 100644 index 000000000..84ced9ea9 --- /dev/null +++ b/test/specs/components/Ref/Ref-test.tsx @@ -0,0 +1,46 @@ +import * as React from 'react' +import { shallow, mount } from 'enzyme' + +import { CompositeClass, CompositeFunction, DOMClass, DOMFunction } from './fixtures' +import Ref from 'src/components/Ref' + +const testInnerRef = Component => { + const innerRef = jest.fn() + const node = mount( + + + , + ).getDOMNode() + + expect(innerRef).toHaveBeenCalledTimes(1) + expect(innerRef).toHaveBeenCalledWith(node) +} + +describe('Ref', () => { + describe('children', () => { + it('renders single child', () => { + const child =

+ const component = shallow({child}) + + expect(component.contains(child)).toBeTruthy() + }) + }) + + describe('innerRef', () => { + it('returns node from a functional component with DOM node', () => { + testInnerRef(DOMFunction) + }) + + it('returns node from a functional component', () => { + testInnerRef(CompositeFunction) + }) + + it('returns node from a class component with DOM node', () => { + testInnerRef(DOMClass) + }) + + it('returns node from a class component', () => { + testInnerRef(CompositeClass) + }) + }) +}) diff --git a/test/specs/components/Ref/fixtures.tsx b/test/specs/components/Ref/fixtures.tsx new file mode 100644 index 000000000..364c65722 --- /dev/null +++ b/test/specs/components/Ref/fixtures.tsx @@ -0,0 +1,18 @@ +import * as React from 'react' +import Component = React.Component + +export const DOMFunction = props =>
+ +export const CompositeFunction = props => + +export class DOMClass extends Component { + render() { + return
+ } +} + +export class CompositeClass extends Component { + render() { + return + } +}