♻️(frontend) replace side panel context with valtio store

centralize refs/triggers in layout store to keep focus behavior
This commit is contained in:
Cyril
2026-01-28 18:23:50 +01:00
parent 71637dc199
commit 096968c222
9 changed files with 193 additions and 144 deletions
@@ -189,12 +189,11 @@ const SidePanelContent = () => {
onBack={() => (layoutStore.activeSubPanelId = null)}
panelRef={panelRef}
>
{/* keepAlive preserves focus restoration + state (e.g. scroll/input) across panels;
revisit if memory becomes a concern */}
<Panel isOpen={isParticipantsOpen} keepAlive={true}>
{/* keepAlive stays only for Info to reduce memory footprint */}
<Panel isOpen={isParticipantsOpen}>
<ParticipantsList />
</Panel>
<Panel isOpen={isEffectsOpen} keepAlive={true}>
<Panel isOpen={isEffectsOpen}>
<Effects />
</Panel>
<Panel isOpen={isChatOpen} keepAlive={true}>
@@ -203,10 +202,10 @@ const SidePanelContent = () => {
<Panel isOpen={isToolsOpen} keepAlive={true}>
<Tools />
</Panel>
<Panel isOpen={isAdminOpen} keepAlive={true}>
<Panel isOpen={isAdminOpen}>
<Admin />
</Panel>
<Panel isOpen={isInfoOpen} keepAlive={true}>
<Panel isOpen={isInfoOpen} >
<Info />
</Panel>
</StyledSidePanel>
@@ -1,29 +0,0 @@
import { useRef, ReactNode } from 'react'
import { SidePanelContext, SidePanelTriggerKey } from './sidePanelContextValue'
export const SidePanelProvider = ({ children }: { children: ReactNode }) => {
const panelRef = useRef<HTMLElement>(null)
const triggersRef = useRef<Record<SidePanelTriggerKey, HTMLElement | null>>({
participants: null,
tools: null,
info: null,
admin: null,
options: null,
effects: null,
cameraMenu: null,
})
const setTrigger = (key: SidePanelTriggerKey, el: HTMLElement | null) => {
triggersRef.current[key] = el
}
const getTrigger = (key: SidePanelTriggerKey) => {
return triggersRef.current[key] ?? null
}
return (
<SidePanelContext.Provider value={{ panelRef, setTrigger, getTrigger }}>
{children}
</SidePanelContext.Provider>
)
}
@@ -1,20 +0,0 @@
import { createContext } from 'react'
export type SidePanelTriggerKey =
| 'participants'
| 'tools'
| 'info'
| 'admin'
| 'options'
| 'effects'
| 'cameraMenu'
export type SidePanelContextValue = {
panelRef: React.RefObject<HTMLElement>
setTrigger: (key: SidePanelTriggerKey, el: HTMLElement | null) => void
getTrigger: (key: SidePanelTriggerKey) => HTMLElement | null
}
export const SidePanelContext = createContext<SidePanelContextValue | null>(
null
)
@@ -1,5 +1,7 @@
import { useSnapshot } from 'valtio'
import { layoutStore } from '@/stores/layout'
import { useEffect, useRef } from 'react'
import type { SidePanelTriggerKey } from '../types/sidePanelTypes'
export enum PanelId {
PARTICIPANTS = 'participants',
@@ -19,6 +21,35 @@ export const useSidePanel = () => {
const layoutSnap = useSnapshot(layoutStore)
const activePanelId = layoutSnap.activePanelId
const activeSubPanelId = layoutSnap.activeSubPanelId
const lastInteractionRef = useRef<'keyboard' | 'mouse' | null>(null)
const prevPanelIdRef = useRef<PanelId | null>(activePanelId)
const resolveTrigger = (panelId: PanelId, activeEl: HTMLElement | null) => {
if (activeEl?.tagName === 'DIV') {
if (panelId === PanelId.TOOLS || panelId === PanelId.EFFECTS) {
return layoutStore.sidePanelTriggers.options ?? activeEl
}
}
const triggerKeyByPanel: Partial<Record<PanelId, SidePanelTriggerKey>> = {
[PanelId.PARTICIPANTS]: 'participants',
[PanelId.TOOLS]: 'tools',
[PanelId.INFO]: 'info',
[PanelId.ADMIN]: 'admin',
[PanelId.EFFECTS]: 'effects',
}
const triggerKey = triggerKeyByPanel[panelId]
return triggerKey
? layoutStore.sidePanelTriggers[triggerKey] ?? activeEl
: activeEl
}
const storeLastTrigger = (panelId: PanelId) => {
const activeEl = document.activeElement as HTMLElement | null
layoutStore.lastSidePanelTriggerRef.current = resolveTrigger(
panelId,
activeEl
)
}
const isParticipantsOpen = activePanelId == PanelId.PARTICIPANTS
const isEffectsOpen = activePanelId == PanelId.EFFECTS
@@ -32,45 +63,92 @@ export const useSidePanel = () => {
const isSubPanelOpen = !!activeSubPanelId
const toggleAdmin = () => {
if (!isAdminOpen) storeLastTrigger(PanelId.ADMIN)
layoutStore.activePanelId = isAdminOpen ? null : PanelId.ADMIN
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleParticipants = () => {
if (!isParticipantsOpen) storeLastTrigger(PanelId.PARTICIPANTS)
layoutStore.activePanelId = isParticipantsOpen ? null : PanelId.PARTICIPANTS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleChat = () => {
if (!isChatOpen) storeLastTrigger(PanelId.CHAT)
layoutStore.activePanelId = isChatOpen ? null : PanelId.CHAT
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleEffects = () => {
if (!isEffectsOpen) storeLastTrigger(PanelId.EFFECTS)
layoutStore.activePanelId = isEffectsOpen ? null : PanelId.EFFECTS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleTools = () => {
if (!isToolsOpen) storeLastTrigger(PanelId.TOOLS)
layoutStore.activePanelId = isToolsOpen ? null : PanelId.TOOLS
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const toggleInfo = () => {
if (!isInfoOpen) storeLastTrigger(PanelId.INFO)
layoutStore.activePanelId = isInfoOpen ? null : PanelId.INFO
if (layoutSnap.activeSubPanelId) layoutStore.activeSubPanelId = null
}
const openTranscript = () => {
storeLastTrigger(PanelId.TOOLS)
layoutStore.activeSubPanelId = SubPanelId.TRANSCRIPT
layoutStore.activePanelId = PanelId.TOOLS
}
const openScreenRecording = () => {
storeLastTrigger(PanelId.TOOLS)
layoutStore.activeSubPanelId = SubPanelId.SCREEN_RECORDING
layoutStore.activePanelId = PanelId.TOOLS
}
useEffect(() => {
const handleKeyDown = () => {
lastInteractionRef.current = 'keyboard'
}
const handleMouseDown = () => {
lastInteractionRef.current = 'mouse'
}
document.addEventListener('keydown', handleKeyDown)
document.addEventListener('mousedown', handleMouseDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.removeEventListener('mousedown', handleMouseDown)
}
}, [])
useEffect(() => {
const wasOpen = prevPanelIdRef.current
if (wasOpen && !activePanelId) {
const trigger = layoutStore.lastSidePanelTriggerRef.current
if (trigger && document.contains(trigger)) {
trigger.focus({ preventScroll: true })
if (lastInteractionRef.current === 'keyboard') {
trigger.setAttribute('data-restore-focus-visible', '')
const handleBlur = () => {
if (document.contains(trigger)) {
trigger.removeAttribute('data-restore-focus-visible')
}
}
trigger.addEventListener('blur', handleBlur, { once: true })
}
}
}
prevPanelIdRef.current = activePanelId
}, [activePanelId])
return {
activePanelId,
activeSubPanelId,
@@ -1,10 +1,5 @@
import { useContext } from 'react'
import { SidePanelContext } from '../contexts/sidePanelContextValue'
import { layoutStore } from '@/stores/layout'
export const useSidePanelRef = () => {
const context = useContext(SidePanelContext)
if (!context) {
throw new Error('useSidePanelRef must be used within SidePanelProvider')
}
return context.panelRef
return layoutStore.sidePanelRef
}
@@ -1,15 +1,13 @@
import { useContext } from 'react'
import { SidePanelContext } from '../contexts/sidePanelContextValue'
import { layoutStore } from '@/stores/layout'
import type { SidePanelTriggerKey } from '../types/sidePanelTypes'
export const useSidePanelTriggers = () => {
const context = useContext(SidePanelContext)
if (!context) {
throw new Error(
'useSidePanelTriggers must be used within SidePanelProvider'
)
}
return {
setTrigger: context.setTrigger,
getTrigger: context.getTrigger,
setTrigger: (key: SidePanelTriggerKey, el: HTMLElement | null) => {
layoutStore.sidePanelTriggers[key] = el
},
getTrigger: (key: SidePanelTriggerKey) => {
return layoutStore.sidePanelTriggers[key] ?? null
},
}
}
@@ -27,7 +27,6 @@ import { FocusLayout } from '../components/FocusLayout'
import { ParticipantTile } from '../components/ParticipantTile'
import { SidePanel } from '../components/SidePanel'
import { useSidePanel } from '../hooks/useSidePanel'
import { SidePanelProvider } from '../contexts/SidePanelContext'
import { RecordingProvider } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { useConnectionObserver } from '../hooks/useConnectionObserver'
@@ -260,77 +259,74 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
value={layoutContext}
// onPinChange={handleFocusStateChange}
>
<SidePanelProvider>
<ScreenShareErrorModal
isOpen={isShareErrorVisible}
onClose={() => setIsShareErrorVisible(false)}
/>
<IsIdleDisconnectModal />
<div
// todo - extract these magic values into constant
style={{
position: 'absolute',
inset: isSidePanelOpen
? `var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px`
: `var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))`,
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
maxHeight: '100%',
}}
>
<LayoutWrapper areSubtitlesOpen={areSubtitlesOpen}>
<div
style={{
display: 'flex',
position: 'relative',
width: '100%',
}}
>
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ScreenShareErrorModal
isOpen={isShareErrorVisible}
onClose={() => setIsShareErrorVisible(false)}
/>
<IsIdleDisconnectModal />
<div
// todo - extract these magic values into constant
style={{
position: 'absolute',
inset: isSidePanelOpen
? `var(--lk-grid-gap) calc(358px + 3rem) calc(80px + var(--lk-grid-gap)) 16px`
: `var(--lk-grid-gap) var(--lk-grid-gap) calc(80px + var(--lk-grid-gap))`,
transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms',
maxHeight: '100%',
}}
>
<LayoutWrapper areSubtitlesOpen={areSubtitlesOpen}>
<div
style={{
display: 'flex',
position: 'relative',
width: '100%',
}}
>
{!focusTrack ? (
<div
className="lk-grid-layout-wrapper"
style={{ height: 'auto' }}
>
<GridLayout tracks={tracks} style={{ padding: 0 }}>
<ParticipantTile />
</GridLayout>
</div>
) : (
<div
className="lk-focus-layout-wrapper"
style={{ height: 'auto' }}
>
<FocusLayoutContainer style={{ padding: 0 }}>
<CarouselLayout
tracks={carouselTracks}
style={{
minWidth: '200px',
}}
>
<ParticipantTile />
</GridLayout>
</div>
) : (
<div
className="lk-focus-layout-wrapper"
style={{ height: 'auto' }}
>
<FocusLayoutContainer style={{ padding: 0 }}>
<CarouselLayout
tracks={carouselTracks}
style={{
minWidth: '200px',
}}
>
<ParticipantTile />
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
</div>
</LayoutWrapper>
<Subtitles />
<MainNotificationToast />
</div>
<ControlBar
onDeviceError={(e) => {
console.error(e)
if (
e.source == Track.Source.ScreenShare &&
e.error.toString() ==
'NotAllowedError: Permission denied by system'
) {
setIsShareErrorVisible(true)
}
}}
/>
<SidePanel />
</SidePanelProvider>
</CarouselLayout>
{focusTrack && <FocusLayout trackRef={focusTrack} />}
</FocusLayoutContainer>
</div>
)}
</div>
</LayoutWrapper>
<Subtitles />
<MainNotificationToast />
</div>
<ControlBar
onDeviceError={(e) => {
console.error(e)
if (
e.source == Track.Source.ScreenShare &&
e.error.toString() == 'NotAllowedError: Permission denied by system'
) {
setIsShareErrorVisible(true)
}
}}
/>
<SidePanel />
</LayoutContextProvider>
)}
<RoomAudioRenderer />
@@ -0,0 +1,9 @@
export type SidePanelTriggerKey =
| 'participants'
| 'tools'
| 'info'
| 'admin'
| 'options'
| 'effects'
| 'cameraMenu'
+24 -1
View File
@@ -1,8 +1,11 @@
import { proxy } from 'valtio'
import { createRef } from 'react'
import { proxy, ref } from 'valtio'
import {
PanelId,
SubPanelId,
} from '@/features/rooms/livekit/hooks/useSidePanel'
import type { SidePanelTriggerKey } from '@/features/rooms/livekit/types/sidePanelTypes'
import type { MutableRefObject, RefObject } from 'react'
type State = {
showHeader: boolean
@@ -10,12 +13,32 @@ type State = {
showSubtitles: boolean
activePanelId: PanelId | null
activeSubPanelId: SubPanelId | null
sidePanelRef: RefObject<HTMLElement>
sidePanelTriggers: Record<SidePanelTriggerKey, HTMLElement | null>
lastSidePanelTriggerRef: MutableRefObject<HTMLElement | null>
}
const sidePanelRef = ref(createRef<HTMLElement>())
const lastSidePanelTriggerRef = ref({
current: null,
} as MutableRefObject<HTMLElement | null>)
const sidePanelTriggers = ref<Record<SidePanelTriggerKey, HTMLElement | null>>({
participants: null,
tools: null,
info: null,
admin: null,
options: null,
effects: null,
cameraMenu: null,
})
export const layoutStore = proxy<State>({
showHeader: false,
showFooter: false,
showSubtitles: false,
activePanelId: null,
activeSubPanelId: null,
sidePanelRef,
sidePanelTriggers,
lastSidePanelTriggerRef,
})