FN-7562: fix mobile oversight menu auto-focus opening a second native select popup

Fixes the mobile oversight overflow menu rendering two overlapping menus by narrowing auto-focus to actionable button menuitems.

- Change the menu-open auto-focus effect to query `button.detail-oversight-menu-item` instead of matching any `.detail-oversight-menu-item` element, since the native level `<select>` also carried that class and was matched first.
- Programmatically focusing the native `<select>` triggered its OS option picker, which rendered as a second overlapping menu on top of the custom `role="menu"` popover.
- Add regression tests covering: auto-focus landing on the first button menuitem when nudge/stop/explain are available, no fallback focus on the select when oversight is off (level-only state), and confirming the desktop inline select is unaffected.
- Update dashboard-guide.md docs to describe the fixed auto-focus behavior.

Files changed:
 docs/dashboard-guide.md                                                    |  2 +-
 packages/dashboard/app/components/TaskDetailModal.tsx                      |  3 +-
 .../app/components/__tests__/TaskDetailModal.oversight-mobile.test.tsx     | 96 ++++++++++++++++++++++
 3 files changed, 99 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-7562

Fusion-Task-Lineage: f39068f1-811f-4663-ade1-ff020a389ae5

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-04 22:01:04 -07:00
parent 6e4c207a7f
commit 1d69dec087
3 changed files with 99 additions and 2 deletions

View File

@@ -3353,12 +3353,13 @@ export function TaskDetailContent({
firstMenuItem?.focus();
}, [showMoveMenu]);
// FNXC:PlannerOversight 2026-07-04-00:00: FN-7562 — auto-focus the first actionable button menuitem, never the native oversight-level <select>; focusing the <select> surfaced its OS picker as a second menu overlapping the custom oversight popover on mobile.
useEffect(() => {
if (!showOversightMenu) {
return;
}
const firstMenuItem = oversightMenuRef.current?.querySelector<HTMLButtonElement | HTMLSelectElement>(".detail-oversight-menu-item");
const firstMenuItem = oversightMenuRef.current?.querySelector<HTMLButtonElement>("button.detail-oversight-menu-item");
firstMenuItem?.focus();
}, [showOversightMenu]);

View File

@@ -384,6 +384,102 @@ describe("TaskDetailModal oversight controls — mobile overflow menu", () => {
expect(trigger).toHaveAttribute("aria-expanded", "false");
});
/*
FNXC:PlannerOversight 2026-07-04-00:00:
FN-7562 regression coverage — the menu-open auto-focus effect used to
select `.detail-oversight-menu-item` generically, which matched the native
`<select>` FIRST (it carries that class too) and focused it. Focusing a
native `<select>` programmatically surfaces its OS option picker, which
rendered as a second menu overlapping the custom `role="menu"` popover.
These tests assert the fixed invariant: auto-focus lands on an actionable
button menuitem (never the select), and only one `role="menu"` surface is
ever present, across both the active-overseer state and the oversight-off
(level-only) state.
*/
it("auto-focuses the first button menuitem (never the native select) when nudge/stop/explain are available", async () => {
render(
<TaskDetailModal
task={makeTask({ id: "FN-213", column: "in-progress", plannerOversightLevel: "autonomous", plannerOverseerState: activeSnapshot })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const trigger = await screen.findByTestId("detail-oversight-menu-trigger");
fireEvent.click(trigger);
const select = await screen.findByTestId("detail-oversight-level-select");
const nudgeBtn = await screen.findByTestId("detail-overseer-nudge");
await waitFor(() => {
expect(document.activeElement).toBe(nudgeBtn);
});
expect(document.activeElement).not.toBe(select);
// Exactly one menu surface renders — the custom popover — and the native
// select stays a closed control (jsdom/browsers do not spawn a second
// top-level popup unless the element is actually focused).
expect(screen.getAllByRole("menu")).toHaveLength(1);
});
it("does not fall back to focusing the native select when oversight is off and only the level control renders", async () => {
render(
<TaskDetailModal
task={makeTask({ id: "FN-214", column: "todo", plannerOversightLevel: "off" })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
const trigger = await screen.findByTestId("detail-oversight-menu-trigger");
fireEvent.click(trigger);
const select = await screen.findByTestId("detail-oversight-level-select");
// No button menuitem exists in this state (nudge/stop/explain are all
// absent), so the auto-focus effect must not fall back to the select.
expect(screen.queryByTestId("detail-overseer-nudge")).not.toBeInTheDocument();
expect(screen.queryByTestId("detail-overseer-stop")).not.toBeInTheDocument();
expect(screen.queryByTestId("detail-overseer-explain")).not.toBeInTheDocument();
await waitFor(() => {
expect(document.activeElement).not.toBe(select);
});
expect(screen.getAllByRole("menu")).toHaveLength(1);
});
it("desktop inline oversight select is unaffected by the mobile auto-focus fix", async () => {
setViewportWidth(DESKTOP_WIDTH);
render(
<TaskDetailModal
task={makeTask({ id: "FN-215", column: "in-progress", plannerOversightLevel: "autonomous", plannerOverseerState: activeSnapshot })}
onClose={noop}
onMoveTask={noopMove}
onDeleteTask={noopDelete}
onMergeTask={noopMerge}
onOpenDetail={noopOpenDetail}
addToast={noop}
/>,
);
// Desktop renders the inline native select directly (no overflow trigger,
// no custom popover) — confirm that surface is untouched by this fix.
expect(screen.queryByTestId("detail-oversight-menu-trigger")).not.toBeInTheDocument();
const select = await screen.findByTestId("detail-oversight-level-select");
expect(select).toBeInTheDocument();
expect(screen.queryByRole("menu")).not.toBeInTheDocument();
setViewportWidth(MOBILE_WIDTH);
});
it("click-outside closes the menu", async () => {
render(
<>