Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{

@rajsite rajsite Jul 8, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Some general thoughts:

  • dialog containing elements (dialog / drawer / other) can be nested, dont' think we should assume the most direct parent is the intended target. It may be a dialog element made visible and not open, just being used as a container
  • Think what we care about is that only when we are open do we want to find the nearest open top layer element. That can be dialog el.matches(':modal') or an arbitrary element with popover api el.matches(':popover-open') (edit: I missed some too, see following comment about the top-layer-observer library). But I have not thought this through and if we want to make the assumption that while the select is open the app will not switch what is in the top-layer (though that may be reasonable).
  • The algorithm changes the scroll event and resize event target from window to the dialog, but elements don't fire resize events. Seems like it should still be registered on window. Maybe to be robust the scroll event on dialog is in addition to window instead of replacing?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Maybe the top-layer-observer library referenced here would be useful: whatwg/html#9075 (comment)
or overkill, not sure yet

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

dialog containing elements (dialog / drawer / other) can be nested, dont' think we should assume the most direct parent is the intended target. It may be a dialog element made visible and not open, just being used as a container

Assuming these events bubble all the way up, we probably want the furthest dialog (or whatever top-layer thing) ancestor. The code was already attaching listeners unconditionally, but I guess we could attach/detach the listener(s) when hidden is toggled.

elements don't fire resize events. Seems like it should still be registered on window. Maybe to be robust the scroll event on dialog is in addition to window instead of replacing?

Yeah, the copilot review caught that, too. I changed it so that we always register for the resize event on the window, but conditionally target an ancestor dialog for the scroll events. I don't think there's a reason to listen on both.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Talked offline, some things discussed:

  • Want to test for each of the known top-layer behaviors as mentioned by that library (dialog, popover api, fullscreen, but not customizable select for now since it is not cross-browser)
  • Lets start with an evaluation that minimizes assumptions. Can we use the library to observe the top-layer state and respond correctly
  • If that complexity is high a simplifying assumption may be to assume while the anchored region is open that top-layers are not changing. This is not a good assumption for every potential usage of an anchored region. Could image someone using a toast or a rich tooltip for a multistep wizard / configuration box that opens additional pop-ups etc.
  • Want to avoid coupling to implementation specific assumptions, i.e. coupling to specific attributes, etc.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I believe I've addressed all the feedback now.

  • The code is generalized to handle modal dialogs, popovers, and fullscreen elements.
  • Using the top-layer-observer library to detect when elements move in/out of the top layer and respond by moving the scroll listener, as needed.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I've done a basic analysis of the top-level-observer and the select-events transitive dep and it does seem pretty heavy weight for our use case.

top-layer-observer is tracking all focus events and doing recursive searches of the shadowroots and registering event listeners on each. I'd be very concerned with potential memory leaks on something like the table that has lots of nested shadow root trees.
select-events seems even heavier registering mutationobservers recursively on all shadowroots. That's a ton of book keeping as well.

I think we should fallback to the assumption we discussed offline that we are checking parent elements for the different types of top-layers and finding the closest open one to register scroll listeners on.

@rajsite rajsite Jul 14, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Talked offline and discussed the two alternate implementation strategies that are the alternate to top-layer-observer:

  • (less ideal, effectively rejected) in the current state we can't rely on connect/disconnect corresponding to when the anchored-region should be active. The select and combobox have the anchored region always connected and just change visibility. So we would need to track all parents potential to enter and exit the top-layer. This would require entering all parent shadow roots to observer for toggle events and likely other events for the different popover layer types
  • (ideal but requires anchored region api changes) we update the anchored region to have an active/inactive state that components use for when the anchored region should be enabled. Then rely on the assumption that while the anchored region is active we can find the current active top-layer parent if any and that while the anchored region is active the current top-layer parents don't change. It is an assumption that a parent won't enter / exit the top layer while the anchored region is open. Not a perfectly robust assumption but may be good enough. This relies on component usage of anchored region using a new api on anchored region to configure active / inactive states.
    • also discussed having components instead use anchored region as expected by the current api where they connect / disconnect in template with a when directive, etc. but seems very likely to cause issues as the child slot elements will connect/disconnect and the slotted options are required for state of the parent elements. PRobably much easier / less risky to expose a new active/inactive api on anchored region
  • (rejected) instead of using detection for what is currently active in the top-layer we assume / configure the anchored region to what is assumed possible top-layer elements to be (certain elements by tagname, etc). Undesired as it couples to specific component implementations and is not robust for an imminent future for more elements participating in popover apis in angular material, etc. Prefer a more robust / general top-layer detection strategy.

"type": "patch",
"comment": "Properly update anchored regions in dialogs",
"packageName": "@ni/fast-foundation",
"email": "7282195+m-akinc@users.noreply.github.com",
"dependentChangeType": "patch"
}
203 changes: 46 additions & 157 deletions package-lock.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/web-components/fast-foundation/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
"@ni/fast-element": "^10.1.2",
"@ni/fast-web-utilities": "^10.0.4",
"tabbable": "^6.2.0",
"top-layer-observer": "^0.1.0",
"tslib": "^2.8.1"
},
"customElements": "dist/custom-elements.json"
Expand Down
2 changes: 2 additions & 0 deletions packages/web-components/fast-foundation/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()],
},
],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
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
})

