diff --git a/packages/@adobe/react-spectrum/src/dialog/AlertDialog.tsx b/packages/@adobe/react-spectrum/src/dialog/AlertDialog.tsx
index 9583141f1e9..2f2d89b0e7e 100644
--- a/packages/@adobe/react-spectrum/src/dialog/AlertDialog.tsx
+++ b/packages/@adobe/react-spectrum/src/dialog/AlertDialog.tsx
@@ -11,6 +11,7 @@
*/
import AlertMedium from '@spectrum-icons/ui/AlertMedium';
+import {AriaLabelingProps, DOMProps, DOMRef, StyleProps} from '@react-types/shared';
import {Button, SpectrumButtonProps} from '../button/Button';
import {ButtonGroup} from '../buttongroup/ButtonGroup';
import {chain} from 'react-aria/chain';
@@ -19,7 +20,6 @@ import {Content} from '../view/Content';
import {Dialog} from './Dialog';
import {DialogContext, DialogContextValue} from './context';
import {Divider} from '../divider/Divider';
-import {DOMProps, DOMRef, StyleProps} from '@react-types/shared';
import {filterDOMProps} from 'react-aria/filterDOMProps';
import {Heading} from '../text/Heading';
import intlMessages from '../../intl/dialog/*.json';
@@ -29,7 +29,7 @@ import styles from '@adobe/spectrum-css-temp/components/dialog/vars.css';
import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter';
import {useStyleProps} from '../utils/styleProps';
-export interface SpectrumAlertDialogProps extends DOMProps, StyleProps {
+export interface SpectrumAlertDialogProps extends AriaLabelingProps, DOMProps, StyleProps {
/** The [visual style](https://spectrum.adobe.com/page/alert-dialog/#Options) of the AlertDialog. */
variant?: 'confirmation' | 'information' | 'destructive' | 'error' | 'warning';
/** The title of the AlertDialog. */
@@ -105,7 +105,7 @@ export const AlertDialog = forwardRef(function AlertDialog(
size="M"
role="alertdialog"
ref={ref}
- {...filterDOMProps(props)}>
+ {...filterDOMProps(props, {labelable: true})}>
{title}
{(variant === 'error' || variant === 'warning') && (
diff --git a/packages/@adobe/react-spectrum/src/dialog/Dialog.tsx b/packages/@adobe/react-spectrum/src/dialog/Dialog.tsx
index 5ac767085cd..b115addc2ee 100644
--- a/packages/@adobe/react-spectrum/src/dialog/Dialog.tsx
+++ b/packages/@adobe/react-spectrum/src/dialog/Dialog.tsx
@@ -70,7 +70,7 @@ export const Dialog = React.forwardRef(function Dialog(props: SpectrumDialogProp
let domRef = useDOMRef(ref);
let gridRef = useRef(null);
let sizeVariant = sizeMap[type] || sizeMap[size];
- let {dialogProps, titleProps} = useDialog(mergeProps(contextProps, props), domRef);
+ let {dialogProps, titleProps, contentProps} = useDialog(mergeProps(contextProps, props), domRef);
let hasHeader = useHasChild(`.${styles['spectrum-Dialog-header']}`, unwrapDOMRef(gridRef));
let hasHeading = useHasChild(`.${styles['spectrum-Dialog-heading']}`, unwrapDOMRef(gridRef));
@@ -96,7 +96,7 @@ export const Dialog = React.forwardRef(function Dialog(props: SpectrumDialogProp
},
typeIcon: {UNSAFE_className: styles['spectrum-Dialog-typeIcon']},
divider: {UNSAFE_className: styles['spectrum-Dialog-divider'], size: 'M'},
- content: {UNSAFE_className: styles['spectrum-Dialog-content']},
+ content: {UNSAFE_className: styles['spectrum-Dialog-content'], ...contentProps},
footer: {UNSAFE_className: styles['spectrum-Dialog-footer']},
buttonGroup: {
UNSAFE_className: classNames(styles, 'spectrum-Dialog-buttonGroup', {
@@ -106,7 +106,7 @@ export const Dialog = React.forwardRef(function Dialog(props: SpectrumDialogProp
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}),
- [hasFooter, hasHeader, titleProps]
+ [hasFooter, hasHeader, titleProps, contentProps]
);
return (
diff --git a/packages/@adobe/react-spectrum/test/dialog/AlertDialog.test.js b/packages/@adobe/react-spectrum/test/dialog/AlertDialog.test.js
index 9a85f56405a..0c840198996 100644
--- a/packages/@adobe/react-spectrum/test/dialog/AlertDialog.test.js
+++ b/packages/@adobe/react-spectrum/test/dialog/AlertDialog.test.js
@@ -246,4 +246,37 @@ describe('AlertDialog', function () {
let primaryBtn = getByTestId('rsp-AlertDialog-confirmButton');
expect(primaryBtn).toBeDefined();
});
+
+ it('should have aria-describedby pointing to the content', function () {
+ let {getByRole} = render(
+
+
+ Content body
+
+
+ );
+
+ let dialog = getByRole('alertdialog');
+ expect(dialog).toHaveAttribute('aria-describedby');
+ let contentId = dialog.getAttribute('aria-describedby');
+ let content = document.getElementById(contentId);
+ expect(content).not.toBeNull();
+ expect(content.textContent).toBe('Content body');
+ });
+
+ it('accepts custom aria-describedby', function () {
+ let {getByRole} = render(
+
+
+ Content body
+
+
+ );
+
+ expect(getByRole('alertdialog')).toHaveAttribute('aria-describedby', 'content-id');
+ });
});
diff --git a/packages/@react-spectrum/s2/src/AlertDialog.tsx b/packages/@react-spectrum/s2/src/AlertDialog.tsx
index 94237308ae4..10f08ea40c3 100644
--- a/packages/@react-spectrum/s2/src/AlertDialog.tsx
+++ b/packages/@react-spectrum/s2/src/AlertDialog.tsx
@@ -11,13 +11,14 @@
*/
import AlertTriangle from '../s2wf-icons/S2_Icon_AlertTriangle_20_N.svg';
+import {AriaLabelingProps, DOMProps, DOMRef} from '@react-types/shared';
import {Button} from './Button';
import {ButtonGroup} from './ButtonGroup';
import {CenterBaseline} from './CenterBaseline';
import {chain} from 'react-aria/chain';
import {Content, Heading} from './Content';
import {Dialog} from './Dialog';
-import {DOMProps, DOMRef} from '@react-types/shared';
+import {filterDOMProps} from 'react-aria/filterDOMProps';
import {forwardRef, ReactNode} from 'react';
import {IconContext} from './Icon';
// @ts-ignore
@@ -28,7 +29,7 @@ import {style} from '../style' with {type: 'macro'};
import {UnsafeStyles} from './style-utils' with {type: 'macro'};
import {useLocalizedStringFormatter} from 'react-aria/useLocalizedStringFormatter';
-export interface AlertDialogProps extends DOMProps, UnsafeStyles {
+export interface AlertDialogProps extends AriaLabelingProps, DOMProps, UnsafeStyles {
/**
* The [visual style](https://spectrum.adobe.com/page/alert-dialog/#Options) of the AlertDialog.
* @default 'confirmation'
@@ -104,8 +105,11 @@ export const AlertDialog = forwardRef(function AlertDialog(props: AlertDialogPro
buttonVariant = 'negative';
}
+ let domProps = filterDOMProps(props, {labelable: true});
+
return (
}
{/* Main content */}
-
- {children}
-
+
+ {value => {
+ let contentValue = {};
+ if (value && 'slots' in value && value.slots?.description) {
+ contentValue = value.slots.description;
+ }
+ return (
+
+ {children}
+
+ );
+ }}
+
{/* Footer and button group */}
{
+ 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..a35318fd556
--- /dev/null
+++ b/packages/@react-spectrum/s2/test/StandardDialog.test.tsx
@@ -0,0 +1,120 @@
+/*
+ * 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 TitleHeader
diff --git a/packages/react-aria-components/src/Dialog.tsx b/packages/react-aria-components/src/Dialog.tsx
index 4b3009c861d..128fff599b3 100644
--- a/packages/react-aria-components/src/Dialog.tsx
+++ b/packages/react-aria-components/src/Dialog.tsx
@@ -40,6 +40,7 @@ import React, {
useRef
} 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';
@@ -120,7 +121,7 @@ export const Dialog = /*#__PURE__*/ (forwardRef as forwardRefType)(function Dial
) {
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.
@@ -172,6 +173,15 @@ export const Dialog = /*#__PURE__*/ (forwardRef as forwardRefType)(function Dial
}
}
],
+ [
+ TextContext,
+ {
+ slots: {
+ [DEFAULT_SLOT]: {},
+ description: contentProps
+ }
+ }
+ ],
[
ButtonContext,
{
diff --git a/packages/react-aria-components/test/Dialog.test.js b/packages/react-aria-components/test/Dialog.test.js
index 7864e5cd002..c1d040ace74 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.
>
)}
@@ -325,6 +358,7 @@ describe('Dialog', () => {
{({close}) => (
<>
Alert
+ This is the alert message.
>
)}
@@ -367,6 +401,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 ca27333d12d..0a68d7253d8 100644
--- a/packages/react-aria-components/test/RadioGroup.test.js
+++ b/packages/react-aria-components/test/RadioGroup.test.js
@@ -524,6 +524,7 @@ describe.each(['RadioGroup', 'RadioField'])('%s', comp => {
buttonClassName: ({isFocusVisible}) => (isFocusVisible ? 'focus' : '')
}}
/>
+ Alert description
>
)}
diff --git a/packages/react-aria/src/dialog/useDialog.ts b/packages/react-aria/src/dialog/useDialog.ts
index 4e3a237ddee..40babfd555d 100644
--- a/packages/react-aria/src/dialog/useDialog.ts
+++ b/packages/react-aria/src/dialog/useDialog.ts
@@ -38,6 +38,9 @@ export interface DialogAria {
/** Props for the dialog title element. */
titleProps: DOMAttributes;
+
+ /** Props for the dialog content/description element. Used for aria-describedby on alertdialogs. */
+ contentProps: DOMAttributes;
}
/**
@@ -52,6 +55,9 @@ export function useDialog(
let titleId: string | undefined = useSlotId();
titleId = props['aria-label'] ? undefined : titleId;
+ let contentId: string | undefined = useSlotId();
+ contentId = role === 'alertdialog' && !props['aria-describedby'] ? contentId : undefined;
+
let isRefocusing = useRef(false);
// Focus the dialog itself on mount, unless a child element is already focused.
@@ -104,6 +110,19 @@ export function useDialog(
}
});
+ let ariaDescribedby = props['aria-describedby'] ?? contentId;
+ let hasAriaDescribedbyWarn = useRef(false);
+ useEffect(() => {
+ if (!ariaDescribedby && props['role'] === 'alertdialog') {
+ if (process.env.NODE_ENV !== 'production' && !hasAriaDescribedbyWarn.current) {
+ console.warn(
+ 'If a Dialog does not contain a , it must have an aria-describedby for accessibility'
+ );
+ hasAriaDescribedbyWarn.current = true;
+ }
+ }
+ });
+
// We do not use aria-modal due to a Safari bug which forces the first focusable element to be focused
// on mount when inside an iframe, no matter which element we programmatically focus.
// See https://bugs.webkit.org/show_bug.cgi?id=211934.
@@ -114,7 +133,8 @@ export function useDialog(
...filterDOMProps(props, {labelable: true}),
role,
tabIndex: -1,
- 'aria-labelledby': props['aria-labelledby'] || titleId,
+ 'aria-labelledby': props['aria-labelledby'] ?? titleId,
+ 'aria-describedby': ariaDescribedby,
// Prevent blur events from reaching useOverlay, which may cause
// popovers to close. Since focus is contained within the dialog,
// we don't want this to occur due to the above useEffect.
@@ -126,6 +146,9 @@ export function useDialog(
},
titleProps: {
id: titleId
+ },
+ contentProps: {
+ id: contentId
}
};
}
diff --git a/packages/react-aria/test/dialog/useDialog.test.js b/packages/react-aria/test/dialog/useDialog.test.js
index 13f469e5771..83c0b09c91a 100644
--- a/packages/react-aria/test/dialog/useDialog.test.js
+++ b/packages/react-aria/test/dialog/useDialog.test.js
@@ -33,7 +33,9 @@ describe('useDialog', function () {
});
it('should accept role="alertdialog"', function () {
- let res = render();
+ let res = render(
+
+ );
let el = res.getByTestId('test');
expect(el).toHaveAttribute('role', 'alertdialog');
});
@@ -55,6 +57,61 @@ describe('useDialog', function () {
expect(document.activeElement).toBe(input);
});
+ describe('aria-describedby for alertdialog', function () {
+ function AlertDialogExample(props) {
+ let ref = useRef();
+ let {dialogProps, titleProps, contentProps} = useDialog({role: 'alertdialog', ...props}, ref);
+ return (
+
+
Alert Title
+ {props.showContent &&
Alert message content
}
+ {props.children}
+
+ );
+ }
+
+ it('should set aria-describedby on alertdialog when content is rendered', function () {
+ let res = render();
+ let el = res.getByTestId('test');
+ let contentEl = el.querySelector('p');
+ expect(el).toHaveAttribute('aria-describedby', contentEl.id);
+ });
+
+ it('should not auto-wire aria-describedby on regular dialog, but contentProps.id is still provided', function () {
+ function RegularDialogExample(props) {
+ let ref = useRef();
+ let {dialogProps, titleProps, contentProps} = useDialog(props, ref);
+ return (
+
+
Title
+
Content
+
+ );
+ }
+
+ let res = render();
+ let el = res.getByTestId('test');
+ expect(el).not.toHaveAttribute('aria-describedby');
+ });
+
+ it('should allow aria-describedby override on alertdialog', function () {
+ let res = render(
+
+ );
+ let el = res.getByTestId('test');
+ expect(el).toHaveAttribute('aria-describedby', 'custom-id');
+ });
+
+ it('should not generate contentProps.id when aria-describedby is provided', function () {
+ let res = render(
+
+ );
+ let el = res.getByTestId('test');
+ let contentEl = el.querySelector('p');
+ expect(contentEl).not.toHaveAttribute('id');
+ });
+ });
+
describe('dev warnings', function () {
let originalWarn;