From cfc6b90bd7928bc07c778ee1eba5500951fce78c Mon Sep 17 00:00:00 2001 From: Cyril Date: Thu, 15 Jan 2026 10:23:58 +0100 Subject: [PATCH] =?UTF-8?q?=E2=99=BF=EF=B8=8F(frontend)=20add=20visible=20?= =?UTF-8?q?focus=20ring=20on=20restore=20focus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show focus ring when restoring focus, keyboard navigation only --- src/frontend/src/hooks/useRestoreFocus.ts | 44 ++++++++++++++++++++++- src/frontend/src/styles/index.css | 4 ++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/hooks/useRestoreFocus.ts b/src/frontend/src/hooks/useRestoreFocus.ts index 359003b6..da5cd5cb 100644 --- a/src/frontend/src/hooks/useRestoreFocus.ts +++ b/src/frontend/src/hooks/useRestoreFocus.ts @@ -26,8 +26,21 @@ export function useRestoreFocus( const prevIsOpenRef = useRef(false) const triggerRef = useRef(null) + const cleanupRef = useRef<(() => void) | null>(null) + const lastInteractionRef = useRef<'keyboard' | 'mouse' | null>(null) useEffect(() => { + // Track last interaction type (like native :focus-visible behavior) + const handleKeyDown = () => { + lastInteractionRef.current = 'keyboard' + } + const handleMouseDown = () => { + lastInteractionRef.current = 'mouse' + } + + document.addEventListener('keydown', handleKeyDown) + document.addEventListener('mousedown', handleMouseDown) + const wasOpen = prevIsOpenRef.current // Just opened @@ -41,7 +54,28 @@ export function useRestoreFocus( if (wasOpen && !isOpen) { const trigger = triggerRef.current if (trigger && document.contains(trigger)) { - const focus = () => trigger.focus({ preventScroll }) + const focus = () => { + trigger.focus({ preventScroll }) + // Only show focus ring if last interaction was keyboard (like native :focus-visible) + if (lastInteractionRef.current === 'keyboard') { + trigger.setAttribute('data-focus-visible', '') + // Remove focus ring when focus moves to another element + const handleFocusChange = (e: FocusEvent) => { + if (e.target !== trigger && document.contains(trigger)) { + trigger.removeAttribute('data-focus-visible') + } + } + document.addEventListener('focusin', handleFocusChange, { + once: true, + }) + // Store cleanup for unmount case + cleanupRef.current = () => { + if (document.contains(trigger)) { + trigger.removeAttribute('data-focus-visible') + } + } + } + } if (restoreFocusRaf) requestAnimationFrame(focus) else focus() } @@ -50,6 +84,14 @@ export function useRestoreFocus( } prevIsOpenRef.current = isOpen + + // Cleanup: remove focus ring if component unmounts before focus changes + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.removeEventListener('mousedown', handleMouseDown) + cleanupRef.current?.() + cleanupRef.current = null + } }, [ isOpen, onClosed, diff --git a/src/frontend/src/styles/index.css b/src/frontend/src/styles/index.css index 2aabeb8d..906159af 100644 --- a/src/frontend/src/styles/index.css +++ b/src/frontend/src/styles/index.css @@ -25,7 +25,9 @@ body, [data-rac][data-focus-visible]:not(label, .react-aria-Select), :is(a, button, input[type='text'], select, textarea):not( [data-rac] - ):focus-visible { + ):focus-visible, +/* Show focus ring when data-focus-visible is set programmatically (e.g., when restoring focus) */ +[data-focus-visible]:is(a, button, input[type='text'], select, textarea):focus { outline: 2px solid var(--colors-focus-ring); outline-offset: 1px; }