{
+ let user;
+ beforeAll(() => {
+ jest.useFakeTimers();
+ user = userEvent.setup({delay: null, pointerMap});
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ act(() => jest.runAllTimers());
+ });
+
+ afterAll(function () {
+ jest.restoreAllMocks();
+ });
+
+ it('automatically links to the content with aria-describedby', async () => {
+ let {getByRole} = render(
+
+ Open dialog
+
+ Test content
+
+
+ );
+
+ let trigger = getByRole('button');
+ await user.click(trigger);
+ act(() => {jest.runAllTimers();});
+ let dialog = getByRole('alertdialog');
+ expect(dialog).toBeVisible();
+ let description = dialog.getAttribute('aria-describedby');
+ expect(description).toBeDefined();
+ let content = document.getElementById(description!);
+ expect(content).toHaveTextContent('Test content');
+ });
+
+ it('accepts custom aria-describedby', async () => {
+ let {getByRole} = render(
+
+ Open dialog
+
+ Test content
Extra content
+
+
+ );
+
+ let trigger = getByRole('button');
+ await user.click(trigger);
+ act(() => {jest.runAllTimers();});
+ let dialog = getByRole('alertdialog');
+ expect(dialog).toBeVisible();
+ let description = dialog.getAttribute('aria-describedby');
+ expect(description).toBeDefined();
+ let content = document.getElementById(description!);
+ expect(content).toHaveTextContent('Test content');
+ });
+});
diff --git a/packages/@react-spectrum/s2/test/StandardDialog.test.tsx b/packages/@react-spectrum/s2/test/StandardDialog.test.tsx
new file mode 100644
index 00000000000..85c6ab08b60
--- /dev/null
+++ b/packages/@react-spectrum/s2/test/StandardDialog.test.tsx
@@ -0,0 +1,106 @@
+/*
+ * Copyright 2025 Adobe. All rights reserved.
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ * OF ANY KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+
+import {act, pointerMap, render} from '@react-spectrum/test-utils-internal';
+import {ActionButton} from '../src/ActionButton';
+import {Button} from '../src/Button';
+import {ButtonGroup} from '../src/ButtonGroup';
+import {Checkbox} from '../src/Checkbox';
+import {Content, Footer, Header, Heading} from '../src/Content';
+import {Dialog} from '../src/Dialog';
+import {DialogTrigger} from '../src/DialogTrigger';
+import React from 'react';
+import userEvent from '@testing-library/user-event';
+
+describe('StandardDialog', () => {
+ let user;
+ beforeAll(() => {
+ jest.useFakeTimers();
+ user = userEvent.setup({delay: null, pointerMap});
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ act(() => jest.runAllTimers());
+ });
+
+ afterAll(function () {
+ jest.restoreAllMocks();
+ });
+
+ it('does not automatically add aria-describedby', async () => {
+ let {getByRole} = render(
+
+ Open dialog
+
+
+ );
+
+ let trigger = getByRole('button');
+ await user.click(trigger);
+ act(() => {jest.runAllTimers();});
+ let dialog = getByRole('dialog');
+ expect(dialog).toBeVisible();
+ let description = dialog.getAttribute('aria-describedby');
+ expect(description).toBeNull();
+ });
+
+ it('accepts custom aria-describedby', async () => {
+ let {getByRole} = render(
+
+ Open dialog
+
+
+ );
+
+ let trigger = getByRole('button');
+ await user.click(trigger);
+ act(() => {jest.runAllTimers();});
+ let dialog = getByRole('dialog');
+ expect(dialog).toBeVisible();
+ let description = dialog.getAttribute('aria-describedby');
+ expect(description).toBeDefined();
+ let content = document.getElementById(description!);
+ expect(content).toHaveTextContent('This is the content of the dialog.');
+ });
+});
diff --git a/packages/dev/s2-docs/pages/s2/Dialog.mdx b/packages/dev/s2-docs/pages/s2/Dialog.mdx
index ba071d22176..94dc70e7dfb 100644
--- a/packages/dev/s2-docs/pages/s2/Dialog.mdx
+++ b/packages/dev/s2-docs/pages/s2/Dialog.mdx
@@ -29,7 +29,7 @@ function Example(props) {
{({close}) => (
<>
-
+
Subscribe to our newsletter
Enter your information to subscribe to our newsletter and receive updates about new features and announcements.
@@ -76,7 +76,7 @@ function Example(props) {
{({close}) => (
<>
-
+
Dialog Title
diff --git a/packages/react-aria-components/src/Dialog.tsx b/packages/react-aria-components/src/Dialog.tsx
index d3d2a8b4fcc..98ca95419aa 100644
--- a/packages/react-aria-components/src/Dialog.tsx
+++ b/packages/react-aria-components/src/Dialog.tsx
@@ -22,6 +22,7 @@ import {PopoverContext} from './Popover';
import {PressResponder} from 'react-aria/private/interactions/PressResponder';
import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useCallback, useContext, useRef, useState} from 'react';
import {RootMenuTriggerStateContext} from './Menu';
+import {TextContext} from './Text';
import {useId} from 'react-aria/useId';
import {useMenuTriggerState} from 'react-stately/useMenuTriggerState';
import {useOverlayTrigger} from 'react-aria/useOverlayTrigger';
@@ -105,7 +106,7 @@ export function DialogTrigger(props: DialogTriggerProps): JSX.Element {
export const Dialog = /*#__PURE__*/ (forwardRef as forwardRefType)(function Dialog(props: DialogProps, ref: ForwardedRef) {
let originalAriaLabelledby = props['aria-labelledby'];
[props, ref] = useContextProps(props, ref, DialogContext);
- let {dialogProps, titleProps} = useDialog({
+ let {dialogProps, titleProps, contentProps} = useDialog({
...props,
// Only pass aria-labelledby from props, not context.
// Context is used as a fallback below.
@@ -122,6 +123,12 @@ export const Dialog = /*#__PURE__*/ (forwardRef as forwardRefType)(function Dial
console.warn('If a Dialog does not contain a , it must have an aria-label or aria-labelledby attribute for accessibility.');
}
}
+
+ if (!dialogProps['aria-describedby'] && dialogProps['role'] === 'alertdialog') {
+ if (process.env.NODE_ENV !== 'production') {
+ console.warn('If a Dialog does not contain a , it must have an aria-describedby for accessibility');
+ }
+ }
let renderProps = useRenderProps({
defaultClassName: 'react-aria-Dialog',
@@ -149,6 +156,12 @@ export const Dialog = /*#__PURE__*/ (forwardRef as forwardRefType)(function Dial
title: {...titleProps, level: 2}
}
}],
+ [TextContext, {
+ slots: {
+ [DEFAULT_SLOT]: {},
+ description: contentProps
+ }
+ }],
[ButtonContext, {
slots: {
[DEFAULT_SLOT]: {},
diff --git a/packages/react-aria-components/test/Dialog.test.js b/packages/react-aria-components/test/Dialog.test.js
index 69375f92fd3..563fbd589ab 100644
--- a/packages/react-aria-components/test/Dialog.test.js
+++ b/packages/react-aria-components/test/Dialog.test.js
@@ -23,6 +23,7 @@ import {OverlayArrow} from '../src/OverlayArrow';
import {Popover} from '../src/Popover';
import React, {useRef} from 'react';
import * as stories from '../stories/Modal.stories';
+import {Text} from '../src/Text';
import {TextField} from '../src/TextField';
import {UNSAFE_PortalProvider} from 'react-aria/PortalProvider';
import {User} from '@react-aria/test-utils';
@@ -59,6 +60,7 @@ describe('Dialog', () => {
{({close}) => (
<>
Alert
+ This is the alert message.
>
)}
@@ -75,7 +77,6 @@ describe('Dialog', () => {
let heading = getByRole('heading');
expect(dialog).toHaveAttribute('aria-labelledby', heading.id);
expect(dialog).toHaveAttribute('data-test', 'dialog');
-
expect(dialog.closest('.react-aria-Modal')).toHaveAttribute('data-test', 'modal');
expect(dialog.closest('.react-aria-ModalOverlay')).toBeInTheDocument();
@@ -85,6 +86,36 @@ describe('Dialog', () => {
expect(dialog).not.toBeInTheDocument();
});
+ it('should set aria-describedby when Text slot="description" is used in alertdialog', async () => {
+ let {getByRole} = render(
+
+
+
+
+
+
+ );
+
+ let button = getByRole('button');
+ let dialogTester = testUtilUser.createTester('Dialog', {root: button, overlayType: 'modal'});
+ await dialogTester.open();
+ let dialog = dialogTester.dialog;
+ expect(dialog).toHaveAttribute('role', 'alertdialog');
+ expect(dialog).toHaveAttribute('aria-describedby');
+ let descId = dialog.getAttribute('aria-describedby');
+ let descEl = document.getElementById(descId);
+ expect(descEl).not.toBeNull();
+ expect(descEl.textContent).toBe('This is the alert message.');
+ });
+
it('works with modal and custom underlay', async () => {
let {getByRole} = render(
@@ -95,6 +126,7 @@ describe('Dialog', () => {
{({close}) => (
<>
Alert
+ This is the alert message.
>
)}
@@ -130,6 +162,7 @@ describe('Dialog', () => {
{({close}) => (
<>
Alert
+ This is the alert message.
>
)}
@@ -321,6 +354,7 @@ describe('Dialog', () => {
{({close}) => (
<>
Alert
+ This is the alert message.
>
)}
@@ -361,6 +395,7 @@ describe('Dialog', () => {
{({close}) => (
<>
Alert
+ This is the alert message.
>
)}
diff --git a/packages/react-aria-components/test/RadioGroup.test.js b/packages/react-aria-components/test/RadioGroup.test.js
index f12aece756d..ffec54a7701 100644
--- a/packages/react-aria-components/test/RadioGroup.test.js
+++ b/packages/react-aria-components/test/RadioGroup.test.js
@@ -433,6 +433,7 @@ describe.each(['RadioGroup', 'RadioField'])('%s', (comp) => {
{({close}) => (
<>
isFocusVisible ? 'focus' : ''}} />
+ Alert description
>
)}
diff --git a/packages/react-aria/src/dialog/useDialog.ts b/packages/react-aria/src/dialog/useDialog.ts
index 8a2015231fa..f9df3371643 100644
--- a/packages/react-aria/src/dialog/useDialog.ts
+++ b/packages/react-aria/src/dialog/useDialog.ts
@@ -31,7 +31,10 @@ export interface DialogAria {
dialogProps: DOMAttributes,
/** Props for the dialog title element. */
- titleProps: DOMAttributes
+ titleProps: DOMAttributes,
+
+ /** Props for the dialog content/description element. Used for aria-describedby on alertdialogs. */
+ contentProps: DOMAttributes
}
/**
@@ -45,6 +48,9 @@ export function useDialog(props: AriaDialogProps, ref: RefObject
+ Alert Title
+ {props.showContent && Alert message content
}
+ {props.children}
+
+ );
+ }
+
+ it('should set aria-describedby on alertdialog when content is rendered', function () {
+ let res = render(