From 7ef0dec0047c94070deb424ca3b5bafc155df101 Mon Sep 17 00:00:00 2001 From: Brian Love Date: Wed, 2 Sep 2026 17:01:38 -0700 Subject: [PATCH] fix(workspace): keep utility panel focus when the drawer opens in one commit Opening the tablet context surface from the rail mounts the utility panel and flips the drawer to open in the same commit. The panel focuses its own heading, then MobileNavOverlay's open effect ran focusable()[0].focus() -- a selector that excludes tabindex="-1" -- and took focus to the Close navigation button. Child passive effects run before parent ones, so the drawer always won. Dev hid this: StrictMode double-invokes effects of newly mounted components only, and the overlay stays mounted while closed, so the panel's second invocation landed last. The dev-server e2e job passed 85/85 while the deploy job's verify step against the production build failed 3/3 on the same commit, blocking refs/deploy/last-promoted from advancing. Claim initial drawer focus only when nothing inside it already holds focus. Co-Authored-By: Claude Opus 5 --- .../components/mobile-nav-overlay.spec.tsx | 42 ++++++++++++++++++- .../src/lib/components/mobile-nav-overlay.tsx | 5 ++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/libs/workspace-react/src/lib/components/mobile-nav-overlay.spec.tsx b/libs/workspace-react/src/lib/components/mobile-nav-overlay.spec.tsx index 9d08b43bd..d9537a949 100644 --- a/libs/workspace-react/src/lib/components/mobile-nav-overlay.spec.tsx +++ b/libs/workspace-react/src/lib/components/mobile-nav-overlay.spec.tsx @@ -32,6 +32,7 @@ const snapshot = createRuntimeSnapshot(parseRuntimeTarget(null), entry.topic); const renderOverlay = ({ initialOpen = true, + initialActiveUtility = null, onPresenceChange = vi.fn(), strict = false, variant = 'mobile', @@ -41,6 +42,7 @@ const renderOverlay = ({ onContextAction = vi.fn(), }: { initialOpen?: boolean; + initialActiveUtility?: CockpitUtility; onPresenceChange?: ReturnType; strict?: boolean; variant?: 'mobile' | 'tablet'; @@ -65,7 +67,8 @@ const renderOverlay = ({ function Harness() { const [isOpen, setIsOpen] = useState(initialOpen); - const [activeUtility, setActiveUtility] = useState(null); + const [activeUtility, setActiveUtility] = + useState(initialActiveUtility); const [, setSharedState] = useState(0); setOpen = setIsOpen; bumpSharedState = () => setSharedState((value) => value + 1); @@ -264,6 +267,43 @@ describe('MobileNavOverlay', () => { } ); + it.each([ + ['Activity', 'activity'], + ['Settings', 'settings'], + ] as const)( + 'leaves the %s panel heading focused when the utility opens the drawer', + (heading, utility) => { + // The tablet rail selects the utility and opens the context surface in + // one batch, so the panel mounts in the same commit that opens the + // dialog. The panel focuses its own heading; the drawer must not then + // pull focus back to its first tabbable control. + const result = renderOverlay({ + initialOpen: false, + initialActiveUtility: utility, + variant: 'tablet', + controlPlaneLayout: 'pane', + }); + + result.reopen(); + + expect(document.activeElement).toBe( + within(dialog()).getByRole('heading', { name: heading }) + ); + } + ); + + it('still claims drawer focus when no panel claims it first', () => { + const result = renderOverlay({ initialOpen: false }); + + result.reopen(); + + // Which control wins is the document-order first tabbable, and jsdom + // groups a selector list by selector instead of document order -- so + // assert the drawer took focus rather than naming the element. + expect(document.activeElement).not.toBe(document.body); + expect(dialog().contains(document.activeElement)).toBe(true); + }); + it.each([ ['Escape', () => fireEvent.keyDown(document, { key: 'Escape' })], [ diff --git a/libs/workspace-react/src/lib/components/mobile-nav-overlay.tsx b/libs/workspace-react/src/lib/components/mobile-nav-overlay.tsx index 349127d62..14c36fa79 100644 --- a/libs/workspace-react/src/lib/components/mobile-nav-overlay.tsx +++ b/libs/workspace-react/src/lib/components/mobile-nav-overlay.tsx @@ -327,7 +327,10 @@ export function MobileNavOverlay({ 'a[href], button:not(:disabled), [tabindex]:not([tabindex="-1"])' ) ?? [] ); - focusable()[0]?.focus(); + // A utility panel mounting in the same commit that opens the drawer + // focuses its own heading first, and child effects run before this one. + // Claiming the first tabbable control unconditionally would undo that. + if (!dialog?.contains(document.activeElement)) focusable()[0]?.focus(); const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault();