Skip to content

Commit 7242825

Browse files
authored
feat(uui-popover-container): firefox polyfill (#628)
* open and close * deactivate focus listener * handle parent closing * only move container to body when opened * beforeToggleProxy * move polyfill to external function * cleanup * fix eventlisteners not unsubscribing. and parent bug * add 9999 z-index * add focus dismiss * findAncestorByAttributeValue now looks at shadowroot hosts * fix polyfill * ts * fix polyfill * add test story * move container back to original dom position when closed * insert style tag to preserve styles from parent * update test story * add comment * test * fix styles * test story * prefix popover id to prevent css overwrites * remove unnecessary check * cleanup * deprecate popover * comment * update docs * remove test story * update docs * add tooltip example * note * add tooltip story * cleanup
1 parent d33d4e6 commit 7242825

File tree

7 files changed

+278
-17
lines changed

7 files changed

+278
-17
lines changed

packages/uui-base/lib/utils/findAncestorByAttributeValue.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@ export const findAncestorByAttributeValue = (
3333
}
3434

3535
// Move up the DOM tree to the parent or parentNode, whichever is available
36-
currentNode = currentNode.parentElement || currentNode.parentNode || null;
36+
currentNode =
37+
currentNode.parentElement ||
38+
currentNode.parentNode ||
39+
(currentNode as ShadowRoot).host ||
40+
null;
3741
}
3842

3943
return null; // No matching ancestor found

packages/uui-popover-container/lib/uui-popover-container.element.ts

+5-11
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { defineElement } from '@umbraco-ui/uui-base/lib/registration';
22
import { findAncestorByAttributeValue } from '@umbraco-ui/uui-base/lib/utils';
33
import { css, html, LitElement } from 'lit';
44
import { property, state } from 'lit/decorators.js';
5+
import { polyfill } from './uui-popover-polyfill.js';
56

