fix(ui): prevent toast from stealing focus on open#840
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Greptile OverviewGreptile SummaryThis PR fixes an annoying UX issue where Radix toast notifications steal focus from the active element (terminal, editor) when they appear. The fix tracks the last focused element outside the toast viewport using a Key changes:
Potential consideration: Confidence Score: 4/5
|
| Filename | Overview |
|---|---|
| src/renderer/components/ui/toaster.tsx | Improved focus restoration logic to prevent toast from stealing focus from active elements (terminal, editor) |
| pnpm-workspace.yaml | Added standard packages field to pnpm workspace config |
Last reviewed commit: 5d2b0d6
| useEffect(() => { | ||
| const currentToastsCount = toasts.length; | ||
| const toastsChanged = currentToastsCount !== previousToastsCount.current; | ||
| if (!toastsChanged) return; | ||
|
|
||
| if (toastsChanged) { | ||
| // Save the currently focused element when a toast appears | ||
| if (currentToastsCount > previousToastsCount.current) { | ||
| previousActiveElement.current = document.activeElement as HTMLElement; | ||
| } | ||
|
|
||
| // Only restore focus if it was stolen by the toast (i.e., current focus is on toast or body) | ||
| // Don't restore if user intentionally moved focus elsewhere | ||
| requestAnimationFrame(() => { | ||
| const currentFocus = document.activeElement; | ||
| const focusIsOnToast = currentFocus?.closest('[data-radix-toast-viewport]') !== null; | ||
| const focusIsOnBody = currentFocus === document.body; | ||
| requestAnimationFrame(() => { | ||
| const currentFocus = document.activeElement; | ||
| const focusIsOnToast = isInToastViewport(currentFocus); | ||
| const focusIsOnBody = currentFocus === document.body; | ||
| const restoreTarget = lastFocusedOutsideToast.current; | ||
|
|
||
| // Only restore if focus is on toast/body AND we have a valid saved element | ||
| if ( | ||
| (focusIsOnToast || focusIsOnBody) && | ||
| previousActiveElement.current && | ||
| document.body.contains(previousActiveElement.current) | ||
| ) { | ||
| previousActiveElement.current.focus(); | ||
| } | ||
| }); | ||
| } | ||
| if ( | ||
| (focusIsOnToast || focusIsOnBody) && | ||
| restoreTarget && | ||
| document.body.contains(restoreTarget) && | ||
| restoreTarget !== document.activeElement | ||
| ) { | ||
| restoreTarget.focus(); | ||
| } | ||
| }); | ||
|
|
||
| previousToastsCount.current = currentToastsCount; | ||
| }, [toasts.length]); |
There was a problem hiding this comment.
The focus restoration also runs when toasts are removed (count decreases), potentially re-focusing elements when the user has intentionally moved focus elsewhere during toast display.
Consider restoring focus only when count increases:
| useEffect(() => { | |
| const currentToastsCount = toasts.length; | |
| const toastsChanged = currentToastsCount !== previousToastsCount.current; | |
| if (!toastsChanged) return; | |
| if (toastsChanged) { | |
| // Save the currently focused element when a toast appears | |
| if (currentToastsCount > previousToastsCount.current) { | |
| previousActiveElement.current = document.activeElement as HTMLElement; | |
| } | |
| // Only restore focus if it was stolen by the toast (i.e., current focus is on toast or body) | |
| // Don't restore if user intentionally moved focus elsewhere | |
| requestAnimationFrame(() => { | |
| const currentFocus = document.activeElement; | |
| const focusIsOnToast = currentFocus?.closest('[data-radix-toast-viewport]') !== null; | |
| const focusIsOnBody = currentFocus === document.body; | |
| requestAnimationFrame(() => { | |
| const currentFocus = document.activeElement; | |
| const focusIsOnToast = isInToastViewport(currentFocus); | |
| const focusIsOnBody = currentFocus === document.body; | |
| const restoreTarget = lastFocusedOutsideToast.current; | |
| // Only restore if focus is on toast/body AND we have a valid saved element | |
| if ( | |
| (focusIsOnToast || focusIsOnBody) && | |
| previousActiveElement.current && | |
| document.body.contains(previousActiveElement.current) | |
| ) { | |
| previousActiveElement.current.focus(); | |
| } | |
| }); | |
| } | |
| if ( | |
| (focusIsOnToast || focusIsOnBody) && | |
| restoreTarget && | |
| document.body.contains(restoreTarget) && | |
| restoreTarget !== document.activeElement | |
| ) { | |
| restoreTarget.focus(); | |
| } | |
| }); | |
| previousToastsCount.current = currentToastsCount; | |
| }, [toasts.length]); | |
| useEffect(() => { | |
| const currentToastsCount = toasts.length; | |
| const toastsChanged = currentToastsCount !== previousToastsCount.current; | |
| const toastsIncreased = currentToastsCount > previousToastsCount.current; | |
| if (!toastsChanged || !toastsIncreased) { | |
| previousToastsCount.current = currentToastsCount; | |
| return; | |
| } | |
| requestAnimationFrame(() => { | |
| const currentFocus = document.activeElement; | |
| const focusIsOnToast = isInToastViewport(currentFocus); | |
| const focusIsOnBody = currentFocus === document.body; | |
| const restoreTarget = lastFocusedOutsideToast.current; | |
| if ( | |
| (focusIsOnToast || focusIsOnBody) && | |
| restoreTarget && | |
| document.body.contains(restoreTarget) && | |
| restoreTarget !== document.activeElement | |
| ) { | |
| restoreTarget.focus(); | |
| } | |
| }); | |
| previousToastsCount.current = currentToastsCount; | |
| }, [toasts.length]); |
Summary
requestAnimationFrameAlertCircle) and improve toast layout with flex stylingpnpm-workspace.yamlwithonlyBuiltDependenciesallowlistDetails
Radix
ToastProvidermoves focus to the toast viewport when a new toast opens, which interrupts the user's workflow (e.g. typing in a terminal or editor). This fix listens forfocusinevents to remember the last focused element outside the toast, then restores it whenever the toast count changes and focus has been stolen.