Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Layer: Reintroduce Portals with Event Blocking #6211

Merged
merged 17 commits into from
Sep 6, 2018
Merged

Layer: Reintroduce Portals with Event Blocking #6211

merged 17 commits into from
Sep 6, 2018

Conversation

JasonGore
Copy link
Member

@JasonGore JasonGore commented Sep 4, 2018

Pull request checklist

Description of changes

Layer currently uses React's unstable_renderSubtreeIntoContainer, which will soon be removed. Additionally, it's incompatible with React 16 Context and has hampered our efforts to evolve theming with Layered components. As a result, we've had to prioritize reintroducing React portals use in Layer.

React Portals more accurately respect virtual hierarchy in allowing events to bubble up and trickle down as well as in processing mouse behavior. I've reintroduced Layer's use of React portals with event blocking added to more closely simulate Layer's existing behavior. Event bubbling is controlled via a new Layer prop eventBubblingEnabled and is disabled by default for backwards compatibility.

However, portals and this PR do introduce a couple of known behavior changes regarding the events onMouseEnter and onMouseLeave.

image

These events cannot be blocked without breaking backwards compatibility and invasive and hacky changes to how React operates. With portals, when Layered components are moused over, every onMouseEnter handler along the subtree from the common ancestor of the component left down to the Layered component will be triggered. (This didn't happen with old Layer because these events just traversed directly to the Layer subtree attached to root.)

For example, if a Tooltip contains a Modal and the Modal is open, the Tooltip will appear over the Modal when the mouse enters the browser window. This happens because Tooltip's onMouseEnter handler is now triggered with portals where it wasn't before. For these types of cases, a utilities helper portalContainsElement was added to help work around this specific example:

  private _onTooltipMouseEnter = (ev: React.MouseEvent<HTMLElement>): void => {
    ...
	
    if (ev.target && portalContainsElement(ev.target as HTMLElement, this._getTargetElement())) {
      // Do not show tooltip when target is inside a portal relative to TooltipHost.
      return;
    }

    ...
  };

If both TooltipHost and ev.target are contained within the same portal, the Tooltip will continue to appear as expected.

portals

Likewise, onMouseLeave used to be generated when Layered components rendered. Now that Layered components are more accurately viewed as part of the hierarchy, onMouseLeave events may not get generated when rendering Layered content. Additionally, onMouseLeave events will now get generated for the entire root->content->Layer->Layered component subtree when the mouse leaves the browser window or any point in that subtree.

Focus areas to test

Unit tests added for event blocking and utility helper.

Microsoft Reviewers: Open in CodeFlow

cschleiden and others added 6 commits August 28, 2018 14:44
* 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
* Traditionally Layer has not had this behavior. This prop preserves backwards compatibility by
* default while allowing users to opt in to the new event bubbling functionality.
*/
eventBubblingEnabled?: boolean;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this limit us going forward? Should we have a new Layer that does this by default and that we recommend people to use?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good question. In many use cases (Modals, Panels, Dialogs, etc.) I don't think you'll ever want event bubbling behavior. While portals more accurately portray virtual hierarchy, I don't think it's how people will ever want these common use cases to behave.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I agree here.

The spirit of a layer is that you are rendering on a higher elevation than the default body. It really is an attached layer, like photoshop layers, which simply overlay and do not interact.

There are cases where you need to know the originating dom heirarchy, and we have virtual parents for that. But I've never run into a case where events should bubble into or out of layers. That would kind of break the spirit.

Now I have seen web sites which use DOM eventing to raise all sorts of custom action events, so that they can be mediated through the root dom. Kind of like redux and central state, but with respect to DOM structure to provide a framework agnostic but tree sensitive data flow. In that case? Sure I could see a need to propagate events.

eventBubblingEnabled ? (
<Fabric className={classNames.content}>{this.props.children}</Fabric>
) : (
<Fabric
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be its own component? Something like EventBoundary?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't see the need since it's pretty simple in implementation. It's just one set of JSX args and one handler. If there's a case for reuse it could always be broken out later pretty easily.

packages/utilities/src/dom.ts Outdated Show resolved Hide resolved
@@ -11,6 +11,8 @@ interface IVirtualElement extends HTMLElement {
};
}

export const dataPortalAttribute = 'data-portal-element';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

string constants like these probably should be CAPITALIZED_LIKE_THIS

* If both parent and child are within the same portal this function will return false.
*/
export function portalContainsElement(target: HTMLElement, parent?: HTMLElement): boolean {
let elementMatch = findElementRecursive(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

const

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder why tslint didn't catch that?

setPortalAttribute(portal);
});

it('works correctly', () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you describe what "works correctly" means in this so that readers can understand the "spec" of this?

const files: string[] = glob.sync(path.resolve(process.cwd(), 'src/components/**/examples/*Example*.tsx'));

beforeAll(() => {
// Mock createPortal to capture its component hierarchy in snapshot output.
ReactDOM.createPortal = jest.fn(element => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pretty sure you're the only one testing with portals, so not an immediate issue here. But you should restore the implementation when you're all done inside an afterAll() block.

const doc = getDocument(rootElement);

if (!doc || !rootElement) {
const doc = getDocument();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe getDocument takes an element, so if host is available, maybe pass that in?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cschleiden Do you remember the rationale here?

Copy link
Member

@kenotron kenotron left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay my comments are resolved!

@JasonGore JasonGore merged commit a0d0b91 into microsoft:master Sep 6, 2018
@cliffkoh
Copy link
Contributor

cliffkoh commented Sep 7, 2018

👏

@KevinTCoughlin
Copy link
Member

🥇

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Re-introduce Portals support
6 participants