diff --git a/packages/react-aria/src/dialog/useDialog.ts b/packages/react-aria/src/dialog/useDialog.ts index f44cf365089..04e67dccdd2 100644 --- a/packages/react-aria/src/dialog/useDialog.ts +++ b/packages/react-aria/src/dialog/useDialog.ts @@ -75,6 +75,28 @@ export function useDialog(props: AriaDialogProps, ref: RefObject { + if (process.env.NODE_ENV !== 'production' && !hasWarned.current && ref.current) { + let el = ref.current; + let hasAriaLabel = el.hasAttribute('aria-label'); + let hasAriaLabelledby = el.hasAttribute('aria-labelledby'); + if (!hasAriaLabel && !hasAriaLabelledby) { + console.warn( + 'A dialog must have a visible title for accessibility. ' + + 'Either provide an aria-label or aria-labelledby prop, or render a heading element inside the dialog with the titleProps from useDialog.' + ); + hasWarned.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. diff --git a/packages/react-aria/test/dialog/useDialog.test.js b/packages/react-aria/test/dialog/useDialog.test.js index 3d824a65a68..48b24d3fafa 100644 --- a/packages/react-aria/test/dialog/useDialog.test.js +++ b/packages/react-aria/test/dialog/useDialog.test.js @@ -16,25 +16,30 @@ import {useDialog} from '../../src/dialog/useDialog'; function Example(props) { let ref = useRef(); - let {dialogProps} = useDialog(props, ref); - return
{props.children}
; + let {dialogProps, titleProps} = useDialog(props, ref); + return ( +
+ {props.showTitle &&

Title

} + {props.children} +
+ ); } describe('useDialog', function () { it('should have role="dialog" by default', function () { - let res = render(); + let res = render(); let el = res.getByTestId('test'); expect(el).toHaveAttribute('role', 'dialog'); }); it('should accept role="alertdialog"', function () { - let res = render(); + let res = render(); let el = res.getByTestId('test'); expect(el).toHaveAttribute('role', 'alertdialog'); }); it('should focus the overlay on mount', function () { - let res = render(); + let res = render(); let el = res.getByTestId('test'); expect(el).toHaveAttribute('tabIndex', '-1'); expect(document.activeElement).toBe(el); @@ -42,11 +47,46 @@ describe('useDialog', function () { it('should not focus the overlay if something inside is auto focused', function () { let res = render( - + ); let input = res.getByTestId('input'); expect(document.activeElement).toBe(input); }); + + describe('dev warnings', function () { + let originalWarn; + + beforeEach(function () { + originalWarn = console.warn; + console.warn = jest.fn(); + }); + + afterEach(function () { + console.warn = originalWarn; + }); + + it('should warn when dialog has no accessible title', function () { + render(); + expect(console.warn).toHaveBeenCalledWith( + expect.stringContaining('A dialog must have a visible title for accessibility') + ); + }); + + it('should not warn when aria-label is provided', function () { + render(); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it('should not warn when aria-labelledby is provided', function () { + render(); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it('should not warn when a title element is rendered', function () { + render(); + expect(console.warn).not.toHaveBeenCalled(); + }); + }); }); diff --git a/scripts/setupTests.js b/scripts/setupTests.js index fedbf8c0bd3..891aa7cd563 100644 --- a/scripts/setupTests.js +++ b/scripts/setupTests.js @@ -39,7 +39,8 @@ const ERROR_PATTERNS_WE_SHOULD_FIX_BUT_ALLOW = [ ]; const WARNING_PATTERNS_WE_SHOULD_FIX_BUT_ALLOW = [ - 'Browserslist: caniuse-lite is outdated' + 'Browserslist: caniuse-lite is outdated', + 'A dialog must have a visible title for accessibility' ]; function failTestOnConsoleError() {