async function setup() {
const { element, connect, disconnect, parent } = await fixture(FASTAnchoredRegion());
async function setup(parentElement?: HTMLElement) {
const { element, connect, disconnect, parent } = await fixture(FASTAnchoredRegion(), { parent: parentElement });

const button = document.createElement("button");
const content = document.createElement("div");
Expand All @@ -27,6 +30,7 @@ async function setup() {
element.appendChild(content);
element.setAttribute("viewport", "viewport");
element.setAttribute("anchor", "anchor");
element.setAttribute("auto-update-mode", "auto");
element.id = "region";

return { element, connect, disconnect, content };
Expand Down Expand Up @@ -67,4 +71,156 @@ describe("Anchored Region", () => {

await disconnect();
});

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");
}

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");
}

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");
}

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");
}

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 };
}

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 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 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 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();
});

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();
});

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);
setupSpies(dialog);

// Move out of top layer
dialog.close();
await DOM.nextUpdate();

assertChangedToListeningOnWindow();

await disconnect();
});
});
});
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
import { attr, DOM, observable } from "@ni/fast-element";
import { Direction, eventResize, eventScroll } from "@ni/fast-web-utilities";
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";
import { IntersectionService } from "../utilities/intersection-service.js";
import type {
ResizeObserverClassDefinition,
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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Just to capture as a comment, likely something we should document somewhere. Nimble and transitive libraries have gone through significant effort to have a static dependency graph and should not rely on dynamic imports. Applications can for performance optimization, etc. but it should not be forced on applications at the library level. As can be seen by the related rollup config changes, it forces downstream build configuration behavior and gates certain performance characteristics (blocks pages on additional network requests) that applications should instead have control over.

}

/**
* Defines the base behavior of an anchored region on a particular axis
*
Expand Down Expand Up @@ -404,13 +412,17 @@ export class AnchoredRegion extends FoundationElement {
// justify a layout update that affects the dom (prevents repeated sub-pixel corrections)
private updateThreshold: number = 0.5;

private scrollListenerTarget: EventTarget = window;
private topLayerObserver: TopLayerObserver | null = null;

private static intersectionService: IntersectionService = new IntersectionService();

/**
* @internal
*/
connectedCallback() {
super.connectedCallback();
this.scrollListenerTarget = topLayerRootAncestor(this) ?? window;
if (this.autoUpdateMode === "auto") {
this.startAutoUpdateEventListeners();
}
Expand Down Expand Up @@ -1315,10 +1327,11 @@ export class AnchoredRegion extends FoundationElement {
*/
private startAutoUpdateEventListeners = (): void => {
window.addEventListener(eventResize, this.update, { passive: true });
window.addEventListener(eventScroll, this.update, {
passive: true,
capture: true,
});
this.addScrollListener();
if (topLayerObserverConstructor !== undefined) {
this.topLayerObserver ??= new topLayerObserverConstructor(this.handleTopLayerChange);
this.topLayerObserver.observe();
}
if (this.resizeDetector !== null && this.viewportElement !== null) {
Comment on lines 1328 to 1335
this.resizeDetector.observe(this.viewportElement);
}
Expand All @@ -1328,10 +1341,33 @@ export class AnchoredRegion extends FoundationElement {
* stops event listeners that can trigger auto updating
*/
private stopAutoUpdateEventListeners = (): void => {
this.topLayerObserver?.disconnect();
window.removeEventListener(eventResize, this.update);
window.removeEventListener(eventScroll, this.update);
this.removeScrollListener();
if (this.resizeDetector !== null && this.viewportElement !== null) {
this.resizeDetector.unobserve(this.viewportElement);
}
};

private handleTopLayerChange = (): void => {
const newValue = topLayerRootAncestor(this) ?? window;
if (newValue !== this.scrollListenerTarget) {
this.removeScrollListener();
this.scrollListenerTarget = newValue;
this.addScrollListener();
}
};

private addScrollListener = (): void => {
this.scrollListenerTarget.addEventListener(eventScroll, this.update, {
passive: true,
capture: true,
});
};

private removeScrollListener = (): void => {
this.scrollListenerTarget.removeEventListener(eventScroll, this.update, {
capture: true,
});
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,43 @@
* 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
*/
Comment thread
Copilot marked this conversation as resolved.
export function composedParent<T extends HTMLElement>(element: T): HTMLElement | null {
const parentNode = element.parentElement;
export function composedParent<T extends HTMLElement>(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;
}

/** @internal */
export function topLayerRootAncestor(element: HTMLElement): HTMLElement | null {
let parentElement: HTMLElement | null = composedParent(element, true);
while (parentElement) {
if (isTopLayerRoot(parentElement)) {
return parentElement;
}
parentElement = composedParent(parentElement, true);
}
return null;
}

function isTopLayerRoot(element: HTMLElement): boolean {
return element.matches(':popover-open')
|| element.matches(':modal')
|| document.fullscreenElement === element;
}
Comment on lines +42 to +46
Loading