diff --git a/packages/react/src/components/Drawer/Drawer.test.tsx b/packages/react/src/components/Drawer/Drawer.test.tsx index 4de141bbc..04524fa24 100644 --- a/packages/react/src/components/Drawer/Drawer.test.tsx +++ b/packages/react/src/components/Drawer/Drawer.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import Drawer from './'; +import Drawer, { DrawerHeading } from './'; import axe from '../../axe'; afterEach(() => { @@ -9,18 +9,55 @@ afterEach(() => { jest.restoreAllMocks(); }); +const renderHeading = () => Drawer title; + test('should render children', () => { render( + {renderHeading()} Hello World ); expect(screen.getByText('Hello World')).toBeInTheDocument(); }); +test('should render as a dialog', () => { + render( + + {renderHeading()} + Children + + ); + + expect(screen.getByRole('dialog', { name: 'Drawer title' })).toBeVisible(); +}); + +test('should set aria-modal when modal', () => { + render( + + {renderHeading()} + Children + + ); + + expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true'); +}); + +test('should not set aria-modal when non-modal', () => { + render( + + {renderHeading()} + Children + + ); + + expect(screen.getByRole('dialog')).not.toHaveAttribute('aria-modal'); +}); + test('should support className prop', () => { render( + {renderHeading()} Children ); @@ -39,6 +76,7 @@ test('should support open prop', () => { expect(drawer).not.toBeVisible(); rerender( + {renderHeading()} Children ); @@ -58,6 +96,7 @@ test('should support position prop', () => { }; const { rerender } = render( + {renderHeading()} Children ); @@ -87,6 +126,7 @@ test('should call onClose prop on esc keypress', async () => { const user = userEvent.setup(); render( + {renderHeading()} Children ); @@ -101,6 +141,7 @@ test('should call onClose prop on click outside', async () => { const user = userEvent.setup(); render( + {renderHeading()} Children ); @@ -120,6 +161,7 @@ test('should set focus to drawer by default when opened', () => { expect(screen.getByTestId('drawer')).not.toHaveFocus(); rerender( + {renderHeading()} Children ); @@ -144,6 +186,7 @@ test('should set focus to focusable element when opened', () => { initialFocus: button }} > + {renderHeading()} ); @@ -171,6 +214,7 @@ test('should set focus to custom element when opened', () => { focusOptions={{ initialFocus: ref.current as HTMLElement }} open > + {renderHeading()} @@ -199,6 +243,7 @@ test('should set focus to custom ref element', () => { focusOptions={{ initialFocus: ref }} open > + {renderHeading()} @@ -224,6 +269,7 @@ test('should return focus to triggering element when closed', () => { <> + {renderHeading()} Children @@ -260,6 +306,7 @@ test('should return focus to custom element when closed', () => { open focusOptions={{ returnFocus: button }} > + {renderHeading()} Children ); @@ -280,6 +327,7 @@ test('should support ref prop', () => { const ref = React.createRef(); render( + {renderHeading()} Children ); @@ -294,6 +342,7 @@ test('should not trap focus when behavior is non-modal', async () => { <> + {renderHeading()}
@@ -315,6 +364,7 @@ test('should not trap focus when behavior is non-modal', async () => { test('should return no axe violations when open', async () => { render( + {renderHeading()} Children ); @@ -333,3 +383,15 @@ test('should return no axe violations when closed', async () => { const results = await axe(await screen.findByTestId('drawer')); expect(results).toHaveNoViolations(); }); + +test('should throw when opened without a DrawerHeading', () => { + expect(() => + render( + + Children + + ) + ).toThrow( + 'Drawer: No heading provided. Include a DrawerHeading component for accessibility.' + ); +}); diff --git a/packages/react/src/components/Drawer/DrawerContext.tsx b/packages/react/src/components/Drawer/DrawerContext.tsx new file mode 100644 index 000000000..04324a8cc --- /dev/null +++ b/packages/react/src/components/Drawer/DrawerContext.tsx @@ -0,0 +1,20 @@ +import React, { createContext, useContext } from 'react'; + +interface DrawerContextValue { + headingId: string; +} + +const DrawerContext = createContext(null); + +function useDrawerContext(): DrawerContextValue { + const context = useContext(DrawerContext); + if (!context) { + throw new Error( + 'Drawer compound components must be rendered within a Drawer' + ); + } + return context; +} + +export { DrawerContext, useDrawerContext }; +export type { DrawerContextValue }; diff --git a/packages/react/src/components/Drawer/index.tsx b/packages/react/src/components/Drawer/index.tsx index 82c725157..fea4b834c 100644 --- a/packages/react/src/components/Drawer/index.tsx +++ b/packages/react/src/components/Drawer/index.tsx @@ -4,10 +4,12 @@ import React, { useState, useEffect, useCallback, + useMemo, useRef } from 'react'; import { createPortal } from 'react-dom'; import classnames from 'classnames'; +import { useId } from 'react-id-generator'; import Scrim from '../Scrim'; import ClickOutsideListener from '../ClickOutsideListener'; import useEscapeKey from '../../utils/useEscapeKey'; @@ -16,6 +18,11 @@ import useFocusTrap from '../../utils/useFocusTrap'; import resolveElement from '../../utils/resolveElement'; import AriaIsolate from '../../utils/aria-isolate'; import { isBrowser } from '../../utils/is-browser'; +import { + DrawerContext, + useDrawerContext, + type DrawerContextValue +} from './DrawerContext'; export interface DrawerProps< T extends HTMLElement = HTMLElement @@ -50,6 +57,7 @@ const Drawer = forwardRef( ) => { const drawerRef = useSharedRef(ref); const openRef = useRef(!!open); + const [headingId] = useId(1, 'drawer-title-'); const { initialFocus: focusInitial, returnFocus: focusReturn } = focusOptions; const [isTransitioning, setIsTransitioning] = useState(!!open); @@ -115,6 +123,24 @@ const Drawer = forwardRef( returnFocusElement: focusReturn }); + useEffect(() => { + if (open && drawerRef.current) { + const hasHeading = drawerRef.current.querySelector('.Drawer__heading'); + if (process.env.NODE_ENV !== 'production' && !hasHeading) { + throw Error( + 'Drawer: No heading provided. Include a DrawerHeading component for accessibility.' + ); + } + } + }, [open]); + + const contextValue: DrawerContextValue = useMemo( + () => ({ + headingId + }), + [headingId] + ); + const portalElement = resolveElement(portal); return createPortal( @@ -127,6 +153,7 @@ const Drawer = forwardRef( >
( 'Drawer--right': position === 'right' })} aria-hidden={!open || undefined} + aria-modal={isModal ? true : undefined} + aria-labelledby={headingId} style={{ visibility: !open && !isTransitioning ? 'hidden' : undefined, ...style @@ -142,7 +171,9 @@ const Drawer = forwardRef( tabIndex={open ? -1 : undefined} {...props} > - {children} + + {children} +
@@ -156,4 +187,31 @@ const Drawer = forwardRef( Drawer.displayName = 'Drawer'; +export interface DrawerHeadingProps extends React.HTMLAttributes { + children: React.ReactNode; + className?: string; + level?: number; +} + +const DrawerHeading = ({ + children, + className, + level = 2, + ...other +}: DrawerHeadingProps) => { + const { headingId } = useDrawerContext(); + const HeadingLevel = `h${level}` as 'h1'; + return ( + + {children} + + ); +}; +DrawerHeading.displayName = 'DrawerHeading'; + export default Drawer; +export { Drawer, DrawerHeading }; diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 91f03e754..0991702aa 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -140,7 +140,7 @@ export { default as Popover } from './components/Popover'; export { default as Timeline, TimelineItem } from './components/Timeline'; export { default as TextEllipsis } from './components/TextEllipsis'; export { default as CopyButton } from './components/CopyButton'; -export { default as Drawer } from './components/Drawer'; +export { default as Drawer, DrawerHeading } from './components/Drawer'; export { default as BottomSheet } from './components/BottomSheet'; export { default as AnchoredOverlay } from './components/AnchoredOverlay'; export { default as FieldGroup } from './components/FieldGroup';