Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions libs/core/src/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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';
Expand Down
143 changes: 141 additions & 2 deletions libs/core/src/components/pds-modal/docs/pds-modal.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -1318,10 +1318,149 @@ Modals can be nested, with only the topmost modal responding to escape key and b
</pds-modal>
</div>
</DocCanvas>

### Overlays Above the Modal
Comment thread
QuintonJason marked this conversation as resolved.

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 &mdash; not even with a higher `z-index` &mdash; 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 &mdash; 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`.

<DocCanvas
mdxSource={{
react: `
<div>
<PdsButton onClick={() => {
const modal = document.querySelector('#top-layer-modal');
if (modal) modal.open = true;
}}>Open Modal</PdsButton>

<PdsModal id="top-layer-modal" componentId="top-layer-modal" disableTopLayer>
<PdsModalHeader>
<PdsBox direction="column" fit padding="md">
<PdsBox alignItems="center" fit justifyContent="space-between">
<PdsText tag="h2" size="h3">Overlays Above the Modal</PdsText>
<PdsButton
class="pds-modal__close"
variant="unstyled"
iconOnly
onclick="document.querySelector('#top-layer-modal').open = false"
aria-label="Close modal"
>
<PdsIcon slot="start" name="remove" aria-hidden="true"></PdsIcon>
</PdsButton>
</PdsBox>
</PdsBox>
</PdsModalHeader>
<PdsModalContent>
<PdsBox fit direction="column" gap="sm" paddingInlineStart="md" paddingInlineEnd="md">
<p>This modal uses <code>disable-top-layer</code>, so an overlay appended to <code>document.body</code> can display above it.</p>
<PdsButton variant="secondary" onClick={() => {
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</PdsButton>
</PdsBox>
</PdsModalContent>
<PdsModalFooter>
<PdsBox fit justifyContent="end" padding="md" gap="sm">
<PdsButton onClick={() => {
const modal = document.querySelector('#top-layer-modal');
if (modal) modal.open = false;
}}>Close</PdsButton>
</PdsBox>
</PdsModalFooter>
</PdsModal>
</div>
`,
webComponent: `
<div>
<pds-button onclick="document.querySelector('#top-layer-modal').open = true">Open Modal</pds-button>

<pds-modal id="top-layer-modal" component-id="top-layer-modal" disable-top-layer="true">
<pds-modal-header>
<pds-box direction="column" fit padding="md">
<pds-box align-items="center" fit justify-content="space-between">
<pds-text tag="h2" size="h3">Overlays Above the Modal</pds-text>
<pds-button
class="pds-modal__close"
variant="unstyled"
icon-only="true"
onclick="document.querySelector('#top-layer-modal').open = false"
aria-label="Close modal"
>
<pds-icon slot="start" name="remove" aria-hidden="true"></pds-icon>
</pds-button>
</pds-box>
</pds-box>
</pds-modal-header>
<pds-modal-content>
<pds-box fit direction="column" gap="sm" padding-inline-start="md" padding-inline-end="md">
<p>This modal uses <code>disable-top-layer</code>, so an overlay appended to <code>document.body</code> can display above it.</p>
<pds-button variant="secondary" onclick="(function(){var 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');var modal=document.querySelector('#top-layer-modal');var remove=function(){o.remove()};o.onclick=remove;o.addEventListener('keydown',function(e){if(e.key==='Escape')remove()});if(modal)modal.addEventListener('pdsModalClose',remove,{once:true});document.body.appendChild(o);o.focus();})()">Show overlay above modal</pds-button>
</pds-box>
</pds-modal-content>
<pds-modal-footer>
<pds-box fit justify-content="end" padding="md" gap="sm">
<pds-button onclick="document.querySelector('#top-layer-modal').open = false">Close</pds-button>
</pds-box>
</pds-modal-footer>
</pds-modal>
</div>
`
}}
>
<div>
<pds-button onClick={() => {
const modal = document.querySelector('#top-layer-modal');
if (modal) modal.open = true;
}}>Open Modal</pds-button>

<pds-modal id="top-layer-modal" component-id="top-layer-modal" disable-top-layer="true">
<pds-modal-header>
<pds-box direction="column" fit padding="md">
<pds-box align-items="center" fit justify-content="space-between">
<pds-text tag="h2" size="h3">Overlays Above the Modal</pds-text>
<pds-button
class="pds-modal__close"
variant="unstyled"
icon-only="true"
onclick="document.querySelector('#top-layer-modal').open = false"
aria-label="Close modal"
>
<pds-icon slot="start" name="remove" aria-hidden="true"></pds-icon>
</pds-button>
</pds-box>
</pds-box>
</pds-modal-header>
<pds-modal-content>
<pds-box fit direction="column" gap="sm" padding-inline-start="md" padding-inline-end="md">
<p>This modal uses <code>disable-top-layer</code>, so an overlay appended to <code>document.body</code> can display above it.</p>
<pds-button variant="secondary" onclick="(function(){var 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=function(){o.remove()};document.body.appendChild(o);o.focus();})()">Show overlay above modal</pds-button>
</pds-box>
</pds-modal-content>
<pds-modal-footer>
<pds-box fit justify-content="end" padding="md" gap="sm">
<pds-button onclick="document.querySelector('#top-layer-modal').open = false">Close</pds-button>
</pds-box>
</pds-modal-footer>
</pds-modal>
</div>
</DocCanvas>

## Technical Notes

- The component renders a native `<dialog>` 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 (`<pds-text tag="h2">`) 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 `<dialog>` and automatically sets `aria-labelledby` based on the slotted heading content (`${componentId}-heading`). Ensure your modal header contains a semantic heading (`<pds-text tag="h2">`) 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.


Expand Down
36 changes: 33 additions & 3 deletions libs/core/src/components/pds-modal/pds-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down Expand Up @@ -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();
}
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
QuintonJason marked this conversation as resolved.
this.open = true;

// Update focusable elements and set initial focus
Expand Down Expand Up @@ -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
Expand All @@ -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;

Expand Down Expand Up @@ -303,7 +333,7 @@ export class PdsModal {
'pds-modal__backdrop': true,
'open': this.open
}}
aria-modal="true"
aria-modal={this.disableTopLayer ? 'false' : 'true'}
Comment thread
QuintonJason marked this conversation as resolved.
aria-labelledby={`${this.componentId}-heading`}
onClick={this.handleBackdropClick}
>
Expand Down
Loading
Loading