From a2bce98126bb20a1656f0624c17aa22b81206d58 Mon Sep 17 00:00:00 2001 From: Mert Akinc <7282195+m-akinc@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:25:28 -0500 Subject: [PATCH 01/10] Properly update anchored regions in dialogs --- .../src/anchored-region/anchored-region.ts | 15 ++++++--- .../src/utilities/composed-parent.ts | 33 +++++++++++++------ 2 files changed, 34 insertions(+), 14 deletions(-) diff --git a/packages/web-components/fast-foundation/src/anchored-region/anchored-region.ts b/packages/web-components/fast-foundation/src/anchored-region/anchored-region.ts index 37121e81303..91cb4e75bad 100644 --- a/packages/web-components/fast-foundation/src/anchored-region/anchored-region.ts +++ b/packages/web-components/fast-foundation/src/anchored-region/anchored-region.ts @@ -1,6 +1,7 @@ import { attr, DOM, observable } from "@ni/fast-element"; import { Direction, eventResize, eventScroll } from "@ni/fast-web-utilities"; import { FoundationElement } from "../foundation-element/foundation-element.js"; +import { closestAncestorDialog } from "../utilities/composed-parent.js"; import { getDirection } from "../utilities/direction.js"; import { IntersectionService } from "../utilities/intersection-service.js"; import type { @@ -404,6 +405,8 @@ export class AnchoredRegion extends FoundationElement { // justify a layout update that affects the dom (prevents repeated sub-pixel corrections) private updateThreshold: number = 0.5; + private ancestorDialog: HTMLDialogElement | null = null; + private static intersectionService: IntersectionService = new IntersectionService(); /** @@ -411,6 +414,7 @@ export class AnchoredRegion extends FoundationElement { */ connectedCallback() { super.connectedCallback(); + this.ancestorDialog = closestAncestorDialog(this); if (this.autoUpdateMode === "auto") { this.startAutoUpdateEventListeners(); } @@ -422,6 +426,7 @@ export class AnchoredRegion extends FoundationElement { */ public disconnectedCallback(): void { super.disconnectedCallback(); + this.ancestorDialog = null; if (this.autoUpdateMode === "auto") { this.stopAutoUpdateEventListeners(); } @@ -1314,8 +1319,9 @@ export class AnchoredRegion extends FoundationElement { * starts event listeners that can trigger auto updating */ private startAutoUpdateEventListeners = (): void => { - window.addEventListener(eventResize, this.update, { passive: true }); - window.addEventListener(eventScroll, this.update, { + const eventTarget = this.ancestorDialog ?? window; + eventTarget.addEventListener(eventResize, this.update, { passive: true }); + eventTarget.addEventListener(eventScroll, this.update, { passive: true, capture: true, }); @@ -1328,8 +1334,9 @@ export class AnchoredRegion extends FoundationElement { * stops event listeners that can trigger auto updating */ private stopAutoUpdateEventListeners = (): void => { - window.removeEventListener(eventResize, this.update); - window.removeEventListener(eventScroll, this.update); + const eventTarget = this.ancestorDialog ?? window; + eventTarget.removeEventListener(eventResize, this.update); + eventTarget.removeEventListener(eventScroll, this.update); if (this.resizeDetector !== null && this.viewportElement !== null) { this.resizeDetector.unobserve(this.viewportElement); } diff --git a/packages/web-components/fast-foundation/src/utilities/composed-parent.ts b/packages/web-components/fast-foundation/src/utilities/composed-parent.ts index b3dad61cb93..eac81f22e17 100644 --- a/packages/web-components/fast-foundation/src/utilities/composed-parent.ts +++ b/packages/web-components/fast-foundation/src/utilities/composed-parent.ts @@ -7,19 +7,32 @@ * * @public */ -export function composedParent(element: T): HTMLElement | null { - const parentNode = element.parentElement; +export function composedParent(element: T, asSlotted = false): HTMLElement | null { + if (asSlotted && element.assignedSlot) { + return element.assignedSlot; + } - if (parentNode) { - return parentNode; - } else { - const rootNode = element.getRootNode(); + return element.parentElement ?? shadowDomHost(element); +} - if ((rootNode as ShadowRoot).host instanceof HTMLElement) { - // this is shadow-root - return (rootNode as ShadowRoot).host as HTMLElement; - } +function shadowDomHost(element: HTMLElement): HTMLElement | null { + const rootNode = element.getRootNode(); + + if ((rootNode as ShadowRoot).host instanceof HTMLElement) { + // this is shadow-root + return (rootNode as ShadowRoot).host as HTMLElement; } return null; } + +export function closestAncestorDialog(element: HTMLElement): HTMLDialogElement | null { + let node: HTMLElement | null = composedParent(element, true); + while (node) { + if (node instanceof HTMLDialogElement) { + return node; + } + node = composedParent(node, true); + } + return null; +} From 3b7f7474c5f9572d52adf255fc5c49714d0bab6d Mon Sep 17 00:00:00 2001 From: Mert Akinc <7282195+m-akinc@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:25:55 -0500 Subject: [PATCH 02/10] Change files --- ...st-foundation-7b80b38c-f554-4f52-87cd-81b3a9c205cc.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 change/@ni-fast-foundation-7b80b38c-f554-4f52-87cd-81b3a9c205cc.json diff --git a/change/@ni-fast-foundation-7b80b38c-f554-4f52-87cd-81b3a9c205cc.json b/change/@ni-fast-foundation-7b80b38c-f554-4f52-87cd-81b3a9c205cc.json new file mode 100644 index 00000000000..9629f6c042c --- /dev/null +++ b/change/@ni-fast-foundation-7b80b38c-f554-4f52-87cd-81b3a9c205cc.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Properly update anchored regions in dialogs", + "packageName": "@ni/fast-foundation", + "email": "7282195+m-akinc@users.noreply.github.com", + "dependentChangeType": "patch" +} From 2d8946a8831df3b5655e474f402063c87a4aee49 Mon Sep 17 00:00:00 2001 From: Mert Akinc <7282195+m-akinc@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:46:49 -0500 Subject: [PATCH 03/10] Address AI feedback --- .../anchored-region/anchored-region.spec.ts | 36 ++++++++++++++++++- .../src/anchored-region/anchored-region.ts | 16 +++++---- .../src/utilities/composed-parent.ts | 2 ++ 3 files changed, 46 insertions(+), 8 deletions(-) diff --git a/packages/web-components/fast-foundation/src/anchored-region/anchored-region.spec.ts b/packages/web-components/fast-foundation/src/anchored-region/anchored-region.spec.ts index 9acf7367cbe..88c27ff3bdf 100644 --- a/packages/web-components/fast-foundation/src/anchored-region/anchored-region.spec.ts +++ b/packages/web-components/fast-foundation/src/anchored-region/anchored-region.spec.ts @@ -1,8 +1,11 @@ -import { expect } from "chai"; +import chai, { expect } from "chai"; +import spies from "chai-spies"; import { AnchoredRegion, anchoredRegionTemplate as template } from "./index"; import { fixture } from "../test-utilities/fixture"; import { DOM } from "@ni/fast-element"; +chai.use(spies); + const FASTAnchoredRegion = AnchoredRegion.compose({ baseName: "anchored-region", template @@ -67,4 +70,35 @@ describe("Anchored Region", () => { await disconnect(); }); + + it("should attach scroll listeners to ancestor dialog on connect and remove them on disconnect", async () => { + const dialog = document.createElement("dialog"); + dialog.id = "dialog-viewport"; + + const { element, connect, disconnect } = await fixture(FASTAnchoredRegion(), { parent: dialog }); + + const button = document.createElement("button"); + button.id = "dialog-anchor"; + dialog.insertBefore(button, element); + + element.setAttribute("anchor", "dialog-anchor"); + element.setAttribute("viewport", "dialog-viewport"); + element.setAttribute("auto-update-mode", "auto"); + + const addListenerSpy = chai.spy.on(dialog, "addEventListener"); + + await connect(); + + expect(addListenerSpy).to.have.been.called.with("scroll"); + + chai.spy.restore(dialog, "addEventListener"); + + const removeListenerSpy = chai.spy.on(dialog, "removeEventListener"); + + await disconnect(); + + expect(removeListenerSpy).to.have.been.called.with("scroll"); + + chai.spy.restore(dialog, "removeEventListener"); + }); }); diff --git a/packages/web-components/fast-foundation/src/anchored-region/anchored-region.ts b/packages/web-components/fast-foundation/src/anchored-region/anchored-region.ts index 91cb4e75bad..de8b7d09b65 100644 --- a/packages/web-components/fast-foundation/src/anchored-region/anchored-region.ts +++ b/packages/web-components/fast-foundation/src/anchored-region/anchored-region.ts @@ -426,12 +426,12 @@ export class AnchoredRegion extends FoundationElement { */ public disconnectedCallback(): void { super.disconnectedCallback(); - this.ancestorDialog = null; if (this.autoUpdateMode === "auto") { this.stopAutoUpdateEventListeners(); } this.stopObservers(); this.disconnectResizeDetector(); + this.ancestorDialog = null; } /** @@ -1319,9 +1319,9 @@ export class AnchoredRegion extends FoundationElement { * starts event listeners that can trigger auto updating */ private startAutoUpdateEventListeners = (): void => { - const eventTarget = this.ancestorDialog ?? window; - eventTarget.addEventListener(eventResize, this.update, { passive: true }); - eventTarget.addEventListener(eventScroll, this.update, { + const windowOrDialog = this.ancestorDialog ?? window; + window.addEventListener(eventResize, this.update, { passive: true }); + windowOrDialog.addEventListener(eventScroll, this.update, { passive: true, capture: true, }); @@ -1334,9 +1334,11 @@ export class AnchoredRegion extends FoundationElement { * stops event listeners that can trigger auto updating */ private stopAutoUpdateEventListeners = (): void => { - const eventTarget = this.ancestorDialog ?? window; - eventTarget.removeEventListener(eventResize, this.update); - eventTarget.removeEventListener(eventScroll, this.update); + const windowOrDialog = this.ancestorDialog ?? window; + window.removeEventListener(eventResize, this.update); + windowOrDialog.removeEventListener(eventScroll, this.update, { + capture: true, + }); if (this.resizeDetector !== null && this.viewportElement !== null) { this.resizeDetector.unobserve(this.viewportElement); } diff --git a/packages/web-components/fast-foundation/src/utilities/composed-parent.ts b/packages/web-components/fast-foundation/src/utilities/composed-parent.ts index eac81f22e17..fcbfbf37cf9 100644 --- a/packages/web-components/fast-foundation/src/utilities/composed-parent.ts +++ b/packages/web-components/fast-foundation/src/utilities/composed-parent.ts @@ -4,6 +4,7 @@ * element of the shadow root. Otherwise it will return the parent node or null if * no parent node exists. * @param element - The element for which to retrieve the composed parent + * @param asSlotted - When true, returns the element's assignedSlot (if any) as the composed parent. * * @public */ @@ -26,6 +27,7 @@ function shadowDomHost(element: HTMLElement): HTMLElement | null { return null; } +/** @internal */ export function closestAncestorDialog(element: HTMLElement): HTMLDialogElement | null { let node: HTMLElement | null = composedParent(element, true); while (node) { From dd0a941eae5999caccaa4190887b1dbd73f0ecba Mon Sep 17 00:00:00 2001 From: Mert Akinc <7282195+m-akinc@users.noreply.github.com> Date: Wed, 8 Jul 2026 12:58:35 -0500 Subject: [PATCH 04/10] Add third-party top-layer-observer library --- .../third-party/top-layer-observer/.gitignore | 4 + .../third-party/top-layer-observer/LICENSE.md | 21 ++ .../third-party/top-layer-observer/README.md | 111 +++++++ .../top-layer-observer/package.json | 33 +++ .../top-layer-observer/src/core.js | 275 ++++++++++++++++++ .../src/topLayerObserver.js | 36 +++ .../top-layer-observer/src/types.d.ts | 28 ++ 7 files changed, 508 insertions(+) create mode 100644 packages/third-party/top-layer-observer/.gitignore create mode 100644 packages/third-party/top-layer-observer/LICENSE.md create mode 100644 packages/third-party/top-layer-observer/README.md create mode 100644 packages/third-party/top-layer-observer/package.json create mode 100644 packages/third-party/top-layer-observer/src/core.js create mode 100644 packages/third-party/top-layer-observer/src/topLayerObserver.js create mode 100644 packages/third-party/top-layer-observer/src/types.d.ts diff --git a/packages/third-party/top-layer-observer/.gitignore b/packages/third-party/top-layer-observer/.gitignore new file mode 100644 index 00000000000..76f7c577f1c --- /dev/null +++ b/packages/third-party/top-layer-observer/.gitignore @@ -0,0 +1,4 @@ +/.vscode/ +/unused/ +/node_modules/ +/package-lock.json diff --git a/packages/third-party/top-layer-observer/LICENSE.md b/packages/third-party/top-layer-observer/LICENSE.md new file mode 100644 index 00000000000..2395382e2fe --- /dev/null +++ b/packages/third-party/top-layer-observer/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Martin Winkler + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/third-party/top-layer-observer/README.md b/packages/third-party/top-layer-observer/README.md new file mode 100644 index 00000000000..104ec1dbe4a --- /dev/null +++ b/packages/third-party/top-layer-observer/README.md @@ -0,0 +1,111 @@ +# top layer observer + +The `top-layer-observer` tracks the content of the top layer; it offers web developers the means to... +1. check at any time which elements are displayed in the top layer, in what order, and which web platform mechanism promoted each of them into the top layer (_modal dialog_, _popover_, _fullscreen_, _customized select dropdown_) +2. register a callback that is called when top layer content is added or removed + +```js +import { TopLayerObserver } from 'top-layer-observer' + +const printTopLayerStats = (currentTopLayerStack) => { + if (currentTopLayerStack.length === 0) { + console.log("The top layer is empty."); + } else { + console.log(`There are currently ${currentTopLayerStack.length} different entries in the top layer`); + const topEntry = currentTopLayerStack.at(-1); + console.log(`The top-most entry was caused by: ${topEntry.cause}`); + console.log("The corresponding element is:"); + console.log(topEntry.element); + } +}; + +printTopLayerStats(TopLayerObserver.currentTopLayerStack); + +const observer = new TopLayerObserver((observation, observer) => { + console.log("The top layer changed."); + printTopLayerStats(observation.currentTopLayerStack); +}); +observer.observe(); +``` + +## motivation + +Having more control over the top layer, or at least a way to know whether a given top layer entry is obscured by other content (and ideally to get notified when this fact changes), is a much requested feature in open WHATWG discussions. +- [API to get top layer elements](https://github.com/whatwg/html/issues/9075) +- [Expose an API to get the top layer element](https://github.com/whatwg/html/issues/8783) +- [Expose a stack of blocking elements](https://github.com/whatwg/html/issues/897) +- [Feature Request: Top Layer Stack Management](https://github.com/whatwg/html/issues/10370) + +This package is an attempt to solve the immediate need (albeit in user land, therefore with limitations) and to test whether an API that follows the established _Observer pattern_ is a good fit; it might thereby give new impetus to the WHATWG feature discussion. + +## API + +The API is modelled on other web platform _Observer_ APIs; the callback is provided as a constructor argument; the observer is started via `.observe()` and stopped via `.disconnect()`. + +The package has only one export: the `TopLayerObserver` + +```js +import { TopLayerObserver } from 'top-layer-observer' +``` + +### TopLayerObserver.currentTopLayerStack + +```js +TopLayerObserver.currentTopLayerStack +``` + +Static getter to access the current top layer stack, which is an array of `TopLayerEntry` instances. +The result is identical to the result of the instance accessor of the same name. + +### Constructor + +```js +new TopLayerObserver(callback) + +new TopLayerObserver((observation, observer) => {...}) +``` + +You must provide a callback to the constructor. The callback will be invoked when top layer changes are detected. +The callback will receive two arguments: +- `observation`: a plain object that contains the property `currentTopLayerStack` (which is an array of `TopLayerEntry` instances) +- `observer`: reference to the `TopLayerObserver` instance + +### instance.observe() + +Call `.observe()` to begin watching for top layer changes. + +### instance.disconnect() + +Call `.disconnect()` to stop watching for top layer changes. After `disconnect` you can call `observe` again to continue watching for top layer changes. + +### instance.currentTopLayerStack + +Instance getter to access the current top layer stack, which is an array of `TopLayerEntry` instances. +The result is identical to the result of the static accessor of the same name. + +### TopLayerEntry + +A `TopLayerEntry` object represents an entry in the current top layer. It has two properties: +- `element`: reference to the corresponding DOM element. +- `cause`: a string that identifies the web platform mechanism that caused the top layer promotion + `'dialog.modal' | 'fullscreen' | 'popover' | 'select.popover'` + +The `TopLayerEntry` object only holds a weak reference to the DOM element, therefore keeping a strong reference to the `TopLayerEntry` object itself does not create any risk for memory leaks, the weak reference is dereferenced when `TopLayerEntry.element` is accessed and returns a strong reference again, so don't hold on to those references longer than needed. + +## Capabilities and Limitations + +Currently four different ways exist to promote elements to the top layer +- opening a modal `` +- opening a `popover` +- requesting `fullscreen` +- opening the picker dropdown of a customized `` - -Each of the four mechanisms can concern elements in the regular DOM as well as elements in shadow DOM. -Currently `top-layer-observer` is capable of detecting any top layer content caused by any of those four mechanisms... -- when in the regular DOM -- when in _open_ shadow DOM -...but not when in _closed_ shadow DOM. - -## Feedback - -Any feedback is welcome; if you encounter any bugs please report them. diff --git a/packages/third-party/top-layer-observer/package.json b/packages/third-party/top-layer-observer/package.json deleted file mode 100644 index 682f960a50c..00000000000 --- a/packages/third-party/top-layer-observer/package.json +++ /dev/null @@ -1,33 +0,0 @@ -{ - "name": "top-layer-observer", - "version": "0.1.0", - "description": "Scripting API to query the document's top layer and observe changes, i.e. elements entering and leaving the top layer", - "author": { - "name": "Martin Winkler", - "url": "https://github.com/teetotum" - }, - "keywords": [ - "top layer", - "observer", - "control", - "inspect", - "query", - "watch" - ], - "homepage": "https://github.com/teetotum/top-layer-observer", - "bugs": "https://github.com/teetotum/top-layer-observer/issues", - "repository": { - "type": "git", - "url": "git+https://github.com/teetotum/top-layer-observer.git" - }, - "license": "MIT", - "type": "module", - "main": "./src/topLayerObserver.js", - "types": "./src/types.d.ts", - "files": [ - "src" - ], - "dependencies": { - "select-events": "0.2.1" - } -} diff --git a/packages/third-party/top-layer-observer/src/core.js b/packages/third-party/top-layer-observer/src/core.js deleted file mode 100644 index 02546364713..00000000000 --- a/packages/third-party/top-layer-observer/src/core.js +++ /dev/null @@ -1,275 +0,0 @@ -import { observeGlobally } from "select-events/core"; - -class EntryWrapper { - #weakRef - #cause - - constructor(weakRef, cause) { - this.#weakRef = weakRef; - this.#cause = cause; - } - - get element() { - return this.#weakRef.deref(); - } - - get cause() { - return this.#cause; - } -} - -export const setupWatcher = (onChange) => { - const popovers = new WeakSet(); - const modalDialogs = new WeakSet(); - const nonModalDialogs = new WeakSet(); - const details = new WeakSet(); - const selectPickerPopovers = new WeakSet(); - const selectPickerNonPopovers = new WeakSet(); - const shadowRoots = new WeakSet(); - let focusScanBookedIn_modalDialogs = new WeakSet(); - let focusScanBookedIn_popovers = new WeakSet(); - const resetFocusScanBookedInRefs = () => { - focusScanBookedIn_modalDialogs = new WeakSet(); - focusScanBookedIn_popovers = new WeakSet(); - }; - - const bookIn = (element, cause) => { - switch (cause) { - case 'popover': - popovers.add(element); - break; - case 'dialog.modal': - modalDialogs.add(element); - break; - case 'dialog.default': - nonModalDialogs.add(element); - break; - case 'details.default': - details.add(element); - break; - case 'fullscreen': - // do nothing, we don't need to keep references for this case - break; - case 'select.popover': - selectPickerPopovers.add(element); - break; - case 'select.default': - selectPickerNonPopovers.add(element); - break; - default: throw new Error('not implemented'); - } - } - - const bookOut = (element, cause) => { - switch (cause) { - case 'popover': - popovers.delete(element); - break; - case 'dialog.modal': - modalDialogs.delete(element); - break; - case 'dialog.default': - nonModalDialogs.delete(element); - break; - case 'details.default': - details.delete(element); - break; - case 'fullscreen': - // do nothing, we don't need to keep references for this case - break; - case 'select.popover': - selectPickerPopovers.delete(element); - break; - case 'select.default': - selectPickerNonPopovers.delete(element); - break; - default: throw new Error('not implemented'); - } - } - - const causedByPopoverOpened = (event) => - event.newState === 'open' && event.target.matches(':popover-open'); - const causedByPopoverClosed = (event) => - event.newState === 'closed' && popovers.has(event.target); - - - const causedByModalDialogOpened = (event) => - event.newState === 'open' && event.target.tagName === 'DIALOG' && event.target.matches(':modal'); - const causedByModalDialogClosed = (event) => - event.newState === 'closed' && event.target.tagName === 'DIALOG' && modalDialogs.has(event.target); - - - const causedByNonModalDialogOpened = (event) => - event.newState === 'open' && event.target.tagName === 'DIALOG' && !event.target.matches(':modal') && !event.target.matches(':popover-open'); - const causedByNonModalDialogClosed = (event) => - event.newState === 'closed' && event.target.tagName === 'DIALOG' && nonModalDialogs.has(event.target); - - - const causedByDetailsOpened = (event) => - event.newState === 'open' && event.target.tagName === 'DETAILS' && !event.target.matches(':popover-open'); - const causedByDetailsClosed = (event) => - event.newState === 'closed' && event.target.tagName === 'DETAILS' && details.has(event.target); - - - const causedBySelectPickerPopoverOpened = (element, isBaseSelect, selectOpened) => selectOpened && isBaseSelect; - const causedBySelectPickerPopoverClosed = (element, isBaseSelect, selectOpened) => !selectOpened && selectPickerPopovers.has(element); - - const causedBySelectPickerLegacyOpened = (element, isBaseSelect, selectOpened) => selectOpened && !isBaseSelect; - const causedBySelectPickerLegacyClosed = (element, isBaseSelect, selectOpened) => !selectOpened && selectPickerNonPopovers.has(element); - - const currentState = []; - let currentStateForExternalConsumers = []; - const updateStateForExternalConsumersAndNotify = () => { - currentStateForExternalConsumers = Object.freeze(currentState.map((x) => new EntryWrapper(x.ref, x.cause))); - onChange(); - }; - - const handleEnterTopLayer = (target, cause) => { - const existingEntryIndex = currentState.findIndex(entry => entry.ref.deref() === target); - if (existingEntryIndex > -1) { - const existingEntry = currentState[existingEntryIndex]; - currentState.splice(existingEntryIndex, 1); - bookOut(existingEntry.ref.deref(), existingEntry.cause); - } - const newEntry = { - ref: new WeakRef(target), - cause, - } - currentState.push(newEntry); - bookIn(target, cause); - updateStateForExternalConsumersAndNotify(); - }; - - const handleExitTopLayer = (target, cause, silent = false) => { - const existingEntryIndex = currentState.findIndex(entry => entry.ref.deref() === target); - if (existingEntryIndex === -1) { - return; - } - const existingEntry = currentState[existingEntryIndex]; - currentState.splice(existingEntryIndex, 1); - bookOut(existingEntry.ref.deref(), existingEntry.cause); - if (!silent) updateStateForExternalConsumersAndNotify(); - }; - - const handleRemoved = (mutationRecords) => { - const deletedNodes = new Set(mutationRecords.flatMap(mutationRecord => [...mutationRecord.removedNodes])); - const topLayerNodes = new Set(currentState.map(entry => entry.ref.deref())); - const deletedTopLayerNodes = topLayerNodes.intersection(deletedNodes); - deletedTopLayerNodes.forEach((node) => handleExitTopLayer(node, '', true)); - updateStateForExternalConsumersAndNotify(); - }; - - const deletedNodesObserver = new MutationObserver(handleRemoved); - deletedNodesObserver.observe(document, { childList: true, subtree: true }); - - const abortController = new AbortController(); - - const toggleEventHandler = (event) => { - if (causedByPopoverOpened(event)) { - if (!focusScanBookedIn_popovers.has(event.target)) handleEnterTopLayer(event.target, 'popover'); - } else if (causedByPopoverClosed(event)) { - handleExitTopLayer(event.target, 'popover'); - } else if (causedByModalDialogOpened(event)) { - if (!focusScanBookedIn_modalDialogs.has(event.target)) handleEnterTopLayer(event.target, 'dialog.modal'); - } else if (causedByModalDialogClosed(event)) { - handleExitTopLayer(event.target, 'dialog.modal'); - } else if (causedByNonModalDialogOpened(event)) { - bookIn(event.target, 'dialog.default'); - } else if (causedByNonModalDialogClosed(event)) { - bookOut(event.target, 'dialog.default'); - } else if (causedByDetailsOpened(event)) { - bookIn(event.target, 'details.default'); - } else if (causedByDetailsClosed(event)) { - bookOut(event.target, 'details.default'); - } else { - console.warn(`Top-Layer-Observer :: Unhandled Case :: toggle event originated in ${event.target.tagName} from unknown cause.`) - }; - }; - - const watchShadowRoot = (shadowRoot) => { - if (!shadowRoots.has(shadowRoot)) { - shadowRoots.add(shadowRoot); - shadowRoot.addEventListener('toggle', toggleEventHandler, { capture: true, signal: abortController.signal }); - } - }; - - document.addEventListener('toggle', toggleEventHandler, { capture: true, signal: abortController.signal }); - - // we need a focus listener because 'toggle' events are not composed (that's a real bummer), - // so in order to not miss dialogs/popovers in shadow DOM we need to listen for focus events - // and when we thereby discover a shadowRoot we attach a 'toggle' listener for the shadowDOM - // and scan the shadowDOM for any open dialogs or popups that might have already fired their opening toggle event. - // As far as I can tell from the observations I made, it seems that the focus event will actually precede the toggle (newState: open) event. - // But I don't trust that I can rely on that being always the case, and therefore run the scan upon focus. - document.addEventListener('focus', (event) => { - if (event.target.shadowRoot) { - watchShadowRoot(event.target.shadowRoot); - const untrackedOpenModalDialogs = []; - const untrackedOpenPopovers = []; - const findTopLayerCausesInShadowDOM = (rootNode) => { - // search for open! modal! dialogs! that we have not yet booked in! - // and that have been opened via .showModal() and not via .requestFullscreen() - rootNode.querySelectorAll('dialog:modal:not(:fullscreen)').forEach((dialog) => { - if (!modalDialogs.has(dialog)) untrackedOpenModalDialogs.push(dialog); - }); - // search for open! popovers! that we have not yet booked in! - rootNode.querySelectorAll('[popover]:popover-open').forEach((popover) => { - if (!popovers.has(popover)) untrackedOpenPopovers.push(popover); - }); - rootNode.querySelectorAll('*').forEach((child) => { - if (child.shadowRoot) { - watchShadowRoot(child.shadowRoot); - findTopLayerCausesInShadowDOM(child.shadowRoot); - } - }); - } - findTopLayerCausesInShadowDOM(event.target.shadowRoot); - untrackedOpenModalDialogs.forEach((dialog) => { - focusScanBookedIn_modalDialogs.add(dialog); - handleEnterTopLayer(dialog, 'dialog.modal'); - }); - untrackedOpenPopovers.forEach((popover) => { - focusScanBookedIn_popovers.add(popover); - handleEnterTopLayer(popover, 'popover'); - }); - window.setTimeout(resetFocusScanBookedInRefs); - } - }, { capture: true, signal: abortController.signal }); - - document.addEventListener('fullscreenchange', (event) => { - const isEnteringFullscreenMode = document.fullscreenElement !== null; - if (isEnteringFullscreenMode) { - handleEnterTopLayer(event.target, 'fullscreen'); - } else { - handleExitTopLayer(event.target, 'fullscreen'); - } - }, { capture: true, signal: abortController.signal }); - - const { disconnect: stopPickerObserver } = observeGlobally((element, selectOpened) => { - const selectStyle = window.getComputedStyle(element); - const pickerStyle = window.getComputedStyle(element, '::picker(select)'); - const isBaseSelect = selectStyle.getPropertyValue('appearance') === 'base-select' && pickerStyle.getPropertyValue('appearance') === 'base-select'; - - if (causedBySelectPickerPopoverOpened(element, isBaseSelect, selectOpened)) { - handleEnterTopLayer(element, 'select.popover'); - } else if (causedBySelectPickerPopoverClosed(element, isBaseSelect, selectOpened)) { - handleExitTopLayer(element, 'select.popover'); - } else if (causedBySelectPickerLegacyOpened(element, isBaseSelect, selectOpened)) { - bookIn(element, 'select.default'); - } else if (causedBySelectPickerLegacyClosed(element, isBaseSelect, selectOpened)) { - bookOut(element, 'select.default'); - } else { - console.warn(`Top-Layer-Observer :: Unhandled Case :: select picker opening/closing :: neither 'select.popover' nor 'select.default'`) - }; - }); - - const shutdown = () => { - deletedNodesObserver.disconnect(); - abortController.abort(); - stopPickerObserver(); - }; - - const getCurrentState = () => currentStateForExternalConsumers; - - return { shutdown, getCurrentState }; -} diff --git a/packages/third-party/top-layer-observer/src/topLayerObserver.js b/packages/third-party/top-layer-observer/src/topLayerObserver.js deleted file mode 100644 index 43efa5bef21..00000000000 --- a/packages/third-party/top-layer-observer/src/topLayerObserver.js +++ /dev/null @@ -1,36 +0,0 @@ -import { setupWatcher } from './core.js' - -const TOP_LAYER_CHANGE = 'top-layer-change'; -const eventEmitter = new EventTarget(); -const { shutdown, getCurrentState } = setupWatcher( - () => eventEmitter.dispatchEvent(new Event(TOP_LAYER_CHANGE)) -); - -export class TopLayerObserver { - - #callback - #listener - - static get currentTopLayerStack() { - return getCurrentState(); - } - - constructor(callback) { - this.#callback = callback; - this.#listener = () => { - callback({ currentTopLayerStack: TopLayerObserver.currentTopLayerStack }, this); - }; - } - - get currentTopLayerStack() { - return TopLayerObserver.currentTopLayerStack; - } - - observe() { - eventEmitter.addEventListener(TOP_LAYER_CHANGE, this.#listener); - } - - disconnect() { - eventEmitter.removeEventListener(TOP_LAYER_CHANGE, this.#listener); - } -} diff --git a/packages/third-party/top-layer-observer/src/types.d.ts b/packages/third-party/top-layer-observer/src/types.d.ts deleted file mode 100644 index 2452494599d..00000000000 --- a/packages/third-party/top-layer-observer/src/types.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -export type TopLayerEntryCause = 'dialog.modal' | 'fullscreen' | 'popover' | 'select.popover' - -export interface TopLayerEntry { - readonly element: Element; - readonly cause: TopLayerEntryCause; -} - -export type TopLayerStack = Array; - -export interface TopLayerObservation { - readonly currentTopLayerStack: TopLayerStack; -} - -export interface TopLayerCallback { - (observation: TopLayerObservation, observer: TopLayerObserver): void; -} - -export interface TopLayerObserver { - readonly currentTopLayerStack: TopLayerStack; - observe(): void; - disconnect(): void; -} - -declare var TopLayerObserver: { - prototype: TopLayerObserver; - new(callback: TopLayerCallback): TopLayerObserver; - readonly currentTopLayerStack: TopLayerStack; -}; From 2d91fb7c2f281974ead1d2bb052e000539ae45ce Mon Sep 17 00:00:00 2001 From: Mert Akinc <7282195+m-akinc@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:48:03 -0500 Subject: [PATCH 08/10] Remove old workspace --- package-lock.json | 1 - package.json | 1 - 2 files changed, 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 858fe944bc6..722918dd1e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "packages/utilities/fast-eslint-rules", "packages/utilities/fast-colors", "packages/utilities/fast-web-utilities", - "packages/third-party/top-layer-observer", "packages/web-components/fast-element", "packages/web-components/fast-foundation", "packages/utilities/fast-react-wrapper" diff --git a/package.json b/package.json index 72aa3dccdfa..f9da6ad3da7 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,6 @@ "packages/utilities/fast-eslint-rules", "packages/utilities/fast-colors", "packages/utilities/fast-web-utilities", - "packages/third-party/top-layer-observer", "packages/web-components/fast-element", "packages/web-components/fast-foundation", "packages/utilities/fast-react-wrapper" From e156fbb30c7e93948f13dd9f14945bcc45a8ae24 Mon Sep 17 00:00:00 2001 From: Mert Akinc <7282195+m-akinc@users.noreply.github.com> Date: Thu, 9 Jul 2026 17:48:32 -0500 Subject: [PATCH 09/10] Refactor tests per request --- .../anchored-region/anchored-region.spec.ts | 239 ++++++++++-------- 1 file changed, 127 insertions(+), 112 deletions(-) diff --git a/packages/web-components/fast-foundation/src/anchored-region/anchored-region.spec.ts b/packages/web-components/fast-foundation/src/anchored-region/anchored-region.spec.ts index f70187c9861..68302c3f720 100644 --- a/packages/web-components/fast-foundation/src/anchored-region/anchored-region.spec.ts +++ b/packages/web-components/fast-foundation/src/anchored-region/anchored-region.spec.ts @@ -37,8 +37,6 @@ async function setup(parentElement?: HTMLElement) { } describe("Anchored Region", () => { - afterEach(() => chai.spy.restore()); - it("should set positioning modes to 'uncontrolled' by default", async () => { const { element, connect, disconnect } = await setup(); @@ -74,138 +72,155 @@ describe("Anchored Region", () => { await disconnect(); }); - interface ScrollListenerTestOptions { - createParent: () => HTMLElement, - show: (parent: HTMLElement) => void, - hide: (parent: HTMLElement) => void, - expectListensOnParent: boolean - } + describe("scroll event listener", () => { + let parentAddListenerSpy: any; + let parentRemoveListenerSpy: any; + let windowAddListenerSpy: any; + let windowRemoveListenerSpy: any; + + function setupSpies(parent: HTMLElement) { + parentAddListenerSpy = chai.spy.on(parent, "addEventListener"); + parentRemoveListenerSpy = chai.spy.on(parent, "removeEventListener"); + windowAddListenerSpy = chai.spy.on(window, "addEventListener"); + windowRemoveListenerSpy = chai.spy.on(window, "removeEventListener"); + } - async function scrollListenerTest({ createParent, show, hide, expectListensOnParent }: ScrollListenerTestOptions) { - const parent = createParent(); - const { element, connect, disconnect } = await setup(parent); + function assertListenedOnTopLayerElement() { + expect(windowRemoveListenerSpy).not.to.have.been.called.with("scroll"); + expect(windowAddListenerSpy).not.to.have.been.called.with("scroll"); + expect(parentAddListenerSpy).to.have.been.called.with("scroll"); + expect(parentRemoveListenerSpy).to.have.been.called.with("scroll"); + } - // We will re-attach the anchored region after displaying the parent. - element.remove(); + function assertListenedOnWindow() { + expect(parentRemoveListenerSpy).not.to.have.been.called.with("scroll"); + expect(parentAddListenerSpy).not.to.have.been.called.with("scroll"); + expect(windowAddListenerSpy).to.have.been.called.with("scroll"); + expect(windowRemoveListenerSpy).to.have.been.called.with("scroll"); + } - const parentAddListenerSpy = chai.spy.on(parent, "addEventListener"); - const parentRemoveListenerSpy = chai.spy.on(parent, "removeEventListener"); - const windowAddListenerSpy = chai.spy.on(window, "addEventListener"); - const windowRemoveListenerSpy = chai.spy.on(window, "removeEventListener"); + function assertChangedToListeningOnWindow() { + expect(windowRemoveListenerSpy).not.to.have.been.called.with("scroll"); + expect(parentAddListenerSpy).not.to.have.been.called.with("scroll"); + expect(parentRemoveListenerSpy).to.have.been.called.with("scroll"); + expect(windowAddListenerSpy).to.have.been.called.with("scroll"); + } - await connect(); - show(parent); - parent.appendChild(element); - hide(parent); - await disconnect(); + function assertChangedToListeningOnTopLayerElement() { + expect(parentRemoveListenerSpy).not.to.have.been.called.with("scroll"); + expect(windowAddListenerSpy).not.to.have.been.called.with("scroll"); + expect(windowRemoveListenerSpy).to.have.been.called.with("scroll"); + expect(parentAddListenerSpy).to.have.been.called.with("scroll"); + } + + async function setupScrollListenerTest(parent: HTMLElement) { + const { element, connect, disconnect } = await setup(parent); + + // We will re-attach the anchored region after displaying the parent. + element.remove(); + setupSpies(parent); + await connect(); + + return { region: element, disconnect }; + } - const expectedAddSpy = expectListensOnParent ? parentAddListenerSpy : windowAddListenerSpy; - const expectedRemoveSpy = expectListensOnParent ? parentRemoveListenerSpy : windowRemoveListenerSpy; - const notExpectedAddSpy = expectListensOnParent ? windowAddListenerSpy : parentAddListenerSpy; - const notExpectedRemoveSpy = expectListensOnParent ? windowRemoveListenerSpy : parentRemoveListenerSpy; - - expect(notExpectedRemoveSpy).not.to.have.been.called.with("scroll"); - expect(notExpectedAddSpy).not.to.have.been.called.with("scroll"); - expect(expectedAddSpy).to.have.been.called.with("scroll"); - expect(expectedRemoveSpy).to.have.been.called.with("scroll"); - } - - it("should attach/detach scroll listener to ancestor modal dialog instead of window", async () => { - await scrollListenerTest({ - createParent: () => document.createElement("dialog"), - show: (dialog: HTMLDialogElement) => dialog.showModal(), - hide: (dialog: HTMLDialogElement) => dialog.close(), - expectListensOnParent: true + afterEach(() => chai.spy.restore()); + + it("should be on containing modal dialog instead of window", async () => { + const dialog = document.createElement("dialog"); + const { region, disconnect } = await setupScrollListenerTest(dialog); + dialog.showModal(); + + dialog.appendChild(region); // adds scroll listener + region.remove(); // removes scroll listener + + dialog.close(); + await disconnect(); + + assertListenedOnTopLayerElement(); }); - }); - it("should attach scroll listener to window when ancestor dialog is not modal", async () => { - await scrollListenerTest({ - createParent: () => document.createElement("dialog"), - show: (dialog: HTMLDialogElement) => dialog.show(), - hide: (dialog: HTMLDialogElement) => dialog.close(), - expectListensOnParent: false + it("should be on window when in non-modal dialog", async () => { + const dialog = document.createElement("dialog"); + const { region, disconnect } = await setupScrollListenerTest(dialog); + dialog.show(); + + dialog.appendChild(region); // adds scroll listener + region.remove(); // removes scroll listener + + dialog.close(); + await disconnect(); + + assertListenedOnWindow(); }); - }); - it("should attach/detach scroll listener to ancestor open popover instead of window", async () => { - await scrollListenerTest({ - createParent: () => { - const div = document.createElement("div"); - div.setAttribute("popover", ""); - return div; - }, - show: (element) => element.showPopover(), - hide: (element) => element.hidePopover(), - expectListensOnParent: true + it("should be on containing popover element instead of window", async () => { + const popoverDiv = document.createElement("div"); + popoverDiv.setAttribute("popover", ""); + const { region, disconnect } = await setupScrollListenerTest(popoverDiv); + popoverDiv.showPopover(); + + popoverDiv.appendChild(region); // adds scroll listener + region.remove(); // removes scroll listener + + popoverDiv.hidePopover(); + await disconnect(); + + assertListenedOnTopLayerElement(); }); - }); - it("should attach/detach scroll listener to ancestor fullscreen element instead of window", async () => { - await scrollListenerTest({ - createParent: () => document.createElement("div"), - show: (element) => { - Object.defineProperty(document, "fullscreenElement", { - get: () => element, - configurable: true, - }); - }, - hide: () => { - Object.defineProperty(document, "fullscreenElement", { - get: () => null, - configurable: true, - }); - }, - expectListensOnParent: true + it("should be on containing fullscreen element instead of window", async () => { + const fullscreenDiv = document.createElement("div"); + const { region, disconnect } = await setupScrollListenerTest(fullscreenDiv); + Object.defineProperty(document, "fullscreenElement", { + get: () => fullscreenDiv, + configurable: true, + }); + + fullscreenDiv.appendChild(region); // adds scroll listener + region.remove(); // removes scroll listener + + Object.defineProperty(document, "fullscreenElement", { + get: () => null, + configurable: true, + }); + await disconnect(); + + assertListenedOnTopLayerElement(); }); - }); - async function topLayerTransitionTest(startInTopLayer: boolean) { - const dialog = document.createElement("dialog"); - const { element, connect, disconnect } = await setup(dialog); + it("should switch to containing dialog when it is shown as modal", async () => { + const dialog = document.createElement("dialog"); + const { connect, disconnect } = await setup(dialog); + await connect(); + setupSpies(dialog); + + // Move into top layer + dialog.showModal(); + await DOM.nextUpdate(); + + assertChangedToListeningOnTopLayerElement(); + + dialog.close(); + await disconnect(); + }); - if (startInTopLayer) { + it("should switch to window when containing dialog stops being modal", async () => { + const dialog = document.createElement("dialog"); + const { element, connect, disconnect } = await setup(dialog); element.remove(); await connect(); dialog.showModal(); dialog.appendChild(element); - } else { - await connect(); - } + setupSpies(dialog); - const dialogAddListenerSpy = chai.spy.on(dialog, "addEventListener"); - const dialogRemoveListenerSpy = chai.spy.on(dialog, "removeEventListener"); - const windowAddListenerSpy = chai.spy.on(window, "addEventListener"); - const windowRemoveListenerSpy = chai.spy.on(window, "removeEventListener"); - - if (startInTopLayer) { // Move out of top layer dialog.close(); - } else { - // Move into top layer - dialog.showModal(); - } - await DOM.nextUpdate(); + await DOM.nextUpdate(); - const expectedRemoveSpy = startInTopLayer ? dialogRemoveListenerSpy : windowRemoveListenerSpy; - const expectedAddSpy = startInTopLayer ? windowAddListenerSpy : dialogAddListenerSpy; - const notExpectedAddSpy = startInTopLayer ? dialogAddListenerSpy : windowAddListenerSpy; - const notExpectedRemoveSpy = startInTopLayer ? windowRemoveListenerSpy : dialogRemoveListenerSpy; + assertChangedToListeningOnWindow(); - expect(notExpectedRemoveSpy).not.to.have.been.called.with("scroll"); - expect(notExpectedAddSpy).not.to.have.been.called.with("scroll"); - expect(expectedRemoveSpy).to.have.been.called.with("scroll"); - expect(expectedAddSpy).to.have.been.called.with("scroll"); - - dialog.close(); - await disconnect(); - } - - it("should move scroll listener to dialog when it enters the top layer", async () => { - await topLayerTransitionTest(false); - }); - - it("should move scroll listener to window when dialog leaves the top layer", async () => { - await topLayerTransitionTest(true); + await disconnect(); + }); }); }); From 9fc3e0bb24366f31325324d420203bd3e47cd1e0 Mon Sep 17 00:00:00 2001 From: Mert Akinc <7282195+m-akinc@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:21:18 -0500 Subject: [PATCH 10/10] Avoid crash on Node.js --- .../fast-foundation/rollup.config.js | 2 ++ .../src/anchored-region/anchored-region.ts | 14 +++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/web-components/fast-foundation/rollup.config.js b/packages/web-components/fast-foundation/rollup.config.js index 7b604bbf141..20064682dfd 100644 --- a/packages/web-components/fast-foundation/rollup.config.js +++ b/packages/web-components/fast-foundation/rollup.config.js @@ -12,10 +12,12 @@ export default [ { file: "dist/fast-foundation.js", format: "esm", + inlineDynamicImports: true, }, { file: "dist/fast-foundation.min.js", format: "esm", + inlineDynamicImports: true, plugins: [terser()], }, ], diff --git a/packages/web-components/fast-foundation/src/anchored-region/anchored-region.ts b/packages/web-components/fast-foundation/src/anchored-region/anchored-region.ts index 5a3636d6da6..d8d453786df 100644 --- a/packages/web-components/fast-foundation/src/anchored-region/anchored-region.ts +++ b/packages/web-components/fast-foundation/src/anchored-region/anchored-region.ts @@ -1,6 +1,6 @@ import { attr, DOM, observable } from "@ni/fast-element"; import { Direction, eventResize, eventScroll } from "@ni/fast-web-utilities"; -import { TopLayerObserver } from "top-layer-observer"; +import type { TopLayerCallback, TopLayerObserver } from "top-layer-observer"; import { FoundationElement } from "../foundation-element/foundation-element.js"; import { topLayerRootAncestor } from "../utilities/composed-parent.js"; import { getDirection } from "../utilities/direction.js"; @@ -10,6 +10,12 @@ import type { ResizeObserverEntry, } from "../utilities/resize-observer.js"; +// The "top layer" does not exist in Node.js, and the top-layer-observer module crashes when loaded in Node.js. +let topLayerObserverConstructor: (new (callback: TopLayerCallback) => TopLayerObserver) | undefined; +if (typeof document !== "undefined") { + void import("top-layer-observer").then(m => topLayerObserverConstructor = m.TopLayerObserver); +} + /** * Defines the base behavior of an anchored region on a particular axis * @@ -1322,8 +1328,10 @@ export class AnchoredRegion extends FoundationElement { private startAutoUpdateEventListeners = (): void => { window.addEventListener(eventResize, this.update, { passive: true }); this.addScrollListener(); - this.topLayerObserver ??= new TopLayerObserver(this.handleTopLayerChange); - this.topLayerObserver.observe(); + if (topLayerObserverConstructor !== undefined) { + this.topLayerObserver ??= new topLayerObserverConstructor(this.handleTopLayerChange); + this.topLayerObserver.observe(); + } if (this.resizeDetector !== null && this.viewportElement !== null) { this.resizeDetector.observe(this.viewportElement); }