-
Notifications
You must be signed in to change notification settings - Fork 34
fix: add accessible dialog semantics to Drawer #2434
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import React, { createContext, useContext } from 'react'; | ||
|
|
||
| interface DrawerContextValue { | ||
| headingId: string; | ||
| } | ||
|
|
||
| const DrawerContext = createContext<DrawerContextValue | null>(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 }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<HTMLDivElement, DrawerProps>( | |
| ) => { | ||
| 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<HTMLDivElement, DrawerProps>( | |
| 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]); | ||
|
Comment on lines
+126
to
+135
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Issue (blocking): This throws whenever there's no
Suggested change — accept any accessible name, and use useEffect(() => {
if (process.env.NODE_ENV === 'production' || !open || !drawerRef.current) return;
const hasHeading = !!drawerRef.current.querySelector('.Drawer__heading');
if (!hasHeading && !ariaLabel && !ariaLabelledby) {
console.error(
'Drawer: no accessible name. Add a DrawerHeading, a `heading` prop, or an `aria-label`.'
);
}
}, [open, ariaLabel, ariaLabelledby]);
|
||
|
|
||
| const contextValue: DrawerContextValue = useMemo( | ||
| () => ({ | ||
| headingId | ||
| }), | ||
| [headingId] | ||
| ); | ||
|
|
||
| const portalElement = resolveElement(portal); | ||
|
|
||
| return createPortal( | ||
|
|
@@ -127,6 +153,7 @@ const Drawer = forwardRef<HTMLDivElement, DrawerProps>( | |
| > | ||
| <div | ||
| ref={drawerRef} | ||
| role="dialog" | ||
| className={classnames(className, 'Drawer', { | ||
| 'Drawer--open': !!open, | ||
| 'Drawer--top': position === 'top', | ||
|
|
@@ -135,14 +162,18 @@ const Drawer = forwardRef<HTMLDivElement, DrawerProps>( | |
| 'Drawer--right': position === 'right' | ||
| })} | ||
| aria-hidden={!open || undefined} | ||
| aria-modal={isModal ? true : undefined} | ||
| aria-labelledby={headingId} | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Issue (blocking): Suggested change — read the label props and set it conditionally: const { 'aria-label': ariaLabel, 'aria-labelledby': ariaLabelledby, ...rest } = props;
const labelledBy = ariaLabelledby ?? (ariaLabel ? undefined : headingId);
// on the div: aria-label={ariaLabel} aria-labelledby={labelledBy} {...rest}Now a caller's |
||
| style={{ | ||
| visibility: !open && !isTransitioning ? 'hidden' : undefined, | ||
| ...style | ||
| }} | ||
| tabIndex={open ? -1 : undefined} | ||
| {...props} | ||
| > | ||
| {children} | ||
| <DrawerContext.Provider value={contextValue}> | ||
| {children} | ||
| </DrawerContext.Provider> | ||
| </div> | ||
| </ClickOutsideListener> | ||
| <Scrim show={!!open && !!isModal} /> | ||
|
|
@@ -156,4 +187,31 @@ const Drawer = forwardRef<HTMLDivElement, DrawerProps>( | |
|
|
||
| Drawer.displayName = 'Drawer'; | ||
|
|
||
| export interface DrawerHeadingProps extends React.HTMLAttributes<HTMLHeadingElement> { | ||
| children: React.ReactNode; | ||
| className?: string; | ||
| level?: number; | ||
| } | ||
|
Comment on lines
+190
to
+194
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| const DrawerHeading = ({ | ||
| children, | ||
| className, | ||
| level = 2, | ||
| ...other | ||
| }: DrawerHeadingProps) => { | ||
| const { headingId } = useDrawerContext(); | ||
| const HeadingLevel = `h${level}` as 'h1'; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When level is typed as |
||
| return ( | ||
| <HeadingLevel | ||
| className={classnames('Drawer__heading', className)} | ||
| id={headingId} | ||
| {...other} | ||
| > | ||
| {children} | ||
| </HeadingLevel> | ||
| ); | ||
| }; | ||
|
Comment on lines
+196
to
+213
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No test asserts the heading's level. If the default or the |
||
| DrawerHeading.displayName = 'DrawerHeading'; | ||
|
|
||
| export default Drawer; | ||
| export { Drawer, DrawerHeading }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please add a test to check that this guard throws if
DrawerHeadingis rendered outside aDrawer.