diff --git a/libs/core/src/components.d.ts b/libs/core/src/components.d.ts index c0eddce4f..37777adde 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; + /** + * 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; /** * Closes the modal */ @@ -4783,6 +4788,11 @@ declare namespace LocalJSX { * A unique identifier used for the underlying component `id` attribute. */ "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. Read when the modal opens; changing it while the modal is open is not supported. + * @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/docs/pds-modal.mdx b/libs/core/src/components/pds-modal/docs/pds-modal.mdx index 4e839cf1d..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,10 +1318,149 @@ 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"`). 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`. + + + { + 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('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'); + 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 +
+
+ + + { + 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. -- 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. +- 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. 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/pds-modal.tsx b/libs/core/src/components/pds-modal/pds-modal.tsx index 21c91b2d9..9535a5f32 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; + /** + * 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 + */ + @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 @@ -255,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 @@ -266,6 +291,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; @@ -303,7 +333,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..f6ab2b235 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` | 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 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..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 @@ -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.e2e.ts b/libs/core/src/components/pds-modal/test/pds-modal.e2e.ts index 0fbec5d7c..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 @@ -93,6 +93,109 @@ 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('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( + `
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(); 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..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 @@ -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,69 @@ 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'); + }); + + // 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. + }); });