- 
                Notifications
    You must be signed in to change notification settings 
- Fork 71
feat(drawer): add focus management to embedded drawers #3139
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
base: main
Are you sure you want to change the base?
Conversation
…ore focusing previously focused element
… for improved focus handling
| 🦋 Changeset detectedLatest commit: df328af The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
 Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR | 
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.
Pull Request Overview
This PR adds comprehensive focus management to embedded drawers, enhancing accessibility and keyboard navigation. When an embedded drawer opens, focus automatically moves to the first focusable element within the drawer, and when closed, focus is restored to the previously focused element.
Key changes:
- Implements focus management logic in PanelGridcomponent for embedded drawers
- Removes redundant focus handling and ARIA attributes from existing components
- Adds interaction stories to test focus behavior for both embedded and overlay drawer modes
Reviewed Changes
Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description | 
|---|---|
| packages/drawer/src/LayoutComponent/PanelGrid/PanelGrid.tsx | Adds focus management logic with useIsomorphicLayoutEffect to handle focus transitions | 
| packages/drawer/src/DrawerToolbarLayout/DrawerToolbarLayout/DrawerToolbarLayoutContent.tsx | Removes redundant aria-live attributes that are now handled elsewhere | 
| packages/drawer/src/DrawerToolbarLayout/DrawerToolbarLayout/DrawerToolbarLayout.interactions.stories.tsx | Adds comprehensive interaction stories to test focus management behavior | 
| packages/drawer/src/Drawer/Drawer.tsx | Removes old focus handling logic and adds VisuallyHidden live region for screen reader announcements | 
| .changeset/beige-lights-kick.md | Documents the changes for release notes | 
| Size Change: +141 B (+0.01%) Total Size: 1.6 MB 
 ℹ️ View Unchanged
 | 
…sableElement for improved accessibility
…manage drawer toggle behavior and enhance focus management
…ing focus to previously focused element on close
…ractions and add resizer data attributes
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.
Pull Request Overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
| } else { | ||
| // If the previously focused element is no longer in the DOM, focus the body | ||
| // This mimics the behavior of the native HTML Dialog element | ||
| document.body.focus(); | 
    
      
    
      Copilot
AI
    
    
    
      Oct 16, 2025 
    
  
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.
[nitpick] Falling back to document.body.focus() may fail because body is not guaranteed to be focusable, leaving keyboard users without a clear focus target. Consider ensuring the fallback element is explicitly focusable (e.g., adding tabIndex='-1' to body or selecting another stable focusable ancestor) or skipping the fallback to avoid a failed focus call.
| document.body.focus(); | |
| const body = document.body; | |
| const hadTabIndex = body.hasAttribute('tabIndex'); | |
| if (!hadTabIndex) { | |
| body.setAttribute('tabIndex', '-1'); | |
| } | |
| body.focus(); | |
| if (!hadTabIndex) { | |
| body.removeAttribute('tabIndex'); | |
| } | 
| const isAriaVisible = element.getAttribute('aria-hidden') === 'false'; | ||
| const { display, opacity, visibility } = window.getComputedStyle(element); | ||
|  | ||
| const isCSSVisible = | 
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.
I'm removing this check because Drawer no longer updates these properties directly in the component, meaning the values were always the same regardless of whether the drawer was open or closed.
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.
noted, is there something else we should check in addition to the aria attribute?
| }); | ||
| }; | ||
|  | ||
| // Reusable play function for testing focus management with toolbar buttons | 
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.
looks like there is a flaky snapshot because it's taking before animations/repositioning completes. can we add snapshot delays to ensure UI is stable before capturing? https://www.chromatic.com/docs/delay/
| closeButton: `${root}-close_button`, | ||
| scrollContainer: `${root}-scroll_container`, | ||
| toolbar: `${root}-toolbar`, | ||
| resizer: `${root}-resizer`, | 
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.
curious what is the reason for including this in this PR?
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.
I can remove this. I thought I was going to end up needing it.
| const isAriaVisible = element.getAttribute('aria-hidden') === 'false'; | ||
| const { display, opacity, visibility } = window.getComputedStyle(element); | ||
|  | ||
| const isCSSVisible = | 
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.
noted, is there something else we should check in addition to the aria attribute?
| * Focuses the first focusable element in the drawer when the drawer is opened. | ||
| * Also handles restoring focus when the drawer is closed. | ||
| * | ||
| * This is only necessary for embedded drawers. Overlay drawers use the native focus behavior of the dialog element. | 
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.
queryFirstFocusableElement has a set list of focusable selectors it considers and "the native focus behavior of the dialog element" is to look for children with the autofocus attribute. However, these don't actually cover all cases that consumers may want to focus, such as a code editor.
After receiving feedback about this use case, it led to this change reintroducing the initialFocus prop in Modal. We don't use Modal and Drawer in all the same ways, but seeing this change makes me think they should follow the same focus management pattern. We may be able to use a similar method as  focusModalChildElement for the first part of this effect
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.
I like this pattern. However, I think if we go with this approach, then we might have to override the default dialog behavior since initialFocus does not work by default.
So the pattern would be in a native dialog and the embedded div:
- Drawer opens
- We check the initialFocus prop
 a) if auto, we find the autofocus attribute or the first focusable item
 b) if provided, we focus that provided element
 c) if null, we don't focus anything
