fix: add accessible dialog semantics to Drawer#2434
Conversation
|
cqh seems not to be a GitHub user. You need a GitHub account to be able to sign the CLA. If you have already a GitHub account, please add the email address used for this commit to your account. You have signed the CLA already but the status is still pending? Let us recheck it. |
rheisler-deque
left a comment
There was a problem hiding this comment.
Thanks for taking this on! We appreciate you helping us make Cauldron better!
Note
I'm leaving the review below for anyone to work on whether or not the original author completes the Contributor License Agreement. We can't merge this PR until they do, but if they don't, we can recreate it. Either way, I think my feedback gets us closer to what we want.
The structure follows Dialog as the ticket asked for, but there are a few issues to resolve before merging. Most issues are inline below, but there are a few in this comment as well.
Documentation
Please update the docs/pages/components/Drawer.mdx examples to document the naming options. As written, they'd warn and demonstrate an unnamed drawer.
PR Name
Since this changes the public API of the component and adds functionality, the PR title should start with feat: instead of fix:. The main reason is so it will increment the minor version on next release.
Heading Prop
Please add an optional heading prop like Dialog has, so the name requirement is discoverable from DrawerProps, with DrawerHeading kept for custom headers. It would take string | { text: React.ReactNode; level?: number };
When set, render a DrawerHeading from it inside the provider, before children:
{heading != null && (
<DrawerHeading level={typeof heading === 'object' ? heading.level : undefined}>
{typeof heading === 'object' ? heading.text : heading}
</DrawerHeading> )}Use the prop or a DrawerHeading child, not both. Passing both would render two heading elements with the same ID.
Focus issue
My team encountered an issue just last week: When the drawer opens, focus moves to the drawer container. The focus indicator on the container creates a bad UX - it shows a pink line outside the drawer by a few pixels, and only on one side. See the screenshow below.
Since this PR is about the drawer's accessibility and adding a heading to it, I think it makes sense to do as part of this PR. Could you please move focus to the heading when the drawer opens. Dialog does this already. The ticket didn't ask for this originally, but I've updated it to include this detail
Concretely: give DrawerHeading a ref and tabIndex={-1}, carry that ref through DrawerContext (like DialogContext.headingRef), and pass it to useFocusTrap as initialFocusElement. That removes the full-drawer focus ring and still announces the title on open.
| 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]); |
There was a problem hiding this comment.
Issue (blocking): This throws whenever there's no DrawerHeading. That breaks two cases:
- an existing drawer with no heading crashes on upgrade, and
- a drawer correctly named with
aria-labelgets flagged anyway. The drawer still renders, so this should warn, not crash.
Suggested change — accept any accessible name, and use console.error:
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]);ariaLabel/ariaLabelledby come from the destructure in the next comment. Keep throw only for the useDrawerContext case below, where nothing can render.
| })} | ||
| aria-hidden={!open || undefined} | ||
| aria-modal={isModal ? true : undefined} | ||
| aria-labelledby={headingId} |
There was a problem hiding this comment.
Issue (blocking): aria-labelledby is always set to headingId. If there's no DrawerHeading, it points at an id that doesn't exist. And if the caller passes aria-label, the drawer ends up with both a real label and a dangling reference.
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 aria-labelledby wins, an aria-label names the dialog cleanly, and the auto id is the fallback.
| export interface DrawerHeadingProps extends React.HTMLAttributes<HTMLHeadingElement> { | ||
| children: React.ReactNode; | ||
| className?: string; | ||
| level?: number; | ||
| } |
There was a problem hiding this comment.
level is typed as number, so level={7} would render an invalid <h7>. Narrowing it to 1 | 2 | 3 | 4 | 5 | 6 would catch that at compile time. DialogHeading has the same issue, but while were adding this, let's fix it here.
| const DrawerHeading = ({ | ||
| children, | ||
| className, | ||
| level = 2, | ||
| ...other | ||
| }: DrawerHeadingProps) => { | ||
| const { headingId } = useDrawerContext(); | ||
| const HeadingLevel = `h${level}` as 'h1'; | ||
| return ( | ||
| <HeadingLevel | ||
| className={classnames('Drawer__heading', className)} | ||
| id={headingId} | ||
| {...other} | ||
| > | ||
| {children} | ||
| </HeadingLevel> | ||
| ); | ||
| }; |
There was a problem hiding this comment.
No test asserts the heading's level. If the default or the h${level} logic regressed, the suite would still pass. Please add a test that checks the rendered level for both the default and a passed-in level.
| function useDrawerContext(): DrawerContextValue { | ||
| const context = useContext(DrawerContext); | ||
| if (!context) { | ||
| throw new Error( | ||
| 'Drawer compound components must be rendered within a Drawer' | ||
| ); | ||
| } | ||
| return context; | ||
| } |
There was a problem hiding this comment.
Please add a test to check that this guard throws if DrawerHeading is rendered outside a Drawer.
| ...other | ||
| }: DrawerHeadingProps) => { | ||
| const { headingId } = useDrawerContext(); | ||
| const HeadingLevel = `h${level}` as 'h1'; |
There was a problem hiding this comment.
When level is typed as 1 | 2 | 3 | 4 | 5 | 6, please also remove as 'h1'; from here. We shouldn't need the arbitrary typecast anymore because it should infer a type of h1 | h2 | h3...
Summary
Closes #2428.
Test plan
git diff --check