Skip to content

fix: add accessible dialog semantics to Drawer#2434

Open
remove334 wants to merge 1 commit into
dequelabs:developfrom
remove334:fix/drawer-dialog-accessibility
Open

fix: add accessible dialog semantics to Drawer#2434
remove334 wants to merge 1 commit into
dequelabs:developfrom
remove334:fix/drawer-dialog-accessibility

Conversation

@remove334

Copy link
Copy Markdown

Summary

  • gives Drawer dialog content accessible dialog semantics
  • wires the dialog heading context/export so the drawer has an accessible name
  • adds focused tests for the dialog/heading behavior

Closes #2428.

Test plan

  • git diff --check
  • Full test run was not available in this local environment because dependency installation/linking was blocked by network/tooling issues.

@remove334 remove334 requested a review from a team as a code owner June 9, 2026 01:56
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.


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 rheisler-deque left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +126 to +135
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]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue (blocking): This throws whenever there's no DrawerHeading. That breaks two cases:

  1. an existing drawer with no heading crashes on upgrade, and
  2. a drawer correctly named with aria-label gets 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}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +190 to +194
export interface DrawerHeadingProps extends React.HTMLAttributes<HTMLHeadingElement> {
children: React.ReactNode;
className?: string;
level?: number;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +196 to +213
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>
);
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +9 to +17
function useDrawerContext(): DrawerContextValue {
const context = useContext(DrawerContext);
if (!context) {
throw new Error(
'Drawer compound components must be rendered within a Drawer'
);
}
return context;
}

Copy link
Copy Markdown
Contributor

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 DrawerHeading is rendered outside a Drawer.

...other
}: DrawerHeadingProps) => {
const { headingId } = useDrawerContext();
const HeadingLevel = `h${level}` as 'h1';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Drawer: missing role="dialog" and automatic accessible name

3 participants