diff --git a/app/src/App.tsx b/app/src/App.tsx index ba49415d717..b165ff01cf1 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -263,7 +263,10 @@ export function AppShellDesktop() { return ( -
+
{chromeless ? ( diff --git a/app/src/components/notices/NoticeCenter.tsx b/app/src/components/notices/NoticeCenter.tsx index bbee5aaa64e..1b0eeb2874e 100644 --- a/app/src/components/notices/NoticeCenter.tsx +++ b/app/src/components/notices/NoticeCenter.tsx @@ -95,6 +95,43 @@ export default function NoticeCenter() { const notices = useAppNotices(); const [open, setOpen] = useState(false); const panelRef = useRef(null); + const triggerRef = useRef(null); + const closeRef = useRef(null); + const wasOpenRef = useRef(false); + const previousFocusRef = useRef(null); + const containsFocusRef = useRef(false); + + // Focus enters only when the user opens the non-modal panel. On close, + // return to the bell, or to a stable app control if the bell disappears. + useEffect(() => { + const visible = open && notices.length > 0; + if (visible && !wasOpenRef.current) { + closeRef.current?.focus(); + } else if (!visible && wasOpenRef.current && containsFocusRef.current) { + // A focused notice item may unmount with a null relatedTarget, leaving + // the blur tracker stale. Preserve focus already moved to another app + // control rather than incorrectly restoring the notice trigger. + const active = document.activeElement; + const focusMovedElsewhere = + active instanceof HTMLElement && + active !== document.body && + active !== document.documentElement && + !panelRef.current?.contains(active) && + !triggerRef.current?.contains(active); + if (focusMovedElsewhere) { + wasOpenRef.current = false; + return; + } + const previous = previousFocusRef.current; + const target = + triggerRef.current ?? + (previous?.isConnected + ? previous + : document.querySelector('[data-app-focus-fallback]')); + target?.focus(); + } + wasOpenRef.current = visible; + }, [open, notices.length]); // Close on Escape while the panel is open. A fixed overlay with no dismissal // affordance beyond its own toggle is a trap for keyboard users. @@ -121,7 +158,22 @@ export default function NoticeCenter() { return (
+ data-testid="notice-center" + onFocusCapture={event => { + const previous = event.relatedTarget; + if (previous instanceof HTMLElement && !event.currentTarget.contains(previous)) { + previousFocusRef.current = previous; + } + containsFocusRef.current = true; + }} + onBlurCapture={event => { + if ( + event.relatedTarget instanceof Node && + !event.currentTarget.contains(event.relatedTarget) + ) { + containsFocusRef.current = false; + } + }}> {open && (
( +
+ +
@@ -105,6 +111,62 @@ describe('NoticeCenter', () => { expect(within(panel).getByText('API key required')).toBeInTheDocument(); }); + it('focuses the close control when opened and restores focus to the bell on close', async () => { + renderCenter([keyError]); + const trigger = screen.getByTestId('notice-trigger'); + + await userEvent.click(trigger); + expect(screen.getByTestId('notice-close')).toHaveFocus(); + + await userEvent.click(screen.getByTestId('notice-close')); + expect(screen.queryByTestId('notice-panel')).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + }); + + it('restores app focus after dismissing the last notice', async () => { + renderCenter([keyError]); + const previous = screen.getByRole('button', { name: 'Resume work' }); + previous.focus(); + await userEvent.click(screen.getByTestId('notice-trigger')); + await userEvent.click(screen.getByTestId('notice-dismiss')); + expect(screen.queryByTestId('notice-center')).not.toBeInTheDocument(); + expect(previous).toHaveFocus(); + }); + + it('falls back to persistent app content if the previous control was unmounted', async () => { + renderCenter([keyError]); + const previous = screen.getByRole('button', { name: 'Resume work' }); + previous.focus(); + await userEvent.click(screen.getByTestId('notice-trigger')); + previous.remove(); + await userEvent.click(screen.getByTestId('notice-dismiss')); + expect(screen.getByTestId('notice-fallback')).toHaveFocus(); + }); + + it('preserves outside focus when notices disappear', async () => { + const { store } = renderCenter([keyError]); + await userEvent.click(screen.getByTestId('notice-trigger')); + const previous = screen.getByRole('button', { name: 'Resume work' }); + previous.focus(); + act(() => store.dispatch(dismissUserError({ id: keyError.id }))); + expect(previous).toHaveFocus(); + }); + + it('does not steal focus after a focused notice disappears but another remains', async () => { + const { store } = renderCenter([keyError, modelWarning]); + await userEvent.click(screen.getByTestId('notice-trigger')); + const dismiss = screen.getAllByTestId('notice-dismiss')[0]; + dismiss.focus(); + act(() => store.dispatch(dismissUserError({ id: keyError.id }))); + + const outside = screen.getByRole('button', { name: 'Resume work' }); + outside.focus(); + act(() => store.dispatch(dismissUserError({ id: modelWarning.id }))); + + expect(screen.queryByTestId('notice-center')).not.toBeInTheDocument(); + expect(outside).toHaveFocus(); + }); + it('raises the usage limit as a notice that cannot be dismissed at the limit', async () => { usageState.teamUsage = { plan: 'free' }; usageState.isAtLimit = true;