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