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 + + {({close}) => ( + <> + Dialog title +
Header
+ This is the content of the dialog. +
+ Don't show this again +
+ + + + + + )} +
+
+ ); + + 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 + + {({close}) => ( + <> + Dialog title +
Header
+ +

This is the content of the dialog.

+

Extra content

+
+
+ Don't show this again +
+ + + + + + )} +
+
+ ); + + 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
Header
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( + + + + + {({close}) => ( + <> + Alert Title + This is the alert message. + + + )} + + + + ); + + 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;