Skip to content

feat(Popover): Support nesting #18148

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

Merged
merged 28 commits into from
May 18, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
1b898bc
nested popover story
ling1726 May 11, 2021
72ba2cc
feat(Popover): Support nesting
ling1726 May 11, 2021
5bb2423
Change files
ling1726 May 11, 2021
59551ce
move virtual parent code to portal
ling1726 May 12, 2021
1db41ad
remove package, refactor vparent
ling1726 May 12, 2021
850353a
Change files
ling1726 May 12, 2021
8c1cfb2
Add docs and spec for portal
ling1726 May 12, 2021
766af0e
fix api extractor
ling1726 May 12, 2021
eb9683e
PR suggestions
ling1726 May 12, 2021
6372751
Add E2E tests for nested popovers
ling1726 May 12, 2021
5452246
turn off as conformance test
ling1726 May 12, 2021
35529c3
nested popover story
ling1726 May 11, 2021
cdf6331
feat(Popover): Support nesting
ling1726 May 11, 2021
3476574
Change files
ling1726 May 11, 2021
4696c6f
move virtual parent code to portal
ling1726 May 12, 2021
e0a2a06
remove package, refactor vparent
ling1726 May 12, 2021
5a9c743
Change files
ling1726 May 12, 2021
26ba87e
Add docs and spec for portal
ling1726 May 12, 2021
fb419ad
fix api extractor
ling1726 May 12, 2021
046aeca
PR suggestions
ling1726 May 12, 2021
0245f3b
Add E2E tests for nested popovers
ling1726 May 12, 2021
9f0b576
turn off as conformance test
ling1726 May 12, 2021
f096691
update tooltip snapshots due to portal virtual parent
ling1726 May 12, 2021
2c6a184
Change files
ling1726 May 12, 2021
4330c10
Merge branch 'feat/nested-popup' of https://github.com/ling1726/fluen…
ling1726 May 12, 2021
80f363d
Merge remote-tracking branch 'origin/master' into feat/nested-popup
ling1726 May 12, 2021
319c017
Merge remote-tracking branch 'origin' into feat/nested-popup
ling1726 May 18, 2021
aae1997
PR suggestions
ling1726 May 18, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "Render portals with virtual parent",
"packageName": "@fluentui/react-portal",
"email": "[email protected]",
"dependentChangeType": "patch"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "none",
"comment": "Update snapshots referencing months (#10023)",
"packageName": "@fluentui/react-tooltip",
"email": "[email protected]",
"dependentChangeType": "none"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "prerelease",
"comment": "Support custom `contains` check",
"packageName": "@fluentui/react-utilities",
"email": "[email protected]",
"dependentChangeType": "patch"
}
52 changes: 52 additions & 0 deletions packages/react-popover/e2e/Popover.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,55 @@ describe('With custom trigger', () => {
.should('not.exist');
});
});

describe('Nested', () => {
beforeEach(() => {
// Open the whole stack of popovers
cy.visitStory('Popover', 'NestedPopovers')
.contains('Root')
.click()
.get('body')
.contains('First')
.click()
.get('body')
.contains('Second')
.first()
.click();
});

it('should dismiss all nested popovers on outside click', () => {
cy.get('body')
.click('bottomRight')
.get(popoverContentSelector)
.should('not.exist');
});

it('should not dismiss when clicking on nested content', () => {
cy.contains('Second nested button')
.click()
.get(popoverContentSelector)
.should('have.length', 3);
});

it('should dismiss child popovers when clicking on parents', () => {
cy.contains('First nested button')
.click()
.get(popoverContentSelector)
.should('have.length', 2)
.contains('Root button')
.click()
.get(popoverContentSelector)
.should('have.length', 1);
});

it('should when opening a sibling popover, should dismiss other sibling popover', () => {
cy.contains('Sibling nested trigger')
.click()
.get(popoverContentSelector)
.should('have.length', 3)
.contains('Second nested trigger')
.click()
.get(popoverContentSelector)
.should('have.length', 3);
});
});
45 changes: 45 additions & 0 deletions packages/react-popover/src/Popover.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,51 @@ export const WithCustomTrigger = () => {
);
};

export const NestedPopovers = () => {
return (
<Popover>
<PopoverTrigger>
<button>Root trigger</button>
</PopoverTrigger>

<PopoverContent>
<ExampleContent />
<button>Root button</button>
<Popover>
<PopoverTrigger>
<button style={{ marginLeft: 100 }}>First nested trigger</button>
</PopoverTrigger>

<PopoverContent>
<ExampleContent />
<button>First nested button</button>
<Popover>
<PopoverTrigger>
<button>Second nested trigger</button>
</PopoverTrigger>

<PopoverContent>
<ExampleContent />
<button>Second nested button</button>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger>
<button style={{ marginLeft: 100 }}>Sibling nested trigger</button>
</PopoverTrigger>

<PopoverContent>
<ExampleContent />
<button>Second nested button</button>
</PopoverContent>
</Popover>
</PopoverContent>
</Popover>
</PopoverContent>
</Popover>
);
};