We'll end up losing the native dialog focus management this way, but it does become more customizable.
However, I am curious about the modal focus management. Why do we need a ref option when you can add autofocus to the element?
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.
What is the native dialog focus management you're concerned about losing? In Modal, initialFocus is mostly additive and doesn't conflict
autofocus can only be applied to the typical focusable elements but can't be used for something like a code editor. Although the initial implementation of Modal used a css selector like "#my-code-editor", I added a ref option because it's more type-safe and robust than a string
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.
What is the native dialog focus management you're concerned about losing?
Not necessarily losing, but having to re-implement it. I noticed here that if any child has autofocus, we let the browser focus it automatically, but we manually focus the first focusable element. So in this case, aren't we manually overriding the default behavior of finding the first focusable element and re-implementing it?
leafygreen-ui/packages/modal/src/utils/focusModalChildElement.ts
Lines 44 to 54 in f6058df
| const autoFocusElement = modalElement.querySelector( | |
| '[autofocus]', | |
| ) as HTMLElement; | |
| if (autoFocusElement) { | |
| // Don't need to explicitly call `focus` because the browser handles it | |
| return autoFocusElement; | |
| } | |
| // Otherwise, focus first focusable element | |
| const firstFocusableElement = queryFirstFocusableElement(modalElement); | 
autofocus can only be applied to the typical focusable elements but can't be used for something like a code editor. Although the initial implementation of Modal used a css selector like "#my-code-editor", I added a ref option because it's more type-safe and robust than a string
Thanks for clarifying.
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.
So in this case, aren't we manually overriding the default behavior of finding the first focusable element and re-implementing it?
focusModalChildElement is the logic we use to determine how a child element should be focused. The consumer has full control using either the initialFocus prop or the autoFocus attribute on a child element. If initialFocus is not specified and a child element does not have autoFocus specified, then we query for the first focusable element. Happy to huddle on this if further clarification is needed!
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.
Yes, actually would love to huddle on this, I'll Slack you
| return; | ||
| } | ||
|  | ||
| // Restore focus when closing (only if we had handled focus during this session) | 
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.
This is starting to feel like a re-implementation of the <dialog> element's focus patterns. Is there an explicit ticket requesting this functionality? If so, I'm curious what do you think about using the <dialog> under the hood for displayMode="embedded"? Since we're using a non-modal <dialog> element, we already end up having to apply the overlay styles for displayMode="overlay". The benefit would be that we get focus management for free; trade-off is that we'd be incorrectly using a dialog element
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.
There was no ticket requesting this. I just observed that the embedded drawer was not accessible when it was opened. And you're correct, I tried to mimic the native dialog behavior. I like that if we use a non-modal dialog, then we get this behavior for free, but I don't know if using dialog incorrectly is worth it. We're trying to be more intentional, and this seems semantically incorrect. It's not really a dialog, and screen readers would announce it as a dialog as well.
Also, If we end up adding the same focus management as the modal, then we probably won't even be using the free focus management from dialog.
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.
Curious, why do you consider using a dialog incorrect in this case? Is it because a dialog is supposed to float on top of content?
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.
Yes, my understanding is that a dialog is intended for modal or non-modal overlays. What makes the embedded drawer not accessible? I'm curious what determines focus jumping to the embedded drawer as accessible vs not doing that?
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.
My concern about the embedded drawer is that, when it is opened, the focus does not shift to the drawer. It's hard to find out what the correct accessibility behavior of the embedded drawer should be since this is a div and not a dialog. Since the overlay drawer is sitting on top of the content, it makes sense that the focus should automatically move to the drawer. For the embedded drawer, the drawer opens and shifts the content, but the focus remains on the trigger (if there is a trigger). The consumer would have to do a lot of tabbing to get to the drawer.
The research I've done on this hasn't been that helpful, but I still think I should do some more investigating before moving forward with this change.
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.
I see, this sounds like it may vary depending on product usage. Can we pull design in and maybe discuss usage with product engineers before applying to all cases?
Alternatively, we could put this behind a discriminated union prop and make this opt-in. i.e.
- if overlay, rely onautoFocusbeing set (although this also may requireinitialFocus)
- if embedded, check prop to determine focus management. Could see this working in a few ways; a couple ideas are:- default focus first focusable (breaking change)
- default no focus (minor change) and allow users to specify "auto"which would focus first focusable or specify an element ref
 
✍️ Proposed changes
This PR enhances the drawer component by adding comprehensive focus management for embedded drawers. The changes ensure that when an embedded drawer is opened, focus automatically moves to the first focusable element within the drawer, and when closed, focus is restored to the previously focused element (typically the trigger button). This improves keyboard navigation and accessibility compliance.
🎟️ Jira ticket: No Jira ticket found
✅ Checklist
pnpm changesetand documented my changes🧪 How to test changes
Test embedded drawer focus management:
Test overlay drawer behavior:
Test accessibility with screen readers:
Run the new interaction stories:
EmbeddedToolbarIsFocusedOnCloseandEmbeddedButtonIsFocusedOnCloseOverlayToolbarIsFocusedOnCloseandOverlayButtonIsFocusedOnClose