From 70e9cca4d45c5b2b2df52d9ffbe2b77224383a2f Mon Sep 17 00:00:00 2001 From: Quinton Jason Date: Thu, 27 Aug 2026 15:48:19 -0500 Subject: [PATCH 1/8] feat(pds-modal): add disableTopLayer to opt out of the browser top layer --- libs/core/src/components.d.ts | 11 +++ .../src/components/pds-modal/pds-modal.tsx | 24 +++++- libs/core/src/components/pds-modal/readme.md | 15 ++-- .../pds-modal/stories/pds-modal.stories.js | 77 +++++++++++++++++++ .../pds-modal/test/pds-modal.spec.tsx | 61 +++++++++++++++ 5 files changed, 178 insertions(+), 10 deletions(-) diff --git a/libs/core/src/components.d.ts b/libs/core/src/components.d.ts index c0eddce4f..195f14d75 100644 --- a/libs/core/src/components.d.ts +++ b/libs/core/src/components.d.ts @@ -1476,6 +1476,11 @@ export namespace Components { * A unique identifier used for the underlying component `id` attribute. */ "componentId": string; + /** + * When `true`, the modal opens as a non-modal dialog (`dialog.show()`) in the normal stacking context instead of the browser top layer (`dialog.showModal()`). This lets overlays rendered elsewhere in the DOM — e.g. file pickers, rich-text editor menus — display above the modal via `z-index`, which is impossible while the modal sits in the top layer. Note that the rest of the page is not made inert in this mode. + * @default false + */ + "disableTopLayer": boolean; /** * Closes the modal */ @@ -4783,6 +4788,11 @@ declare namespace LocalJSX { * A unique identifier used for the underlying component `id` attribute. */ "componentId"?: string; + /** + * When `true`, the modal opens as a non-modal dialog (`dialog.show()`) in the normal stacking context instead of the browser top layer (`dialog.showModal()`). This lets overlays rendered elsewhere in the DOM — e.g. file pickers, rich-text editor menus — display above the modal via `z-index`, which is impossible while the modal sits in the top layer. Note that the rest of the page is not made inert in this mode. + * @default false + */ + "disableTopLayer"?: boolean; /** * Emitted when the modal is closed */ @@ -6233,6 +6243,7 @@ declare namespace LocalJSX { "open": boolean; "size": 'sm' | 'md' | 'lg' | 'fullscreen'; "scrollable": boolean; + "disableTopLayer": boolean; } interface PdsModalContentAttributes { "border": 'none' | 'both' | 'top' | 'bottom'; diff --git a/libs/core/src/components/pds-modal/pds-modal.tsx b/libs/core/src/components/pds-modal/pds-modal.tsx index 21c91b2d9..cde5b6164 100644 --- a/libs/core/src/components/pds-modal/pds-modal.tsx +++ b/libs/core/src/components/pds-modal/pds-modal.tsx @@ -41,6 +41,17 @@ export class PdsModal { */ @Prop() scrollable = true; + /** + * When `true`, the modal opens as a non-modal dialog (`dialog.show()`) in the + * normal stacking context instead of the browser top layer + * (`dialog.showModal()`). This lets overlays rendered elsewhere in the DOM — + * e.g. file pickers, rich-text editor menus — display above the modal via + * `z-index`, which is impossible while the modal sits in the top layer. Note + * that the rest of the page is not made inert in this mode. + * @default false + */ + @Prop() disableTopLayer = false; + /** * Emitted when the modal is opened */ @@ -163,8 +174,15 @@ export class PdsModal { // Store the currently focused element to restore focus when modal closes this.previousActiveElement = document.activeElement as HTMLElement; - // Use native dialog showModal method which makes the rest of the page inert - this.modalRef.showModal(); + // showModal() promotes the dialog to the browser top layer (and makes the + // rest of the page inert), which prevents any overlay outside the dialog + // from ever painting above it. show() opens a non-modal dialog that stays + // in the normal stacking context so those overlays can stack above it. + if (this.disableTopLayer) { + this.modalRef.show(); + } else { + this.modalRef.showModal(); + } this.open = true; // Update focusable elements and set initial focus @@ -303,7 +321,7 @@ export class PdsModal { 'pds-modal__backdrop': true, 'open': this.open }} - aria-modal="true" + aria-modal={this.disableTopLayer ? 'false' : 'true'} aria-labelledby={`${this.componentId}-heading`} onClick={this.handleBackdropClick} > diff --git a/libs/core/src/components/pds-modal/readme.md b/libs/core/src/components/pds-modal/readme.md index eb23c6f0b..7a6693c37 100644 --- a/libs/core/src/components/pds-modal/readme.md +++ b/libs/core/src/components/pds-modal/readme.md @@ -7,13 +7,14 @@ ## Properties -| Property | Attribute | Description | Type | Default | -| ----------------- | ------------------ | --------------------------------------------------------------------- | -------------------------------------- | ----------- | -| `backdropDismiss` | `backdrop-dismiss` | Whether the modal can be dismissed by clicking the backdrop | `boolean` | `true` | -| `componentId` | `component-id` | A unique identifier used for the underlying component `id` attribute. | `string` | `undefined` | -| `open` | `open` | Whether the modal is open | `boolean` | `false` | -| `scrollable` | `scrollable` | Whether the modal content should be scrollable | `boolean` | `true` | -| `size` | `size` | The size of the modal | `"fullscreen" \| "lg" \| "md" \| "sm"` | `'md'` | +| Property | Attribute | Description | Type | Default | +| ----------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ----------- | +| `backdropDismiss` | `backdrop-dismiss` | Whether the modal can be dismissed by clicking the backdrop | `boolean` | `true` | +| `componentId` | `component-id` | A unique identifier used for the underlying component `id` attribute. | `string` | `undefined` | +| `disableTopLayer` | `disable-top-layer` | When `true`, the modal opens as a non-modal dialog (`dialog.show()`) in the normal stacking context instead of the browser top layer (`dialog.showModal()`). This lets overlays rendered elsewhere in the DOM — e.g. file pickers, rich-text editor menus — display above the modal via `z-index`, which is impossible while the modal sits in the top layer. Note that the rest of the page is not made inert in this mode. | `boolean` | `false` | +| `open` | `open` | Whether the modal is open | `boolean` | `false` | +| `scrollable` | `scrollable` | Whether the modal content should be scrollable | `boolean` | `true` | +| `size` | `size` | The size of the modal | `"fullscreen" \| "lg" \| "md" \| "sm"` | `'md'` | ## Events diff --git a/libs/core/src/components/pds-modal/stories/pds-modal.stories.js b/libs/core/src/components/pds-modal/stories/pds-modal.stories.js index 332e0ddbb..034077621 100644 --- a/libs/core/src/components/pds-modal/stories/pds-modal.stories.js +++ b/libs/core/src/components/pds-modal/stories/pds-modal.stories.js @@ -8,6 +8,7 @@ export default { args: { backdropDismiss: true, componentId: 'modal-demo', + disableTopLayer: false, open: false, scrollable: true, size: 'md', @@ -26,6 +27,7 @@ const BaseTemplate = (args) => html` component-id="${args.componentId}" size="${args.size}" ?backdrop-dismiss=${args.backdropDismiss} + ?disable-top-layer=${args.disableTopLayer} scrollable="${args.scrollable}" ?open=${args.open} key="${args.scrollable ? 'scrollable' : 'non-scrollable'}" @@ -652,3 +654,78 @@ export const CustomBorders = CustomBordersTemplate.bind({}); CustomBorders.args = { componentId: 'custom-borders-modal', }; + +// Demonstrates `disable-top-layer`: the modal opens as a non-modal dialog +// (dialog.show()) so an overlay appended to document.body can display above it. +// With the default top-layer behavior the same overlay would render behind the +// modal and be unreachable. +const DisableTopLayerTemplate = (args) => html` +
+ + Open Modal (disable-top-layer) + + + + + + + Modal Title + + + + + + + + + +

+ This modal uses disable-top-layer, so it renders in the normal stacking + context instead of the browser top layer. +

+

+ An overlay appended to document.body with a higher z-index can + therefore display above it — useful for file pickers, rich-text editor menus, and + other body-mounted overlays that would otherwise be trapped behind a top-layer dialog. +

+ + Show overlay above modal + +
+
+ + + + + Close + + + +
+
+`; + +export const DisableTopLayer = DisableTopLayerTemplate.bind({}); +DisableTopLayer.args = { + componentId: 'disable-top-layer-modal', + disableTopLayer: true, + size: 'md', + open: false, +}; diff --git a/libs/core/src/components/pds-modal/test/pds-modal.spec.tsx b/libs/core/src/components/pds-modal/test/pds-modal.spec.tsx index 9f1774f7b..4e9536160 100644 --- a/libs/core/src/components/pds-modal/test/pds-modal.spec.tsx +++ b/libs/core/src/components/pds-modal/test/pds-modal.spec.tsx @@ -1,5 +1,6 @@ import { newSpecPage } from '@stencil/core/testing'; import { MockPdsModal } from './mock-pds-modal'; +import { PdsModal } from '../pds-modal'; // Test the modal component using our mock implementation describe('pds-modal', () => { @@ -192,4 +193,64 @@ describe('pds-modal', () => { // Modal should still be open expect(page.rootInstance.open).toBe(true); }); + + // disableTopLayer — exercises the real component (the mock does not touch the + // API). modalRef is stubbed so show()/showModal() are observable + // without a real dialog element. + describe('disableTopLayer', () => { + // showModal() schedules a post-open setTimeout that focuses inside modalRef. + // Stub the dialog + focus helpers so that deferred callback is inert and + // cannot throw into a later test if it fires after this one completes. + const stubDialog = (instance: PdsModal) => { + const show = jest.fn(); + const showModal = jest.fn(); + /* eslint-disable @typescript-eslint/no-explicit-any */ + (instance as any).modalRef = { show, showModal, close: jest.fn(), querySelectorAll: () => [] }; + (instance as any).updateFocusableElements = jest.fn(); + (instance as any).setInitialFocus = jest.fn(); + /* eslint-enable @typescript-eslint/no-explicit-any */ + return { show, showModal }; + }; + + it('opens with showModal() (top layer) by default', async () => { + const page = await newSpecPage({ + components: [PdsModal], + html: ``, + }); + const { show, showModal } = stubDialog(page.rootInstance); + + await page.rootInstance.showModal(); + + expect(showModal).toHaveBeenCalled(); + expect(show).not.toHaveBeenCalled(); + }); + + it('opens with show() (non-modal) when disableTopLayer is true', async () => { + const page = await newSpecPage({ + components: [PdsModal], + html: ``, + }); + const { show, showModal } = stubDialog(page.rootInstance); + + await page.rootInstance.showModal(); + + expect(show).toHaveBeenCalled(); + expect(showModal).not.toHaveBeenCalled(); + expect(page.rootInstance.open).toBe(true); + }); + + it('reflects the mode on aria-modal', async () => { + const topLayer = await newSpecPage({ + components: [PdsModal], + html: ``, + }); + expect(topLayer.root?.querySelector('dialog')?.getAttribute('aria-modal')).toBe('true'); + + const nonModal = await newSpecPage({ + components: [PdsModal], + html: ``, + }); + expect(nonModal.root?.querySelector('dialog')?.getAttribute('aria-modal')).toBe('false'); + }); + }); }); From 64302cd041e15f90d18e50d88a6d307a55c3d0da Mon Sep 17 00:00:00 2001 From: Quinton Jason Date: Thu, 27 Aug 2026 16:01:36 -0500 Subject: [PATCH 2/8] docs(pds-modal): document disableTopLayer with an overlay-above-modal example --- .../components/pds-modal/docs/pds-modal.mdx | 134 +++++++++++++++++- 1 file changed, 133 insertions(+), 1 deletion(-) diff --git a/libs/core/src/components/pds-modal/docs/pds-modal.mdx b/libs/core/src/components/pds-modal/docs/pds-modal.mdx index 4e839cf1d..1314397b9 100644 --- a/libs/core/src/components/pds-modal/docs/pds-modal.mdx +++ b/libs/core/src/components/pds-modal/docs/pds-modal.mdx @@ -1318,9 +1318,141 @@ Modals can be nested, with only the topmost modal responding to escape key and b +### Overlays Above the Modal + +By default a modal opens with the native `dialog.showModal()`, which promotes it to the browser [top layer](https://developer.mozilla.org/en-US/docs/Glossary/Top_layer). Nothing outside the dialog can paint above the top layer — not even with a higher `z-index` — so an overlay mounted elsewhere in the DOM (a file picker, a rich-text editor menu, a third-party widget appended to `document.body`) renders _behind_ the modal and becomes unreachable. + +Set `disable-top-layer` to open the modal as a non-modal dialog (`dialog.show()`) in the normal stacking context instead. Overlays with a higher `z-index` can then display above it. + +> **Note:** In this mode the browser does not make the rest of the page `inert`. Use it only when you specifically need an external overlay to appear above the modal. Focus trapping, Escape handling, and the backdrop still work. + + + { + const modal = document.querySelector('#top-layer-modal'); + if (modal) modal.open = true; + }}>Open Modal + + + + + + Overlays Above the Modal + + + + + + + + +

This modal uses disable-top-layer, so an overlay appended to document.body can display above it.

+ { + const o = document.createElement('div'); + o.textContent = 'Overlay appended to document.body, above the modal. Click to dismiss.'; + o.setAttribute('style', 'position:fixed;inset:auto 2rem 2rem auto;max-width:20rem;padding:1rem;border-radius:8px;background:#111;color:#fff;z-index:2147483647;cursor:pointer'); + o.onclick = () => o.remove(); + document.body.appendChild(o); + }}>Show overlay above modal +
+
+ + + { + const modal = document.querySelector('#top-layer-modal'); + if (modal) modal.open = false; + }}>Close + + +
+ + `, + webComponent: ` +
+ Open Modal + + + + + + Overlays Above the Modal + + + + + + + + +

This modal uses disable-top-layer, so an overlay appended to document.body can display above it.

+ Show overlay above modal +
+
+ + + Close + + +
+
+ ` + }} +> +
+ { + const modal = document.querySelector('#top-layer-modal'); + if (modal) modal.open = true; + }}>Open Modal + + + + + + Overlays Above the Modal + + + + + + + + +

This modal uses disable-top-layer, so an overlay appended to document.body can display above it.

+ Show overlay above modal +
+
+ + + Close + + +
+
+
+ ## Technical Notes -- The component renders a native `` with `aria-modal="true"` and automatically sets `aria-labelledby` based on the slotted heading content (`${componentId}-heading`). Ensure your modal header contains a semantic heading (``) so assistive technologies announce it. +- The component renders a native `` and automatically sets `aria-labelledby` based on the slotted heading content (`${componentId}-heading`). Ensure your modal header contains a semantic heading (``) so assistive technologies announce it. `aria-modal` is `true` by default and `false` when `disable-top-layer` is set (a non-modal dialog). - Focus trapping is managed internally. When `open` becomes `true`, the component stores the previously focused element, queries for focusable nodes inside the modal, and moves focus to the first match. When `hideModal()` is called (directly or indirectly), focus is restored to the element that opened the modal. - Nested modals respect z-index order: backdrop clicks and Escape close only the top-most modal. Use this when stacking wizard dialogs or confirm prompts. From 6abab99c5a9bb1a65c314fcacfeda52bfd058d79 Mon Sep 17 00:00:00 2001 From: Quinton Jason Date: Thu, 27 Aug 2026 16:17:59 -0500 Subject: [PATCH 3/8] fix(pds-modal): release focus in non-top-layer mode and simplify the disableTopLayer doc --- libs/core/src/components.d.ts | 4 ++-- libs/core/src/components/pds-modal/pds-modal.tsx | 16 ++++++++++------ libs/core/src/components/pds-modal/readme.md | 16 ++++++++-------- 3 files changed, 20 insertions(+), 16 deletions(-) diff --git a/libs/core/src/components.d.ts b/libs/core/src/components.d.ts index 195f14d75..99442184e 100644 --- a/libs/core/src/components.d.ts +++ b/libs/core/src/components.d.ts @@ -1477,7 +1477,7 @@ export namespace Components { */ "componentId": string; /** - * When `true`, the modal opens as a non-modal dialog (`dialog.show()`) in the normal stacking context instead of the browser top layer (`dialog.showModal()`). This lets overlays rendered elsewhere in the DOM — e.g. file pickers, rich-text editor menus — display above the modal via `z-index`, which is impossible while the modal sits in the top layer. Note that the rest of the page is not made inert in this mode. + * Whether the modal opens outside the browser top layer as a non-modal dialog. When `true` it opens with `dialog.show()` instead of `dialog.showModal()`, so overlays rendered elsewhere in the DOM (file pickers, editor menus) can display above it via `z-index`. The page is not made inert and focus is not trapped in this mode. * @default false */ "disableTopLayer": boolean; @@ -4789,7 +4789,7 @@ declare namespace LocalJSX { */ "componentId"?: string; /** - * When `true`, the modal opens as a non-modal dialog (`dialog.show()`) in the normal stacking context instead of the browser top layer (`dialog.showModal()`). This lets overlays rendered elsewhere in the DOM — e.g. file pickers, rich-text editor menus — display above the modal via `z-index`, which is impossible while the modal sits in the top layer. Note that the rest of the page is not made inert in this mode. + * Whether the modal opens outside the browser top layer as a non-modal dialog. When `true` it opens with `dialog.show()` instead of `dialog.showModal()`, so overlays rendered elsewhere in the DOM (file pickers, editor menus) can display above it via `z-index`. The page is not made inert and focus is not trapped in this mode. * @default false */ "disableTopLayer"?: boolean; diff --git a/libs/core/src/components/pds-modal/pds-modal.tsx b/libs/core/src/components/pds-modal/pds-modal.tsx index cde5b6164..6eb37d449 100644 --- a/libs/core/src/components/pds-modal/pds-modal.tsx +++ b/libs/core/src/components/pds-modal/pds-modal.tsx @@ -42,12 +42,11 @@ export class PdsModal { @Prop() scrollable = true; /** - * When `true`, the modal opens as a non-modal dialog (`dialog.show()`) in the - * normal stacking context instead of the browser top layer - * (`dialog.showModal()`). This lets overlays rendered elsewhere in the DOM — - * e.g. file pickers, rich-text editor menus — display above the modal via - * `z-index`, which is impossible while the modal sits in the top layer. Note - * that the rest of the page is not made inert in this mode. + * Whether the modal opens outside the browser top layer as a non-modal dialog. + * When `true` it opens with `dialog.show()` instead of `dialog.showModal()`, so + * overlays rendered elsewhere in the DOM (file pickers, editor menus) can display + * above it via `z-index`. The page is not made inert and focus is not trapped in + * this mode. * @default false */ @Prop() disableTopLayer = false; @@ -284,6 +283,11 @@ export class PdsModal { // Handle Tab key for focus trapping if (e.key === 'Tab') { + // In non-top-layer mode the modal is deliberately not focus-isolated: focus + // must be able to leave it into overlays stacked above (the whole point of + // disableTopLayer), so do not trap Tab here. + if (this.disableTopLayer) return; + // If there are no focusable elements, do nothing if (this.focusableElements.length === 0) return; diff --git a/libs/core/src/components/pds-modal/readme.md b/libs/core/src/components/pds-modal/readme.md index 7a6693c37..1793f15c9 100644 --- a/libs/core/src/components/pds-modal/readme.md +++ b/libs/core/src/components/pds-modal/readme.md @@ -7,14 +7,14 @@ ## Properties -| Property | Attribute | Description | Type | Default | -| ----------------- | ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ----------- | -| `backdropDismiss` | `backdrop-dismiss` | Whether the modal can be dismissed by clicking the backdrop | `boolean` | `true` | -| `componentId` | `component-id` | A unique identifier used for the underlying component `id` attribute. | `string` | `undefined` | -| `disableTopLayer` | `disable-top-layer` | When `true`, the modal opens as a non-modal dialog (`dialog.show()`) in the normal stacking context instead of the browser top layer (`dialog.showModal()`). This lets overlays rendered elsewhere in the DOM — e.g. file pickers, rich-text editor menus — display above the modal via `z-index`, which is impossible while the modal sits in the top layer. Note that the rest of the page is not made inert in this mode. | `boolean` | `false` | -| `open` | `open` | Whether the modal is open | `boolean` | `false` | -| `scrollable` | `scrollable` | Whether the modal content should be scrollable | `boolean` | `true` | -| `size` | `size` | The size of the modal | `"fullscreen" \| "lg" \| "md" \| "sm"` | `'md'` | +| Property | Attribute | Description | Type | Default | +| ----------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- | ----------- | +| `backdropDismiss` | `backdrop-dismiss` | Whether the modal can be dismissed by clicking the backdrop | `boolean` | `true` | +| `componentId` | `component-id` | A unique identifier used for the underlying component `id` attribute. | `string` | `undefined` | +| `disableTopLayer` | `disable-top-layer` | Whether the modal opens outside the browser top layer as a non-modal dialog. When `true` it opens with `dialog.show()` instead of `dialog.showModal()`, so overlays rendered elsewhere in the DOM (file pickers, editor menus) can display above it via `z-index`. The page is not made inert and focus is not trapped in this mode. | `boolean` | `false` | +| `open` | `open` | Whether the modal is open | `boolean` | `false` | +| `scrollable` | `scrollable` | Whether the modal content should be scrollable | `boolean` | `true` | +| `size` | `size` | The size of the modal | `"fullscreen" \| "lg" \| "md" \| "sm"` | `'md'` | ## Events From cd44538ea66604dc1ce81e1d20ae8a1d63aaacb9 Mon Sep 17 00:00:00 2001 From: Quinton Jason Date: Thu, 27 Aug 2026 16:18:09 -0500 Subject: [PATCH 4/8] test(pds-modal): cover the disableTopLayer top-layer contract with e2e --- .../pds-modal/test/pds-modal.e2e.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/libs/core/src/components/pds-modal/test/pds-modal.e2e.ts b/libs/core/src/components/pds-modal/test/pds-modal.e2e.ts index 0fbec5d7c..cf1c976d8 100644 --- a/libs/core/src/components/pds-modal/test/pds-modal.e2e.ts +++ b/libs/core/src/components/pds-modal/test/pds-modal.e2e.ts @@ -93,6 +93,76 @@ describe('pds-modal', () => { expect(await modal.getProperty('backdropDismiss')).toBe(false); }); + describe('disableTopLayer', () => { + // The top-layer contract is only observable in a real browser: showModal() + // promotes the dialog to the top layer (:modal true), show() does not. + const dialogState = (page) => + page.evaluate(() => { + const dialog = document.querySelector('pds-modal dialog') as HTMLDialogElement | null; + return { + isModal: dialog ? dialog.matches(':modal') : null, + isOpen: dialog ? dialog.hasAttribute('open') : null, + ariaModal: dialog ? dialog.getAttribute('aria-modal') : null, + }; + }); + + it('opens in the top layer by default (:modal)', async () => { + const page = await newE2EPage(); + await page.setContent(`
Content
`); + + const modal = await page.find('pds-modal'); + await modal.callMethod('showModal'); + await page.waitForChanges(); + + const state = await dialogState(page); + expect(state.isOpen).toBe(true); + expect(state.isModal).toBe(true); + expect(state.ariaModal).toBe('true'); + }); + + it('opens outside the top layer as a non-modal dialog when disableTopLayer is set', async () => { + const page = await newE2EPage(); + await page.setContent( + `
Content
`, + ); + + const modal = await page.find('pds-modal'); + await modal.callMethod('showModal'); + await page.waitForChanges(); + + const state = await dialogState(page); + // Open, but NOT in the top layer — so a higher-z overlay can paint above it. + expect(state.isOpen).toBe(true); + expect(state.isModal).toBe(false); + expect(state.ariaModal).toBe('false'); + }); + + it('lets a higher z-index overlay paint above the non-modal dialog', async () => { + const page = await newE2EPage(); + await page.setContent( + `
Content
`, + ); + + const modal = await page.find('pds-modal'); + await modal.callMethod('showModal'); + await page.waitForChanges(); + + // A fixed, higher-z element appended to the body should sit on top of the + // (non-top-layer) dialog — impossible when the dialog is in the top layer. + const overlayOnTop = await page.evaluate(() => { + const o = document.createElement('div'); + o.id = 'probe-overlay'; + o.setAttribute( + 'style', + 'position:fixed;top:0;left:0;width:100px;height:100px;z-index:2147483647', + ); + document.body.appendChild(o); + return document.elementFromPoint(10, 10) === o; + }); + expect(overlayOnTop).toBe(true); + }); + }); + describe('accessibility', () => { it('has no axe violations when closed', async () => { const page = await newE2EPage(); From 83d2b3a438d7dae2b5af3132fba51487630df9f9 Mon Sep 17 00:00:00 2001 From: Quinton Jason Date: Thu, 27 Aug 2026 16:18:18 -0500 Subject: [PATCH 5/8] docs(pds-modal): describe non-top-layer behavior accurately and make the demo overlay focusable --- .../src/components/pds-modal/docs/pds-modal.mdx | 14 ++++++++------ .../pds-modal/stories/pds-modal.stories.js | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/libs/core/src/components/pds-modal/docs/pds-modal.mdx b/libs/core/src/components/pds-modal/docs/pds-modal.mdx index 1314397b9..84b346ec1 100644 --- a/libs/core/src/components/pds-modal/docs/pds-modal.mdx +++ b/libs/core/src/components/pds-modal/docs/pds-modal.mdx @@ -1324,7 +1324,7 @@ By default a modal opens with the native `dialog.showModal()`, which promotes it Set `disable-top-layer` to open the modal as a non-modal dialog (`dialog.show()`) in the normal stacking context instead. Overlays with a higher `z-index` can then display above it. -> **Note:** In this mode the browser does not make the rest of the page `inert`. Use it only when you specifically need an external overlay to appear above the modal. Focus trapping, Escape handling, and the backdrop still work. +> **Note:** This mode is for letting a higher-`z-index` overlay (a file picker, an editor menu) appear _above_ the modal — not for making the modal itself non-blocking. The dimming backdrop still covers the page and an outside click still dismisses it (unless `backdrop-dismiss="false"`), and Escape still closes it. What changes: the page is **not** made `inert` and **focus is not trapped**, so controls in an overlay stacked above the modal stay reachable by keyboard and assistive tech (which is why `aria-modal` is `false` in this mode). Two caveats: because the dialog now participates in normal stacking, a page element with its own higher stacking context can overlap it; and avoid mixing a `disable-top-layer` modal with a default (top-layer) modal in the same stack, since backdrop/Escape dismiss targeting is resolved by `z-index`.

This modal uses disable-top-layer, so an overlay appended to document.body can display above it.

{ - const o = document.createElement('div'); - o.textContent = 'Overlay appended to document.body, above the modal. Click to dismiss.'; - o.setAttribute('style', 'position:fixed;inset:auto 2rem 2rem auto;max-width:20rem;padding:1rem;border-radius:8px;background:#111;color:#fff;z-index:2147483647;cursor:pointer'); + const o = document.createElement('button'); + o.type = 'button'; + o.textContent = 'Overlay above the modal — focusable, keyboard-dismissable. Click or press Enter.'; + o.setAttribute('style', 'position:fixed;inset:auto 2rem 2rem auto;max-width:20rem;padding:1rem;border:0;border-radius:8px;text-align:start;background:#111;color:#fff;z-index:2147483647;cursor:pointer'); o.onclick = () => o.remove(); document.body.appendChild(o); + o.focus(); }}>Show overlay above modal @@ -1399,7 +1401,7 @@ Set `disable-top-layer` to open the modal as a non-modal dialog (`dialog.show()`

This modal uses disable-top-layer, so an overlay appended to document.body can display above it.

- Show overlay above modal + Show overlay above modal
@@ -1438,7 +1440,7 @@ Set `disable-top-layer` to open the modal as a non-modal dialog (`dialog.show()`

This modal uses disable-top-layer, so an overlay appended to document.body can display above it.

- Show overlay above modal + Show overlay above modal
diff --git a/libs/core/src/components/pds-modal/stories/pds-modal.stories.js b/libs/core/src/components/pds-modal/stories/pds-modal.stories.js index 034077621..e97636220 100644 --- a/libs/core/src/components/pds-modal/stories/pds-modal.stories.js +++ b/libs/core/src/components/pds-modal/stories/pds-modal.stories.js @@ -704,7 +704,7 @@ const DisableTopLayerTemplate = (args) => html`

Show overlay above modal From 1d43a1566775ef008180a3acd38d26ea0de1db17 Mon Sep 17 00:00:00 2001 From: Quinton Jason Date: Thu, 27 Aug 2026 16:32:29 -0500 Subject: [PATCH 6/8] fix(pds-modal): leave Escape to overlays that own focus in non-top-layer mode --- libs/core/src/components.d.ts | 4 ++-- libs/core/src/components/pds-modal/pds-modal.tsx | 10 +++++++++- libs/core/src/components/pds-modal/readme.md | 16 ++++++++-------- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/libs/core/src/components.d.ts b/libs/core/src/components.d.ts index 99442184e..37777adde 100644 --- a/libs/core/src/components.d.ts +++ b/libs/core/src/components.d.ts @@ -1477,7 +1477,7 @@ export namespace Components { */ "componentId": string; /** - * Whether the modal opens outside the browser top layer as a non-modal dialog. When `true` it opens with `dialog.show()` instead of `dialog.showModal()`, so overlays rendered elsewhere in the DOM (file pickers, editor menus) can display above it via `z-index`. The page is not made inert and focus is not trapped in this mode. + * Whether the modal opens outside the browser top layer as a non-modal dialog. When `true` it opens with `dialog.show()` instead of `dialog.showModal()`, so overlays rendered elsewhere in the DOM (file pickers, editor menus) can display above it via `z-index`. The page is not made inert and focus is not trapped in this mode. Read when the modal opens; changing it while the modal is open is not supported. * @default false */ "disableTopLayer": boolean; @@ -4789,7 +4789,7 @@ declare namespace LocalJSX { */ "componentId"?: string; /** - * Whether the modal opens outside the browser top layer as a non-modal dialog. When `true` it opens with `dialog.show()` instead of `dialog.showModal()`, so overlays rendered elsewhere in the DOM (file pickers, editor menus) can display above it via `z-index`. The page is not made inert and focus is not trapped in this mode. + * Whether the modal opens outside the browser top layer as a non-modal dialog. When `true` it opens with `dialog.show()` instead of `dialog.showModal()`, so overlays rendered elsewhere in the DOM (file pickers, editor menus) can display above it via `z-index`. The page is not made inert and focus is not trapped in this mode. Read when the modal opens; changing it while the modal is open is not supported. * @default false */ "disableTopLayer"?: boolean; diff --git a/libs/core/src/components/pds-modal/pds-modal.tsx b/libs/core/src/components/pds-modal/pds-modal.tsx index 6eb37d449..9535a5f32 100644 --- a/libs/core/src/components/pds-modal/pds-modal.tsx +++ b/libs/core/src/components/pds-modal/pds-modal.tsx @@ -46,7 +46,8 @@ export class PdsModal { * When `true` it opens with `dialog.show()` instead of `dialog.showModal()`, so * overlays rendered elsewhere in the DOM (file pickers, editor menus) can display * above it via `z-index`. The page is not made inert and focus is not trapped in - * this mode. + * this mode. Read when the modal opens; changing it while the modal is open is + * not supported. * @default false */ @Prop() disableTopLayer = false; @@ -272,6 +273,13 @@ export class PdsModal { // Handle Escape key to close the modal if (e.key === 'Escape') { + // In non-top-layer mode, focus can move into an overlay stacked above the + // modal (the reason disableTopLayer exists). If that overlay owns focus, + // leave Escape to it rather than dismissing this modal out from under it. + const active = document.activeElement; + if (this.disableTopLayer && active && active !== document.body && !this.el.contains(active)) { + return; + } // Always prevent native dialog close behavior e.preventDefault(); // Only close if backdropDismiss is enabled and this is the innermost modal diff --git a/libs/core/src/components/pds-modal/readme.md b/libs/core/src/components/pds-modal/readme.md index 1793f15c9..f6ab2b235 100644 --- a/libs/core/src/components/pds-modal/readme.md +++ b/libs/core/src/components/pds-modal/readme.md @@ -7,14 +7,14 @@ ## Properties -| Property | Attribute | Description | Type | Default | -| ----------------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- | ----------- | -| `backdropDismiss` | `backdrop-dismiss` | Whether the modal can be dismissed by clicking the backdrop | `boolean` | `true` | -| `componentId` | `component-id` | A unique identifier used for the underlying component `id` attribute. | `string` | `undefined` | -| `disableTopLayer` | `disable-top-layer` | Whether the modal opens outside the browser top layer as a non-modal dialog. When `true` it opens with `dialog.show()` instead of `dialog.showModal()`, so overlays rendered elsewhere in the DOM (file pickers, editor menus) can display above it via `z-index`. The page is not made inert and focus is not trapped in this mode. | `boolean` | `false` | -| `open` | `open` | Whether the modal is open | `boolean` | `false` | -| `scrollable` | `scrollable` | Whether the modal content should be scrollable | `boolean` | `true` | -| `size` | `size` | The size of the modal | `"fullscreen" \| "lg" \| "md" \| "sm"` | `'md'` | +| Property | Attribute | Description | Type | Default | +| ----------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ----------- | +| `backdropDismiss` | `backdrop-dismiss` | Whether the modal can be dismissed by clicking the backdrop | `boolean` | `true` | +| `componentId` | `component-id` | A unique identifier used for the underlying component `id` attribute. | `string` | `undefined` | +| `disableTopLayer` | `disable-top-layer` | Whether the modal opens outside the browser top layer as a non-modal dialog. When `true` it opens with `dialog.show()` instead of `dialog.showModal()`, so overlays rendered elsewhere in the DOM (file pickers, editor menus) can display above it via `z-index`. The page is not made inert and focus is not trapped in this mode. Read when the modal opens; changing it while the modal is open is not supported. | `boolean` | `false` | +| `open` | `open` | Whether the modal is open | `boolean` | `false` | +| `scrollable` | `scrollable` | Whether the modal content should be scrollable | `boolean` | `true` | +| `size` | `size` | The size of the modal | `"fullscreen" \| "lg" \| "md" \| "sm"` | `'md'` | ## Events From 2ef45d4efdd45dfd4f9a051a758dfec8b384e616 Mon Sep 17 00:00:00 2001 From: Quinton Jason Date: Thu, 27 Aug 2026 16:32:49 -0500 Subject: [PATCH 7/8] test(pds-modal): cover Escape ownership for non-top-layer overlays in e2e --- .../pds-modal/test/pds-modal.e2e.ts | 33 +++++++++++++++++++ .../pds-modal/test/pds-modal.spec.tsx | 5 +++ 2 files changed, 38 insertions(+) diff --git a/libs/core/src/components/pds-modal/test/pds-modal.e2e.ts b/libs/core/src/components/pds-modal/test/pds-modal.e2e.ts index cf1c976d8..a36f708c7 100644 --- a/libs/core/src/components/pds-modal/test/pds-modal.e2e.ts +++ b/libs/core/src/components/pds-modal/test/pds-modal.e2e.ts @@ -137,6 +137,39 @@ describe('pds-modal', () => { expect(state.ariaModal).toBe('false'); }); + it('leaves Escape to an overlay above it and closes normally when focus is inside', async () => { + const page = await newE2EPage(); + await page.setContent( + `
Content
`, + ); + + const modal = await page.find('pds-modal'); + await modal.callMethod('showModal'); + await page.waitForChanges(); + expect(await modal.getProperty('open')).toBe(true); + + // An overlay mounted on the body owns focus — Escape should not dismiss the modal. + await page.evaluate(() => { + const o = document.createElement('button'); + o.id = 'probe-overlay'; + o.textContent = 'Overlay'; + document.body.appendChild(o); + o.focus(); + }); + await page.keyboard.press('Escape'); + await page.waitForChanges(); + expect(await modal.getProperty('open')).toBe(true); + + // Remove the overlay so focus is no longer held outside the modal — Escape + // now dismisses the modal as usual. + await page.evaluate(() => { + (document.getElementById('probe-overlay') as HTMLElement)?.remove(); + }); + await page.keyboard.press('Escape'); + await page.waitForChanges(); + expect(await modal.getProperty('open')).toBe(false); + }); + it('lets a higher z-index overlay paint above the non-modal dialog', async () => { const page = await newE2EPage(); await page.setContent( diff --git a/libs/core/src/components/pds-modal/test/pds-modal.spec.tsx b/libs/core/src/components/pds-modal/test/pds-modal.spec.tsx index 4e9536160..0276bffc5 100644 --- a/libs/core/src/components/pds-modal/test/pds-modal.spec.tsx +++ b/libs/core/src/components/pds-modal/test/pds-modal.spec.tsx @@ -252,5 +252,10 @@ describe('pds-modal', () => { }); expect(nonModal.root?.querySelector('dialog')?.getAttribute('aria-modal')).toBe('false'); }); + + // The Escape-ownership behavior (leave Escape to an overlay that holds focus, + // close otherwise) depends on document.activeElement and dialog.close(), which + // the spec mock-doc environment does not implement — it is covered in the e2e + // suite where a real browser exercises focus and key events. }); }); From c16b5acddeb00301981a728d530c72084e82fee9 Mon Sep 17 00:00:00 2001 From: Quinton Jason Date: Thu, 27 Aug 2026 16:33:03 -0500 Subject: [PATCH 8/8] docs(pds-modal): fix heading parse, clarify Escape behavior, auto-remove demo overlay --- .../src/components/pds-modal/docs/pds-modal.mdx | 13 +++++++++---- .../pds-modal/stories/pds-modal.stories.js | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/libs/core/src/components/pds-modal/docs/pds-modal.mdx b/libs/core/src/components/pds-modal/docs/pds-modal.mdx index 84b346ec1..fc511ecbb 100644 --- a/libs/core/src/components/pds-modal/docs/pds-modal.mdx +++ b/libs/core/src/components/pds-modal/docs/pds-modal.mdx @@ -1318,13 +1318,14 @@ Modals can be nested, with only the topmost modal responding to escape key and b
+ ### Overlays Above the Modal By default a modal opens with the native `dialog.showModal()`, which promotes it to the browser [top layer](https://developer.mozilla.org/en-US/docs/Glossary/Top_layer). Nothing outside the dialog can paint above the top layer — not even with a higher `z-index` — so an overlay mounted elsewhere in the DOM (a file picker, a rich-text editor menu, a third-party widget appended to `document.body`) renders _behind_ the modal and becomes unreachable. Set `disable-top-layer` to open the modal as a non-modal dialog (`dialog.show()`) in the normal stacking context instead. Overlays with a higher `z-index` can then display above it. -> **Note:** This mode is for letting a higher-`z-index` overlay (a file picker, an editor menu) appear _above_ the modal — not for making the modal itself non-blocking. The dimming backdrop still covers the page and an outside click still dismisses it (unless `backdrop-dismiss="false"`), and Escape still closes it. What changes: the page is **not** made `inert` and **focus is not trapped**, so controls in an overlay stacked above the modal stay reachable by keyboard and assistive tech (which is why `aria-modal` is `false` in this mode). Two caveats: because the dialog now participates in normal stacking, a page element with its own higher stacking context can overlap it; and avoid mixing a `disable-top-layer` modal with a default (top-layer) modal in the same stack, since backdrop/Escape dismiss targeting is resolved by `z-index`. +> **Note:** This mode is for letting a higher-`z-index` overlay (a file picker, an editor menu) appear _above_ the modal — not for making the modal itself non-blocking. The dimming backdrop still covers the page and an outside click still dismisses it (unless `backdrop-dismiss="false"`). What changes: the page is **not** made `inert` and **focus is not trapped**, so controls in an overlay stacked above the modal stay reachable by keyboard and assistive tech (which is why `aria-modal` is `false` in this mode). Escape closes the modal while focus is inside it; when an overlay above the modal owns focus, Escape is left to that overlay so it can close itself. Two caveats: because the dialog now participates in normal stacking, a page element with its own higher stacking context can overlap it; and avoid mixing a `disable-top-layer` modal with a default (top-layer) modal in the same stack, since backdrop/Escape dismiss targeting is resolved by `z-index`. o.remove(); + const modal = document.querySelector('#top-layer-modal'); + const remove = () => o.remove(); + o.onclick = remove; + o.addEventListener('keydown', (e) => { if (e.key === 'Escape') remove(); }); + if (modal) modal.addEventListener('pdsModalClose', remove, { once: true }); document.body.appendChild(o); o.focus(); }}>Show overlay above modal @@ -1401,7 +1406,7 @@ Set `disable-top-layer` to open the modal as a non-modal dialog (`dialog.show()`

This modal uses disable-top-layer, so an overlay appended to document.body can display above it.

- Show overlay above modal + Show overlay above modal
@@ -1455,7 +1460,7 @@ Set `disable-top-layer` to open the modal as a non-modal dialog (`dialog.show()` ## Technical Notes - The component renders a native `` and automatically sets `aria-labelledby` based on the slotted heading content (`${componentId}-heading`). Ensure your modal header contains a semantic heading (``) so assistive technologies announce it. `aria-modal` is `true` by default and `false` when `disable-top-layer` is set (a non-modal dialog). -- Focus trapping is managed internally. When `open` becomes `true`, the component stores the previously focused element, queries for focusable nodes inside the modal, and moves focus to the first match. When `hideModal()` is called (directly or indirectly), focus is restored to the element that opened the modal. +- Focus trapping is managed internally. When `open` becomes `true`, the component stores the previously focused element, queries for focusable nodes inside the modal, and moves focus to the first match. When `hideModal()` is called (directly or indirectly), focus is restored to the element that opened the modal. In `disable-top-layer` mode focus is still placed on open but **not** trapped, so it can move to overlays stacked above the modal. - Nested modals respect z-index order: backdrop clicks and Escape close only the top-most modal. Use this when stacking wizard dialogs or confirm prompts. diff --git a/libs/core/src/components/pds-modal/stories/pds-modal.stories.js b/libs/core/src/components/pds-modal/stories/pds-modal.stories.js index e97636220..d2dc2fe17 100644 --- a/libs/core/src/components/pds-modal/stories/pds-modal.stories.js +++ b/libs/core/src/components/pds-modal/stories/pds-modal.stories.js @@ -704,7 +704,7 @@ const DisableTopLayerTemplate = (args) => html`

Show overlay above modal