Fleet UI: Keyboard focus enters, traps in, and restores from modals#49437
Fleet UI: Keyboard focus enters, traps in, and restores from modals#49437RachelElysia wants to merge 8 commits into
Conversation
Wrap the modal container in Radix FocusScope so keyboard focus moves into the modal on open, stays trapped while it's open, and returns to the previously focused element on close. Enabled modals autofocus the first control in the content section; disabled modals keep the header close button as the initial focus target so the user can still dismiss. Part of #23266. Supersedes #25020.
When the trigger button's Enter or Space is held long enough for the browser's autorepeat to fire (as short as ~500ms on default macOS settings), the modal opens on the first keydown and its own document keydown listeners then catch the autorepeated Enter, submitting the form before the user has interacted with anything. - Modal's mount autofocus now targets the container (tabIndex=-1) instead of the first content button, so a still-held Enter can't activate a newly-focused Save. First user Tab moves into content. - Modal's built-in onEnter handler and ManageQueryAutomationsModal's local document keydown listener both guard on event.repeat so the trigger's continued keypress can't fire the submit.
The container div is focused programmatically (tabIndex=-1) as a keyboard landing target when the modal opens; it isn't an interactive control, so :focus / :focus-visible shouldn't paint a ring on the entire modal panel.
FocusScope's tab-loop was wrapping focus back to the close button before reaching later controls in modals whose contents transition from disabled to enabled (e.g. the SoftwareFiltersModal severity filters after toggling "Vulnerable software" on). FocusScope decides where to wrap by walking the DOM at Tab time and filtering with getComputedStyle-based visibility checks; something in the enabled render pass made later controls look "hidden" to that walker even though they were fully rendered and tabbable in the browser. Replace the FocusScope wrapper with a small useEffect that: - focuses the container (or the close X for disabled-content modals) on mount, - listens for focusout events and redirects focus back to the container if it tries to leave, providing wrap-around without a getTabbableEdges computation, - restores focus to the previously focused element on unmount. Native browser Tab handles ordering, so any control the browser considers tabbable is reachable. Drops the @radix-ui/react-focus-scope direct dep — no more transitive-vs-direct nuance.
Drop the isContentDisabled special case, the handleFocusIn listener, and the last-focused-inside ref — none of them earned their weight now that focusout redirects escaping focus back to the container. Empty deps array so a mid-life state change won't re-run the effect and steal focus.
| const closeOrSaveWithEnterKey = (event: KeyboardEvent) => { | ||
| // Skip autorepeated keys: the trigger button's Enter may still be | ||
| // held when the modal mounts and this listener attaches. | ||
| if (event.repeat) return; |
There was a problem hiding this comment.
This prevents a bug I saw where if I used enter to open a modal that has enter to submit, it would submit and close the modal automagically on open >.<
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #49437 +/- ##
==========================================
+ Coverage 67.98% 68.05% +0.06%
==========================================
Files 3827 3847 +20
Lines 241078 241886 +808
Branches 12725 13175 +450
==========================================
+ Hits 163903 164608 +705
- Misses 62299 62402 +103
Partials 14876 14876
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
When a modal has isHidden set (Fleet's pattern for cosmetically hiding one modal while another stacks on top), the top modal owns focus. The bottom modal must not fight for it. Read isHidden via a latest-value ref so the focusout listener can consult it without re-running the mount effect (which would re-steal focus every time isHidden toggled). Add tests for the focus-trap redirect, the isHidden bailout, and the autorepeat-Enter guard.
…tore-guard, dedupe Enter listener - Add changes/23266-modal-focus-scope for the release notes. - Split the isHidden test into a mount-guard case and a focusout-guard case so each code path is exercised. - Only restore focus on unmount if the modal actually took focus, so a modal mounted with isHidden=true doesn't yank focus on close. - Route Enter submission through Modal's onEnter prop instead of a duplicate document-level keydown listener inside ManageQueryAutomationsModal. Modal's onEnter now also skips when isHidden, fixing a latent bug where Enter in the Preview Data modal would submit the underlying automations form.
…-state rationale - ManageQueryAutomationsModal: fold the two near-duplicate submit helpers into one shared submitSelected() that both onEnter and the Save button reach through. Drops the queryItems?.forEach defensive optional-chain (state defaults to []). - Rewrite the "autorepeat" changelog bullet in user-facing language. - Comment the isHiddenRef.current = isHidden write-during-render as the deliberate latest-value pattern, so a future reader doesn't litigate ref-vs-state.
Self-review (v4)OverviewSame shape as v3 with review cleanups applied: Must-fixNone. Should-fixNone. Nice-to-have1. 2. Missing dialog ARIA semantics. Still no Style / conventions
Correctness
Performance / securityNothing new. One document VerdictReady to ship. The |
There was a problem hiding this comment.
Warning
- Copilot's review of this pull request may be incomplete because some of the changed files are excluded by your Copilot content exclusion settings. See Excluding content from Copilot for details.
Pull request overview
Improves keyboard accessibility for Fleet modals by ensuring focus moves into the modal on open, is trapped within the modal while it’s active (including stacked modal behavior via isHidden), and is restored to the prior element on close. It also consolidates “Enter to submit” handling to use the shared Modal.onEnter path.
Changes:
- Added modal focus management: focus container on mount, trap focus via
focusout, and restore focus on unmount; plusisHidden/event.repeatguards foronEnter. - Removed a duplicate document-level Enter listener from
ManageQueryAutomationsModaland routed Enter behavior throughModal’sonEnter. - Added focus-management tests for Modal and updated modal container styling.
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| frontend/pages/queries/ManageQueriesPage/components/ManageQueryAutomationsModal/ManageQueryAutomationsModal.tsx | Removes local document Enter handler and uses Modal.onEnter for submit behavior. |
| frontend/components/Modal/Modal.tsx | Implements focus-in/trap/restore logic, adds isHidden + event.repeat guards for Enter, and adjusts close button props. |
| frontend/components/Modal/Modal.tests.tsx | Adds a focus-management test suite covering focus-in, trap, isHidden, event.repeat, and restore-on-close behavior. |
| frontend/components/Modal/_styles.scss | Suppresses browser outline on the focused modal container. |
Files excluded by content exclusion policy (1)
- changes/23266-modal-focus-scope
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const handleFocusOut = (e: FocusEvent) => { | ||
| if (isHiddenRef.current) return; | ||
| const next = e.relatedTarget as Node | null; | ||
| if (next && !container.contains(next)) container.focus(); | ||
| }; |
| it("redirects focus back to the container when it tries to escape", async () => { | ||
| render( | ||
| <> | ||
| <button type="button" data-testid="outside"> | ||
| Outside | ||
| </button> | ||
| <Modal title="Confirm" onExit={noop}> | ||
| <button type="button">Submit</button> | ||
| </Modal> | ||
| </> | ||
| ); | ||
|
|
||
| const modalContainer = document.querySelector( | ||
| ".modal__modal_container" | ||
| ) as HTMLElement; | ||
| await waitFor(() => expect(modalContainer).toHaveFocus()); | ||
|
|
||
| // Simulate focus escaping the modal (Tab past the last control, or a | ||
| // stray focus() call landing on background UI). | ||
| fireEvent.focusOut(modalContainer, { | ||
| relatedTarget: screen.getByTestId("outside"), | ||
| }); | ||
|
|
||
| expect(modalContainer).toHaveFocus(); | ||
| }); |
| if ( | ||
| tookFocus && | ||
| previouslyFocused && | ||
| document.body.contains(previouslyFocused) | ||
| ) { |
WalkthroughThe modal now focuses its container on open, keeps focus inside the active modal, avoids interfering with hidden stacked modals, ignores repeated Enter keydowns, and restores focus on close. Its container focus styling was updated and tests cover these behaviors. Automation submission was centralized and connected to the modal’s Enter handler, replacing the document-level keyboard listener. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 3
🧹 Nitpick comments (1)
frontend/components/Modal/Modal.tests.tsx (1)
238-242: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winExercise real focus changes in these tests.
fireEvent.focusOutdoes not move focus torelatedTarget, so the escape assertion passes even without the trap. The hidden-mount test focuses its control after rendering, masking any focus steal. Calloutside.focus()for the first case, and focus an existing control before mounting/rerendering the hidden modal for the second.Also applies to: 245-261
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@frontend/components/Modal/Modal.tests.tsx` around lines 238 - 242, Update the focus-trap tests around the focusOut scenarios to perform real focus changes: call outside.focus() before asserting the modal container regains focus, and focus an existing control before mounting or rerendering the hidden modal. Keep the assertions intact while ensuring they validate behavior rather than synthetic fireEvent.focusOut effects.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@frontend/components/Modal/Modal.tsx`:
- Around line 90-93: Update Modal’s focus management around handleFocusOut and
the modal keyboard-boundary logic to preserve focus trapping in both directions:
Shift+Tab from the first control must wrap to the last control, while Tab from
the last control must wrap to the first without escaping through the
tabIndex={-1} container. Implement direction-aware handling or focus sentinels
using the existing modal focus references, and add tests covering both Tab
directions.
- Around line 69-72: Move the isHiddenRef.current synchronization out of render
and into a useLayoutEffect in the Modal component, updating it from the
committed isHidden value. Keep the ref available to the existing document
key/focus handlers so they continue reading the latest committed state.
In
`@frontend/pages/queries/ManageQueriesPage/components/ManageQueryAutomationsModal/ManageQueryAutomationsModal.tsx`:
- Around line 143-146: Update ManageQueryAutomationsModal’s Modal/form
interaction to remove the document-level onEnter={submitSelected} path and use
native form onSubmit for Save instead. Ensure Enter handled by Button, Cancel,
and Example data controls does not trigger submission, while the disabled Save
button remains respected.
---
Nitpick comments:
In `@frontend/components/Modal/Modal.tests.tsx`:
- Around line 238-242: Update the focus-trap tests around the focusOut scenarios
to perform real focus changes: call outside.focus() before asserting the modal
container regains focus, and focus an existing control before mounting or
rerendering the hidden modal. Keep the assertions intact while ensuring they
validate behavior rather than synthetic fireEvent.focusOut effects.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 8ec82c65-72cf-43f7-9c89-f5e80a357c45
📒 Files selected for processing (5)
changes/23266-modal-focus-scopefrontend/components/Modal/Modal.tests.tsxfrontend/components/Modal/Modal.tsxfrontend/components/Modal/_styles.scssfrontend/pages/queries/ManageQueriesPage/components/ManageQueryAutomationsModal/ManageQueryAutomationsModal.tsx
| const isHiddenRef = useRef(isHidden); | ||
| // Writing during render is deliberate — React's canonical pattern for | ||
| // mirroring a prop into a ref so long-lived handlers see the latest value. | ||
| isHiddenRef.current = isHidden; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n## File outline\n'
ast-grep outline frontend/components/Modal/Modal.tsx --view expanded || true
printf '\n## Relevant lines\n'
nl -ba frontend/components/Modal/Modal.tsx | sed -n '1,220p'
printf '\n## Search for isHiddenRef usage\n'
rg -n "isHiddenRef|isHidden" frontend/components/Modal/Modal.tsxRepository: fleetdm/fleet
Length of output: 717
🏁 Script executed:
#!/bin/bash
set -euo pipefail
nl -ba frontend/components/Modal/Modal.tsx | sed -n '1,260p'Repository: fleetdm/fleet
Length of output: 191
🌐 Web query:
React docs mutating refs during render latest value concurrent rendering useLayoutEffect stale closures
💡 Result:
In React, you should not read or write to ref.current during the render phase [1][2][3]. Doing so violates the rules of purity for components and hooks [4], leading to unpredictable behavior, especially under Concurrent Rendering [5][6][7]. Why mutating refs during render is unsafe: 1. Concurrent Rendering Risks: React's concurrent architecture allows rendering to be interrupted, paused, or aborted [8][7]. If you mutate a ref during a render that is subsequently discarded, the ref remains in a mutated state, leading to inconsistent UI or bugs that are difficult to track [9][6][7]. 2. Predictability: React does not know when a ref is updated because changing ref.current does not trigger a re-render [1][2][3]. Relying on this value during render makes your component's behavior dependent on the specific timing of React's internal execution, rather than the defined lifecycle [1][3]. 3. Exceptions: The only supported exception is lazy initialization (e.g., if (!ref.current) ref.current = new Thing()), which is considered safe because it is idempotent and only occurs once [1][2][5]. Solving Stale Closures with Refs and useLayoutEffect: To handle scenarios like accessing the latest version of a callback or prop inside a memoized function without causing stale closures, a common pattern is to sync the latest value to a ref using useLayoutEffect [10][11]. Because useLayoutEffect runs synchronously after DOM mutations but before the browser paints [12], it is the correct place to update a ref with the "latest" value from a render [10]. Pattern Example: const latestCallback = useRef(callback); // Sync the callback to the ref after every render useLayoutEffect( => { latestCallback.current = callback; }); // Use the ref in a memoized function without adding it to the dependency array const memoizedFn = useMemo( => { return (...args) => latestCallback.current(...args); }, [delay]); // Only re-run if dependencies change, not when the callback updates This pattern ensures that while your memoized function (e.g., a debounced handler) maintains a stable reference, it can always reach into the ref to retrieve the most up-to-date logic when it is eventually invoked [10][11]. Avoid including ref.current in dependency arrays of useEffect or useMemo, as the ref's mutation will not trigger the re-execution of these hooks [10][13].
Citations:
- 1: https://react.dev/reference/react/useRef
- 2: https://react.dev/learn/referencing-values-with-refs
- 3: https://az.react.dev/learn/referencing-values-with-refs
- 4: https://react.dev/reference/rules/components-and-hooks-must-be-pure
- 5: react/react@c59c3dfe5
- 6: Add ESLint Rule for Safe Ref Usage react/react#19095
- 7: https://stackoverflow.com/questions/68025789/is-it-safe-to-change-a-refs-value-during-render-instead-of-in-useeffect
- 8: https://legacy.reactjs.org/blog/2022/03/29/react-v18.html
- 9: [Compiler Bug]: Compiler doesn't bail out when reading or writing
ref.currentduring a render react/react#29161 - 10: https://www.epicreact.dev/the-latest-ref-pattern-in-react
- 11: https://stackoverflow.com/questions/66616061/react-weird-stale-closure-issue-with-useref
- 12: https://react.dev/reference/react/useLayoutEffect
- 13: useEffect(effect, [ref.current]) is prematurely re-running react/react#14387
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n## isHiddenRef occurrences\n'
rg -n "isHiddenRef|isHidden" frontend/components/Modal/Modal.tsx || true
printf '\n## Modal.tsx excerpt\n'
sed -n '1,260p' frontend/components/Modal/Modal.tsxRepository: fleetdm/fleet
Length of output: 9326
Move isHiddenRef sync into useLayoutEffect.
Writing the ref during render can let a discarded concurrent render leak an uncommitted isHidden value into the document key/focus handlers. Sync it after commit instead.
Proposed fix
-import React, { useCallback, useEffect, useRef, useState } from "react";
+import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
const isHiddenRef = useRef(isHidden);
-isHiddenRef.current = isHidden;
+useLayoutEffect(() => {
+ isHiddenRef.current = isHidden;
+}, [isHidden]);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const isHiddenRef = useRef(isHidden); | |
| // Writing during render is deliberate — React's canonical pattern for | |
| // mirroring a prop into a ref so long-lived handlers see the latest value. | |
| isHiddenRef.current = isHidden; | |
| const isHiddenRef = useRef(isHidden); | |
| useLayoutEffect(() => { | |
| isHiddenRef.current = isHidden; | |
| }, [isHidden]); |
🧰 Tools
🪛 React Doctor (0.7.6)
[error] 72-72: This ref is mutated during render. React can replay or discard render work, so the mutation can leak from UI that never commits.
Move ref writes into an event handler or effect. Render must stay pure because React can replay or discard it. The predictable null-guarded lazy initialization pattern remains supported.
(no-ref-current-in-render)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/components/Modal/Modal.tsx` around lines 69 - 72, Move the
isHiddenRef.current synchronization out of render and into a useLayoutEffect in
the Modal component, updating it from the committed isHidden value. Keep the ref
available to the existing document key/focus handlers so they continue reading
the latest committed state.
Source: Linters/SAST tools
| const handleFocusOut = (e: FocusEvent) => { | ||
| if (isHiddenRef.current) return; | ||
| const next = e.relatedTarget as Node | null; | ||
| if (next && !container.contains(next)) container.focus(); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Map the modal component structure first.
ast-grep outline frontend/components/Modal/Modal.tsx --view expanded || true
# Show line numbers around the cited section.
sed -n '1,220p' frontend/components/Modal/Modal.tsx
# Search for modal focus-related tests/usages.
rg -n "Shift\\+Tab|shift\\+tab|focus trap|tabIndex=\\{-1\\}|relatedTarget|handleFocusOut|container.focus|focus sentinel|sentinel" frontend/components -g '*{test,spec}.{ts,tsx,js,jsx}' -g '*.ts' -g '*.tsx'Repository: fleetdm/fleet
Length of output: 9399
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf 'noop\n'Repository: fleetdm/fleet
Length of output: 156
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n== Modal.tsx (around focus handling and container markup) ==\n'
sed -n '70,110p' frontend/components/Modal/Modal.tsx
printf '\n'
sed -n '230,265p' frontend/components/Modal/Modal.tsx
printf '\n== Modal.tests.tsx (around cited focus tests) ==\n'
sed -n '210,320p' frontend/components/Modal/Modal.tests.tsxRepository: fleetdm/fleet
Length of output: 6830
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Search for any keyboard-based tab trapping tests around the modal.
rg -n "Tab|Shift|focusout|toHaveFocus|tabIndex=-1|container.focus|relatedTarget" frontend/components/Modal frontend/components -g '*{test,spec}.tsx' -g '*{test,spec}.ts'Repository: fleetdm/fleet
Length of output: 151
Preserve reverse-Tab wrapping at the modal boundary. frontend/components/Modal/Modal.tsx:90-93,247-249 — Shift+Tab from the first control can land on the tabIndex={-1} container and then escape again, so keyboard users won’t wrap to the last control. Add direction-aware boundary handling or focus sentinels, and cover both Tab directions with tests.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@frontend/components/Modal/Modal.tsx` around lines 90 - 93, Update Modal’s
focus management around handleFocusOut and the modal keyboard-boundary logic to
preserve focus trapping in both directions: Shift+Tab from the first control
must wrap to the last control, while Tab from the last control must wrap to the
first without escaping through the tabIndex={-1} container. Implement
direction-aware handling or focus sentinels using the existing modal focus
references, and add tests covering both Tab directions.
| <Modal | ||
| title="Manage automations" | ||
| onExit={onCancel} | ||
| onEnter={submitSelected} |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Do not route this interactive modal through document-level onEnter.
Button handles Enter before it bubbles, so Save calls submitSelected twice; Cancel and Example data perform their action and then also submit. This path also bypasses the disabled Save button. Prefer a native <form onSubmit> and remove onEnter, or make Modal ignore handled/interactive-target Enter events.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@frontend/pages/queries/ManageQueriesPage/components/ManageQueryAutomationsModal/ManageQueryAutomationsModal.tsx`
around lines 143 - 146, Update ManageQueryAutomationsModal’s Modal/form
interaction to remove the document-level onEnter={submitSelected} path and use
native form onSubmit for Save instead. Ensure Enter handled by Button, Cancel,
and Example data controls does not trigger submission, while the disabled Save
button remains respected.
| // already owns focus; grabbing focus on unmount would jump the user back | ||
| // to a stale location. | ||
| const tookFocus = !isHiddenRef.current; | ||
| if (tookFocus) container.focus(); |
There was a problem hiding this comment.
I thought this might steal focus from an autofocused input field, and it looks like it does:
Modal.steals.focus.from.autofocused.input.mov
We might skip this if the container already has a document.activeElement, or focus on the first focusable child element.
| if (onEnter) { | ||
| const closeOrSaveWithEnterKey = (event: KeyboardEvent) => { | ||
| // Ignore Enter while a stacked modal is on top of this one. | ||
| if (isHiddenRef.current) return; |
There was a problem hiding this comment.
Seems like this was added here but not to the Escape handler further up? When I hit Esc, it closes both modals:
one.esc.closes.both.stacked.modals.mov
| // A modal mounted with isHidden=true is stacked behind another one that | ||
| // already owns focus; grabbing focus on unmount would jump the user back | ||
| // to a stale location. | ||
| const tookFocus = !isHiddenRef.current; |
There was a problem hiding this comment.
I think this means that a modal that mounts with isHidden=true and gets shown later won't grab focus (or restore focus on close)?
| const next = e.relatedTarget as Node | null; | ||
| if (next && !container.contains(next)) container.focus(); | ||
| }; | ||
| document.addEventListener("focusout", handleFocusOut); |
There was a problem hiding this comment.
I wonder if we should add this to the current container instead of document? 🤔
Issue
Part of #23266. Supersedes #25020.
Description
This is a part of an initiative I started over 1.5 years ago, I had a draft that wasn't quite working perfectly sitting in the fleet repo ever since so I wouldn't forget. I took the time yesterday/today to have Claude have a crack at it. Some massaging and it even caught the instance where one modal opens another modal wouldn't work, and now it's working amazing across the app.
focus()call, it's redirected back to the container. Native browser Tab handles ordering inside.isHidden(e.g. Preview Data on top of Manage Automations), it stops taking focus and its focusout /onEnterhandlers stand down, so the top modal owns focus without interference.onEnteron the still-pressed keydown and auto-submit the form.event.repeatguard added inModal.onEnter.ManageQueryAutomationsModal.tsx— it now routes throughModal.onEnter.Screenrecording
BEFORE Notice I opened the modal with my keyboard but the keyboard focus doesn't move to the modal
Screen.Recording.2026-07-16.at.1.23.48.PM.mov
AFTER ✨
Screen.Recording.2026-07-16.at.11.27.41.AM.mov
Testing
Modal.tests.tsx(mount, escape redirect,isHiddenmount guard,isHiddenfocusout guard,event.repeatguard, restore-on-close). All 15 Modal tests pass.Checklist for submitter
Summary by CodeRabbit
Accessibility Improvements
Bug Fixes