export default {
title: 'Components/Popover',
component: Popover,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import * as React from 'react';
import { makeMergeProps, useControllableValue, useOnClickOutside, useEventCallback } from '@fluentui/react-utilities';
import { makeMergeProps, useControllableValue, useEventCallback, useOnClickOutside } from '@fluentui/react-utilities';
import { useFluent } from '@fluentui/react-shared-contexts';
import { usePopper } from '@fluentui/react-positioning';
import { elementContains } from '@fluentui/react-portal';
import { PopoverProps, PopoverState } from './Popover.types';

const mergeProps = makeMergeProps<PopoverState>({});
Expand Down Expand Up @@ -35,6 +36,7 @@ export const usePopover = (props: PopoverProps, defaultProps?: PopoverProps): Po

const { targetDocument } = useFluent();
useOnClickOutside({
contains: elementContains,
element: targetDocument,
callback: ev => state.setOpen(ev, false),
refs: [state.triggerRef, state.contentRef],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ jest.mock('../../popoverContext');

describe('PopoverContent', () => {
isConformant({
// as render test will pass a span tag which is also considered one of the skipped helperComponents
disabledTests: ['as-renders-html'],
Component: PopoverContent,
displayName: 'PopoverContent',
requiredProps: { open: true },
helperComponents: [Portal],
helperComponents: [Portal, 'span'],
});

let wrapper: ReactWrapper | undefined;
Expand Down
114 changes: 114 additions & 0 deletions packages/react-portal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,117 @@ const node = document.getElementById('customNode');
### Styling

`Portal` renders React children directly to the default/configured DOM node. Therefore styling should be applied to the `children` by users directly.

### Virtual parents

Out of order DOM elements can be problematic when using 'click outside' event listeners since you cannot rely on `element.contains(event.target)` because the `Portal` elements are out of DOM order.

```tsx

const outerButtonRef = React.useRef();
const innerButtonRef = React.useRef();


<Portal>
<div>
<button ref={outerButtonRef}> Outer button </button>
<Portal>
<div>
<button ref={innerButtonRef}> Inner button </button>
</div>
</Portal>
</div>
</Portal>

// DOM output
<div>
<button>Outer button</button>
</div>

<div>
<button>Inner button</button>
</div>

// Let's add an event listener to 'dismss' the outer portal when clicked outside
// ⚠⚠⚠ This will always be called when clicking on the inner button
document.addEventListener((event) => {
if (outerButtonRef.current.contains(event.target)) {
dismissOuterPortal();
}
})
```

When the above case is not required, using `element.contains` is perfectly fine. But nested cases should still be handled appropriately. We do this using the concept of `virtual parents`

`Portal` will make public 2 utilities that will only be used in cases where the user needs to know if an out of order DOM element will need to be used or not.

- `setVirtualParent` - sets virtual parent. Portal uses this already internally.
- `elementContains` - similar to `element.contains` but uses the virtual hierarchy as reference

Below shows what a virtual parent is

```tsx
// Setting a virtual parent

const parent document.getElementById('parent')
const child document.getElement.ById('child');

child._virtual.parent = parent;
```

`Portals` will render a hidden span that will be the virtual parent, by nesting portals virtual parens will also be nested so that `elementContains` will work predictably.

```tsx
<FluentProvider>
<Portal id="portal-1" />
<Portal id="portal-2" />
</FluentProvider>
```

DOM output:

```tsx
<body>
<div>
{/* Virtual parent for portal*/}
<span aria-hidden />
{/* Virtual parent for portal*/}
<span aria-hidden />
</div>

<div id="portal-1" class="theme-provider-0">
{children}
</div>
<div id="portal-2" class="theme-provider-0">
{children}
</div>
</body>
```

```tsx
<FluentProvider>
<Portal id="portal-1">
<Portal id="portal-2" />
</Portal>
</FluentProvider>
```

DOM output:

```tsx
<body>
<div>
{/* Virtual parent for outer portal*/}
<span aria-hidden></span>
</div>

<div id="portal-1" class="theme-provider-0">
{/* Virtual parent for inner portal*/}
<span aria-hidden />
{children}
</div>
<div id="portal-2" class="theme-provider-0">
{children}
</div>
</body>
```
Loading