diff --git a/src/frontend/src/features/rooms/livekit/components/Tools.tsx b/src/frontend/src/features/rooms/livekit/components/Tools.tsx index dfdae586..b8f4a364 100644 --- a/src/frontend/src/features/rooms/livekit/components/Tools.tsx +++ b/src/frontend/src/features/rooms/livekit/components/Tools.tsx @@ -2,8 +2,9 @@ import { A, Div, Text } from '@/primitives' import { css } from '@/styled-system/css' import { Button as RACButton } from 'react-aria-components' import { useTranslation } from 'react-i18next' -import { ReactNode, useEffect, useRef } from 'react' +import { ReactNode } from 'react' import { SubPanelId, useSidePanel } from '../hooks/useSidePanel' +import { useRestoreFocus } from '@/hooks/useRestoreFocus' import { useIsRecordingModeEnabled, RecordingMode, @@ -100,42 +101,19 @@ export const Tools = () => { // Restore focus to the element that opened the Tools panel // following the same pattern as Chat. - const toolsTriggerRef = useRef(null) - const prevIsToolsOpenRef = useRef(false) - - useEffect(() => { - const wasOpen = prevIsToolsOpenRef.current - const isOpen = isToolsOpen - - // Tools just opened - if (!wasOpen && isOpen) { - const activeEl = document.activeElement as HTMLElement | null - - // If the active element is a MenuItem (DIV) that will be unmounted when the menu closes, - // find the "more options" button ("Plus d'options") that opened the menu + useRestoreFocus(isToolsOpen, { + // If the active element is a MenuItem (DIV) that will be unmounted when the menu closes, + // find the "more options" button ("Plus d'options") that opened the menu + resolveTrigger: (activeEl) => { if (activeEl?.tagName === 'DIV') { - toolsTriggerRef.current = document.querySelector( - '#room-options-trigger' - ) - } else { - // For direct button clicks (e.g. "Plus d'outils"), use the active element as is - toolsTriggerRef.current = activeEl + return document.querySelector('#room-options-trigger') } - } - - // Tools just closed - if (wasOpen && !isOpen) { - const trigger = toolsTriggerRef.current - if (trigger && document.contains(trigger)) { - requestAnimationFrame(() => { - trigger.focus({ preventScroll: true }) - }) - } - toolsTriggerRef.current = null - } - - prevIsToolsOpenRef.current = isOpen - }, [isToolsOpen]) + // For direct button clicks (e.g. "Plus d'outils"), use the active element as is + return activeEl + }, + restoreFocusRaf: true, + preventScroll: true, + }) const isTranscriptEnabled = useIsRecordingModeEnabled( RecordingMode.Transcript diff --git a/src/frontend/src/features/rooms/livekit/prefabs/Chat.tsx b/src/frontend/src/features/rooms/livekit/prefabs/Chat.tsx index 7c4e67f4..b6c61a6c 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/Chat.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/Chat.tsx @@ -1,5 +1,5 @@ import type { ChatMessage, ChatOptions } from '@livekit/components-core' -import React, { useEffect } from 'react' +import React from 'react' import { formatChatMessageLinks, useChat, @@ -15,6 +15,7 @@ import { ChatEntry } from '../components/chat/Entry' import { useSidePanel } from '../hooks/useSidePanel' import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client' import { css } from '@/styled-system/css' +import { useRestoreFocus } from '@/hooks/useRestoreFocus' export interface ChatProps extends React.HTMLAttributes, @@ -38,33 +39,16 @@ export function Chat({ ...props }: ChatProps) { // Keep track of the element that opened the chat so we can restore focus // when the chat panel is closed. - const prevIsChatOpenRef = React.useRef(false) - const chatTriggerRef = React.useRef(null) - - useEffect(() => { - const wasChatOpen = prevIsChatOpenRef.current - const isChatPanelOpen = isChatOpen - - // Chat just opened - if (!wasChatOpen && isChatPanelOpen) { - chatTriggerRef.current = document.activeElement as HTMLElement | null - // Avoid layout "jump" during the side panel slide-in animation. - // Focusing can trigger scroll into view; preventScroll keeps the animation smooth. + useRestoreFocus(isChatOpen, { + // Avoid layout "jump" during the side panel slide-in animation. + // Focusing can trigger scroll into view; preventScroll keeps the animation smooth. + onOpened: () => { requestAnimationFrame(() => { inputRef.current?.focus({ preventScroll: true }) }) - } - // Chat just closed - if (wasChatOpen && !isChatPanelOpen) { - const trigger = chatTriggerRef.current - if (trigger && document.contains(trigger)) { - trigger.focus({ preventScroll: true }) - } - chatTriggerRef.current = null - } - - prevIsChatOpenRef.current = isChatPanelOpen - }, [isChatOpen]) + }, + preventScroll: true, + }) // Use useParticipants hook to trigger a re-render when the participant list changes. const participants = useParticipants() diff --git a/src/frontend/src/hooks/useRestoreFocus.ts b/src/frontend/src/hooks/useRestoreFocus.ts new file mode 100644 index 00000000..359003b6 --- /dev/null +++ b/src/frontend/src/hooks/useRestoreFocus.ts @@ -0,0 +1,61 @@ +import { useEffect, useRef } from 'react' + +export type RestoreFocusOptions = { + resolveTrigger?: (activeEl: HTMLElement | null) => HTMLElement | null + onOpened?: () => void + onClosed?: () => void + restoreFocusRaf?: boolean + preventScroll?: boolean +} + +/** + * Capture the element that opened a panel/menu (on open transition) and restore focus to it + * when the panel/menu closes. + */ +export function useRestoreFocus( + isOpen: boolean, + options: RestoreFocusOptions = {} +) { + const { + resolveTrigger, + onOpened, + onClosed, + restoreFocusRaf = false, + preventScroll = true, + } = options + + const prevIsOpenRef = useRef(false) + const triggerRef = useRef(null) + + useEffect(() => { + const wasOpen = prevIsOpenRef.current + + // Just opened + if (!wasOpen && isOpen) { + const activeEl = document.activeElement as HTMLElement | null + triggerRef.current = resolveTrigger ? resolveTrigger(activeEl) : activeEl + onOpened?.() + } + + // Just closed + if (wasOpen && !isOpen) { + const trigger = triggerRef.current + if (trigger && document.contains(trigger)) { + const focus = () => trigger.focus({ preventScroll }) + if (restoreFocusRaf) requestAnimationFrame(focus) + else focus() + } + triggerRef.current = null + onClosed?.() + } + + prevIsOpenRef.current = isOpen + }, [ + isOpen, + onClosed, + onOpened, + preventScroll, + resolveTrigger, + restoreFocusRaf, + ]) +}