DioxusLabs / DioxusLabs/dioxus-components
Dialog / AlertDialog: mark background content inert while a modal is open
- Dominant language
- Rust
- Stars
- 343
- Forks
- 82
- PR merge metrics
- No merged PRs in 30d
Description
## Problem
A modal `Dialog` traps **Tab** but leaves the rest of the page reachable. Nothing outside the
dialog is marked `inert`, so a screen reader's browse-mode virtual cursor, a pointer, or a
programmatic `.focus()` can still reach and read the content behind the modal.
`AlertDialog` has the same gap, and unconditionally — it has no `is_modal` gate because an alert
dialog is always modal.
Observed at `main` (`bf007c15d0cf4d04d3181cc46cf12325aa773955`).
## Evidence
`DialogContent` sets the right roles (`primitives/src/dialog.rs:256-262`):
```rust
div {
id,
role: "dialog",
aria_modal: "true",
...
}
```
but the focus trap installed when `is_modal` (`primitives/src/dialog.rs:231-253`) is, in its
entirety, a Tab-keydown interceptor — `primitives/src/js/focus-trap.js` (deminified for
readability):
```js
this.container.addEventListener("keydown", (event) => {
if (event.key === "Tab") {
if (event.shiftKey) this.focusPrevious(); else this.focusNext();
event.preventDefault();
}
});
```
`grep -rn inert primitives/src/` returns nothing. The only `aria_hidden` in `dialog.rs` is at
`:142`, on the dialog root while it is **closed** — unrelated to background content.
`alert_dialog.rs` installs the same `FOCUS_TRAP_JS` (`:103`) and likewise never touches the
background.
So `Tab` is constrained and nothing else is.
## Why `aria-modal` alone isn't enough
`aria-modal="true"` is the standards-blessed signal and it is already set, so this is a
robustness gap rather than a missing basic. Three reasons to pair it with `inert`:
1. Support has been inconsistent enough across screen-reader / browser pairs that the WAI-ARIA
Authoring Practices and MDN both recommend marking background content `inert` (or
`aria-hidden`) in addition, rather than relying on `aria-modal` alone.
2. It only addresses assistive tech. It does nothing about pointer interaction with background
controls, or about programmatic focus — an app calling `element.focus()` behind the modal, or
a background element that autofocuses on mount, moves focus straight out of the dialog.
3. The trap can't recover. The listener binds to `this.container`, so once focus leaves by any
route other than Tab, no Tab handler fires and focus is never brought back.
`inert` closes all three in one attribute: it removes a subtree from the accessibility tree *and*
makes it non-focusable and non-clickable.
## Suggested shape
**The walk.** The dialog does not portal (no `use_portal` anywhere in `dialog.rs`), so it renders
inline wherever the caller mounts it. "Mark the overlay's siblings" is therefore not correct in
general — it only works when the overlay happens to sit at a known level. Walk from the dialog up
to ``, marking each ancestor's other children:
```js
let node = dialogEl;
while (node && node !== document.body) {
for (const sib of node.parentElement.children) if (sib !== node) mark(sib);
node = node.parentElement;
}
```
**Per-instance markers, not a bare boolean.** Two modal dialogs can be open at once — an
`AlertDialog` over a `Dialog` is the obvious case, and both live in this crate, so the fix should
compose across them by construction. With a single shared marker, closing the top modal would
strip `inert` from elements the one underneath still needs marked. Instead, each dialog writes its
own id into the marker (e.g. `data-inert-by=""`, space-separated when two dialogs mark the
same element); unwinding removes only that dialog's id and clears `inert` only when no ids
remain. This also protects `inert` the application set itself: an app-set `inert` carries no
marker, so it is never touched.
**Bookkeeping.**
- Gate on `is_modal` for `Dialog`, matching the trap's own gate; unconditional for `AlertDialog`.
- Unwind on close **and** on unmount — a dialog can leave the document without a normal close.
- Content mounted behind an already-open modal is not marked until the dialog reopens, unless a
`MutationObserver` is carried for it; accepting and documenting that gap is a reasonable call,
since the modal's premise says it shouldn't happen.
- A prop to opt out is probably worth having for callers who manage `inert` themselves.
## Reference implementation
This is implemented downstream, in a registry that wraps these primitives:
[dialog](https://github.com/sagikazarmark/dioxus-daisyui-components/blob/main/src/components/dialog/component.rs)
and
[alert_dialog](https://github.com/sagikazarmark/dioxus-daisyui-components/blob/main/src/components/alert_dialog/component.rs)
(`INERT_JS` plus a `use_effect`/`use_drop` pair on the open state), with Playwright coverage, and
tracked there in
[sagikazarmark/dioxus-daisyui-components#9](https://github.com/sagikazarmark/dioxus-daisyui-components/issues/9).
It is carried as a stopgap and will be dropped once the primitive grows the behaviour. Happy to
turn it into a PR here if the shape looks right.
## Secondary, separable
`FocusTrap.remove()` is:
```js
remove() { this.restoreFocusElement.focus() }
```
`restoreFocusElement` is captured as `document.activeElement` at construction. If the opener was
unmounted while the dialog was open — routine when the dialog's action re-renders the view behind
it — this focuses a detached node and focus falls to ``, losing keyboard position. A
liveness check with a fallback fixes it:
```js
remove() {
if (this.restoreFocusElement?.isConnected) this.restoreFocusElement.focus();
else document.querySelector("main")?.focus();
}
```
Happy to split this into its own issue if that's preferred.
Contributor guide
No contributing guide indexed for this repository
Research direction
Read primitives/src/dialog.rs and alert_dialog.rs, then inspect primitives/src/js/focus-trap.js and the existing lifecycle hooks around modal state. Check how both components render inline and how focus trapping is installed. Done means modal background content is inert only for the appropriate open dialogs, nested modals compose safely, and cleanup occurs on close and unmount.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- javascript, rust
- Domain
- accessibility, frontend
- Issue type
- Feature
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100