Skip to content
Open
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
28 changes: 28 additions & 0 deletions playwright/alert-dialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,3 +29,31 @@ test('test', async ({ page }) => {
// Assert the dialog is closed after confirming
await expect(dialog).toHaveCount(0);
});

test('alert dialog marks background content inert', async ({ page }) => {
await page.goto('http://127.0.0.1:8080/component/?name=alert_dialog&', { timeout: 20 * 60 * 1000 }); // Increase timeout to 20 minutes

// Is the trigger (which sits behind the dialog) inside an inert subtree?
const triggerIsInert = () => page.evaluate(() => {
const trigger = Array.from(document.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Show Alert Dialog'
);
return trigger ? trigger.closest('[inert]') !== null : null;
});

const trigger = page.getByRole('button', { name: 'Show Alert Dialog' });
await expect(trigger).toBeVisible();
await expect.poll(triggerIsInert).toBe(false);
await trigger.click();
await expect(page.getByRole('alertdialog')).toBeVisible();

await expect.poll(triggerIsInert).toBe(true);
await expect(page.locator('[data-inert-by]').first()).toBeAttached();

await page.keyboard.press('Escape');
await expect(page.getByRole('alertdialog')).toHaveCount(0);

await expect.poll(triggerIsInert).toBe(false);
await expect(page.locator('[data-inert-by]')).toHaveCount(0);
await expect(trigger).toBeFocused();
});
118 changes: 118 additions & 0 deletions playwright/dialog.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,121 @@ test('test', async ({ page }) => {
await page.mouse.click(2, 2);
await expect(dialog).toHaveCount(0);
});

test('modal dialog marks background content inert', async ({ page }) => {
await page.goto('http://127.0.0.1:8080/component/?name=dialog&', { timeout: 20 * 60 * 1000 }); // Increase timeout to 20 minutes

// Is the trigger (which sits behind the dialog) inside an inert subtree?
const triggerIsInert = () => page.evaluate(() => {
const trigger = Array.from(document.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Show Dialog'
);
return trigger ? trigger.closest('[inert]') !== null : null;
});

const trigger = page.getByRole('button', { name: 'Show Dialog' });
await expect(trigger).toBeVisible();
await expect.poll(triggerIsInert).toBe(false);
await trigger.click();
await expect(page.getByRole('dialog')).toBeVisible();

// Background content is inert while the modal is open, and every element that was made
// inert records the dialog that did it.
await expect.poll(triggerIsInert).toBe(true);
await expect(page.locator('[data-inert-by]').first()).toBeAttached();
// The dialog itself stays interactive.
await expect(page.getByRole('dialog')).not.toHaveAttribute('inert', '');

await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).toHaveCount(0);

// Closing unwinds everything it marked and returns focus to the opener.
await expect.poll(triggerIsInert).toBe(false);
await expect(page.locator('[data-inert-by]')).toHaveCount(0);
await expect(trigger).toBeFocused();
});

