diff --git a/packages/@adobe/react-spectrum/src/dialog/AlertDialog.tsx b/packages/@adobe/react-spectrum/src/dialog/AlertDialog.tsx index 35519a82f40..45ed9cafc22 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. */ @@ -100,7 +100,7 @@ export const AlertDialog = forwardRef(function AlertDialog(props: SpectrumAlertD size="M" role="alertdialog" ref={ref} - {...filterDOMProps(props)}> + {...filterDOMProps(props, {labelable: true})}> {title} {(variant === 'error' || variant === 'warning') && + + 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 ffd25220a55..e8d24881fa2 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} - + + + {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..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 + + {({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 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( + + + + + {({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. )} @@ -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(); + 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'); + }); + }); + describe('dev warnings', function () { let originalWarn;