67
export type PopoverContainerPlacement =
78
| 'top'
@@ -70,24 +71,17 @@ export class UUIPopoverContainerElement extends LitElement {
7071
#targetElement: HTMLElement | null = null;
7172

7273
connectedCallback(): void {
74+
//TODO: Remove this polyfill when firefox supports the new popover API
75+
!HTMLElement.prototype.hasOwnProperty('popover') && polyfill.bind(this)();
76+
7377
super.connectedCallback();
7478

7579
this.addEventListener('focusout', this.#onFocusOut);
76-
77-
// CHECK BROWSER SUPPORT
78-
if (!HTMLElement.prototype.hasOwnProperty('popover')) {
79-
alert(
80-
'Browser does not support popovers. Check the docs for info on how to enable: https://developer.mozilla.org/en-US/docs/Web/API/Popover_API'
81-
);
82-
return;
83-
}
84-
8580
this.addEventListener('beforetoggle', this.#onBeforeToggle);
8681
}
8782

8883
disconnectedCallback(): void {
8984
super.disconnectedCallback();
90-
9185
this.removeEventListener('beforetoggle', this.#onBeforeToggle);
9286
}
9387

@@ -100,7 +94,7 @@ export class UUIPopoverContainerElement extends LitElement {
10094
}
10195
};
10296

103-
#onBeforeToggle = async (event: any) => {
97+
#onBeforeToggle = (event: any) => {
10498
this._open = event.newState === 'open';
10599

106100
this.#targetElement = findAncestorByAttributeValue(

packages/uui-popover-container/lib/uui-popover-container.mdx

+31-5
Original file line numberDiff line numberDiff line change
@@ -6,30 +6,33 @@ import * as stories from './uui-popover-container.story';
66
# Popover Container
77

88
This component is a container for popovers. It is used to position the popover relative to the target element.
9-
It is also a native popover. So everything descriped in the **[Popover documentation](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API)** applies.
9+
It is also a native popover. So everything descriped in the **[Popover API documentation](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API)** applies.
1010

1111
<div
1212
style={{
1313
borderLeft: '6px solid #f3d41b',
1414
background: '#f3d41b24',
1515
padding: '0px 16px',
1616
display: 'flex',
17+
flexDirection: 'column',
1718
}}>
1819
When used with UUIButton or any element that is not a native button or input,
1920
the popovertargetaction property will not work, and defaults too toggle.
2021
</div>
2122
<br />
2223
<div
2324
style={{
24-
borderLeft: '6px solid #e25555',
25-
background: '#e2555512',
25+
borderLeft: '6px solid #f3d41b',
26+
background: '#f3d41b24',
2627
padding: '0px 16px',
2728
display: 'flex',
29+
flexDirection: 'column',
2830
}}>
2931
This uses the Popover API which is experimental and will not work in all
30-
browsers. See **[Popover
32+
browsers. See **[Popover API
3133
documentation](https://developer.mozilla.org/en-US/docs/Web/API/Popover_API)**
32-
for more information.
34+
for more information. A polyfill is provided for browsers that do not support
35+
the Popover API.
3336
</div>
3437
<Controls />
3538
## Basic usage
@@ -87,3 +90,26 @@ As a result, you will need to manually close the popover either by clicking the
8790
My popover content
8891
</uui-popover-container>
8992
```
93+
94+
## Example: Tooltip
95+
96+
A tooltip is a small pop-up box that appears when the user hovers over an item, providing additional information or context about that item.
97+
Note: The popover-container always needs a an element with the `popovertarget` attribute, even when it's not a button. This element is used as the anchor to position the popover.
98+
99+
```js
100+
const tooltipToggle = document.getElementById('tooltip-toggle');
101+
const tooltipPopover = document.getElementById('tooltip-popover');
102+
103+
tooltipToggle.addEventListener('mouseenter', () => tooltipPopover.show());
104+
tooltipToggle.addEventListener('mouseleave', () => tooltipPopover.hide());
105+
```
106+
107+
```html
108+
Sometimes words such as
109+
<b id="tooltip-toggle" popovertarget="tooltip-popover">petrichor</b>
110+
needs some more explanation
111+
<uui-popover-container id="tooltip-popover" popover>
112+
A pleasant smell that frequently accompanies the first rain after a long
113+
period of warm, dry weather.
114+
</uui-popover-container>
115+
```

packages/uui-popover-container/lib/uui-popover-container.story.ts

+54
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,57 @@ export const Overview: Story = {
104104
</uui-popover-container>
105105
`,
106106
};
107+
108+
export const Tooltip: Story = {
109+
play: ({ canvasElement }) => {
110+
const tooltipElement = canvasElement.querySelector('#tooltip-toggle');
111+
const popover = canvasElement.querySelector(
112+
'#tooltip-popover'
113+
) as UUIPopoverContainerElement;
114+
115+
tooltipElement?.addEventListener('mouseenter', () => popover.showPopover());
116+
tooltipElement?.addEventListener('mouseleave', () => popover.hidePopover());
117+
},
118+
args: {
119+
placement: 'bottom-start',
120+
margin: 0,
121+
},
122+
argTypes: {
123+
open: {
124+
control: false,
125+
},
126+
placement: {
127+
options: [
128+
'auto',
129+
'top',
130+
'top-start',
131+
'top-end',
132+
'bottom',
133+
'bottom-start',
134+
'bottom-end',
135+
'right',
136+
'right-start',
137+
'right-end',
138+
'left',
139+
'left-start',
140+
'left-end',
141+
],
142+
},
143+
},
144+
render: args => html`
145+
Sometimes words such as
146+
<b id="tooltip-toggle" popovertarget="tooltip-popover">petrichor</b> needs
147+
some more explanation
148+
<uui-popover-container
149+
id="tooltip-popover"
150+
popover
151+
placement=${args.placement}
152+
margin=${args.margin}>
153+
<div
154+
style="background-color: var(--uui-color-surface); max-width: 150px; box-shadow: var(--uui-shadow-depth-4); padding: var(--uui-size-space-4); border-radius: var(--uui-border-radius); font-size: 0.9rem;">
155+
A pleasant smell that frequently accompanies the first rain after a long
156+
period of warm, dry weather.
157+
</div>
158+
</uui-popover-container>
159+
`,
160+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
// @ts-nocheck
2+
3+
export function polyfill() {
4+
const originalAddEventListener = this.addEventListener;
5+
6+
// This is the only way to get access to the private functions onFocusOut and onBeforeToggle.
7+
this.addEventListener = function (type, listener, options) {
8+
if (type === 'focusout') {
9+
// focusout doesn't work properly in firefox, so we ignore it.
10+
return;
11+
}
12+
if (type === 'beforetoggle') {
13+
// Intercept the beforetoggle event so we can dispatch our own event.
14+
this.polyfill_onBeforeToggle = event => {
15+
this.dispatchEvent(
16+
new CustomEvent('polyfill-beforetoggle', {
17+
bubbles: false,
18+
composed: false,
19+
detail: {
20+
oldState: event.oldState,
21+
newState: event.newState,
22+
},
23+
})
24+
);
25+
26+
listener(event);
27+
};
28+
return;
29+
}
30+
originalAddEventListener.call(this, type, listener, options);
31+
};
32+
33+
this.polyfill_onFocusout = event => {
34+
const target = event.relatedTarget;
35+
if (!target) return;
36+
37+
const isInsidePopoverContainer = target?.closest('uui-popover-container');
38+
const isInsidePopoverTarget = target?.closest('[popovertarget]');
39+
40+
if (!isInsidePopoverContainer && !isInsidePopoverTarget) {
41+
this.hidePopover();
42+
}
43+
};
44+
45+
this.polyfill_onClick = event => {
46+
const path = event.composedPath();
47+
const isInsidePopoverContainer = path.some(element => {
48+
return (
49+
element.tagName === 'UUI-POPOVER-CONTAINER' ||
50+
element.attributes?.popovertarget
51+
);
52+
});
53+
54+
if (!isInsidePopoverContainer) {
55+
this.hidePopover();
56+
}
57+
};
58+
59+
this.polyfill_onParentPopoverUpdate = event => {
60+
if (event.detail.newState === 'closed') {
61+
this.hidePopover();
62+
}
63+
};
64+
65+
const findParentPopover = element => {
66+
if (!element.parentElement) return null;
67+
if (element.parentElement?.tagName === 'UUI-POPOVER-CONTAINER') {
68+
return element.parentElement;
69+
}
70+
return findParentPopover(element.parentElement);
71+
};
72+
73+
if (!this.polyfill_hasBeenMovedToBody) {
74+
this.style.display = 'none';
75+
this.style.position = 'fixed';
76+
this.style.inset = '0';
77+
this.style.zIndex = '9999';
78+
}
79+
80+
this.showPopover = () => {
81+
if (!this.polyfill_hasBeenMovedToBody) {
82+
this.polyfill_parentPopoverContainer = findParentPopover(this);
83+
}
84+
85+
this.polyfill_onBeforeToggle({
86+
oldState: 'closed',
87+
newState: 'open',
88+
});
89+
this.style.display = 'block';
90+
this.polyfill_parentPopoverContainer?.addEventListener(
91+
'polyfill-beforetoggle',
92+
this.polyfill_onParentPopoverUpdate
93+
);
94+
window.addEventListener('click', this.polyfill_onClick);
95+
window.addEventListener('focusout', this.polyfill_onFocusout);
96+
97+
//Find the dom position that the popover is currently at and save it so we can restore it later.
98+
this.polyfill_originalParent = this.parentNode;
99+
this.polyfill_originalNextSibling = this.nextSibling;
100+
101+
//TODO: Find the render root of this component and get the stylesheet from there.
102+
const renderRoot = this.getRootNode();
103+
104+
if (renderRoot && renderRoot.adoptedStyleSheets) {
105+
// Styles from the parent are lost when moving the popover to the body, so we need to add them back.
106+
const adoptedStyleSheets = renderRoot.adoptedStyleSheets;
107+
const combinedStyles = adoptedStyleSheets.reduce((acc, styleSheet) => {
108+
return (
109+
acc +
110+
Object.values(styleSheet.cssRules).reduce((acc, rule) => {
111+
return acc + `#${this.id} ${rule.cssText}`;
112+
}, '')
113+
);
114+
}, '');
115+
116+
const styleTag = document.createElement('style');
117+
styleTag.innerHTML = combinedStyles;
118+
styleTag.id = 'uui-popover-polyfill-style';
119+
120+
//look in slot for existing style tag and remove it.
121+
const existingStyleTag = this.shadowRoot.host.querySelector(
122+
'#uui-popover-polyfill-style'
123+
);
124+
125+
if (existingStyleTag) {
126+
existingStyleTag.parentNode.removeChild(existingStyleTag);
127+
}
128+
129+
this.insertAdjacentElement('beforeend', styleTag);
130+
}
131+
132+
//Move the popover to the body so it sits on top of everything else.
133+
if (this.parentNode !== document.body) {
134+
this.parentNode?.removeChild(this);
135+
this.polyfill_hasBeenMovedToBody = true;
136+
document.body.appendChild(this);
137+
}
138+
};
139+
this.hidePopover = () => {
140+
//Restore previous dom position.
141+
if (this.polyfill_hasBeenMovedToBody) {
142+
document.body.removeChild(this);
143+
this.polyfill_hasBeenMovedToBody = false;
144+
this.polyfill_originalParent?.insertBefore(
145+
this,
146+
this.polyfill_originalNextSibling
147+
);
148+
}
149+
150+
window.removeEventListener('click', this.polyfill_onClick);
151+
window.removeEventListener('focusout', this.polyfill_onFocusout);
152+
this.polyfill_parentPopoverContainer?.removeEventListener(
153+
'polyfill-beforetoggle',
154+
this.polyfill_onParentPopoverUpdate
155+
);
156+
this.polyfill_onBeforeToggle({
157+
oldState: 'open',
158+
newState: 'closed',
159+
});
160+
this.style.display = 'none';
161+
};
162+
}

packages/uui-popover/lib/uui-popover.element.ts

+1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ function mathClamp(value: number, min: number, max: number) {
3030
}
3131

3232
/**
33+
* @deprecated This component has been deprecated and will be removed in future releases. It is being replaced by popover-container.
3334
* @element uui-popover
3435
* @description Open a modal aligned with the opening element. This does not jet work within two layers of scroll containers.
3536
* @fires close - When popover is closed by user interaction.
+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { Meta, Primary, Controls, Story } from '@storybook/blocks';
2+
import * as stories from './uui-popover.story';
3+
4+
<Meta of={stories} />
5+
6+
# Popover Container
7+
8+
<div
9+
style={{
10+
borderLeft: '6px solid #f3d41b',
11+
background: '#f3d41b24',
12+
padding: '0px 16px',
13+
display: 'flex',
14+
}}>
15+
Deprecated: This component has been deprecated and will be removed in future
16+
releases. It is being replaced by.
17+
**[popover-container](/docs/uui-popover-container--docs)**
18+
</div>
19+
20+
<Controls />

0 commit comments

Comments
 (0)