Skip to content

Fleet UI: Keyboard focus enters, traps in, and restores from modals#49437

Open
RachelElysia wants to merge 8 commits into
mainfrom
23266-modal-focus-scope
Open

Fleet UI: Keyboard focus enters, traps in, and restores from modals#49437
RachelElysia wants to merge 8 commits into
mainfrom
23266-modal-focus-scope

Conversation

@RachelElysia

@RachelElysia RachelElysia commented Jul 16, 2026

Copy link
Copy Markdown
Member

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 moves into the modal on open (container is the landing target) and returns to the previously focused element on close.
  • Focus is trapped: if it tries to escape the modal via Tab or a stray focus() call, it's redirected back to the container. Native browser Tab handles ordering inside.
  • Stacked modals: when a modal is isHidden (e.g. Preview Data on top of Manage Automations), it stops taking focus and its focusout / onEnter handlers stand down, so the top modal owns focus without interference.
  • Fixes an autorepeat-Enter bug surfaced by the focus-into-modal work: holding Enter to open a modal used to fire the built-in onEnter on the still-pressed keydown and auto-submit the form. event.repeat guard added in Modal.onEnter.
  • Deduped the duplicate document-level Enter listener in ManageQueryAutomationsModal.tsx — it now routes through Modal.onEnter.
  • Modal container no longer shows a focus ring on itself; it's a programmatic landing target, not an interactive control.

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

  • Added 6 focus-management tests to Modal.tests.tsx (mount, escape redirect, isHidden mount guard, isHidden focusout guard, event.repeat guard, restore-on-close). All 15 Modal tests pass.
  • Manually QA'd on the reports/manage automations flow (autorepeat Enter no longer auto-submits) and on the software/inventory filters modal (Tab reaches every enabled control after toggling "Vulnerable software" on).

Checklist for submitter

  • Added/updated automated tests
  • QA'd all new/changed functionality manually

Summary by CodeRabbit

  • Accessibility Improvements

    • Improved modal keyboard navigation by moving focus into the modal when it opens.
    • Focus is kept within the active modal and restored to the previously focused control when it closes.
    • Improved focus handling for stacked modals.
  • Bug Fixes

    • Prevented repeated Enter key presses from submitting automation forms before the modal opens.
    • Refined modal focus visuals for a smoother experience.

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;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

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

codecov Bot commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.14286% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.05%. Comparing base (fd7a586) to head (88f1314).
⚠️ Report is 56 commits behind head on main.

Files with missing lines Patch % Lines
...ryAutomationsModal/ManageQueryAutomationsModal.tsx 0.00% 5 Missing ⚠️
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              
Flag Coverage Δ
frontend 60.21% <82.14%> (+0.60%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.
@RachelElysia
RachelElysia marked this pull request as ready for review July 16, 2026 20:19
@RachelElysia
RachelElysia requested a review from a team as a code owner July 16, 2026 20:19
Copilot AI review requested due to automatic review settings July 16, 2026 20:19
@RachelElysia

Copy link
Copy Markdown
Member Author

Self-review (v4)

Overview

Same shape as v3 with review cleanups applied: submitSelected deduped in ManageQueryAutomationsModal, changelog second bullet rewritten in user-facing language, ref-vs-state rationale commented inline on isHiddenRef.current = isHidden.

Must-fix

None.

Should-fix

None.

Nice-to-have

1. onSubmitQueryAutomations's parameter type still has | KeyboardEvent (ManageQueryAutomationsModal.tsx:135-137) — leftover from when the local keydown listener called it. After the dedupe, its only caller is the Save button's onClick, so the union is dead. Two-word cleanup if we want to sweep it in this PR.

2. Missing dialog ARIA semantics. Still no role="dialog" / aria-modal="true" / aria-labelledby. Screen-reader announcement of "dialog opened" is a real gap — recommend a follow-up issue rather than expanding this PR's scope.

Style / conventions

  • The write-during-render is deliberate comment on isHiddenRef.current = isHidden will save future reviewers from re-litigating ref-vs-state.
  • Changelog now reads as a user-facing note.
  • Test coverage matches every new code path (mount, escape redirect, isHidden mount guard, isHidden focusout guard, event.repeat guard, restore-on-close).

Correctness

  • Both tookFocus and isHiddenRef guards behave as intended: a modal mounted with isHidden=true doesn't take or restore focus; a modal that later becomes hidden stops its focusout listener from stealing focus back.
  • submitSelected is recreated per render, but that's fine — Modal.onEnter's effect re-binds anyway, matching the previous no-deps listener's freshness behavior.

Performance / security

Nothing new. One document focusout listener + one document keydown listener per open modal, both trivial. Nothing security-relevant.

Verdict

Ready to ship. The KeyboardEvent union in the Save button handler's type is the only thing worth an inline nudge, and it's a nit.

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

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; plus isHidden/event.repeat guards for onEnter.
  • Removed a duplicate document-level Enter listener from ManageQueryAutomationsModal and routed Enter behavior through Modal’s onEnter.
  • 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.

Comment on lines +90 to +94
const handleFocusOut = (e: FocusEvent) => {
if (isHiddenRef.current) return;
const next = e.relatedTarget as Node | null;
if (next && !container.contains(next)) container.focus();
};
Comment on lines +219 to +243
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();
});
Comment on lines +99 to +103
if (
tookFocus &&
previouslyFocused &&
document.body.contains(previouslyFocused)
) {
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The 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)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main change: modal keyboard focus management with trapping and restoration.
Description check ✅ Passed The description includes issue context, change summary, screenrecording, testing, and relevant checklist items, so it is mostly complete.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch 23266-modal-focus-scope

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 3

🧹 Nitpick comments (1)
frontend/components/Modal/Modal.tests.tsx (1)

238-242: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Exercise real focus changes in these tests.

fireEvent.focusOut does not move focus to relatedTarget, so the escape assertion passes even without the trap. The hidden-mount test focuses its control after rendering, masking any focus steal. Call outside.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

📥 Commits

Reviewing files that changed from the base of the PR and between 57bab9e and 88f1314.

📒 Files selected for processing (5)
  • changes/23266-modal-focus-scope
  • frontend/components/Modal/Modal.tests.tsx
  • frontend/components/Modal/Modal.tsx
  • frontend/components/Modal/_styles.scss
  • frontend/pages/queries/ManageQueriesPage/components/ManageQueryAutomationsModal/ManageQueryAutomationsModal.tsx

Comment on lines +69 to +72
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;

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.

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

Repository: 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:


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

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

Suggested change
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

Comment on lines +90 to +93
const handleFocusOut = (e: FocusEvent) => {
if (isHiddenRef.current) return;
const next = e.relatedTarget as Node | null;
if (next && !container.contains(next)) container.focus();

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.

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

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

Comment on lines 143 to +146
<Modal
title="Manage automations"
onExit={onCancel}
onEnter={submitSelected}

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.

🗄️ 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();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I wonder if we should add this to the current container instead of document? 🤔

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.

3 participants