diff --git a/.changeset/twelve-snakes-wait.md b/.changeset/twelve-snakes-wait.md new file mode 100644 index 000000000..5ab79c707 --- /dev/null +++ b/.changeset/twelve-snakes-wait.md @@ -0,0 +1,28 @@ +--- +'@lion/ui': patch +--- + +Overlay System uses `` for top layer functionality of all overlays. +This means overlays positioned relative to viewport won't be moved to the body. + +This has many benefits for the App Developer: + +- "context" will be kept: + - css variables and parts/theme will work + - events work without the need for "repropagation" (from body to original context) + - accessibility relations between overlay content and its context do not get lost +- initial renderings become more predictable (since we don't need multiple initializations on multiple connectedCallbacks) +- performance: less initialization, thus better performance +- maintainability: handling all edge cases involved in moving an overlay to the body grew out of hand +- developer experience: + - no extra container components like overlay-frame/calendar-frame needed that maintain styles + - adding a contentWrapperNode is not needed anymore + +There could be small differences in timings though (usually we're done rendering quicker now). +Code that relies on side effects could be affected. Like: + +- the existence of a global Root node) +- the fact that global styles would reach a dialog placed in the body + +For most users using either OverlayController, OverlayMixin or an element that uses OverlayMixin (like LionInputDatepicker, LionRichSelect etc. etc.) +nothing will change in the public api. diff --git a/docs/components/dialog/use-cases.md b/docs/components/dialog/use-cases.md index eef2dd85d..4e2378689 100644 --- a/docs/components/dialog/use-cases.md +++ b/docs/components/dialog/use-cases.md @@ -76,9 +76,9 @@ export const closeOverlayFromComponent = () => html` ```js preview-story export const placementOverrides = () => { const dialog = placement => html` - + -
+
Hello! You can close this notification here: +
+ `, + )} + + + +
+
+
+ `; + } +} +customElements.define('demo-overlay-positioning', DemoOverlayPositioning); diff --git a/docs/fundamentals/systems/overlays/assets/demo-overlay-system.js b/docs/fundamentals/systems/overlays/assets/demo-overlay-system.js deleted file mode 100644 index 30c1ec87e..000000000 --- a/docs/fundamentals/systems/overlays/assets/demo-overlay-system.js +++ /dev/null @@ -1,42 +0,0 @@ -import { html, LitElement } from 'lit'; -import { OverlayMixin } from '@lion/ui/overlays.js'; - -/** - * @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig - */ -class DemoOverlaySystem extends OverlayMixin(LitElement) { - // eslint-disable-next-line class-methods-use-this - _defineOverlayConfig() { - return /** @type {OverlayConfig} */ ({ - placementMode: 'global', - }); - } - - _setupOpenCloseListeners() { - super._setupOpenCloseListeners(); - - if (this._overlayInvokerNode) { - this._overlayInvokerNode.addEventListener('click', this.toggle); - } - } - - _teardownOpenCloseListeners() { - super._teardownOpenCloseListeners(); - - if (this._overlayInvokerNode) { - this._overlayInvokerNode.removeEventListener('click', this.toggle); - } - } - - render() { - return html` - - -
- -
-
popup is ${this.opened ? 'opened' : 'closed'}
- `; - } -} -customElements.define('demo-overlay-system', DemoOverlaySystem); diff --git a/docs/fundamentals/systems/overlays/assets/demo-overlay-system.mjs b/docs/fundamentals/systems/overlays/assets/demo-overlay-system.mjs new file mode 100644 index 000000000..66e6d8feb --- /dev/null +++ b/docs/fundamentals/systems/overlays/assets/demo-overlay-system.mjs @@ -0,0 +1,121 @@ +/* eslint-disable max-classes-per-file */ +/* eslint-disable import/no-extraneous-dependencies */ +import { html, LitElement, css } from 'lit'; +import { OverlayMixin } from '@lion/ui/overlays.js'; +import { LionButton } from '@lion/ui/button.js'; + +/** + * @typedef {import('@lion/ui/types/overlays.js').OverlayConfig} OverlayConfig + */ +class DemoOverlaySystem extends OverlayMixin(LitElement) { + // eslint-disable-next-line class-methods-use-this + _defineOverlayConfig() { + return /** @type {OverlayConfig} */ ({ + placementMode: 'global', + }); + } + + _setupOpenCloseListeners() { + super._setupOpenCloseListeners(); + + if (this._overlayInvokerNode) { + this._overlayInvokerNode.addEventListener('click', this.toggle); + } + } + + _teardownOpenCloseListeners() { + super._teardownOpenCloseListeners(); + + if (this._overlayInvokerNode) { + this._overlayInvokerNode.removeEventListener('click', this.toggle); + } + } + + render() { + return html` + + + + +
popup is ${this.opened ? 'opened' : 'closed'}
+ `; + } +} +customElements.define('demo-overlay-system', DemoOverlaySystem); + +class DemoOverlay extends OverlayMixin(LitElement) { + static get styles() { + return [ + css` + ::slotted([slot='content']) { + background-color: #333; + color: white; + padding: 8px; + } + + .close-button { + background: none; + border: none; + color: white; + font-weight: bold; + font-size: 16px; + padding: 4px; + } + `, + ]; + } + + // eslint-disable-next-line class-methods-use-this + _defineOverlayConfig() { + return /** @type {OverlayConfig} */ ({ + placementMode: 'global', + }); + } + + _setupOpenCloseListeners() { + super._setupOpenCloseListeners(); + + if (this._overlayInvokerNode) { + this._overlayInvokerNode.addEventListener('click', this.toggle); + } + } + + _teardownOpenCloseListeners() { + super._teardownOpenCloseListeners(); + + if (this._overlayInvokerNode) { + this._overlayInvokerNode.removeEventListener('click', this.toggle); + } + } + + render() { + return html` + + +
+ +
+ `; + } +} +customElements.define('demo-overlay', DemoOverlay); + +class DemoCloseButton extends LionButton { + static get styles() { + return [ + css` + ::host { + background: none; + } + `, + ]; + } + + connectedCallback() { + super.connectedCallback(); + + this.innerText = '⨯'; + this.setAttribute('aria-label', 'Close'); + } +} +customElements.define('demo-close-button', DemoCloseButton); diff --git a/docs/fundamentals/systems/overlays/configuration.md b/docs/fundamentals/systems/overlays/configuration.md index 108d6a145..157a55d9d 100644 --- a/docs/fundamentals/systems/overlays/configuration.md +++ b/docs/fundamentals/systems/overlays/configuration.md @@ -2,8 +2,8 @@ ```js script import { html } from '@mdjs/mdjs-preview'; -import './assets/demo-overlay-system.js'; -import './assets/applyDemoOverlayStyles.js'; +import './assets/demo-overlay-system.mjs'; +import './assets/applyDemoOverlayStyles.mjs'; ``` The `OverlayController` has many configuration options. @@ -351,11 +351,11 @@ Options: export const viewportConfig = () => { const viewportConfig = { placementMode: 'global', - viewportConfig: { placement: 'top-left' }, + viewportConfig: { placement: 'bottom-left' }, }; return html` - +
Hello! You can close this notification here: +
absolute (for demo)
+ +
+ + + `; +``` + +#### Overflow: the solution + +Two solutions are thinkable: + +- use a modal overlay +- use the fixed positioning strategy for the non-modal overlay + +Our overlay system makes sure that there's always a fixed layer that pops out of the hidden parent. + +```js preview-story +export const edgeCaseOverflowSolution = () => + html` +
+
+
+ + +
no matter
+
+ + + +
what configuration
+
+ + + +
...it
+
+ + + +
just
+
+ + + +
works
+
+
+
+
+ `; +``` + +### Stacking context + +When using non modal overlays, always make sure that the surrounding [stacking context](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Positioning/Understanding_z_index/The_stacking_context) does not paint on top of your overlay. +The example below shows the difference between a modal and non-modal overlay placed in a stacking context with a lower priority than its parent/sibling contexts. + +#### Stacking context: the problem + +```js preview-story +export const edgeCaseStackProblem = () => + html` +
+
+ I am on top and I don't care about your 9999 +
+ +
+ + +
+ The overlay can never be in front, since the parent stacking context has a lower + priority than its sibling. +
+ So, even if we add a new stacking context in our overlay with z-index 9999, it will + never be painted on top. +
+
+
+
+
+ `; +``` diff --git a/docs/fundamentals/systems/overlays/scope.md b/docs/fundamentals/systems/overlays/scope.md deleted file mode 100644 index b26ceb8ff..000000000 --- a/docs/fundamentals/systems/overlays/scope.md +++ /dev/null @@ -1,176 +0,0 @@ -# Systems >> Overlays >> Scope ||50 - -The goal of this document is to specify the goal and duties of the overlay system, mainly by -identifying all different appearances and types of overlays. - -## What is an overlay manager? - -An overlay is a visual element that is painted on top of a page, breaking out of the regular -document flow. An overlay manager is a global repository keeping track of all different types of overlays. The need for a global housekeeping mainly arises when multiple overlays are opened simultaneously. As opposed to a single overlay, the overlay manager stores knowledge about: - -- Whether the scroll behaviour of the body element can be manipulated. -- What space is available in the window for drawing new overlays. - -The manager is in charge of rendering an overlay to the DOM. Therefore, a developer should be able to control: - -- Its ‘physical position’ (where the dialog is attached). This can either be: - - globally: at root level of the DOM. This guarantees a total control over its painting, since the stacking context can be controlled from here and interfering parents (that set overflow values or transforms) can’t be apparent. Additionally, making a modal dialog requiring all surroundings to have aria-hidden="true", will be easier when the overlay is attached on body level. - - locally: next to the invoking element. This gives advantages for accessibility and(performant) rendering of an overlay next to its invoker on scroll and window resizes -- Toggling of the ‘shown’ state of the overlay -- Positioning preferences(for instance ‘bottom-left’) and strategies (ordered fallback preferences) - -Presentation/styling of the overlay is out of the scope of the manager, except for its positioning in its context. - -Accessibility is usually dependent on the type of overlay, its specific implementation and its browser/screen reader support (aria 1.0 vs 1.1). We strive for an optimum here by supporting 1.0 as a bare minimum and add 1.1 enrichments on top. - -For every overlay, the manager has access to the overlay element and the invoker (and possible other elements that are needed for focus delegation as described in (notes). - -## Defining different types of overlays - -When browsing through the average ui library, one can encounter multiple names for occurrences of overlays. Here is a list of names encountered throughout the years: - -- dialog -- modal -- popover -- popup -- popdown -- popper -- bubble -- balloon -- dropdown -- dropup -- tooltip -- layover -- overlay -- toast -- snackbar -- sheet (bottom, top, left, right) -- etc.. - -The problem with most of those terms is their lack of clear definition: what might be considered a tooltip in UI framework A, can be considered a popover in framework B. What can be called a modal in framework C, might actually be just a dialog. Etc etc… - -### Official specifications - -In order to avoid confusion and be as specification compliant as possible, it’s always a good idea to consult the W3C. This website shows a full list with specifications of accessible web widgets: . -A great overview of all widget-, structure- and role relations can be found in the ontology diagram below: - - - -Out of all the overlay names mentioned above, we can only identify the dialog and the tooltip as official roles. Let’s take a closer look at their definitions... - -### Dialog - -The dialog is described as follows by the W3C: - -> “A dialog is a window overlaid on either the primary window or another dialog window. Windows -> under a modal dialog are inert. That is, users cannot interact with content outside an active -> dialog window. Inert content outside an active dialog is typically visually obscured or dimmed so -> it is difficult to discern, and in some implementations, attempts to interact with the inert -> content cause the dialog to close. -> Like non-modal dialogs, modal dialogs contain their tab sequence. That is, Tab and Shift + Tab do -> not move focus outside the dialog. However, unlike most non-modal dialogs, modal dialogs do not -> provide means for moving keyboard focus outside the dialog window without closing the dialog.” - -- specification: -- widget description: - -### Tooltip - -According to W3C, a tooltip is described by the following: - -> “A tooltip is a popup that displays information related to an element when the element receives. -> keyboard focus or the mouse hovers over it. It typically appears after a small delay and disappears. -> when Escape is pressed or on mouse out. -> Tooltip widgets do not receive focus. A hover that contains focusable elements can be made using. -> a non-modal dialog.” - -- specification: -- widget description: - -What needs to be mentioned is that the W3C taskforce didn’t reach consensus yet about the above tooltip description. A good alternative resource: - - -### Dialog vs tooltip - -Summarizing, the main differences between dialogs and tooltips are: - -- Dialogs have a modal option, tooltips don’t -- Dialogs have interactive content, tooltips don’t -- Dialogs are opened via regular buttons (click/space/enter), tooltips act on focus/mouseover - -### Other roles and concepts - -Other roles worth mentioning are _alertdialog_ (a specific instance of the dialog for system alerts), select (an abstract role), _combobox_ and _menu_. - -Also, the W3C document often refers to _popup_. This term is mentioned in the context of _combobox_, _listbox_, _grid_, _tree_, _dialog_ and _tooltip_. Therefore, one could say it could be a term - -_aria-haspopup_ attribute needs to be mentioned: it can have values ‘menu’, ‘listbox’, ‘grid’, ’tree’ and ‘dialog’. - -## Common Overlay Components - -In our component library, we want to have the following overlay ‘child’ components: - -- Dialog -- Tooltip -- Popover -- Dropdown -- Toast -- Sheet (bottom, top, left, right) -- Select -- Combobox/autocomplete -- Application menu - -### Dialog Component - -The dialog is pretty much the dialog as described in the W3C spec, having the modal option applied by default. The flexibility in focus delegation (see notes) is not implemented, however. -Addressing these: - -- The first focusable element in the content: although delegate this focus management to the - implementing developer is highly preferred, since this is highly dependent on the moment the dialog content has finished rendering. This is something the overlay manager or dialog widget should not be aware of in order to provide.a clean and reliable component. -- The focusable element after a close: by default, this is the invoker. For different - behaviour, a reference should be supplied to a more logical element in the particular workflow. - -### Tooltip Component - -The tooltip is always invoked on hover and has no interactive content. See (the tooltip example, not the toggle tip). - -### Popover Component - -The popover looks like a crossover between the dialog and the tooltip. Popovers are invoked on click. For non interactive content, the toggletip could be applied. Whenever there would be a close button present, the ‘non interactiveness’ wouldn’t apply. - -An alternative implementation: -This looks more like a small dialog, except that the page flow is respected (no rotating tab). - -### Dropdown Component - -The dropdown is not an official aria-widget and thus can’t be tied to a specific role. It exists in a lot of UI libraries and most of them share these properties: - -- Preferred position is ‘down’. -- When no space at bottom, they show ‘up’ (in which. Case it behaves a as a dropup). -- Unlike popovers and tooltips, it will never be positioned horizontally. - -Aliases are ‘popdown’, ‘pulldown’ and many others. - -### Select Component - -Implemented as a dropdown listbox with invoker button. Depending on the content of the options, the child list can either be of type listbox or grid. -See: - -### Combobox Component - -Implemented as a dropdown combobox with invoker input. Input is used as search filter and can contain autocomplete or autosuggest functionality: -See: - -### (Application) menu Component - -Or sometimes called context-menu. Uses a dropdown to position its content. -See: -Be aware not to use role=“menu”: - -### Toast Component - -See: . Should probably be implemented as an alertdialog. - -### Sheet Component - -See: . Should probably be a global(modal) dialog. diff --git a/docs/fundamentals/systems/overlays/use-cases.md b/docs/fundamentals/systems/overlays/use-cases.md index f51dec896..e5f92345f 100644 --- a/docs/fundamentals/systems/overlays/use-cases.md +++ b/docs/fundamentals/systems/overlays/use-cases.md @@ -11,9 +11,9 @@ import { withModalDialogConfig, } from '@lion/ui/overlays.js'; -import './assets/demo-overlay-system.js'; -import './assets/demo-overlay-backdrop.js'; -import './assets/applyDemoOverlayStyles.js'; +import './assets/demo-overlay-system.mjs'; +import './assets/demo-overlay-backdrop.mjs'; +import './assets/applyDemoOverlayStyles.mjs'; import { ref, createRef } from 'lit/directives/ref.js'; ``` @@ -329,52 +329,55 @@ _defineOverlay({ invokerNode, contentNode }) { Below is another demo where you can toggle between configurations using buttons. ```js preview-story -export const responsiveSwitching2 = () => html` - - Change config to: - - - -
- - -
- Hello! You can close this notification here: - -
-
-`; + }; + const onSelectChange = e => { + overlayRef.value.config = getConfig(e.target.value); + }; + + return html` + + Change config to: + + + +
+ + +
+ Hello! You can close this notification here: + +
+
+ `; +}; ``` ## Opened state diff --git a/packages/ui/components/dialog/test/lion-dialog.test.js b/packages/ui/components/dialog/test/lion-dialog.test.js index 4f434cf69..82e633f40 100644 --- a/packages/ui/components/dialog/test/lion-dialog.test.js +++ b/packages/ui/components/dialog/test/lion-dialog.test.js @@ -57,15 +57,11 @@ describe('lion-dialog', () => {
`); - // @ts-expect-error you're not allowed to call protected _overlayInvokerNode in public context, but for testing it's okay + // @ts-expect-error [allow-protected] in tests el._overlayInvokerNode.click(); expect(el.opened).to.be.true; - const overlaysContainer = /** @type {HTMLElement} */ ( - document.querySelector('.global-overlays') - ); - const wrapperNode = Array.from(overlaysContainer.children)[1]; - const nestedDialog = /** @type {LionDialog} */ (wrapperNode.querySelector('lion-dialog')); + const nestedDialog = /** @type {LionDialog} */ (el.querySelector('lion-dialog')); // @ts-expect-error you're not allowed to call protected _overlayInvokerNode in public context, but for testing it's okay nestedDialog?._overlayInvokerNode.click(); expect(nestedDialog.opened).to.be.true; diff --git a/packages/ui/components/listbox/test-suites/ListboxMixin.suite.js b/packages/ui/components/listbox/test-suites/ListboxMixin.suite.js index 80541b5c1..845a984bf 100644 --- a/packages/ui/components/listbox/test-suites/ListboxMixin.suite.js +++ b/packages/ui/components/listbox/test-suites/ListboxMixin.suite.js @@ -394,7 +394,7 @@ export function runListboxMixinSuite(customConfig = {}) { await el.updateComplete; await el.updateComplete; // need 2 awaits as overlay.show is an async function - await expect(el).to.be.accessible(); + await expect(el).to.be.accessible({ ignoredRules: ['aria-allowed-role'] }); }); // NB: regular listbox is always 'opened', but needed for combobox and select-rich @@ -405,7 +405,7 @@ export function runListboxMixinSuite(customConfig = {}) { <${optionTag} .choiceValue=${20}>Item 2 `); - await expect(el).to.be.accessible(); + await expect(el).to.be.accessible({ ignoredRules: ['aria-allowed-role'] }); }); it('does not have a tabindex', async () => { @@ -430,7 +430,7 @@ export function runListboxMixinSuite(customConfig = {}) { const el = await fixture(html` <${tag} opened has-no-default-selected autocomplete="none" show-all-on-empty> <${optionTag} .choiceValue=${'10'} id="first">Item 1 - <${optionTag} .choiceValue=${'20'} checked id="second">Item 2 + <${optionTag} .choiceValue=${'20'} id="second">Item 2 `); const { _activeDescendantOwnerNode } = getListboxMembers(el); diff --git a/packages/ui/components/overlays/src/OverlayController.js b/packages/ui/components/overlays/src/OverlayController.js index dc4175899..4012cc9fa 100644 --- a/packages/ui/components/overlays/src/OverlayController.js +++ b/packages/ui/components/overlays/src/OverlayController.js @@ -1,34 +1,114 @@ import { EventTargetShim } from '@lion/ui/core.js'; +import { adoptStyles } from 'lit'; import { overlays } from './singleton.js'; import { containFocus } from './utils/contain-focus.js'; +import { globalOverlaysStyle } from './globalOverlaysStyle.js'; /** - * @typedef {import('../types/OverlayConfig.js').OverlayConfig} OverlayConfig - * @typedef {import('../types/OverlayConfig.js').ViewportConfig} ViewportConfig - * @typedef {import('@popperjs/core/lib/popper.js').createPopper} Popper - * @typedef {import('@popperjs/core/lib/popper.js').Options} PopperOptions - * @typedef {import('@popperjs/core/lib/enums.js').Placement} Placement + * @typedef {import('@lion/ui/types/overlays.js').OverlayConfig} OverlayConfig + * @typedef {import('@lion/ui/types/overlays.js').ViewportConfig} ViewportConfig + * @typedef {import('@popperjs/core').createPopper} Popper + * @typedef {import('@popperjs/core').Options} PopperOptions + * @typedef {import('@popperjs/core').Placement} Placement * @typedef {{ createPopper: Popper }} PopperModule * @typedef {'setup'|'init'|'teardown'|'before-show'|'show'|'hide'|'add'|'remove'} OverlayPhase */ +/** + * From: + * - wrappingDialogNodeL1: `` + * - contentWrapperNodeL2: `
` + * - contentNodeL3: `
` + * To: + * ```html + * + *
+ * + * + *
+ *
+ * ``` + * + * `` belonging to `
` will be wrapped with wrappingDialogNodeL1 and contentWrapperNodeL2 + * inside shadow dom. With the help of temp markers, ``'s original position will be respected. + * + * @param {{ wrappingDialogNodeL1:HTMLDialogElement|HTMLDivElement; contentWrapperNodeL2:Element; contentNodeL3: Element }} opts + */ +function rearrangeNodes({ wrappingDialogNodeL1, contentWrapperNodeL2, contentNodeL3 }) { + if (!(contentWrapperNodeL2.isConnected || contentNodeL3.isConnected)) { + throw new Error( + '[OverlayController] Could not find a render target, since the provided contentNode is not connected to the DOM. Make sure that it is connected, e.g. by doing "document.body.appendChild(contentNode)", before passing it on.', + ); + } + + let parentElement; + const tempMarker = document.createComment('temp-marker'); + + if (contentWrapperNodeL2.isConnected) { + // This is the case when contentWrapperNode (living in shadow dom, wrapping ) is already provided via controller. + parentElement = contentWrapperNodeL2.parentElement || contentWrapperNodeL2.getRootNode(); + parentElement.insertBefore(tempMarker, contentWrapperNodeL2); + // Wrap... + wrappingDialogNodeL1.appendChild(contentWrapperNodeL2); + } + // if contentNodeL3.isConnected + else { + const contentIsProjected = contentNodeL3.assignedSlot; + if (contentIsProjected) { + parentElement = + contentNodeL3.assignedSlot.parentElement || contentNodeL3.assignedSlot.getRootNode(); + parentElement.insertBefore(tempMarker, contentNodeL3.assignedSlot); + wrappingDialogNodeL1.appendChild(contentWrapperNodeL2); + // Important: we do not move around contentNodeL3, but the assigned slot + contentWrapperNodeL2.appendChild(contentNodeL3.assignedSlot); + } else { + parentElement = contentNodeL3.parentElement || contentNodeL3.getRootNode(); + parentElement.insertBefore(tempMarker, contentNodeL3); + wrappingDialogNodeL1.appendChild(contentWrapperNodeL2); + contentWrapperNodeL2.appendChild(contentNodeL3); + } + } + + /** + * From: + * ```html + * #shadow-root: + *
+ * + * + *
+ * ``` + * + * To: + * ```html + * #shadow-root: + *
+ * + * + *
+ * + *
+ *
+ *
+ * ``` + */ + parentElement.insertBefore(wrappingDialogNodeL1, tempMarker); + parentElement?.removeChild(tempMarker); +} + /** * @returns {Promise} */ async function preloadPopper() { - /* eslint-disable import/no-extraneous-dependencies */ // -- [external]: import complains about untyped module, but we typecast it ourselves - // @ts-ignore + // @ts-ignore [external]: import complains about untyped module, but we typecast it ourselves return /** @type {* & Promise} */ (import('@popperjs/core/dist/esm/popper.js')); - /* eslint-enable import/no-extraneous-dependencies */ } -const GLOBAL_OVERLAYS_CONTAINER_CLASS = 'global-overlays__overlay-container'; -const GLOBAL_OVERLAYS_CLASS = 'global-overlays__overlay'; // @ts-expect-error [external]: CSS not yet typed const supportsCSSTypedObject = window.CSS?.number && document.body.attributeStyleMap?.set; /** - * @desc OverlayController is the fundament for every single type of overlay. With the right + * OverlayController is the fundament for every single type of overlay. With the right * configuration, it can be used to build (modal) dialogs, tooltips, dropdowns, popovers, * bottom/top/left/right sheets etc. * @@ -74,7 +154,7 @@ const supportsCSSTypedObject = window.CSS?.number && document.body.attributeStyl * #### html structure for a content projected node *
* - *
+ *
*
* * Structure above depicts [l4] @@ -97,6 +177,9 @@ export class OverlayController extends EventTargetShim { /** @private */ this.__sharedConfig = config; + /** @type {OverlayConfig} */ + this.config = {}; + /** * @type {OverlayConfig} * @protected @@ -123,7 +206,7 @@ export class OverlayController extends EventTargetShim { handlesAccessibility: false, popperConfig: { placement: 'top', - strategy: 'absolute', + strategy: 'fixed', modifiers: [ { name: 'preventOverflow', @@ -156,6 +239,8 @@ export class OverlayController extends EventTargetShim { viewportConfig: { placement: 'center', }, + + zIndex: 9999, }; this.manager.add(this); @@ -163,25 +248,11 @@ export class OverlayController extends EventTargetShim { this._contentId = `overlay-content--${Math.random().toString(36).substr(2, 10)}`; /** @private */ this.__originalAttrs = new Map(); - if (this._defaultConfig.contentNode) { - if (!this._defaultConfig.contentNode.isConnected) { - throw new Error( - '[OverlayController] Could not find a render target, since the provided contentNode is not connected to the DOM. Make sure that it is connected, e.g. by doing "document.body.appendChild(contentNode)", before passing it on.', - ); - } - this.__isContentNodeProjected = Boolean(this._defaultConfig.contentNode.assignedSlot); - } this.updateConfig(config); /** @private */ this.__hasActiveTrapsKeyboardFocus = false; /** @private */ this.__hasActiveBackdrop = true; - /** - * @type {HTMLElement | undefined} - * @private - */ - this.__backdropNodeToBeTornDown = undefined; - /** @private */ this.__escKeyHandler = this.__escKeyHandler.bind(this); } @@ -196,10 +267,10 @@ export class OverlayController extends EventTargetShim { /** * The contentWrapperNode - * @type {HTMLElement} + * @type {HTMLDialogElement | HTMLDivElement} */ get content() { - return /** @type {HTMLElement} */ (this.contentWrapperNode); + return /** @type {HTMLDialogElement | HTMLDivElement} */ (this.__wrappingDialogNode); } /** @@ -385,28 +456,6 @@ export class OverlayController extends EventTargetShim { return /** @type {ViewportConfig} */ (this.config?.viewportConfig); } - /** - * Usually the parent node of contentWrapperNode that either exists locally or globally. - * When a responsive scenario is created (in which we switch from global to local or vice versa) - * we need to know where we should reappend contentWrapperNode (or contentNode in case it's - * projected). - * @type {HTMLElement} - * @protected - */ - get _renderTarget() { - /** config [g1] */ - if (this.placementMode === 'global') { - return this.manager.globalRootNode; - } - /** config [l2] or [l4] */ - if (this.__isContentNodeProjected) { - // @ts-expect-error [external]: fix Node types - return this.__originalContentParent?.getRootNode().host; - } - /** config [l1] or [l3] */ - return /** @type {HTMLElement} */ (this.__originalContentParent); - } - /** * @desc The element our local overlay will be positioned relative to. * @type {HTMLElement | undefined} @@ -420,12 +469,8 @@ export class OverlayController extends EventTargetShim { * @param {number} value */ set elevation(value) { - if (this.contentWrapperNode) { - this.contentWrapperNode.style.zIndex = `${value}`; - } - if (this.backdropNode) { - this.backdropNode.style.zIndex = `${value}`; - } + // @ts-expect-error find out why config would/could be undfined + this.__wrappingDialogNode.style.zIndex = `${this.config.zIndex + value}`; } /** @@ -472,9 +517,9 @@ export class OverlayController extends EventTargetShim { }; /** @private */ - this.__validateConfiguration(/** @type {OverlayConfig} */ (this.config)); + this.__validateConfiguration(this.config); /** @protected */ - this._init({ cfgToAdd }); + this._init(); /** @private */ this.__elementToFocusAfterHide = undefined; } @@ -498,16 +543,6 @@ export class OverlayController extends EventTargetShim { if (!newConfig.contentNode) { throw new Error('[OverlayController] You need to provide a .contentNode'); } - if (this.__isContentNodeProjected && !newConfig.contentWrapperNode) { - throw new Error( - '[OverlayController] You need to provide a .contentWrapperNode when .contentNode is projected', - ); - } - if (newConfig.isTooltip && newConfig.placementMode !== 'local') { - throw new Error( - '[OverlayController] .isTooltip should be configured with .placementMode "local"', - ); - } if (newConfig.isTooltip && !newConfig.handlesAccessibility) { throw new Error( '[OverlayController] .isTooltip only takes effect when .handlesAccessibility is enabled', @@ -519,85 +554,95 @@ export class OverlayController extends EventTargetShim { } /** - * @param {{ cfgToAdd: OverlayConfig }} options * @protected */ - _init({ cfgToAdd }) { - this.__initContentWrapperNode({ cfgToAdd }); - this.__initConnectionTarget(); + _init() { + if (!this.__contentHasBeenInitialized) { + this.__initContentDomStructure(); + this.__contentHasBeenInitialized = true; + } + + // Reset all positioning styles (local, c.q. Popper) and classes (global) + this.contentWrapperNode.removeAttribute('style'); + this.contentWrapperNode.removeAttribute('class'); if (this.placementMode === 'local') { // Lazily load Popper if not done yet if (!OverlayController.popperModule) { - // a@ts-expect-error FIXME: for some reason createPopper is missing here OverlayController.popperModule = preloadPopper(); } + } else { + const rootNode = /** @type {ShadowRoot} */ (this.contentWrapperNode.getRootNode()); + adoptStyles(rootNode, [...(rootNode.adoptedStyleSheets || []), globalOverlaysStyle]); } this._handleFeatures({ phase: 'init' }); } - /** @private */ - __initConnectionTarget() { - // Now, add our node to the right place in dom (renderTarget) - if (this.contentWrapperNode !== this.__prevConfig?.contentWrapperNode) { - if (this.config?.placementMode === 'global' || !this.__isContentNodeProjected) { - /** @type {HTMLElement} */ - (this.contentWrapperNode).appendChild(this.contentNode); - } - } - - if (!this._renderTarget) { - return; - } - - if (this.__isContentNodeProjected && this.placementMode === 'local') { - // We add the contentNode in its slot, so that it will be projected by contentWrapperNode - this._renderTarget.appendChild(this.contentNode); - } else { - const isInsideRenderTarget = this._renderTarget === this.contentWrapperNode.parentNode; - const nodeContainsTarget = this.contentWrapperNode.contains(this._renderTarget); - if (!isInsideRenderTarget && !nodeContainsTarget) { - // contentWrapperNode becomes the direct (non projected) parent of contentNode - this._renderTarget.appendChild(this.contentWrapperNode); - } - } - } - /** - * Cleanup ._contentWrapperNode. We do this, because creating a fresh wrapper - * can lead to problems with event listeners... - * @param {{ cfgToAdd: OverlayConfig }} options + * Here we arrange our content node via: + * 1. HTMLDialogElement: the content will always be painted to the browser's top layer + * - no matter what context the contentNode lives in, the overlay will be painted correctly via the element, + * even if 'overflow:hidden' or a css transform is applied in its parent hierarchy. + * - the dialog element will be unstyled, but will span the whole screen + * - a backdrop element will be a child of the dialog element, so it leverages the capabilities of the parent + * (filling the whole screen if wanted an always painted to top layer) + * 2. ContentWrapper: the content receives the right positioning styles in a clean/non conflicting way: + * - local positioning: receive inline (position) styling that can never conflict with the already existing computed styles + * - global positioning: receive flex (child) classes that position the content correctly relative to the viewport + * + * The resulting structure that will be created looks like this: + * + * ... + * + *
+ *
+ * + * + *
+ *
+ * ... + * * @private */ - __initContentWrapperNode({ cfgToAdd }) { - if (this.config?.contentWrapperNode && this.placementMode === 'local') { - /** config [l2],[l3],[l4] */ - this.__contentWrapperNode = this.config.contentWrapperNode; - } else { - /** config [l1],[g1] */ + __initContentDomStructure() { + const wrappingDialogElement = document.createElement( + this.config?._noDialogEl ? 'div' : 'dialog', + ); + // We use a dialog for its visual capabilities: it renders to the top layer. + // A11y will depend on the type of overlay and is arranged on contentNode level. + // Also see: https://www.scottohara.me/blog/2019/03/05/open-dialog.html + wrappingDialogElement.setAttribute('role', 'none'); + // N.B. position: fixed is needed to escape out of 'overflow: hidden' + // We give a high z-index for non-modal dialogs, so that we at least win from all siblings of our + // parent stacking context + wrappingDialogElement.style.cssText = `display:none; background-image: none; border-style: none; padding: 0; z-index: ${this.config.zIndex};`; + this.__wrappingDialogNode = wrappingDialogElement; + + /** + * Based on the configuration of the developer, multiple scenarios are accounted for + * A. We already have a contentWrapperNode () + */ + if (!this.config?.contentWrapperNode) { this.__contentWrapperNode = document.createElement('div'); } + this.contentWrapperNode.setAttribute('data-id', 'content-wrapper'); - this.contentWrapperNode.style.cssText = ''; - this.contentWrapperNode.style.display = 'none'; + rearrangeNodes({ + wrappingDialogNodeL1: wrappingDialogElement, + contentWrapperNodeL2: this.contentWrapperNode, + contentNodeL3: this.contentNode, + }); + // @ts-ignore + wrappingDialogElement.open = true; + + this.__wrappingDialogNode.style.display = 'none'; + this.contentWrapperNode.style.zIndex = '1'; if (getComputedStyle(this.contentNode).position === 'absolute') { // Having a _contWrapperNode and a contentNode with 'position:absolute' results in // computed height of 0... this.contentNode.style.position = 'static'; } - - if (this.__isContentNodeProjected && this.contentWrapperNode.isConnected) { - // We need to keep track of the original local context. - /** config [l2], [l4] */ - this.__originalContentParent = /** @type {HTMLElement} */ ( - this.contentWrapperNode.parentNode - ); - } else if (cfgToAdd.contentNode && cfgToAdd.contentNode.isConnected) { - // We need to keep track of the original local context. - /** config [l1], [l3], [g1] */ - this.__originalContentParent = /** @type {HTMLElement} */ (this.contentNode?.parentNode); - } } /** @@ -613,7 +658,7 @@ export class OverlayController extends EventTargetShim { if (phase === 'setup') { const zIndexNumber = Number(getComputedStyle(this.contentNode).zIndex); if (zIndexNumber < 1 || Number.isNaN(zIndexNumber)) { - this.contentWrapperNode.style.zIndex = '1'; + this.contentNode.style.zIndex = '1'; } } } @@ -686,7 +731,7 @@ export class OverlayController extends EventTargetShim { } get isShown() { - return Boolean(this.contentWrapperNode.style.display !== 'none'); + return Boolean(this.__wrappingDialogNode?.style.display !== 'none'); } /** @@ -717,7 +762,11 @@ export class OverlayController extends EventTargetShim { const event = new CustomEvent('before-show', { cancelable: true }); this.dispatchEvent(event); if (!event.defaultPrevented) { - this.contentWrapperNode.style.display = ''; + if (this.__wrappingDialogNode instanceof HTMLDialogElement) { + this.__wrappingDialogNode.show(); + } + // @ts-ignore + this.__wrappingDialogNode.style.display = ''; this._keepBodySize({ phase: 'before-show' }); await this._handleFeatures({ phase: 'show' }); this._keepBodySize({ phase: 'show' }); @@ -739,11 +788,17 @@ export class OverlayController extends EventTargetShim { */ async _handlePosition({ phase }) { if (this.placementMode === 'global') { - const addOrRemove = phase === 'show' ? 'add' : 'remove'; - const placementClass = `${GLOBAL_OVERLAYS_CONTAINER_CLASS}--${this.viewportConfig.placement}`; - this.contentWrapperNode.classList[addOrRemove](GLOBAL_OVERLAYS_CONTAINER_CLASS); - this.contentWrapperNode.classList[addOrRemove](placementClass); - this.contentNode.classList[addOrRemove](GLOBAL_OVERLAYS_CLASS); + const placementClass = `global-overlays__overlay-container--${this.viewportConfig.placement}`; + + if (phase === 'show') { + this.contentWrapperNode.classList.add('global-overlays__overlay-container'); + this.contentWrapperNode.classList.add(placementClass); + this.contentNode.classList.add('global-overlays__overlay'); + } else if (phase === 'hide') { + this.contentWrapperNode.classList.remove('global-overlays__overlay-container'); + this.contentWrapperNode.classList.remove(placementClass); + this.contentNode.classList.remove('global-overlays__overlay'); + } } else if (this.placementMode === 'local' && phase === 'show') { /** * Popper is weird about properly positioning the popper element when it is recreated so @@ -753,7 +808,7 @@ export class OverlayController extends EventTargetShim { * This is however necessary for initial placement. */ await this.__createPopperInstance(); - /** @type {Popper} */ (this._popper).forceUpdate(); + this._popper.forceUpdate(); } } @@ -829,7 +884,11 @@ export class OverlayController extends EventTargetShim { contentNode: this.contentNode, }); - this.contentWrapperNode.style.display = 'none'; + if (this.__wrappingDialogNode instanceof HTMLDialogElement) { + this.__wrappingDialogNode.close(); + } + // @ts-ignore + this.__wrappingDialogNode.style.display = 'none'; this._handleFeatures({ phase: 'hide' }); this._keepBodySize({ phase: 'hide' }); this.dispatchEvent(new Event('hide')); @@ -843,6 +902,7 @@ export class OverlayController extends EventTargetShim { * * @param {{backdropNode:HTMLElement, contentNode:HTMLElement}} hideConfig */ + // @ts-ignore // eslint-disable-next-line class-methods-use-this, no-empty-function, no-unused-vars async transitionHide(hideConfig) {} @@ -851,48 +911,27 @@ export class OverlayController extends EventTargetShim { * @protected */ // eslint-disable-next-line class-methods-use-this, no-empty-function, no-unused-vars - async _transitionHide(hideConfig) { + async _transitionHide({ backdropNode, contentNode }) { // `this.transitionHide` is a hook for our users - await this.transitionHide({ backdropNode: this.backdropNode, contentNode: this.contentNode }); - - if (hideConfig.backdropNode) { - hideConfig.backdropNode.classList.remove( - `${this.placementMode}-overlays__backdrop--animation-in`, - ); - /** @type {() => void} */ - let afterFadeOut = () => {}; - hideConfig.backdropNode.classList.add( - `${this.placementMode}-overlays__backdrop--animation-out`, - ); - this.__backdropAnimation = new Promise(resolve => { - afterFadeOut = () => { - if (hideConfig.backdropNode) { - hideConfig.backdropNode.classList.remove( - `${this.placementMode}-overlays__backdrop--animation-out`, - ); - hideConfig.backdropNode.classList.remove( - `${this.placementMode}-overlays__backdrop--visible`, - ); - hideConfig.backdropNode.removeEventListener('animationend', afterFadeOut); - } - resolve(undefined); - }; - }); - - hideConfig.backdropNode.addEventListener('animationend', afterFadeOut); + await this.transitionHide({ backdropNode, contentNode }); + this._handlePosition({ phase: 'hide' }); + if (!backdropNode) { + return; } + backdropNode.classList.remove(`global-overlays__backdrop--animation-in`); } /** * To be overridden by subclassers * - * @param {{backdropNode:HTMLElement, contentNode:HTMLElement}} showConfig + * @param {{backdropNode:HTMLElement; contentNode:HTMLElement}} showConfig */ + // @ts-ignore // eslint-disable-next-line class-methods-use-this, no-empty-function, no-unused-vars async transitionShow(showConfig) {} /** - * @param {{backdropNode:HTMLElement, contentNode:HTMLElement}} showConfig + * @param {{backdropNode:HTMLElement; contentNode:HTMLElement}} showConfig */ // eslint-disable-next-line class-methods-use-this, no-empty-function, no-unused-vars async _transitionShow(showConfig) { @@ -900,23 +939,16 @@ export class OverlayController extends EventTargetShim { await this.transitionShow({ backdropNode: this.backdropNode, contentNode: this.contentNode }); if (showConfig.backdropNode) { - showConfig.backdropNode.classList.add( - `${this.placementMode}-overlays__backdrop--animation-in`, - ); + showConfig.backdropNode.classList.add(`global-overlays__backdrop--animation-in`); } } /** @protected */ _restoreFocus() { - const { activeElement } = /** @type {* & ShadowRoot} */ ( - this.__contentWrapperNode - ).getRootNode(); + const { activeElement } = /** @type {ShadowRoot} */ (this.contentWrapperNode.getRootNode()); // We only are allowed to move focus if we (still) 'own' it. // Otherwise we assume the 'outside world' has, purposefully, taken over - if ( - activeElement && - /** @type {HTMLElement} */ (this.__contentWrapperNode).contains(activeElement) - ) { + if (activeElement instanceof HTMLElement && this.contentWrapperNode.contains(activeElement)) { if (this.elementToFocusAfterHide) { this.elementToFocusAfterHide.focus(); } else { @@ -1010,55 +1042,28 @@ export class OverlayController extends EventTargetShim { * @protected */ _handleBackdrop({ phase }) { + // eslint-disable-next-line default-case switch (phase) { case 'init': { - if (!this.backdropNode) { - this.__backdropNode = document.createElement('div'); - /** @type {HTMLElement} */ - (this.backdropNode).slot = 'backdrop'; - /** @type {HTMLElement} */ - (this.backdropNode).classList.add(`${this.placementMode}-overlays__backdrop`); + if (!this.__backdropInitialized) { + if (!this.config?.backdropNode) { + this.__backdropNode = document.createElement('div'); + // If backdropNode existed in config, styles are applied by implementing party + this.__backdropNode.classList.add(`global-overlays__backdrop`); + } + // @ts-ignore + this.__wrappingDialogNode.prepend(this.backdropNode); + this.__backdropInitialized = true; } - - let insertionAnchor = /** @type {HTMLElement} */ (this.contentNode.parentNode); - let insertionBefore = this.contentNode; - if (this.placementMode === 'global') { - insertionAnchor = /** @type {HTMLElement} */ (this.contentWrapperNode.parentElement); - insertionBefore = this.contentWrapperNode; - } - insertionAnchor.insertBefore(this.backdropNode, insertionBefore); break; } case 'show': - this.backdropNode.classList.add(`${this.placementMode}-overlays__backdrop--visible`); + this.backdropNode.classList.add(`global-overlays__backdrop--visible`); this.__hasActiveBackdrop = true; break; case 'hide': - if (!this.backdropNode) { - return; - } this.__hasActiveBackdrop = false; break; - case 'teardown': - if (!this.backdropNode || !this.backdropNode.parentNode) { - return; - } - if (this.__backdropAnimation) { - this.__backdropNodeToBeTornDown = this.backdropNode; - - this.__backdropAnimation.then(() => { - if (this.__backdropNodeToBeTornDown && this.__backdropNodeToBeTornDown.parentNode) { - this.__backdropNodeToBeTornDown.parentNode.removeChild( - this.__backdropNodeToBeTornDown, - ); - } - }); - } else { - this.backdropNode.parentNode.removeChild(this.backdropNode); - } - this.__backdropNode = undefined; - break; - /* no default */ } } @@ -1072,7 +1077,16 @@ export class OverlayController extends EventTargetShim { */ _handleTrapsKeyboardFocus({ phase }) { if (phase === 'show') { + // @ts-ignore + if ('showModal' in this.__wrappingDialogNode) { + // @ts-ignore + this.__wrappingDialogNode.close(); + // @ts-ignore + this.__wrappingDialogNode.showModal(); + } + // else { this.enableTrapsKeyboardFocus(); + // } } else if (phase === 'hide' || phase === 'teardown') { this.disableTrapsKeyboardFocus(); } @@ -1271,24 +1285,6 @@ export class OverlayController extends EventTargetShim { teardown() { this._handleFeatures({ phase: 'teardown' }); - - if (this.placementMode === 'global' && this.__isContentNodeProjected) { - /** @type {HTMLElement} */ (this.__originalContentParent).appendChild(this.contentNode); - } - - // Remove the content node wrapper from the global rootnode - this._teardownContentWrapperNode(); - } - - /** @protected */ - _teardownContentWrapperNode() { - if ( - this.placementMode === 'global' && - this.contentWrapperNode && - this.contentWrapperNode.parentNode - ) { - this.contentWrapperNode.parentNode.removeChild(this.contentWrapperNode); - } } /** @private */ diff --git a/packages/ui/components/overlays/src/OverlayMixin.js b/packages/ui/components/overlays/src/OverlayMixin.js index 6aa6dc7e5..9e9499873 100644 --- a/packages/ui/components/overlays/src/OverlayMixin.js +++ b/packages/ui/components/overlays/src/OverlayMixin.js @@ -27,9 +27,8 @@ export const OverlayMixinImplementation = superclass => constructor() { super(); this.opened = false; - /** @private */ - this.__needsSetup = true; - /** @type {OverlayConfig} */ + + /** @type {Partial} */ this.config = {}; /** @type {EventListener} */ @@ -163,16 +162,13 @@ export const OverlayMixinImplementation = superclass => } } - connectedCallback() { - super.connectedCallback(); - // we do a setup after every connectedCallback as firstUpdated will only be called once - this.__needsSetup = true; - this.updateComplete.then(() => { - if (this.__needsSetup) { - this._setupOverlayCtrl(); - } - this.__needsSetup = false; - }); + /** + * @param {import('lit-element').PropertyValues } changedProperties + */ + firstUpdated(changedProperties) { + super.firstUpdated(changedProperties); + + this._setupOverlayCtrl(); } disconnectedCallback() { @@ -197,9 +193,12 @@ export const OverlayMixinImplementation = superclass => } get _overlayBackdropNode() { - return /** @type {HTMLElement | undefined} */ ( - Array.from(this.children).find(child => child.slot === 'backdrop') - ); + if (!this.__cachedOverlayBackdropNode) { + this.__cachedOverlayBackdropNode = /** @type {HTMLElement | undefined} */ ( + Array.from(this.children).find(child => child.slot === 'backdrop') + ); + } + return this.__cachedOverlayBackdropNode; } get _overlayContentNode() { diff --git a/packages/ui/components/overlays/src/OverlaysManager.js b/packages/ui/components/overlays/src/OverlaysManager.js index b3c4a0720..9fdf860a8 100644 --- a/packages/ui/components/overlays/src/OverlaysManager.js +++ b/packages/ui/components/overlays/src/OverlaysManager.js @@ -4,7 +4,6 @@ */ import { globalOverlaysStyle } from './globalOverlaysStyle.js'; -import { setSiblingsInert, unsetSiblingsInert } from './utils/inert-siblings.js'; // Export this as protected var, so that we can easily mock it in tests // TODO: combine with browserDetection of core? @@ -23,13 +22,6 @@ export const _browserDetection = { * `OverlaysManager` which manages overlays which are rendered into the body */ export class OverlaysManager { - static __createGlobalRootNode() { - const rootNode = document.createElement('div'); - rootNode.classList.add('global-overlays'); - document.body.appendChild(rootNode); - return rootNode; - } - static __createGlobalStyleNode() { const styleTag = document.createElement('style'); styleTag.setAttribute('data-global-overlays', ''); @@ -38,19 +30,6 @@ export class OverlaysManager { return styleTag; } - /** - * no setter as .list is intended to be read-only - * You can use .add or .remove to modify it - */ - // eslint-disable-next-line class-methods-use-this - get globalRootNode() { - if (!OverlaysManager.__globalRootNode) { - OverlaysManager.__globalRootNode = OverlaysManager.__createGlobalRootNode(); - OverlaysManager.__globalStyleNode = OverlaysManager.__createGlobalStyleNode(); - } - return OverlaysManager.__globalRootNode; - } - /** * no setter as .list is intended to be read-only * You can use .add or .remove to modify it @@ -85,6 +64,10 @@ export class OverlaysManager { * @private */ this.__blockingMap = new WeakMap(); + + if (!OverlaysManager.__globalStyleNode) { + OverlaysManager.__globalStyleNode = OverlaysManager.__createGlobalStyleNode(); + } } /** @@ -108,6 +91,7 @@ export class OverlaysManager { throw new Error('could not find controller to remove'); } this.__list = this.list.filter(ctrl => ctrl !== ctrlToRemove); + this.__shownList = this.shownList.filter(ctrl => ctrl !== ctrlToRemove); } /** @@ -147,13 +131,7 @@ export class OverlaysManager { this.__shownList = []; this.__siblingsInert = false; - const rootNode = OverlaysManager.__globalRootNode; - if (rootNode) { - if (rootNode.parentElement) { - rootNode.parentElement.removeChild(rootNode); - } - OverlaysManager.__globalRootNode = undefined; - + if (OverlaysManager.__globalStyleNode) { document.head.removeChild( /** @type {HTMLStyleElement} */ (OverlaysManager.__globalStyleNode), ); @@ -180,9 +158,6 @@ export class OverlaysManager { */ informTrapsKeyboardFocusGotEnabled(placementMode) { if (this.siblingsInert === false && placementMode === 'global') { - if (OverlaysManager.__globalRootNode) { - setSiblingsInert(this.globalRootNode); - } this.__siblingsInert = true; } } @@ -199,9 +174,6 @@ export class OverlaysManager { next.enableTrapsKeyboardFocus(); } } else if (this.siblingsInert === true) { - if (OverlaysManager.__globalRootNode) { - unsetSiblingsInert(this.globalRootNode); - } this.__siblingsInert = false; } } @@ -242,8 +214,7 @@ export class OverlaysManager { */ requestToShowOnly(blockingCtrl) { const controllersToHide = this.shownList.filter(ctrl => ctrl !== blockingCtrl); - - controllersToHide.map(ctrl => ctrl.hide()); + controllersToHide.forEach(ctrl => ctrl.hide()); this.__blockingMap.set(blockingCtrl, controllersToHide); } @@ -255,11 +226,10 @@ export class OverlaysManager { const controllersWhichGotHidden = /** @type {OverlayController[]} */ ( this.__blockingMap.get(blockingCtrl) ); - controllersWhichGotHidden.map(ctrl => ctrl.show()); + controllersWhichGotHidden.forEach(ctrl => ctrl.show()); } } } -/** @type {HTMLElement | undefined} */ -OverlaysManager.__globalRootNode = undefined; + /** @type {HTMLStyleElement | undefined} */ OverlaysManager.__globalStyleNode = undefined; diff --git a/packages/ui/components/overlays/src/globalOverlaysStyle.js b/packages/ui/components/overlays/src/globalOverlaysStyle.js index 8fb0dc04d..59969afea 100644 --- a/packages/ui/components/overlays/src/globalOverlaysStyle.js +++ b/packages/ui/components/overlays/src/globalOverlaysStyle.js @@ -6,10 +6,6 @@ export const globalOverlaysStyle = css` z-index: 200; } - .global-overlays__overlay { - pointer-events: auto; - } - .global-overlays__overlay-container { display: flex; position: fixed; @@ -20,6 +16,10 @@ export const globalOverlaysStyle = css` pointer-events: none; } + .global-overlays__overlay-container::backdrop { + display: none; + } + .global-overlays__overlay-container--top-left { justify-content: flex-start; align-items: flex-start; @@ -54,6 +54,7 @@ export const globalOverlaysStyle = css` justify-content: flex-end; align-items: flex-end; } + .global-overlays__overlay-container--left { justify-content: flex-start; align-items: center; @@ -68,7 +69,12 @@ export const globalOverlaysStyle = css` width: 100%; } - .global-overlays .global-overlays__backdrop { + ::slotted(.global-overlays__overlay), + .global-overlays__overlay { + pointer-events: auto; + } + + .global-overlays__backdrop { content: ''; position: fixed; top: 0; @@ -81,15 +87,16 @@ export const globalOverlaysStyle = css` display: none; } - .global-overlays .global-overlays__backdrop--visible { + .global-overlays__backdrop--visible { display: block; } - .global-overlays .global-overlays__backdrop--animation-in { + .global-overlays__backdrop--animation-in { animation: global-overlays-backdrop-fade-in 300ms; + opacity: 0.3; } - .global-overlays .global-overlays__backdrop--animation-out { + .global-overlays__backdrop--animation-out { animation: global-overlays-backdrop-fade-out 300ms; opacity: 0; } diff --git a/packages/ui/components/overlays/src/singleton.js b/packages/ui/components/overlays/src/singleton.js index 3b6b4d054..26bacbb1f 100644 --- a/packages/ui/components/overlays/src/singleton.js +++ b/packages/ui/components/overlays/src/singleton.js @@ -1,4 +1,6 @@ import { singletonManager } from 'singleton-manager'; import { OverlaysManager } from './OverlaysManager.js'; -export const overlays = singletonManager.get('@lion/ui::overlays::0.x') || new OverlaysManager(); +export const overlays = + /** @type {OverlaysManager} */ + (singletonManager.get('@lion/ui::overlays::0.x')) || new OverlaysManager(); diff --git a/packages/ui/components/overlays/test-suites/OverlayMixin.suite.js b/packages/ui/components/overlays/test-suites/OverlayMixin.suite.js index babfc1d01..1f4b960e3 100644 --- a/packages/ui/components/overlays/test-suites/OverlayMixin.suite.js +++ b/packages/ui/components/overlays/test-suites/OverlayMixin.suite.js @@ -1,7 +1,7 @@ import { expect, fixture, html, nextFrame, aTimeout } from '@open-wc/testing'; import sinon from 'sinon'; -import { overlays, OverlayController } from '@lion/ui/overlays.js'; +import { overlays as overlaysManager, OverlayController } from '@lion/ui/overlays.js'; import '@lion/ui/define/lion-dialog.js'; import { _browserDetection } from '../src/OverlaysManager.js'; @@ -15,10 +15,12 @@ import { _browserDetection } from '../src/OverlaysManager.js'; * @typedef {LitElement & OverlayHost & {_overlayCtrl:OverlayController}} OverlayEl */ -function getGlobalOverlayNodes() { - return Array.from(overlays.globalRootNode.children).filter( - child => !child.classList.contains('global-overlays__backdrop'), - ); +function getGlobalOverlayCtrls() { + return overlaysManager.list; +} + +function resetOverlaysManager() { + overlaysManager.list.forEach(overlayCtrl => overlaysManager.remove(overlayCtrl)); } /** @@ -220,23 +222,21 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) { function sendCloseEvent(/** @type {Event} */ e) { e.target?.dispatchEvent(new Event('close-overlay', { bubbles: true })); } - const closeBtn = /** @type {OverlayEl} */ ( - await fixture(html` `) - ); const el = /** @type {OverlayEl} */ ( await fixture(html` <${tag} opened>
content of the overlay - ${closeBtn} +
`) ); - closeBtn.click(); - await nextFrame(); // hide takes at least a frame + // @ts-ignore + el.querySelector('[slot=content] button').click(); + await el._overlayCtrl._hideComplete; expect(el.opened).to.be.false; }); @@ -337,10 +337,7 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) { // For some reason, globalRootNode is not cleared properly on disconnectedCallback from previous overlay test fixtures... // Not sure why this "bug" happens... beforeEach(() => { - const globalRootNode = document.querySelector('.global-overlays'); - if (globalRootNode) { - globalRootNode.innerHTML = ''; - } + resetOverlaysManager(); }); it('supports nested overlays', async () => { @@ -362,7 +359,7 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) { ); if (el._overlayCtrl.placementMode === 'global') { - expect(getGlobalOverlayNodes().length).to.equal(2); + expect(getGlobalOverlayCtrls().length).to.equal(2); } el.opened = true; @@ -386,12 +383,12 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) { `) ); if (el._overlayCtrl.placementMode === 'global') { - expect(getGlobalOverlayNodes().length).to.equal(1); + expect(getGlobalOverlayCtrls().length).to.equal(1); const moveTarget = /** @type {OverlayEl} */ (await fixture('
')); moveTarget.appendChild(el); await el.updateComplete; - expect(getGlobalOverlayNodes().length).to.equal(1); + expect(getGlobalOverlayCtrls().length).to.equal(1); } }); @@ -419,14 +416,9 @@ export function runOverlayMixinSuite({ tagString, tag, suffix = '' }) { if (el._overlayCtrl.placementMode === 'global') { // Find the outlets that are not backdrop outlets - const overlayContainerNodes = getGlobalOverlayNodes(); - expect(overlayContainerNodes.length).to.equal(2); - const lastContentNodeInContainer = overlayContainerNodes[1]; + expect(getGlobalOverlayCtrls().length).to.equal(2); // Check that the last container is the nested one with the intended content - expect(lastContentNodeInContainer.firstElementChild.firstChild.textContent).to.equal( - 'content of the nested overlay', - ); - expect(lastContentNodeInContainer.firstElementChild.slot).to.equal('content'); + expect(el.contains(nestedEl)).to.be.true; } else { const contentNode = /** @type {HTMLElement} */ ( // @ts-ignore [allow-protected] in tests diff --git a/packages/ui/components/overlays/test/ArrowMixin.test.js b/packages/ui/components/overlays/test/ArrowMixin.test.js index 576dc6e1c..ae34fc90f 100644 --- a/packages/ui/components/overlays/test/ArrowMixin.test.js +++ b/packages/ui/components/overlays/test/ArrowMixin.test.js @@ -58,7 +58,6 @@ describe('ArrowMixin', () => { `) ); expect(el.hasAttribute('has-arrow')).to.be.true; - const arrowNode = /** @type {Element} */ (el._arrowNode); expect(window.getComputedStyle(arrowNode).getPropertyValue('display')).to.equal('block'); }); diff --git a/packages/ui/components/overlays/test/OverlayController.test.js b/packages/ui/components/overlays/test/OverlayController.test.js index 1acc66734..ae46b899e 100644 --- a/packages/ui/components/overlays/test/OverlayController.test.js +++ b/packages/ui/components/overlays/test/OverlayController.test.js @@ -20,6 +20,18 @@ import { simulateTab } from '../src/utils/simulate-tab.js'; * @typedef {import('../types/OverlayConfig.js').ViewportPlacement} ViewportPlacement */ +const wrappingDialogNodeStyle = + 'display: none; background-image: none; border-style: none; padding: 0px; z-index: 9999;'; + +/** + * @param {HTMLElement} node + */ +function normalizeOverlayContentWapper(node) { + if (node.hasAttribute('style') && !node.style.cssText) { + node.removeAttribute('style'); + } +} + /** * @param {OverlayController} overlayControllerEl */ @@ -66,7 +78,7 @@ describe('OverlayController', () => { ...withGlobalTestConfig(), }); expect(ctrl.content).not.to.be.undefined; - expect(ctrl.contentNode.parentElement).to.equal(ctrl.content); + expect(ctrl.contentNode.parentElement).to.equal(ctrl.contentWrapperNode); }); describe('Z-index on local overlays', () => { @@ -106,17 +118,21 @@ describe('OverlayController', () => { contentNode: await createZNode('auto', { mode: 'global' }), }); await ctrl.show(); - expect(ctrl.content.style.zIndex).to.equal('1'); + // @ts-expect-error find out why config would/could be undfined + expect(ctrl.content.style.zIndex).to.equal(`${ctrl.config.zIndex + 1}`); ctrl.updateConfig({ contentNode: await createZNode('auto', { mode: 'inline' }) }); await ctrl.show(); - expect(ctrl.content.style.zIndex).to.equal('1'); + // @ts-expect-error find out why config would/could be undfined + expect(ctrl.content.style.zIndex).to.equal(`${ctrl.config.zIndex + 1}`); ctrl.updateConfig({ contentNode: await createZNode('0', { mode: 'global' }) }); await ctrl.show(); - expect(ctrl.content.style.zIndex).to.equal('1'); + // @ts-expect-error find out why config would/could be undfined + expect(ctrl.content.style.zIndex).to.equal(`${ctrl.config.zIndex + 1}`); ctrl.updateConfig({ contentNode: await createZNode('0', { mode: 'inline' }) }); await ctrl.show(); - expect(ctrl.content.style.zIndex).to.equal('1'); + // @ts-expect-error find out why config would/could be undfined + expect(ctrl.content.style.zIndex).to.equal(`${ctrl.config.zIndex + 1}`); }); it.skip("doesn't set a z-index when contentNode already has >= 1", async () => { @@ -147,42 +163,7 @@ describe('OverlayController', () => { }); }); - describe('Render target', () => { - it('creates global target for placement mode "global"', async () => { - const ctrl = new OverlayController({ - ...withGlobalTestConfig(), - }); - const { renderTarget } = getProtectedMembers(ctrl); - expect(renderTarget).to.equal(overlays.globalRootNode); - }); - - it.skip('creates local target next to sibling for placement mode "local"', async () => { - const ctrl = new OverlayController({ - ...withLocalTestConfig(), - invokerNode: /** @type {HTMLElement} */ (await fixture(html``)), - }); - const { renderTarget } = getProtectedMembers(ctrl); - expect(renderTarget).to.be.undefined; - expect(ctrl.content).to.equal(ctrl.invokerNode?.nextElementSibling); - }); - - it('keeps local target for placement mode "local" when already connected', async () => { - const parentNode = /** @type {HTMLElement} */ ( - await fixture(html` -
-
Content
-
- `) - ); - const contentNode = /** @type {HTMLElement} */ (parentNode.querySelector('#content')); - const ctrl = new OverlayController({ - ...withLocalTestConfig(), - contentNode, - }); - const { renderTarget } = getProtectedMembers(ctrl); - expect(renderTarget).to.equal(parentNode); - }); - + describe('Offline content', () => { it('throws when passing a content node that was created "offline"', async () => { const contentNode = document.createElement('div'); const createOverlayController = () => { @@ -203,107 +184,245 @@ describe('OverlayController', () => { ...withLocalTestConfig(), contentNode, }); - const { renderTarget } = getProtectedMembers(overlay); - expect(overlay.contentNode.isConnected).to.be.true; - expect(renderTarget).to.not.be.undefined; }); }); }); // TODO: Add teardown feature tests - describe('Teardown', () => { - it('removes the contentWrapperNode from global rootnode upon teardown', async () => { - const ctrl = new OverlayController({ - ...withGlobalTestConfig(), - }); - - expect(ctrl.manager.globalRootNode.children.length).to.equal(1); - ctrl.teardown(); - expect(ctrl.manager.globalRootNode.children.length).to.equal(0); - }); - - it('[global] restores contentNode if it was/is a projected node', async () => { - const shadowHost = document.createElement('div'); - shadowHost.id = 'shadowHost'; - shadowHost.attachShadow({ mode: 'open' }); - /** @type {ShadowRoot} */ - (shadowHost.shadowRoot).innerHTML = ` -
- - -
- `; - const contentNode = document.createElement('div'); - contentNode.slot = 'contentNode'; - shadowHost.appendChild(contentNode); - - const wrapper = /** @type {HTMLElement} */ (await fixture('
')); - // Ensure the contentNode is connected to DOM - wrapper.appendChild(shadowHost); - - // has one child =
- expect(shadowHost.children.length).to.equal(1); - - const ctrl = new OverlayController({ - ...withLocalTestConfig(), - placementMode: 'global', - contentNode, - contentWrapperNode: shadowHost, - }); - - // has no children as content gets moved to the body - expect(shadowHost.children.length).to.equal(0); - ctrl.teardown(); - - // restores original light dom in teardown - expect(shadowHost.children.length).to.equal(1); - }); - }); + describe('Teardown', () => {}); describe('Node Configuration', () => { - it('accepts an .contentNode to directly set content', async () => { - const ctrl = new OverlayController({ - ...withGlobalTestConfig(), - contentNode: /** @type {HTMLElement} */ (await fixture('

direct node

')), - }); - expect(ctrl.contentNode).to.have.trimmed.text('direct node'); - }); - - it('accepts an .invokerNode to directly set invoker', async () => { - const ctrl = new OverlayController({ - ...withGlobalTestConfig(), - invokerNode: /** @type {HTMLElement} */ (await fixture('')), - }); - expect(ctrl.invokerNode).to.have.trimmed.text('invoke'); - }); - - describe('When contentWrapperNode projects contentNode', () => { - it('recognizes projected contentNode', async () => { - const shadowHost = document.createElement('div'); - shadowHost.attachShadow({ mode: 'open' }); - /** @type {ShadowRoot} */ (shadowHost.shadowRoot).innerHTML = ` -
- - -
- `; - const contentNode = document.createElement('div'); - contentNode.slot = 'contentNode'; - shadowHost.appendChild(contentNode); - - // Ensure the contentNode is connected to DOM - document.body.appendChild(shadowHost); - + describe('Content', async () => { + it('accepts a .contentNode for displaying content of the overlay', async () => { + const myContentNode = /** @type {HTMLElement} */ (fixtureSync('

direct node

')); const ctrl = new OverlayController({ - ...withLocalTestConfig(), - contentNode, - contentWrapperNode: /** @type {HTMLElement} */ ( - /** @type {ShadowRoot} */ (shadowHost.shadowRoot).getElementById('contentWrapperNode') - ), + ...withGlobalTestConfig(), + contentNode: myContentNode, + }); + expect(ctrl.contentNode).to.have.trimmed.text('direct node'); + expect(ctrl.contentNode).to.equal(myContentNode); + }); + + describe('Embedded dom structure', async () => { + describe('When projected in shadow dom', async () => { + it('wraps a .contentWrapperNode for style application and a for top layer paints', async () => { + const tagString = defineCE( + class extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + /** @type {ShadowRoot} */ + (this.shadowRoot).innerHTML = ''; + this.innerHTML = '
projected
'; + } + }, + ); + + const el = /** @type {HTMLElement} */ (fixtureSync(`<${tagString}>`)); + const myContentNode = /** @type {HTMLElement} */ (el.querySelector('[slot=content]')); + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + contentNode: myContentNode, + }); + + expect(ctrl.contentNode.assignedSlot?.parentElement).to.equal(ctrl.contentWrapperNode); + expect(ctrl.contentWrapperNode.parentElement?.tagName).to.equal('DIALOG'); + + normalizeOverlayContentWapper(ctrl.contentWrapperNode); + + // The total dom structure created... + expect(el).shadowDom.to.equal(` + +
+ + +
+
+ `); + + expect(el).lightDom.to.equal(`
projected
`); + }); }); - expect(ctrl.__isContentNodeProjected).to.be.true; + describe('When in light dom', async () => { + it('wraps a .contentWrapperNode for style application and a for top layer paints', async () => { + const el = fixtureSync('
non projected
'); + const myContentNode = /** @type {HTMLElement} */ (el.querySelector('#content')); + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + contentNode: myContentNode, + }); + expect(ctrl.contentNode.parentElement).to.equal(ctrl.contentWrapperNode); + expect(ctrl.contentWrapperNode.parentElement?.tagName).to.equal('DIALOG'); + + normalizeOverlayContentWapper(ctrl.contentWrapperNode); + + // The total dom structure created... + expect(el).lightDom.to.equal(` + +
+
non projected
+
+
+ `); + }); + }); + + describe('When .contenWrapperNode provided', async () => { + it('keeps the .contentWrapperNode for style application and wraps a for top layer paints', async () => { + const tagString = defineCE( + class extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + /** @type {ShadowRoot} */ + (this.shadowRoot).innerHTML = '
'; + this.innerHTML = '
projected
'; + } + }, + ); + + const el = /** @type {HTMLElement} */ (fixtureSync(`<${tagString}>`)); + const myContentNode = /** @type {HTMLElement} */ (el.querySelector('[slot=content]')); + const myContentWrapper = /** @type {HTMLElement} */ ( + el.shadowRoot?.querySelector('div') + ); + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + contentNode: myContentNode, + contentWrapperNode: myContentWrapper, + }); + + normalizeOverlayContentWapper(ctrl.contentWrapperNode); + + // The total dom structure created... + expect(el).shadowDom.to.equal(` + +
+ +
+
+ `); + }); + + it("uses the .contentWrapperNode as container for Popper's arrow", async () => { + const tagString = defineCE( + class extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + /** @type {ShadowRoot} */ + (this.shadowRoot).innerHTML = ` +
+
+ +
`; + this.innerHTML = '
projected
'; + } + }, + ); + + const el = /** @type {HTMLElement} */ (fixtureSync(`<${tagString}>`)); + const myContentNode = /** @type {HTMLElement} */ (el.querySelector('[slot=content]')); + const myContentWrapper = /** @type {HTMLElement} */ ( + el.shadowRoot?.querySelector('div') + ); + const ctrl = new OverlayController({ + ...withLocalTestConfig(), + contentNode: myContentNode, + contentWrapperNode: myContentWrapper, + }); + + normalizeOverlayContentWapper(ctrl.contentWrapperNode); + + // The total dom structure created... + expect(el).shadowDom.to.equal(` + +
+
+ +
+
+ `); + }); + }); + }); + }); + + describe('Invoker / Reference', async () => { + it('accepts a .invokerNode to directly set invoker', async () => { + const myInvokerNode = /** @type {HTMLElement} */ (fixtureSync('')); + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + invokerNode: myInvokerNode, + }); + expect(ctrl.invokerNode).to.equal(myInvokerNode); + expect(ctrl.referenceNode).to.equal(undefined); + }); + + it('accepts a .referenceNode as positioning anchor different from .invokerNode', async () => { + const myInvokerNode = /** @type {HTMLElement} */ (fixtureSync('')); + const myReferenceNode = /** @type {HTMLElement} */ (fixtureSync('
anchor
')); + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + invokerNode: myInvokerNode, + referenceNode: myReferenceNode, + }); + expect(ctrl.referenceNode).to.equal(myReferenceNode); + expect(ctrl.invokerNode).to.not.equal(ctrl.referenceNode); + }); + }); + + describe('Backdrop', () => { + it('creates a .backdropNode inside for guaranteed top layer paints and positioning opportunities', async () => { + const tagString = defineCE( + class extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + } + + connectedCallback() { + /** @type {ShadowRoot} */ + (this.shadowRoot).innerHTML = ''; + this.innerHTML = '
projected
'; + } + }, + ); + + const el = /** @type {HTMLElement} */ (fixtureSync(`<${tagString}>`)); + const myContentNode = /** @type {HTMLElement} */ (el.querySelector('[slot=content]')); + + const ctrl = new OverlayController({ + ...withGlobalTestConfig(), + contentNode: myContentNode, + hasBackdrop: true, + }); + + normalizeOverlayContentWapper(ctrl.contentWrapperNode); + + // The total dom structure created... + expect(el).shadowDom.to.equal( + ` + +
+
+ + +
+
+ `, + ); }); }); @@ -1003,24 +1122,24 @@ describe('OverlayController', () => { await ctrl0.show(); await ctrl1.show(); await ctrl2.show(); // blocking - expect(ctrl0.content).to.not.be.displayed; - expect(ctrl1.content).to.not.be.displayed; - expect(ctrl2.content).to.be.displayed; + expect(ctrl0.__wrappingDialogNode).to.not.be.displayed; + expect(ctrl1.__wrappingDialogNode).to.not.be.displayed; + expect(ctrl2.__wrappingDialogNode).to.be.displayed; await ctrl3.show(); await ctrl3._showComplete; - expect(ctrl3.content).to.be.displayed; + expect(ctrl3.__wrappingDialogNode).to.be.displayed; await ctrl2.hide(); await ctrl2._hideComplete; - expect(ctrl0.content).to.be.displayed; - expect(ctrl1.content).to.be.displayed; + expect(ctrl0.__wrappingDialogNode).to.be.displayed; + expect(ctrl1.__wrappingDialogNode).to.be.displayed; await ctrl2.show(); // blocking - expect(ctrl0.content).to.not.be.displayed; - expect(ctrl1.content).to.not.be.displayed; - expect(ctrl2.content).to.be.displayed; - expect(ctrl3.content).to.not.be.displayed; + expect(ctrl0.__wrappingDialogNode).to.not.be.displayed; + expect(ctrl1.__wrappingDialogNode).to.not.be.displayed; + expect(ctrl2.__wrappingDialogNode).to.be.displayed; + expect(ctrl3.__wrappingDialogNode).to.not.be.displayed; }); it('keeps backdrop status when used in combination with blocking', async () => { @@ -1197,11 +1316,11 @@ describe('OverlayController', () => { ctrl.hide(); - expect(getComputedStyle(ctrl.contentWrapperNode).display).to.equal('block'); + expect(getComputedStyle(ctrl.content).display).to.equal('block'); setTimeout(() => { hideTransitionFinished(); setTimeout(() => { - expect(getComputedStyle(ctrl.contentWrapperNode).display).to.equal('none'); + expect(getComputedStyle(ctrl.content).display).to.equal('none'); done(); }, 0); }, 0); @@ -1379,89 +1498,9 @@ describe('OverlayController', () => { expect(properlyInstantiated).to.be.true; }); - it('adds attributes inert and aria-hidden="true" on all siblings of rootNode if an overlay is shown', async () => { - const ctrl = new OverlayController({ - ...withGlobalTestConfig(), - trapsKeyboardFocus: true, - }); - - const sibling1 = document.createElement('div'); - const sibling2 = document.createElement('div'); - document.body.insertBefore(sibling1, ctrl.manager.globalRootNode); - document.body.appendChild(sibling2); - - await ctrl.show(); - - [sibling1, sibling2].forEach(sibling => { - expect(sibling).to.have.attribute('aria-hidden', 'true'); - expect(sibling).to.have.attribute('inert'); - }); - expect(ctrl.content.hasAttribute('aria-hidden')).to.be.false; - expect(ctrl.content.hasAttribute('inert')).to.be.false; - - await ctrl.hide(); - - [sibling1, sibling2].forEach(sibling => { - expect(sibling).to.not.have.attribute('aria-hidden'); - expect(sibling).to.not.have.attribute('inert'); - }); - - // cleanup - document.body.removeChild(sibling1); - document.body.removeChild(sibling2); - }); - - /** - * style.userSelect: - * - chrome: 'none' - * - rest: undefined - * - * style.pointerEvents: - * - chrome: auto - * - IE11: visiblePainted - */ - it('disables pointer events and selection on inert elements', async () => { - const ctrl = new OverlayController({ - ...withGlobalTestConfig(), - trapsKeyboardFocus: true, - }); - - // show+hide are needed to create a root node - await ctrl.show(); - await ctrl.hide(); - - const sibling1 = document.createElement('div'); - const sibling2 = document.createElement('div'); - document.body.insertBefore(sibling1, ctrl.manager.globalRootNode); - document.body.appendChild(sibling2); - - await ctrl.show(); - - [sibling1, sibling2].forEach(sibling => { - expect(window.getComputedStyle(sibling).userSelect).to.be.oneOf(['none', undefined]); - expect(window.getComputedStyle(sibling).pointerEvents).to.equal('none'); - }); - - expect(window.getComputedStyle(ctrl.contentNode).userSelect).to.be.oneOf(['auto', undefined]); - expect(window.getComputedStyle(ctrl.contentNode).pointerEvents).to.be.oneOf([ - 'auto', - 'visiblePainted', - ]); - - await ctrl.hide(); - - [sibling1, sibling2].forEach(sibling => { - expect(window.getComputedStyle(sibling).userSelect).to.be.oneOf(['auto', undefined]); - expect(window.getComputedStyle(sibling).pointerEvents).to.be.oneOf([ - 'auto', - 'visiblePainted', - ]); - }); - - // cleanup - document.body.removeChild(sibling1); - document.body.removeChild(sibling2); - }); + // TODO: check if we covered all functionality. "Inertness" should be handled by the platform with a modal overlay... + it.skip('adds attributes inert and aria-hidden="true" on all siblings of rootNode if an overlay is shown', async () => {}); + it.skip('disables pointer events and selection on inert elements', async () => {}); describe('Tooltip', () => { it('adds [aria-describedby] on invoker', async () => { @@ -1616,47 +1655,6 @@ describe('OverlayController', () => { }).to.throw('[OverlayController] You need to provide a .contentNode'); }); - it('throws if contentNodeWrapper is not provided for projected contentNode', async () => { - const shadowHost = document.createElement('div'); - shadowHost.attachShadow({ mode: 'open' }); - /** @type {ShadowRoot} */ (shadowHost.shadowRoot).innerHTML = ` -
- - -
- `; - const contentNode = document.createElement('div'); - contentNode.slot = 'contentNode'; - shadowHost.appendChild(contentNode); - - // Ensure the contentNode is connected to DOM - document.body.appendChild(shadowHost); - - expect(() => { - new OverlayController({ - ...withLocalTestConfig(), - contentNode, - }); - }).to.throw( - '[OverlayController] You need to provide a .contentWrapperNode when .contentNode is projected', - ); - }); - - it('throws if placementMode is global for a tooltip', async () => { - const contentNode = document.createElement('div'); - document.body.appendChild(contentNode); - expect(() => { - new OverlayController({ - placementMode: 'global', - contentNode, - isTooltip: true, - handlesAccessibility: true, - }); - }).to.throw( - '[OverlayController] .isTooltip should be configured with .placementMode "local"', - ); - }); - it('throws if handlesAccessibility is false for a tooltip', async () => { const contentNode = document.createElement('div'); document.body.appendChild(contentNode); diff --git a/packages/ui/components/overlays/test/OverlaysManager.test.js b/packages/ui/components/overlays/test/OverlaysManager.test.js index cd1f7cf21..401442188 100644 --- a/packages/ui/components/overlays/test/OverlaysManager.test.js +++ b/packages/ui/components/overlays/test/OverlaysManager.test.js @@ -27,24 +27,25 @@ describe('OverlaysManager', () => { mngr.teardown(); }); - it('provides .globalRootNode as a render target on first access', () => { - expect(document.body.querySelectorAll('.global-overlays').length).to.equal(0); - const rootNode = mngr.globalRootNode; - expect(document.body.querySelector('.global-overlays')).to.equal(rootNode); + it('provides global stylesheet for arrangement of body scroll', () => { + expect(document.head.querySelectorAll('[data-global-overlays]').length).to.equal(1); }); it('provides .teardown() for cleanup', () => { - const rootNode = mngr.globalRootNode; - expect(document.body.querySelector('.global-overlays')).to.equal(rootNode); expect(document.head.querySelector('[data-global-overlays=""]')).not.be.undefined; mngr.teardown(); - expect(document.body.querySelectorAll('.global-overlays').length).to.equal(0); expect(document.head.querySelector('[data-global-overlays=""]')).be.null; // safety check via private access (do not use this) - expect(OverlaysManager.__globalRootNode).to.be.undefined; expect(OverlaysManager.__globalStyleNode).to.be.undefined; + + // @ts-ignore [allow-private-in-test] + expect(mngr.__list).to.be.empty; + // @ts-ignore [allow-private-in-test] + expect(mngr.__shownList).to.be.empty; + // @ts-ignore [allow-private-in-test] + expect(mngr.__siblingsInert).to.be.false; }); it('can add/remove controllers', () => { diff --git a/packages/ui/components/overlays/test/global-positioning.test.js b/packages/ui/components/overlays/test/global-positioning.test.js index aef2a4e45..7153ff7c3 100644 --- a/packages/ui/components/overlays/test/global-positioning.test.js +++ b/packages/ui/components/overlays/test/global-positioning.test.js @@ -18,25 +18,15 @@ describe('Global Positioning', () => { overlays.teardown(); }); - describe('Basics', () => { - it('puts ".contentNode" in the body of the page', async () => { - const ctrl = new OverlayController({ - ...withDefaultGlobalConfig(), - }); - await ctrl.show(); - expect(overlays.globalRootNode.children.length).to.equal(1); - expect(overlays.globalRootNode.children[0]).to.have.trimmed.text('my content'); - }); - }); - describe('viewportConfig', () => { it('positions the overlay in center by default', async () => { const ctrl = new OverlayController({ ...withDefaultGlobalConfig(), }); await ctrl.show(); - expect(ctrl.content.classList.contains('global-overlays__overlay-container--center')).to.be - .true; + expect( + ctrl.contentWrapperNode.classList.contains('global-overlays__overlay-container--center'), + ).to.be.true; }); it('positions relative to the viewport ', async () => { @@ -60,7 +50,7 @@ describe('Global Positioning', () => { }); await ctrl.show(); expect( - ctrl.content.classList.contains( + ctrl.contentWrapperNode.classList.contains( `global-overlays__overlay-container--${viewportPlacement}`, ), ).to.be.true; diff --git a/packages/ui/components/overlays/test/local-positioning.test.js b/packages/ui/components/overlays/test/local-positioning.test.js index 7e108386c..026302090 100644 --- a/packages/ui/components/overlays/test/local-positioning.test.js +++ b/packages/ui/components/overlays/test/local-positioning.test.js @@ -9,6 +9,39 @@ import { normalizeTransformStyle } from './utils-tests/local-positioning-helpers * @typedef {import('../types/OverlayConfig.js').ViewportPlacement} ViewportPlacement */ +/** + * Make sure we never use a native button element, since its dimensions + * are not cross browser consistent + * For debugging purposes, add colors... + * @param {{clickHandler?: function; width?: number; height?: number}} opts + */ +function createInvokerSync({ clickHandler = () => {}, width = 100, height = 20 }) { + return /** @type {HTMLDivElement} */ ( + fixtureSync(html` +
+ Invoker +
+ `) + ); +} + +/** + * @param {{ width?: number; height?: number }} opts + */ +function createContentSync({ width = 80, height = 20 }) { + return /** @type {HTMLDivElement} */ ( + fixtureSync(html` +
+ Content +
+ `) + ); +} + const withLocalTestConfig = () => /** @type {OverlayConfig} */ ({ placementMode: 'local', @@ -47,14 +80,13 @@ describe('Local Positioning', () => { }); await fixture(html`
- ${ctrl.invokerNode}${ctrl.content} + ${ctrl.invokerNode}${ctrl.__wrappingDialogNode}
`); await ctrl.show(); - expect(normalizeTransformStyle(ctrl.content.style.transform)).to.equal( - 'translate(-30px, -18px)', - 'translate should be -30px [to center = (80 - 20)/2*-1], -18px [to place above = 10 invoker height + 8 default padding]', + expect(normalizeTransformStyle(ctrl.contentWrapperNode.style.transform)).to.equal( + 'translate(70px, -508px)', ); }); @@ -76,11 +108,11 @@ describe('Local Positioning', () => { }); await fixture(html`
- ${ctrl.invokerNode}${ctrl.content} + ${ctrl.invokerNode}${ctrl.contentWrapperNode}
`); await ctrl.show(); - expect(ctrl.content.getAttribute('data-popper-placement')).to.equal('top'); + expect(ctrl.contentWrapperNode.getAttribute('data-popper-placement')).to.equal('top'); }); it('positions to preferred place if placement is set and space is available', async () => { @@ -104,12 +136,12 @@ describe('Local Positioning', () => { }); await fixture(html`
- ${ctrl.invokerNode}${ctrl.content} + ${ctrl.invokerNode}${ctrl.contentWrapperNode}
`); await ctrl.show(); - expect(ctrl.content.getAttribute('data-popper-placement')).to.equal('left-start'); + expect(ctrl.contentWrapperNode.getAttribute('data-popper-placement')).to.equal('left-start'); }); it('positions to different place if placement is set and no space is available', async () => { @@ -130,11 +162,13 @@ describe('Local Positioning', () => { }, }); await fixture(html` -
${ctrl.invokerNode}${ctrl.content}
+
+ ${ctrl.invokerNode}${ctrl.contentWrapperNode} +
`); await ctrl.show(); - expect(ctrl.content.getAttribute('data-popper-placement')).to.equal('right'); + expect(ctrl.contentWrapperNode.getAttribute('data-popper-placement')).to.equal('right'); }); it('allows the user to override default Popper modifiers', async () => { @@ -164,7 +198,7 @@ describe('Local Positioning', () => { }); await fixture(html`
- ${ctrl.invokerNode}${ctrl.content} + ${ctrl.invokerNode}${ctrl.contentWrapperNode}
`); @@ -175,37 +209,30 @@ describe('Local Positioning', () => { it('positions the Popper element correctly on show', async () => { const ctrl = new OverlayController({ ...withLocalTestConfig(), - contentNode: /** @type {HTMLElement} */ ( - fixtureSync(html`
`) - ), - invokerNode: /** @type {HTMLElement} */ ( - fixtureSync(html` -
ctrl.show()} - >
- `) - ), + contentNode: createContentSync({ width: 80, height: 20 }), + invokerNode: createInvokerSync({ clickHandler: () => ctrl.show(), width: 100, height: 20 }), popperConfig: { placement: 'top', }, }); await fixture(html`
- ${ctrl.invokerNode}${ctrl.content} + ${ctrl.invokerNode}${ctrl.__wrappingDialogNode}
`); + await ctrl.show(); - expect(normalizeTransformStyle(ctrl.content.style.transform)).to.equal( - 'translate(10px, -28px)', - 'Popper positioning values', + + // N.B. margin between invoker and content = 8px + expect(normalizeTransformStyle(ctrl.contentWrapperNode.style.transform)).to.equal( + 'translate(110px, -308px)', + '110 = (100 + (100-80)/2); -308= 300 + 8', ); await ctrl.hide(); await ctrl.show(); - expect(normalizeTransformStyle(ctrl.content.style.transform)).to.equal( - 'translate(10px, -28px)', + expect(normalizeTransformStyle(ctrl.contentWrapperNode.style.transform)).to.equal( + 'translate(110px, -308px)', 'Popper positioning values should be identical after hiding and showing', ); }); @@ -241,12 +268,12 @@ describe('Local Positioning', () => { }); await fixture(html`
- ${ctrl.invokerNode} ${ctrl.content} + ${ctrl.invokerNode} ${ctrl.contentWrapperNode}
`); await ctrl.show(); - expect(normalizeTransformStyle(ctrl.content.style.transform)).to.equal( + expect(normalizeTransformStyle(ctrl.contentWrapperNode.style.transform)).to.equal( 'translate3d(10px, -30px, 0px)', 'Popper positioning values', ); @@ -267,7 +294,7 @@ describe('Local Positioning', () => { }); await ctrl.show(); expect(ctrl._popper.options.modifiers.offset.offset).to.equal('0, 20px'); - expect(normalizeTransformStyle(ctrl.content.style.transform)).to.equal( + expect(normalizeTransformStyle(ctrl.contentWrapperNode.style.transform)).to.equal( 'translate3d(10px, -40px, 0px)', 'Popper positioning Y value should be 10 less than previous, due to the added extra 10px offset', ); @@ -302,12 +329,12 @@ describe('Local Positioning', () => { }); await fixture(html`
- ${ctrl.invokerNode} ${ctrl.content} + ${ctrl.invokerNode} ${ctrl.contentWrapperNode}
`); await ctrl.show(); - expect(normalizeTransformStyle(ctrl.content.style.transform)).to.equal( + expect(normalizeTransformStyle(ctrl.contentWrapperNode.style.transform)).to.equal( 'translate3d(10px, -30px, 0px)', 'Popper positioning values', ); @@ -317,7 +344,7 @@ describe('Local Positioning', () => { modifiers: [{ name: 'offset', enabled: true, options: { offset: [0, 20] } }], }, }); - expect(normalizeTransformStyle(ctrl.content.style.transform)).to.equal( + expect(normalizeTransformStyle(ctrl.contentWrapperNode.style.transform)).to.equal( 'translate3d(10px, -40px, 0px)', 'Popper positioning Y value should be 10 less than previous, due to the added extra 10px offset', ); @@ -333,7 +360,7 @@ describe('Local Positioning', () => { invokerNode, }); await ctrl.show(); - expect(ctrl.content.style.minWidth).to.equal('60px'); + expect(ctrl.contentWrapperNode.style.minWidth).to.equal('60px'); }); it('can set the contentNode maxWidth as the invokerNode width', async () => { @@ -346,7 +373,7 @@ describe('Local Positioning', () => { invokerNode, }); await ctrl.show(); - expect(ctrl.content.style.maxWidth).to.equal('60px'); + expect(ctrl.contentWrapperNode.style.maxWidth).to.equal('60px'); }); it('can set the contentNode width as the invokerNode width', async () => { @@ -359,7 +386,7 @@ describe('Local Positioning', () => { invokerNode, }); await ctrl.show(); - expect(ctrl.content.style.width).to.equal('60px'); + expect(ctrl.contentWrapperNode.style.width).to.equal('60px'); }); }); }); diff --git a/packages/ui/components/overlays/types/OverlayConfig.ts b/packages/ui/components/overlays/types/OverlayConfig.ts index f58c37eb3..4390a5d18 100644 --- a/packages/ui/components/overlays/types/OverlayConfig.ts +++ b/packages/ui/components/overlays/types/OverlayConfig.ts @@ -17,7 +17,7 @@ export interface OverlayConfig { elementToFocusAfterHide?: HTMLElement; /** Whether it should have a backdrop (currently exclusive to globalOverlayController) */ hasBackdrop?: boolean; - /** Hides other overlays when mutiple are opened (currently exclusive to globalOverlayController) */ + /** Hides other overlays when multiple are opened (currently exclusive to globalOverlayController) */ isBlocking?: boolean; /** Prevents scrolling body content when overlay opened (currently exclusive to globalOverlayController) */ preventsScroll?: boolean; @@ -50,6 +50,12 @@ export interface OverlayConfig { popperConfig?: Partial; /** Viewport configuration. Will be used when placementMode is 'global' */ viewportConfig?: ViewportConfig; + + /** Change the default of 9999 */ + zIndex?: number; + + /** render a div instead of dialog */ + _noDialogEl?: Boolean; } export type ViewportPlacement = @@ -61,8 +67,7 @@ export type ViewportPlacement = | 'bottom-right' | 'bottom' | 'bottom-left' - | 'left' - | 'center'; + | 'left'; export interface ViewportConfig { placement: ViewportPlacement; diff --git a/packages/ui/components/overlays/types/OverlayMixinTypes.ts b/packages/ui/components/overlays/types/OverlayMixinTypes.ts index 67f373793..819aeecb3 100644 --- a/packages/ui/components/overlays/types/OverlayMixinTypes.ts +++ b/packages/ui/components/overlays/types/OverlayMixinTypes.ts @@ -18,8 +18,8 @@ export interface DefineOverlayConfig { export declare class OverlayHost { opened: Boolean; - get config(): OverlayConfig; - set config(value: OverlayConfig); + get config(): Partial; + set config(value: Partial); open(): Promise; close(): Promise; diff --git a/packages/ui/components/select-rich/src/LionSelectInvoker.js b/packages/ui/components/select-rich/src/LionSelectInvoker.js index 6b45cff5b..70cd6cf1d 100644 --- a/packages/ui/components/select-rich/src/LionSelectInvoker.js +++ b/packages/ui/components/select-rich/src/LionSelectInvoker.js @@ -15,8 +15,7 @@ import { SlotMixin } from '@lion/ui/core.js'; export class LionSelectInvoker extends SlotMixin(LionButton) { static get styles() { return [ - // TODO switch back to ...super.styles once fixed https://github.com/lit/lit.dev/pull/535 - ...LionButton.styles, + ...super.styles, css` :host { justify-content: space-between; @@ -31,9 +30,9 @@ export class LionSelectInvoker extends SlotMixin(LionButton) { ]; } + /** @type {any} */ static get properties() { return { - ...super.properties, selectedElement: { type: Object }, hostElement: { type: Object }, readOnly: { type: Boolean, reflect: true, attribute: 'readonly' }, diff --git a/packages/ui/components/select-rich/src/LionSelectRich.js b/packages/ui/components/select-rich/src/LionSelectRich.js index ceda23e49..9453935da 100644 --- a/packages/ui/components/select-rich/src/LionSelectRich.js +++ b/packages/ui/components/select-rich/src/LionSelectRich.js @@ -408,27 +408,29 @@ export class LionSelectRich extends SlotMixin(ScopedElementsMixin(OverlayMixin(L * @protected */ async _alignInvokerWidth() { - if (this._overlayCtrl && this._overlayCtrl.content) { - await this.updateComplete; - const initContentDisplay = this._overlayCtrl.content.style.display; - const initContentMinWidth = this._overlayCtrl.content.style.minWidth; - const initContentWidth = this._overlayCtrl.content.style.width; - this._overlayCtrl.content.style.display = ''; - this._overlayCtrl.content.style.minWidth = 'auto'; - this._overlayCtrl.content.style.width = 'auto'; - const contentWidth = this._overlayCtrl.content.getBoundingClientRect().width; - /** - * TODO when inside an overlay the current solution doesn't work. - * Since that dialog is still hidden, open and close the select-rich - * doesn't have any effect so the contentWidth returns 0 - */ - if (contentWidth > 0) { - this._invokerNode.style.width = `${contentWidth + this._arrowWidth}px`; - } - this._overlayCtrl.content.style.display = initContentDisplay; - this._overlayCtrl.content.style.minWidth = initContentMinWidth; - this._overlayCtrl.content.style.width = initContentWidth; + if (!this._overlayCtrl?.content) { + return; } + + await this.updateComplete; + const initContentDisplay = this._overlayCtrl.content.style.display; + const initContentMinWidth = this._overlayCtrl.contentWrapperNode.style.minWidth; + const initContentWidth = this._overlayCtrl.contentWrapperNode.style.width; + this._overlayCtrl.content.style.display = ''; + this._overlayCtrl.contentWrapperNode.style.minWidth = 'auto'; + this._overlayCtrl.contentWrapperNode.style.width = 'auto'; + const contentWidth = this._overlayCtrl.contentWrapperNode.getBoundingClientRect().width; + /** + * TODO when inside an overlay the current solution doesn't work. + * Since that dialog is still hidden, open and close the select-rich + * doesn't have any effect so the contentWidth returns 0 + */ + if (contentWidth > 0) { + this._invokerNode.style.width = `${contentWidth + this._arrowWidth}px`; + } + this._overlayCtrl.content.style.display = initContentDisplay; + this._overlayCtrl.contentWrapperNode.style.minWidth = initContentMinWidth; + this._overlayCtrl.contentWrapperNode.style.width = initContentWidth; } /** diff --git a/packages/ui/components/select-rich/test/lion-select-rich.test.js b/packages/ui/components/select-rich/test/lion-select-rich.test.js index 51144286c..50e83e222 100644 --- a/packages/ui/components/select-rich/test/lion-select-rich.test.js +++ b/packages/ui/components/select-rich/test/lion-select-rich.test.js @@ -1,5 +1,4 @@ import { LitElement } from 'lit'; -import { renderLitAsNode } from '@lion/ui/helpers.js'; import { LionOption } from '@lion/ui/listbox.js'; import { getListboxMembers } from '@lion/ui/listbox-test-helpers.js'; import { OverlayController } from '@lion/ui/overlays.js'; @@ -152,7 +151,7 @@ describe('lion-select-rich', () => { }); it('updates the invoker when the selected element is the same but the modelValue was updated asynchronously', async () => { - const tag = defineCE( + const tagString = defineCE( class LionCustomOption extends LionOption { render() { return html`${this.modelValue.value}`; @@ -163,27 +162,26 @@ describe('lion-select-rich', () => { } }, ); - const tagString = unsafeStatic(tag); - + const tag = unsafeStatic(tagString); const firstOption = /** @type {LionOption} */ ( - renderLitAsNode(html`<${tagString} checked .choiceValue=${10}>`) + await _fixture(html`<${tag} checked .choiceValue=${10}>`) ); const el = await fixture(html` ${firstOption} - <${tagString} .choiceValue=${20}> + <${tag} .choiceValue=${20}> `); const { _invokerNode } = getSelectRichMembers(el); + const firstChild = /** @type {HTMLElement} */ ( /** @type {ShadowRoot} */ (_invokerNode.shadowRoot).firstElementChild ); expect(firstChild.textContent).to.equal('10'); firstOption.modelValue = { value: 30, checked: true }; - await firstOption.updateComplete; await el.updateComplete; expect(firstChild.textContent).to.equal('30'); }); diff --git a/packages/ui/docs/fundamentals/systems/overlays/assets/demo-overlay-backdrop.js b/packages/ui/docs/fundamentals/systems/overlays/assets/demo-overlay-backdrop.js index 06aa651bf..54059a83b 100644 --- a/packages/ui/docs/fundamentals/systems/overlays/assets/demo-overlay-backdrop.js +++ b/packages/ui/docs/fundamentals/systems/overlays/assets/demo-overlay-backdrop.js @@ -1,21 +1,18 @@ import { css, LitElement } from 'lit'; /** - * @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig + * @typedef {import('@lion/ui/types/overlays.js').OverlayConfig} OverlayConfig */ class DemoOverlayBackdrop extends LitElement { static get styles() { return css` :host { - position: fixed; top: 0; left: 0; width: 100%; height: 100%; - z-index: 1; background-color: grey; opacity: 0.3; - display: none; } :host(.local-overlays__backdrop--visible) { diff --git a/packages/ui/docs/fundamentals/systems/overlays/assets/demo-overlay-system.js b/packages/ui/docs/fundamentals/systems/overlays/assets/demo-overlay-system.js index 30c1ec87e..1a7026fe7 100644 --- a/packages/ui/docs/fundamentals/systems/overlays/assets/demo-overlay-system.js +++ b/packages/ui/docs/fundamentals/systems/overlays/assets/demo-overlay-system.js @@ -2,7 +2,7 @@ import { html, LitElement } from 'lit'; import { OverlayMixin } from '@lion/ui/overlays.js'; /** - * @typedef {import('../types/OverlayConfig').OverlayConfig} OverlayConfig + * @typedef {import('../types/OverlayConfig.js').OverlayConfig} OverlayConfig */ class DemoOverlaySystem extends OverlayMixin(LitElement) { // eslint-disable-next-line class-methods-use-this diff --git a/packages/ui/exports/types/overlays.ts b/packages/ui/exports/types/overlays.ts index 1b64530aa..47d9805a1 100644 --- a/packages/ui/exports/types/overlays.ts +++ b/packages/ui/exports/types/overlays.ts @@ -1,4 +1,5 @@ export { OverlayConfig } from '../../components/overlays/types/OverlayConfig.js'; +export { ViewportConfig } from '../../components/overlays/types/OverlayConfig.js'; export { DefineOverlayConfig } from '../../components/overlays/types/OverlayMixinTypes.js'; export { OverlayHost } from '../../components/overlays/types/OverlayMixinTypes.js'; export { ArrowHost } from '../../components/overlays/types/ArrowMixinTypes.js';