From a0d0b91a56f7671d26179abf47aaf1569e087650 Mon Sep 17 00:00:00 2001 From: Jason Gore Date: Thu, 6 Sep 2018 16:30:47 -0700 Subject: [PATCH] Layer: Reintroduce Portals with Event Blocking (#6211) * Move to use React portals when available (#4724) * Use Portals when available for Callout * Update shrinkwrap * Set virtual parent for initial render * Only wait for target when required * Update shrinkwrap * update * Prevent markdown-to-jsx from upgrading * Add patch file for example app base change * Update Layer.tsx * Add event blocking to maintain traditional behavior that can be disabled by new prop. * Remove old LayerBase implementation. * Add portal utility helper and use in Tooltip. * Revert incorrect typing. * Change files. * Update users-cschleid-portalLayers_2018-05-03-15-52.json * Update snapshots for merge. * Add utility unit tests. * List dependency info for CI builds. * Fix test break due to change in enzyme-adapter-react-16. * Add Layer event boundary unit tests. * Allow Tooltip to appear within portal when target is in same portal. * PR feedback. * Clarifying comments. --- ...jg-portals-part-deux_2018-09-04-17-20.json | 11 + ...jg-portals-part-deux_2018-09-04-17-20.json | 11 + ...schleid-portalLayers_2018-05-03-15-52.json | 11 + .../office-ui-fabric-react/jest.config.js | 1 + .../Callout/CalloutContent.base.tsx | 9 +- .../src/components/ComponentExamples.test.tsx | 13 +- .../ContextualMenu/ContextualMenu.test.tsx | 53 -- .../src/components/Layer/Layer.base.tsx | 195 ++++-- .../components/Layer/Layer.notification.ts | 62 ++ .../src/components/Layer/Layer.test.tsx | 88 ++- .../src/components/Layer/Layer.types.ts | 7 + .../src/components/Layer/LayerHost.tsx | 8 +- .../Layer/__snapshots__/Layer.test.tsx.snap | 63 +- .../src/components/Modal/Modal.test.tsx | 17 +- .../Modal/__snapshots__/Modal.test.tsx.snap | 185 ++++-- .../src/components/Panel/Panel.test.tsx | 17 +- .../Panel/__snapshots__/Panel.test.tsx.snap | 623 ++++++++++-------- .../src/components/Tooltip/Tooltip.test.tsx | 9 + .../src/components/Tooltip/TooltipHost.tsx | 8 +- .../__snapshots__/Tooltip.test.tsx.snap | 134 +++- .../Callout.Basic.Example.tsx.shot | 106 ++- .../DatePicker.Bounded.Example.tsx.shot | 2 +- .../Layer.Basic.Example.tsx.shot | 4 +- .../Panel.HiddenOnDismiss.Example.tsx.shot | 366 +++++++++- .../Pivot.Fabric.Example.tsx.shot | 106 ++- packages/utilities/src/dom.test.ts | 78 ++- packages/utilities/src/dom.ts | 27 +- 27 files changed, 1704 insertions(+), 510 deletions(-) create mode 100644 common/changes/@uifabric/utilities/jg-portals-part-deux_2018-09-04-17-20.json create mode 100644 common/changes/office-ui-fabric-react/jg-portals-part-deux_2018-09-04-17-20.json create mode 100644 common/changes/office-ui-fabric-react/users-cschleid-portalLayers_2018-05-03-15-52.json create mode 100644 packages/office-ui-fabric-react/src/components/Layer/Layer.notification.ts diff --git a/common/changes/@uifabric/utilities/jg-portals-part-deux_2018-09-04-17-20.json b/common/changes/@uifabric/utilities/jg-portals-part-deux_2018-09-04-17-20.json new file mode 100644 index 0000000000000..a00bd355065b6 --- /dev/null +++ b/common/changes/@uifabric/utilities/jg-portals-part-deux_2018-09-04-17-20.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@uifabric/utilities", + "comment": "Add helpers for setting and detecting portals", + "type": "minor" + } + ], + "packageName": "@uifabric/utilities", + "email": "jagore@microsoft.com" +} \ No newline at end of file diff --git a/common/changes/office-ui-fabric-react/jg-portals-part-deux_2018-09-04-17-20.json b/common/changes/office-ui-fabric-react/jg-portals-part-deux_2018-09-04-17-20.json new file mode 100644 index 0000000000000..3895904eda2f7 --- /dev/null +++ b/common/changes/office-ui-fabric-react/jg-portals-part-deux_2018-09-04-17-20.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "office-ui-fabric-react", + "comment": "Layer: Add optional event blocking. Tooltip: Detect targets in portals.", + "type": "minor" + } + ], + "packageName": "office-ui-fabric-react", + "email": "jagore@microsoft.com" +} \ No newline at end of file diff --git a/common/changes/office-ui-fabric-react/users-cschleid-portalLayers_2018-05-03-15-52.json b/common/changes/office-ui-fabric-react/users-cschleid-portalLayers_2018-05-03-15-52.json new file mode 100644 index 0000000000000..5161b9dbe2e2e --- /dev/null +++ b/common/changes/office-ui-fabric-react/users-cschleid-portalLayers_2018-05-03-15-52.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "office-ui-fabric-react", + "comment": "Layer: Now use React Portals.", + "type": "minor" + } + ], + "packageName": "office-ui-fabric-react", + "email": "cschleid@microsoft.com" +} diff --git a/packages/office-ui-fabric-react/jest.config.js b/packages/office-ui-fabric-react/jest.config.js index 4bfb5c2bf98f7..6e9aefe1b14cb 100644 --- a/packages/office-ui-fabric-react/jest.config.js +++ b/packages/office-ui-fabric-react/jest.config.js @@ -6,6 +6,7 @@ const config = createConfig({ moduleNameMapper: { // These mappings allow Jest to run snapshot tests against Example files. + 'office-ui-fabric-react/lib/codepen/(.*)$': '/lib/codepen/$1', 'office-ui-fabric-react/lib/(.*)$': '/src/$1' }, diff --git a/packages/office-ui-fabric-react/src/components/Callout/CalloutContent.base.tsx b/packages/office-ui-fabric-react/src/components/Callout/CalloutContent.base.tsx index 090ef30919789..f34c1c4f745c1 100644 --- a/packages/office-ui-fabric-react/src/components/Callout/CalloutContent.base.tsx +++ b/packages/office-ui-fabric-react/src/components/Callout/CalloutContent.base.tsx @@ -321,11 +321,18 @@ export class CalloutContentBase extends BaseComponent { const realToLocaleString = global.Date.prototype.toLocaleString; const realToLocaleTimeString = global.Date.prototype.toLocaleTimeString; const realToLocaleDateString = global.Date.prototype.toLocaleDateString; - const constantDate = new Date(Date.UTC(2017, 13, 6, 4, 41, 20)); + const constantDate = new Date(Date.UTC(2017, 0, 6, 4, 41, 20)); const files: string[] = glob.sync(path.resolve(process.cwd(), 'src/components/**/examples/*Example*.tsx')); + const createPortal = ReactDOM.createPortal; beforeAll(() => { + // Mock createPortal to capture its component hierarchy in snapshot output. + ReactDOM.createPortal = jest.fn(element => { + return element; + }); + // Ensure test output is consistent across machine locale and time zone config. const mockToLocaleString = () => { return constantDate.toUTCString(); @@ -134,6 +142,9 @@ describe('Component Examples', () => { afterAll(() => { jest.restoreAllMocks(); + + ReactDOM.createPortal = createPortal; + global.Date = realDate; global.Date.prototype.toLocaleString = realToLocaleString; global.Date.prototype.toLocaleTimeString = realToLocaleTimeString; diff --git a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.test.tsx b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.test.tsx index c481da7e4c41b..62453cdcf4c25 100644 --- a/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.test.tsx +++ b/packages/office-ui-fabric-react/src/components/ContextualMenu/ContextualMenu.test.tsx @@ -9,7 +9,6 @@ import { ContextualMenu } from './ContextualMenu'; import { canAnyMenuItemsCheck } from './ContextualMenu.base'; import { IContextualMenuItem, ContextualMenuItemType } from './ContextualMenu.types'; import { IContextualMenuRenderItem, IContextualMenuItemStyles } from './ContextualMenuItem.types'; -import { LayerBase as Layer } from '../Layer/Layer.base'; describe('ContextualMenu', () => { afterEach(() => { @@ -842,58 +841,6 @@ describe('ContextualMenu', () => { }).catch(done()); }); - it('ContextualMenu menuOpened callback is called only when menu is available', () => { - let layerMounted = false; - let menuMounted = false; - let menuMountedFirst = false; - let layerMountedFirst = false; - - // Alter the Layer's prototype so that we can confirm that it mounts before the contextualmenu mounts. - /* tslint:disable:no-function-expression */ - Layer.prototype.componentDidMount = (function(componentDidMount): () => void { - return function(): void { - if (menuMounted) { - menuMountedFirst = true; - } - layerMounted = true; - return componentDidMount.call(this); - }; - })(Layer.prototype.componentDidMount); - /* tslint:enable:no-function-expression */ - - const items: IContextualMenuItem[] = [ - { - name: 'TestText 1', - key: 'TestKey1', - className: 'testkey1' - }, - { - name: 'TestText 2', - key: 'TestKey2' - } - ]; - - const onMenuOpened = (): void => { - if (layerMounted) { - layerMountedFirst = true; - } - menuMounted = true; - }; - - ReactTestUtils.renderIntoDocument( -
- - -
- ); - expect(menuMounted).toEqual(true); - expect(layerMountedFirst).toEqual(true); - expect(menuMountedFirst).toEqual(false); - }); - it('merges callout classNames', () => { ReactTestUtils.renderIntoDocument( (); +@customizable('Layer', ['theme', 'hostId']) export class LayerBase extends BaseComponent { public static defaultProps: ILayerProps = { onLayerDidMount: () => undefined, onLayerWillUnmount: () => undefined }; - private _rootElement = createRef(); private _host: Node; private _layerElement: HTMLElement | undefined; - private _hasMounted: boolean; - /** - * Used for notifying applicable Layers that a host is available/unavailable and to re-evaluate Layers that - * care about the specific host. - */ - public static notifyHostChanged(id: string) { - if (_layersByHostId[id]) { - _layersByHostId[id].forEach(layer => layer.forceUpdate()); - } - } - - /** - * Sets the default target selector to use when determining the host in which - * Layered content will be injected into. If not provided, an element will be - * created at the end of the document body. - * - * Passing in a falsey value will clear the default target and reset back to - * using a created element at the end of document body. - */ - public static setDefaultTarget(selector?: string) { - _defaultHostSelector = selector; - } + private _rootElement = createRef(); constructor(props: ILayerProps) { super(props); @@ -52,32 +35,112 @@ export class LayerBase extends BaseComponent { }); if (this.props.hostId) { - if (!_layersByHostId[this.props.hostId]) { - _layersByHostId[this.props.hostId] = []; - } + registerLayer(this.props.hostId, this); + } + } + + public componentWillMount(): void { + this._layerElement = this._getLayerElement(); + } - _layersByHostId[this.props.hostId].push(this); + public componentWillUpdate(): void { + if (!this._layerElement) { + this._layerElement = this._getLayerElement(); } } public componentDidMount(): void { - this.componentDidUpdate(); + this._setVirtualParent(); + + const { onLayerDidMount, onLayerMounted } = this.props; + if (onLayerMounted) { + onLayerMounted(); + } + + if (onLayerDidMount) { + onLayerDidMount(); + } } public componentWillUnmount(): void { this._removeLayerElement(); - if (this.props.hostId) { - _layersByHostId[this.props.hostId] = _layersByHostId[this.props.hostId].filter(layer => layer !== this); - if (!_layersByHostId[this.props.hostId].length) { - delete _layersByHostId[this.props.hostId]; - } + const { onLayerWillUnmount, hostId } = this.props; + if (onLayerWillUnmount) { + onLayerWillUnmount(); + } + + if (hostId) { + unregisterLayer(hostId, this); } } public componentDidUpdate(): void { - const host = this._getHost(); + this._setVirtualParent(); + } + + public render(): React.ReactNode { + const classNames = this._getClassNames(); + const { eventBubblingEnabled } = this.props; + + return ( + + {this._layerElement && + ReactDOM.createPortal( + eventBubblingEnabled ? ( + {this.props.children} + ) : ( + + {this.props.children} + + ), + this._layerElement + )} + + ); + } + /** + * Helper to stop events from bubbling up out of Layer. + */ + private _filterEvent = (ev: React.SyntheticEvent): void => { + // We should just be able to check ev.bubble here and only stop events that are bubbling up. However, even though mouseenter and + // mouseleave do NOT bubble up, they are showing up as bubbling. Therefore we stop events based on event name rather than ev.bubble. + if (ev.type !== 'mouseenter' && ev.type !== 'mouseleave') { + ev.stopPropagation(); + } + }; + + private _getClassNames() { const { className, styles, theme } = this.props; const classNames = getClassNames(styles!, { theme: theme!, @@ -85,6 +148,20 @@ export class LayerBase extends BaseComponent { isNotHost: !this.props.hostId }); + return classNames; + } + + private _setVirtualParent() { + if (this._rootElement && this._rootElement.current && this._layerElement) { + setVirtualParent(this._layerElement, this._rootElement.current); + } + } + + private _getLayerElement(): HTMLElement | undefined { + const host = this._getHost(); + + const classNames = this._getClassNames(); + if (host !== this._host) { this._removeLayerElement(); } @@ -93,63 +170,38 @@ export class LayerBase extends BaseComponent { this._host = host; if (!this._layerElement) { - const rootElement = this._rootElement.current; - const doc = getDocument(rootElement); - - if (!doc || !rootElement) { + const doc = getDocument(); + if (!doc) { return; } this._layerElement = doc.createElement('div'); this._layerElement.className = classNames.root!; + setPortalAttribute(this._layerElement); host.appendChild(this._layerElement); - setVirtualParent(this._layerElement, rootElement); } - - // Using this 'unstable' method allows us to retain the React context across the layer projection. - ReactDOM.unstable_renderSubtreeIntoContainer( - this, - {this.props.children}, - this._layerElement, - () => { - if (!this._hasMounted) { - this._hasMounted = true; - - // TODO: @deprecated cleanup required. - if (this.props.onLayerMounted) { - this.props.onLayerMounted(); - } - - this.props.onLayerDidMount!(); - } - } - ); } - } - public render(): JSX.Element { - return ; + return this._layerElement; } private _removeLayerElement(): void { if (this._layerElement) { this.props.onLayerWillUnmount!(); - ReactDOM.unmountComponentAtNode(this._layerElement); const parentNode = this._layerElement.parentNode; if (parentNode) { parentNode.removeChild(this._layerElement); } this._layerElement = undefined; - this._hasMounted = false; } } private _getHost(): Node | undefined { const { hostId } = this.props; - const doc = getDocument(this._rootElement.current); + const doc = getDocument(); if (!doc) { return undefined; } @@ -157,7 +209,8 @@ export class LayerBase extends BaseComponent { if (hostId) { return doc.getElementById(hostId) as Node; } else { - return _defaultHostSelector ? (doc.querySelector(_defaultHostSelector) as Node) : doc.body; + const defaultHostSelector = getDefaultTarget(); + return defaultHostSelector ? (doc.querySelector(defaultHostSelector) as Node) : doc.body; } } } diff --git a/packages/office-ui-fabric-react/src/components/Layer/Layer.notification.ts b/packages/office-ui-fabric-react/src/components/Layer/Layer.notification.ts new file mode 100644 index 0000000000000..b31793d5fd9f3 --- /dev/null +++ b/packages/office-ui-fabric-react/src/components/Layer/Layer.notification.ts @@ -0,0 +1,62 @@ +const _layersByHostId: { [hostId: string]: React.Component[] } = {}; + +let _defaultHostSelector: string | undefined; + +/** + * Register a layer for a given host id + * @param hostId Id of the layer host + * @param layer Layer instance + */ +export function registerLayer(hostId: string, layer: React.Component) { + if (!_layersByHostId[hostId]) { + _layersByHostId[hostId] = []; + } + + _layersByHostId[hostId].push(layer); +} + +/** + * Unregister a layer for a given host id + * @param hostId Id of the layer host + * @param layer Layer instance + */ +export function unregisterLayer(hostId: string, layer: React.Component) { + if (_layersByHostId[hostId]) { + const idx = _layersByHostId[hostId].indexOf(layer); + if (idx >= 0) { + _layersByHostId[hostId].splice(idx, 1); + if (_layersByHostId[hostId].length === 0) { + delete _layersByHostId[hostId]; + } + } + } +} + +/** + * Used for notifying applicable Layers that a host is available/unavailable and to re-evaluate Layers that + * care about the specific host. + */ +export function notifyHostChanged(id: string) { + if (_layersByHostId[id]) { + _layersByHostId[id].forEach(layer => layer.forceUpdate()); + } +} + +/** + * Sets the default target selector to use when determining the host in which + * Layered content will be injected into. If not provided, an element will be + * created at the end of the document body. + * + * Passing in a falsey value will clear the default target and reset back to + * using a created element at the end of document body. + */ +export function setDefaultTarget(selector?: string) { + _defaultHostSelector = selector; +} + +/** + * Get the default target selector when determining a host + */ +export function getDefaultTarget(): string | undefined { + return _defaultHostSelector; +} diff --git a/packages/office-ui-fabric-react/src/components/Layer/Layer.test.tsx b/packages/office-ui-fabric-react/src/components/Layer/Layer.test.tsx index de6f0709e1f39..89126a0bbc016 100644 --- a/packages/office-ui-fabric-react/src/components/Layer/Layer.test.tsx +++ b/packages/office-ui-fabric-react/src/components/Layer/Layer.test.tsx @@ -1,18 +1,31 @@ -/* tslint:disable:no-unused-variable */ import * as React from 'react'; -import * as ReactDOM from 'react-dom'; import * as PropTypes from 'prop-types'; -/* tslint:enable:no-unused-variable */ import * as renderer from 'react-test-renderer'; +import { mount } from 'enzyme'; import { Layer } from './Layer'; import { LayerHost } from './LayerHost'; +const ReactDOM = require('react-dom'); + +const testEvents: string[] = ( + 'click contextmenu doubleclick drag dragend dragenter dragleave dragover dragstart drop ' + + 'mousedown mousemove mouseout mouseup keydown keypress keyup focus blur change input submit' +).split(' '); + describe('Layer', () => { it('renders Layer correctly', () => { + // Mock createPortal to capture its component hierarchy in snapshot output. + const createPortal = ReactDOM.createPortal; + ReactDOM.createPortal = jest.fn(element => { + return element; + }); + const component = renderer.create(Content); const tree = component.toJSON(); expect(tree).toMatchSnapshot(); + + ReactDOM.createPortal = createPortal; }); it('can render in a targeted LayerHost and pass context through', () => { @@ -80,4 +93,73 @@ describe('Layer', () => { appElement.remove(); } }); + + it('stops events correctly', () => { + // Simulate does not propagate events up the hierarchy. + // Instead, let's check for calls to stopPropagation. + // https://airbnb.io/enzyme/docs/api/ShallowWrapper/simulate.html + const targetClassName = 'ms-Layer-content'; + const expectedStopPropagationCount = testEvents.length; + let stopPropagationCount = 0; + + const eventObject = (event: string) => { + return { + stopPropagation: () => { + // Debug code for figuring out which events are firing on test failures: + // console.log(event); + stopPropagationCount++; + } + }; + }; + + const wrapper = mount(); + + const targetContent = wrapper.find(`.${targetClassName}`).at(0); + + testEvents.forEach(event => { + targetContent.simulate(event, eventObject(event)); + }); + + expect(stopPropagationCount).toEqual(expectedStopPropagationCount); + + // These events should never be stopped + targetContent.simulate('mouseenter', eventObject('mouseenter')); + targetContent.simulate('mouseleave', eventObject('mouseleave')); + + expect(stopPropagationCount).toEqual(expectedStopPropagationCount); + }); + + it('bubbles events correctly', () => { + // Simulate does not propagate events up the hierarchy. + // Instead, let's check for calls to stopPropagation. + // https://airbnb.io/enzyme/docs/api/ShallowWrapper/simulate.html + const targetClassName = 'ms-Layer-content'; + let stopPropagationCount = 0; + + const eventObject = (event: string) => { + return { + stopPropagation: () => { + // Debug code for figuring out which events are firing on test failures: + // console.log(event); + stopPropagationCount++; + } + }; + }; + + const wrapper = mount(); + + const targetContent = wrapper.find(`.${targetClassName}`).at(0); + + testEvents.forEach(event => { + targetContent.simulate(event, eventObject(event)); + }); + + expect(stopPropagationCount).toEqual(0); + + // These events should always bubble + targetContent.simulate('mouseenter', eventObject('mouseenter')); + targetContent.simulate('mouseleave', eventObject('mouseleave')); + + expect(stopPropagationCount).toEqual(0); + }); }); diff --git a/packages/office-ui-fabric-react/src/components/Layer/Layer.types.ts b/packages/office-ui-fabric-react/src/components/Layer/Layer.types.ts index debd2bb9c3948..f95997870b739 100644 --- a/packages/office-ui-fabric-react/src/components/Layer/Layer.types.ts +++ b/packages/office-ui-fabric-react/src/components/Layer/Layer.types.ts @@ -48,6 +48,13 @@ export interface ILayerProps extends React.HTMLAttributes { +export class LayerHost extends BaseComponent { public shouldComponentUpdate() { return false; } public componentDidMount(): void { - LayerBase.notifyHostChanged(this.props.id!); + notifyHostChanged(this.props.id!); } public componentWillUnmount(): void { - LayerBase.notifyHostChanged(this.props.id!); + notifyHostChanged(this.props.id!); } public render(): JSX.Element { diff --git a/packages/office-ui-fabric-react/src/components/Layer/__snapshots__/Layer.test.tsx.snap b/packages/office-ui-fabric-react/src/components/Layer/__snapshots__/Layer.test.tsx.snap index 2ea317e206ef8..0e144a500042e 100644 --- a/packages/office-ui-fabric-react/src/components/Layer/__snapshots__/Layer.test.tsx.snap +++ b/packages/office-ui-fabric-react/src/components/Layer/__snapshots__/Layer.test.tsx.snap @@ -2,6 +2,65 @@ exports[`Layer renders Layer correctly 1`] = ` + className="ms-layer" +> +
+ Content +
+
`; diff --git a/packages/office-ui-fabric-react/src/components/Modal/Modal.test.tsx b/packages/office-ui-fabric-react/src/components/Modal/Modal.test.tsx index 4930d8bec60df..0e2bd3064c6ab 100644 --- a/packages/office-ui-fabric-react/src/components/Modal/Modal.test.tsx +++ b/packages/office-ui-fabric-react/src/components/Modal/Modal.test.tsx @@ -3,22 +3,21 @@ import * as renderer from 'react-test-renderer'; import { Modal } from './Modal'; -// Mock Layer since it otherwise shows nothing in snapshot tests -jest.mock('../../Layer', () => { - return { - Layer: jest.fn().mockImplementation(props => { - return props.children; - }) - }; -}); - describe('Modal', () => { it('renders Modal correctly', () => { + // Mock createPortal to capture its component hierarchy in snapshot output. + const ReactDOM = require('react-dom'); + ReactDOM.createPortal = jest.fn(element => { + return element; + }); + const component = renderer.create( Test Content ); expect(component.toJSON()).toMatchSnapshot(); + + ReactDOM.createPortal.mockClear(); }); }); diff --git a/packages/office-ui-fabric-react/src/components/Modal/__snapshots__/Modal.test.tsx.snap b/packages/office-ui-fabric-react/src/components/Modal/__snapshots__/Modal.test.tsx.snap index 27542c17d0f56..0dac2bd57dafd 100644 --- a/packages/office-ui-fabric-react/src/components/Modal/__snapshots__/Modal.test.tsx.snap +++ b/packages/office-ui-fabric-react/src/components/Modal/__snapshots__/Modal.test.tsx.snap @@ -1,84 +1,145 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Modal renders Modal correctly 1`] = ` -
-
- Test Content +
+
+
+ Test Content +
+
-
+ `; diff --git a/packages/office-ui-fabric-react/src/components/Panel/Panel.test.tsx b/packages/office-ui-fabric-react/src/components/Panel/Panel.test.tsx index 14c5a499607d3..a7a638c205ac8 100644 --- a/packages/office-ui-fabric-react/src/components/Panel/Panel.test.tsx +++ b/packages/office-ui-fabric-react/src/components/Panel/Panel.test.tsx @@ -1,22 +1,19 @@ import * as React from 'react'; -import * as ReactDOM from 'react-dom'; import * as renderer from 'react-test-renderer'; import { PanelBase } from './Panel.base'; import { Panel } from './Panel'; let div: HTMLElement; -// Mock Layer since it otherwise shows nothing in snapshot tests -jest.mock('../../Layer', () => { - return { - Layer: jest.fn().mockImplementation(props => { - return props.children; - }) - }; -}); +const ReactDOM = require('react-dom'); describe('Panel', () => { it('renders Panel correctly', () => { + // Mock createPortal to capture its component hierarchy in snapshot output. + ReactDOM.createPortal = jest.fn(element => { + return element; + }); + const component = renderer.create( Content goes here @@ -24,6 +21,8 @@ describe('Panel', () => { ); expect(component.toJSON()).toMatchSnapshot(); + + ReactDOM.createPortal.mockClear(); }); describe('onClose', () => { diff --git a/packages/office-ui-fabric-react/src/components/Panel/__snapshots__/Panel.test.tsx.snap b/packages/office-ui-fabric-react/src/components/Panel/__snapshots__/Panel.test.tsx.snap index d68a591e195b3..96f4754af6ca3 100644 --- a/packages/office-ui-fabric-react/src/components/Panel/__snapshots__/Panel.test.tsx.snap +++ b/packages/office-ui-fabric-react/src/components/Panel/__snapshots__/Panel.test.tsx.snap @@ -1,335 +1,396 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Panel renders Panel correctly 1`] = ` -
-
+
-
- -
-
-
-
-

+

- Test Panel -

-
-
- - Content goes here - +
+

+ Test Panel +

+
+
+ + Content goes here + +
+
-
+ `; diff --git a/packages/office-ui-fabric-react/src/components/Tooltip/Tooltip.test.tsx b/packages/office-ui-fabric-react/src/components/Tooltip/Tooltip.test.tsx index 7fa111353f391..ec3e9e500a2b2 100644 --- a/packages/office-ui-fabric-react/src/components/Tooltip/Tooltip.test.tsx +++ b/packages/office-ui-fabric-react/src/components/Tooltip/Tooltip.test.tsx @@ -21,9 +21,18 @@ const defaultCalloutProps: ICalloutProps = { describe('Tooltip', () => { it('renders default Tooltip correctly', () => { + // Mock createPortal to capture its component hierarchy in snapshot output. + const ReactDOM = require('react-dom'); + const createPortal = ReactDOM.createPortal; + ReactDOM.createPortal = jest.fn(element => { + return element; + }); + const component = renderer.create(); const tree = component.toJSON(); expect(tree).toMatchSnapshot(); + + ReactDOM.createPortal = createPortal; }); it('uses default documented properties', () => { diff --git a/packages/office-ui-fabric-react/src/components/Tooltip/TooltipHost.tsx b/packages/office-ui-fabric-react/src/components/Tooltip/TooltipHost.tsx index 3970e0898cdf7..6ecd425fc5535 100644 --- a/packages/office-ui-fabric-react/src/components/Tooltip/TooltipHost.tsx +++ b/packages/office-ui-fabric-react/src/components/Tooltip/TooltipHost.tsx @@ -7,7 +7,8 @@ import { getId, assign, hasOverflow, - createRef + createRef, + portalContainsElement } from '../../Utilities'; import { ITooltipHostProps, TooltipOverflowMode } from './TooltipHost.types'; import { Tooltip } from './Tooltip'; @@ -130,6 +131,11 @@ export class TooltipHost extends BaseComponent + className="ms-layer" +> +
+
+
+
+ +
+
+
+
+ `; diff --git a/packages/office-ui-fabric-react/src/components/__snapshots__/Callout.Basic.Example.tsx.shot b/packages/office-ui-fabric-react/src/components/__snapshots__/Callout.Basic.Example.tsx.shot index dde62b20b4551..bb823f1112b11 100644 --- a/packages/office-ui-fabric-react/src/components/__snapshots__/Callout.Basic.Example.tsx.shot +++ b/packages/office-ui-fabric-react/src/components/__snapshots__/Callout.Basic.Example.tsx.shot @@ -129,7 +129,109 @@ exports[`Component Examples renders Callout.Basic.Example.tsx correctly 1`] = `
+ className="ms-layer" + > +
+
+ +
+
`; diff --git a/packages/office-ui-fabric-react/src/components/__snapshots__/DatePicker.Bounded.Example.tsx.shot b/packages/office-ui-fabric-react/src/components/__snapshots__/DatePicker.Bounded.Example.tsx.shot index 16749c85913f6..4e6a8c2eb1981 100644 --- a/packages/office-ui-fabric-react/src/components/__snapshots__/DatePicker.Bounded.Example.tsx.shot +++ b/packages/office-ui-fabric-react/src/components/__snapshots__/DatePicker.Bounded.Example.tsx.shot @@ -5,7 +5,7 @@ exports[`Component Examples renders DatePicker.Bounded.Example.tsx correctly 1`] className="docs-DatePickerExample" >

- When date boundaries are set (via minDate and maxDate props) the DatePicker will not allow out-of-bounds dates to be picked or entered. In this example, the allowed dates are Sun, 06 Jan 2019 04:41:20 GMT-Sun, 06 Jan 2019 04:41:20 GMT + When date boundaries are set (via minDate and maxDate props) the DatePicker will not allow out-of-bounds dates to be picked or entered. In this example, the allowed dates are Wed, 06 Dec 2017 04:41:20 GMT-Wed, 06 Dec 2017 04:41:20 GMT

- Sun, 06 Jan 2019 04:41:20 GMT + Wed, 06 Dec 2017 04:41:20 GMT
`; @@ -202,7 +202,7 @@ exports[`Component Examples renders Layer.Basic.Example.tsx correctly 2`] = ` Hello world.
- Sun, 06 Jan 2019 04:41:20 GMT + Wed, 06 Dec 2017 04:41:20 GMT
diff --git a/packages/office-ui-fabric-react/src/components/__snapshots__/Panel.HiddenOnDismiss.Example.tsx.shot b/packages/office-ui-fabric-react/src/components/__snapshots__/Panel.HiddenOnDismiss.Example.tsx.shot index 7c0fc8416da2a..7f275c601aa7c 100644 --- a/packages/office-ui-fabric-react/src/components/__snapshots__/Panel.HiddenOnDismiss.Example.tsx.shot +++ b/packages/office-ui-fabric-react/src/components/__snapshots__/Panel.HiddenOnDismiss.Example.tsx.shot @@ -118,7 +118,369 @@ exports[`Component Examples renders Panel.HiddenOnDismiss.Example.tsx correctly + className="ms-layer" + > +
+
+
+
+
+
+
+ +
+
+
+
+

+ Hidden on Dismiss Panel +

+
+
+ + When dismissed, this panel will be hidden instead of destroyed. + +
+
+
+
+
+
+
+
`; diff --git a/packages/office-ui-fabric-react/src/components/__snapshots__/Pivot.Fabric.Example.tsx.shot b/packages/office-ui-fabric-react/src/components/__snapshots__/Pivot.Fabric.Example.tsx.shot index 331024fa583db..c00540b7bcf5c 100644 --- a/packages/office-ui-fabric-react/src/components/__snapshots__/Pivot.Fabric.Example.tsx.shot +++ b/packages/office-ui-fabric-react/src/components/__snapshots__/Pivot.Fabric.Example.tsx.shot @@ -647,8 +647,110 @@ exports[`Component Examples renders Pivot.Fabric.Example.tsx correctly 1`] = ` + className="ms-layer" + > +
+
+ +
+
diff --git a/packages/utilities/src/dom.test.ts b/packages/utilities/src/dom.test.ts index 5917366f48d1e..e64f8f4589c77 100644 --- a/packages/utilities/src/dom.test.ts +++ b/packages/utilities/src/dom.test.ts @@ -1,4 +1,13 @@ -import { getDocument, getParent, getWindow, setSSR, elementContains } from './dom'; +import { + DATA_PORTAL_ATTRIBUTE, + elementContains, + getDocument, + getParent, + getWindow, + portalContainsElement, + setPortalAttribute, + setSSR +} from './dom'; let unattachedSvg = document.createElement('svg'); let unattachedDiv = document.createElement('div'); @@ -56,3 +65,70 @@ describe('getDocument', () => { setSSR(false); }); }); + +describe('setPortalAttribute', () => { + it('sets attribute', () => { + let testDiv = document.createElement('div'); + expect(testDiv.getAttribute(DATA_PORTAL_ATTRIBUTE)).toBeFalsy(); + setPortalAttribute(testDiv); + expect(testDiv.getAttribute(DATA_PORTAL_ATTRIBUTE)).toBeTruthy(); + }); +}); + +describe('portalContainsElement', () => { + let root: HTMLElement; + let leaf: HTMLElement; + let parent: HTMLElement; + let portal: HTMLElement; + let unlinked: HTMLElement; + + beforeEach(() => { + root = document.createElement('div'); + leaf = document.createElement('div'); + parent = document.createElement('div'); + portal = document.createElement('div'); + unlinked = document.createElement('div'); + + setPortalAttribute(portal); + }); + + it('works with and without parent specified', () => { + root.appendChild(parent); + parent.appendChild(portal); + portal.appendChild(leaf); + expect(portalContainsElement(root)).toBeFalsy(); + expect(portalContainsElement(parent)).toBeFalsy(); + expect(portalContainsElement(portal)).toBeTruthy(); + expect(portalContainsElement(leaf)).toBeTruthy(); + expect(portalContainsElement(leaf, parent)).toBeTruthy(); + }); + + it('works correctly when parent and child are in same portal', () => { + root.appendChild(portal); + portal.appendChild(parent); + parent.appendChild(leaf); + expect(portalContainsElement(parent)).toBeTruthy(); + expect(portalContainsElement(leaf, parent)).toBeFalsy(); + }); + + it('works with hierarchically invalid parents', () => { + root.appendChild(parent); + parent.appendChild(portal); + portal.appendChild(leaf); + // When parent is invalid, searches should go to root + expect(portalContainsElement(root, leaf)).toBeFalsy(); + expect(portalContainsElement(parent, leaf)).toBeFalsy(); + expect(portalContainsElement(portal, leaf)).toBeTruthy(); + expect(portalContainsElement(leaf, unlinked)).toBeTruthy(); + }); + + it('works when element is parent', () => { + root.appendChild(parent); + parent.appendChild(portal); + portal.appendChild(leaf); + expect(portalContainsElement(root, root)).toBeFalsy(); + expect(portalContainsElement(parent, parent)).toBeFalsy(); + expect(portalContainsElement(portal, portal)).toBeTruthy(); + expect(portalContainsElement(leaf, leaf)).toBeFalsy(); + }); +}); diff --git a/packages/utilities/src/dom.ts b/packages/utilities/src/dom.ts index 44de696f1b083..e9143b3ec597e 100644 --- a/packages/utilities/src/dom.ts +++ b/packages/utilities/src/dom.ts @@ -11,6 +11,8 @@ interface IVirtualElement extends HTMLElement { }; } +export const DATA_PORTAL_ATTRIBUTE = 'data-portal-element'; + /** * Sets the virtual parent of an element. * Pass `undefined` as the `parent` to clear the virtual parent. @@ -204,6 +206,29 @@ export function getRect(element: HTMLElement | Window | null): IRectangle | unde return rect; } +/** + * Identify element as a portal by setting an attribute. + * @param element Element to mark as a portal. + */ +export function setPortalAttribute(element: HTMLElement): void { + element.setAttribute(DATA_PORTAL_ATTRIBUTE, 'true'); +} + +/** + * Determine whether a target is within a portal from perspective of root or optional parent. + * This function only works against portal components that use the setPortalAttribute function. + * If both parent and child are within the same portal this function will return false. + * @param target Element to query portal containment status of. + * @param parent Optional parent perspective. Search for containing portal stops at parent (or root if parent is undefined or invalid.) + */ +export function portalContainsElement(target: HTMLElement, parent?: HTMLElement): boolean { + const elementMatch = findElementRecursive( + target, + (testElement: HTMLElement) => parent === testElement || testElement.hasAttribute(DATA_PORTAL_ATTRIBUTE) + ); + return elementMatch !== null && elementMatch.hasAttribute(DATA_PORTAL_ATTRIBUTE); +} + /** * Finds the first parent element where the matchFunction returns true * @param element element to start searching at @@ -222,7 +247,7 @@ export function findElementRecursive( } /** - * Determines if an element, or any of its ancestors, contian the given attribute + * Determines if an element, or any of its ancestors, contain the given attribute * @param element - element to start searching at * @param attribute - the attribute to search for * @returns the value of the first instance found