♻️(frontend) extract tools panel focus logic into reusable hook
prepares logic reuse for consistent focus restoration across the app
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
61
src/frontend/src/hooks/useRestoreFocus.ts
Normal file
61
src/frontend/src/hooks/useRestoreFocus.ts
Normal 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,
|
||||||
|
])
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user