// The stacking rules live in the focus trap module rather than in any one component, and two
// dialogs open at once has no demo to drive. Exercise the shipped bundle directly: the dialog
// page loads it, and `createFocusTrap` is the same entry point the primitive calls.
test('stacked focus traps compose and unwind independently', async ({ page }) => {
await page.goto('http://127.0.0.1:8080/component/?name=dialog&', { timeout: 20 * 60 * 1000 }); // Increase timeout to 20 minutes
await page.waitForFunction(() => typeof (window as any).createFocusTrap === 'function');

const result = await page.evaluate(() => {
// <fixture>
// <aside> background for both dialogs
// <wrapper> background for the first, ancestor of the second
// <filler> background for the second only
// <second>
// <first>
const html = `<div id="t-aside"></div>
<div id="t-wrapper"><div id="t-filler"></div><div id="t-second"></div></div>
<div id="t-first"></div>`;
const fixture = document.createElement('div');
fixture.id = 't-fixture';
fixture.innerHTML = html;
document.body.append(fixture);
const el = (id: string) => document.getElementById(id)!;
// `inert` is inherited, so an element is unreachable if it or any ancestor carries it.
const blocked = (id: string) => el(id).closest('[inert]') !== null;

const createFocusTrap = (window as any).createFocusTrap;
const first = createFocusTrap(el('t-first'), { inertBackground: 'owner-first' });
const onlyFirst = {
aside: blocked('t-aside'),
wrapper: blocked('t-wrapper'),
second: blocked('t-second'),
first: blocked('t-first'),
};

const second = createFocusTrap(el('t-second'), { inertBackground: 'owner-second' });
const bothOpen = {
aside: blocked('t-aside'),
marker: el('t-aside').getAttribute('data-inert-by'),
wrapper: blocked('t-wrapper'),
filler: blocked('t-filler'),
second: blocked('t-second'),
first: blocked('t-first'),
};

second.remove();
const afterSecond = {
aside: blocked('t-aside'),
marker: el('t-aside').getAttribute('data-inert-by'),
wrapper: blocked('t-wrapper'),
first: blocked('t-first'),
};

first.remove();
const afterBoth = document.querySelectorAll('#t-fixture [inert], #t-fixture [data-inert-by]').length;

fixture.remove();
return { onlyFirst, bothOpen, afterSecond, afterBoth };
});

// One trap: everything outside it is inert, the trap itself is not.
expect(result.onlyFirst).toEqual({ aside: true, wrapper: true, second: true, first: false });

// Two traps: the one installed last is on top, so nothing on its path to <body> is inert —
// and the one underneath becomes inert itself. Shared background records both owners.
expect(result.bothOpen).toEqual({
aside: true,
marker: 'owner-first owner-second',
wrapper: false,
filler: true,
second: false,
first: true,
});

// Closing the top one hands the page back to the one underneath, not to the application.
expect(result.afterSecond).toEqual({
aside: true,
marker: 'owner-first',
wrapper: true,
first: false,
});

// Closing both leaves nothing behind.
expect(result.afterBoth).toBe(0);
});
1 change: 1 addition & 0 deletions preview/src/components/alert_dialog/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ pub fn AlertDialog(props: AlertDialogRootProps) -> Element {
id: props.id,
default_open: props.default_open,
open: props.open,
inert_background: props.inert_background,
on_open_change: props.on_open_change,
attributes: props.attributes,
alert_dialog::AlertDialogContent {
Expand Down
1 change: 1 addition & 0 deletions preview/src/components/dialog/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pub fn Dialog(props: DialogRootProps) -> Element {
class: Styles::dx_dialog_backdrop,
id: props.id,
is_modal: props.is_modal,
inert_background: props.inert_background,
open: props.open,
default_open: props.default_open,
on_open_change: props.on_open_change,
Expand Down
1 change: 1 addition & 0 deletions preview/src/components/sheet/component.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ pub fn Sheet(props: DialogRootProps) -> Element {
"data-slot": "sheet-root",
id: props.id,
is_modal: props.is_modal,
inert_background: props.inert_background,
open: props.open,
default_open: props.default_open,
on_open_change: props.on_open_change,
Expand Down
36 changes: 19 additions & 17 deletions primitives/src/alert_dialog.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
//! Defines the [`AlertDialogRoot`] component and its sub-components.

use crate::use_global_escape_listener;
use crate::{use_animated_open, use_id_or, use_unique_id, FOCUS_TRAP_JS};
use crate::{use_animated_open, use_focus_trap, use_id_or, use_unique_id, FOCUS_TRAP_JS};
use dioxus::document;
use dioxus::prelude::*;

#[derive(Clone)]
struct AlertDialogCtx {
open: Memo<bool>,
set_open: Callback<bool>,
inert_background: ReadSignal<bool>,
labelledby: String,
describedby: String,
}
Expand All @@ -27,6 +28,12 @@ pub struct AlertDialogRootProps {
/// Callback to handle changes in the open state of the dialog.
#[props(default)]
pub on_open_change: Callback<bool>,
/// Whether to mark the content outside of the alert dialog `inert` while it is open, which
/// takes it out of the accessibility tree and makes it unreachable by pointer and by
/// programmatic focus. Defaults to true; set it to false if the application manages `inert`
/// itself.
#[props(default = ReadSignal::new(Signal::new(true)))]
pub inert_background: ReadSignal<bool>,
/// Additional attributes to extend the root element.
#[props(extends = GlobalAttributes)]
pub attributes: Vec<Attribute>,
Expand Down Expand Up @@ -77,6 +84,14 @@ pub struct AlertDialogRootProps {
///
/// The [`AlertDialogRoot`] component defines the following data attributes you can use to control styling:
/// - `data-state`: Indicates if the alert dialog is open or closed. It can be either "open" or "closed".
///
/// ## Accessibility
///
/// While the alert dialog is open, the content outside of it is marked `inert`: it is removed from
/// the accessibility tree and cannot be reached by a pointer or by a programmatic `focus()`. Every
/// element marked this way also carries a `data-inert-by` attribute naming the dialogs that marked
/// it, so stacked dialogs unwind independently, and `inert` the application had already set
/// before the dialog opened is left alone. Set `inert_background` to false to opt out.
#[component]
pub fn AlertDialogRoot(props: AlertDialogRootProps) -> Element {
let labelledby = use_unique_id().to_string();
Expand All @@ -90,6 +105,7 @@ pub fn AlertDialogRoot(props: AlertDialogRootProps) -> Element {
use_context_provider(|| AlertDialogCtx {
open,
set_open,
inert_background: props.inert_background,
labelledby,
describedby,
});
Expand Down Expand Up @@ -177,6 +193,7 @@ pub fn AlertDialogContent(props: AlertDialogContentProps) -> Element {

let open = ctx.open;
let set_open = ctx.set_open;
let inert_background = ctx.inert_background;

// Add a escape key listener to the document when the dialog is open. We can't
// just add this to the dialog itself because it might not be focused if the user
Expand All @@ -185,23 +202,8 @@ pub fn AlertDialogContent(props: AlertDialogContentProps) -> Element {

let gen_id = use_unique_id();
let id = use_id_or(gen_id, props.id);
use_effect(move || {
let eval = document::eval(
r#"let id = await dioxus.recv();
let is_open = await dioxus.recv();
let dialog = document.getElementById(id);

if (is_open) {
dialog.trap = window.createFocusTrap(dialog);
}
if (!is_open && dialog.trap) {
dialog.trap.remove();
dialog.trap = null;
}"#,
);
let _ = eval.send(id.to_string());
let _ = eval.send(open.cloned());
});
use_focus_trap(id, open, inert_background);

rsx! {
div {
Expand Down
49 changes: 25 additions & 24 deletions primitives/src/dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ use dioxus::document;
use dioxus::prelude::*;

use crate::{
use_animated_open, use_controlled, use_global_escape_listener, use_id_or, use_outside_dismiss,
use_unique_id, FOCUS_TRAP_JS,
use_animated_open, use_controlled, use_focus_trap, use_global_escape_listener, use_id_or,
use_outside_dismiss, use_unique_id, FOCUS_TRAP_JS,
};

/// Context for the [`DialogRoot`] component
Expand All @@ -20,6 +20,9 @@ pub struct DialogCtx {
// Whether the dialog is a modal and should capture focus.
#[allow(unused)]
is_modal: ReadSignal<bool>,

// Whether background content should be marked inert while a modal dialog is open.
inert_background: ReadSignal<bool>,
dialog_labelledby: Signal<String>,
dialog_describedby: Signal<String>,
}
Expand All @@ -46,6 +49,13 @@ pub struct DialogRootProps {
#[props(default = ReadSignal::new(Signal::new(true)))]
pub is_modal: ReadSignal<bool>,

/// Whether to mark the content outside of a modal dialog `inert` while it is open, which
/// takes it out of the accessibility tree and makes it unreachable by pointer and by
/// programmatic focus. Defaults to true; set it to false if the application manages `inert`
/// itself. It has no effect if the dialog is not modal.
#[props(default = ReadSignal::new(Signal::new(true)))]
pub inert_background: ReadSignal<bool>,

/// The controlled `open` state of the dialog.
pub open: ReadSignal<Option<bool>>,

Expand Down Expand Up @@ -111,6 +121,14 @@ pub struct DialogRootProps {
///
/// The [`DialogRoot`] component defines the following data attributes you can use to control styling:
/// - `data-state`: Indicates if the dialog is open or closed. It can be either "open" or "closed".
///
/// ## Accessibility
///
/// While a modal dialog is open, the content outside of it is marked `inert`: it is removed from
/// the accessibility tree and cannot be reached by a pointer or by a programmatic `focus()`. Every
/// element marked this way also carries a `data-inert-by` attribute naming the dialogs that marked
/// it, so stacked dialogs unwind independently, and `inert` the application had already set
/// before the dialog opened is left alone. Set `inert_background` to false to opt out.
#[component]
pub fn DialogRoot(props: DialogRootProps) -> Element {
let dialog_labelledby = use_unique_id();
Expand All @@ -125,6 +143,7 @@ pub fn DialogRoot(props: DialogRootProps) -> Element {
open,
set_open,
is_modal: props.is_modal,
inert_background: props.inert_background,
dialog_labelledby,
dialog_describedby,
});
Expand Down Expand Up @@ -218,6 +237,7 @@ pub fn DialogContent(props: DialogContentProps) -> Element {
let ctx: DialogCtx = use_context();
let open = ctx.open;
let is_modal = ctx.is_modal;
let inert_background = ctx.inert_background;
let set_open = ctx.set_open;

// Add a escape key listener to the document when the dialog is open. We can't
Expand All @@ -229,29 +249,10 @@ pub fn DialogContent(props: DialogContentProps) -> Element {
let id = use_id_or(gen_id, props.id);

use_outside_dismiss(id, move || set_open.call(false));
use_effect(move || {
let is_modal = is_modal();
if !is_modal {
// If the dialog is not modal, we don't need to trap focus.
return;
}

let eval = document::eval(
r#"let id = await dioxus.recv();
let is_open = await dioxus.recv();
let dialog = document.getElementById(id);

if (is_open) {
dialog.trap = window.createFocusTrap(dialog);
}
if (!is_open && dialog.trap) {
dialog.trap.remove();
dialog.trap = null;
}"#,
);
let _ = eval.send(id.to_string());
let _ = eval.send(open.cloned());
});
// Only a modal dialog traps focus; a non-modal one leaves the rest of the page usable.
let trap_focus = use_memo(move || is_modal() && open());
use_focus_trap(id, trap_focus, inert_background);

rsx! {
div {
Expand Down
2 changes: 1 addition & 1 deletion primitives/src/js/focus-trap.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion primitives/src/js/hash.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
[11982134631128731377]
[4799083237276783177]
Loading