♻️(frontend) extract tools panel focus logic into reusable hook

prepares logic reuse for consistent focus restoration across the app
This commit is contained in:
Cyril
2026-01-07 10:52:40 +01:00
committed by aleb_the_flash
parent 6e20bc1f43
commit bbfbb23be5
3 changed files with 83 additions and 60 deletions

View File

@@ -2,8 +2,9 @@ import { A, Div, Text } from '@/primitives'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { Button as RACButton } from 'react-aria-components' import { Button as RACButton } from 'react-aria-components'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { ReactNode, useEffect, useRef } from 'react' import { ReactNode } from 'react'
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel' import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
import { import {
useIsRecordingModeEnabled, useIsRecordingModeEnabled,
RecordingMode, RecordingMode,
@@ -100,42 +101,19 @@ export const Tools = () => {
// Restore focus to the element that opened the Tools panel // Restore focus to the element that opened the Tools panel
// following the same pattern as Chat. // following the same pattern as Chat.
const toolsTriggerRef = useRef<HTMLElement | null>(null) useRestoreFocus(isToolsOpen, {
const prevIsToolsOpenRef = useRef(false) // 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
useEffect(() => { resolveTrigger: (activeEl) => {
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
if (activeEl?.tagName === 'DIV') { if (activeEl?.tagName === 'DIV') {
toolsTriggerRef.current = document.querySelector<HTMLButtonElement>( return document.querySelector<HTMLElement>('#room-options-trigger')
'#room-options-trigger'
)
} else {
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
toolsTriggerRef.current = activeEl
} }
} // For direct button clicks (e.g. "Plus d'outils"), use the active element as is
return activeEl
// Tools just closed },
if (wasOpen && !isOpen) { restoreFocusRaf: true,
const trigger = toolsTriggerRef.current preventScroll: true,
if (trigger && document.contains(trigger)) { })
requestAnimationFrame(() => {
trigger.focus({ preventScroll: true })
})
}
toolsTriggerRef.current = null
}
prevIsToolsOpenRef.current = isOpen
}, [isToolsOpen])
const isTranscriptEnabled = useIsRecordingModeEnabled( const isTranscriptEnabled = useIsRecordingModeEnabled(
RecordingMode.Transcript RecordingMode.Transcript

View File

@@ -1,5 +1,5 @@
import type { ChatMessage, ChatOptions } from '@livekit/components-core' import type { ChatMessage, ChatOptions } from '@livekit/components-core'
import React, { useEffect } from 'react' import React from 'react'
import { import {
formatChatMessageLinks, formatChatMessageLinks,
useChat, useChat,
@@ -15,6 +15,7 @@ import { ChatEntry } from '../components/chat/Entry'
import { useSidePanel } from '../hooks/useSidePanel' import { useSidePanel } from '../hooks/useSidePanel'
import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client' import { LocalParticipant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
export interface ChatProps export interface ChatProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
@@ -38,33 +39,16 @@ export function Chat({ ...props }: ChatProps) {
// Keep track of the element that opened the chat so we can restore focus // Keep track of the element that opened the chat so we can restore focus
// when the chat panel is closed. // when the chat panel is closed.
const prevIsChatOpenRef = React.useRef(false) useRestoreFocus(isChatOpen, {
const chatTriggerRef = React.useRef<HTMLElement | null>(null) // Avoid layout "jump" during the side panel slide-in animation.
// Focusing can trigger scroll into view; preventScroll keeps the animation smooth.
useEffect(() => { onOpened: () => {
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.
requestAnimationFrame(() => { requestAnimationFrame(() => {
inputRef.current?.focus({ preventScroll: true }) inputRef.current?.focus({ preventScroll: true })
}) })
} },
// Chat just closed preventScroll: true,
if (wasChatOpen && !isChatPanelOpen) { })
const trigger = chatTriggerRef.current
if (trigger && document.contains(trigger)) {
trigger.focus({ preventScroll: true })
}
chatTriggerRef.current = null
}
prevIsChatOpenRef.current = isChatPanelOpen
}, [isChatOpen])
// Use useParticipants hook to trigger a re-render when the participant list changes. // Use useParticipants hook to trigger a re-render when the participant list changes.
const participants = useParticipants() const participants = useParticipants()

View File

@@ -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<HTMLElement | null>(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,
])
}