mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-26 20:08:24 +00:00
♿️(frontend) use trigger refs for side panel focus
Replace global DOM queries with scoped refs for focus management
This commit is contained in:
@@ -13,11 +13,13 @@ import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePubl
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
import { useSidePanelRef } from '../hooks/useSidePanelRef'
|
||||
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
|
||||
|
||||
export const Admin = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
||||
const { isAdminOpen } = useSidePanel()
|
||||
const panelRef = useSidePanelRef()
|
||||
const { getTrigger } = useSidePanelTriggers()
|
||||
|
||||
const { roomId } = useParams()
|
||||
|
||||
@@ -46,11 +48,7 @@ export const Admin = () => {
|
||||
// Restore focus to the element that opened the Admin panel
|
||||
useRestoreFocus(isAdminOpen, {
|
||||
resolveTrigger: (activeEl) => {
|
||||
// Find the Admin toggle button - it doesn't have a data-attr, so we'll search by aria-label
|
||||
const adminButton = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('button')
|
||||
).find((btn) => btn.getAttribute('aria-label')?.includes('admin'))
|
||||
return adminButton || activeEl
|
||||
return getTrigger('admin') ?? activeEl
|
||||
},
|
||||
// Focus the first focusable element when the panel opens (first Field switch)
|
||||
onOpened: () => {
|
||||
@@ -58,9 +56,8 @@ export const Admin = () => {
|
||||
const panel = panelRef.current
|
||||
if (panel) {
|
||||
// Find the first switch in the moderation section
|
||||
const firstSwitch = panel.querySelector<HTMLElement>(
|
||||
'[role="switch"]:first-of-type'
|
||||
)
|
||||
const firstSwitch =
|
||||
panel.querySelector<HTMLElement>('[role="switch"]')
|
||||
if (firstSwitch) {
|
||||
firstSwitch.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useCallback } from 'react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { RiAdminLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -5,6 +6,7 @@ import { css } from '@/styled-system/css'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
|
||||
|
||||
export const AdminToggle = ({
|
||||
variant = 'primaryTextDark',
|
||||
@@ -14,10 +16,17 @@ export const AdminToggle = ({
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.admin' })
|
||||
|
||||
const { isAdminOpen, toggleAdmin } = useSidePanel()
|
||||
const { setTrigger } = useSidePanelTriggers()
|
||||
const tooltipLabel = isAdminOpen ? 'open' : 'closed'
|
||||
const setAdminTriggerRef = useCallback(
|
||||
(el: HTMLElement | null) => {
|
||||
setTrigger('admin', el)
|
||||
},
|
||||
[setTrigger]
|
||||
)
|
||||
|
||||
const hasAdminAccess = useIsAdminOrOwner()
|
||||
if (!hasAdminAccess) return
|
||||
if (!hasAdminAccess) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -32,6 +41,7 @@ export const AdminToggle = ({
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isAdminOpen}
|
||||
ref={setAdminTriggerRef}
|
||||
onPress={(e) => {
|
||||
toggleAdmin()
|
||||
onPress?.(e)
|
||||
|
||||
@@ -12,11 +12,13 @@ import { useCopyRoomToClipboard } from '../hooks/useCopyRoomToClipboard'
|
||||
import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
import { useSidePanelRef } from '../hooks/useSidePanelRef'
|
||||
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
|
||||
|
||||
export const Info = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'info' })
|
||||
const { isInfoOpen } = useSidePanel()
|
||||
const panelRef = useSidePanelRef()
|
||||
const { getTrigger } = useSidePanelTriggers()
|
||||
|
||||
const data = useRoomData()
|
||||
const roomUrl = getRouteUrl('room', data?.slug)
|
||||
@@ -31,17 +33,14 @@ export const Info = () => {
|
||||
|
||||
// Restore focus to the element that opened the Info panel
|
||||
useRestoreFocus(isInfoOpen, {
|
||||
resolveTrigger: () => {
|
||||
// Find the Info toggle button
|
||||
return document.querySelector<HTMLElement>('[data-attr*="controls-info"]')
|
||||
},
|
||||
resolveTrigger: (activeEl) => getTrigger('info') ?? activeEl,
|
||||
// Focus the first focusable element when the panel opens
|
||||
onOpened: () => {
|
||||
requestAnimationFrame(() => {
|
||||
const panel = panelRef.current
|
||||
if (panel) {
|
||||
const firstButton = panel.querySelector<HTMLElement>(
|
||||
'[data-attr="copy-info-sidepannel"]'
|
||||
'[data-attr="copy-info-sidepanel"]'
|
||||
)
|
||||
if (firstButton) {
|
||||
firstButton.focus({ preventScroll: true })
|
||||
@@ -100,7 +99,7 @@ export const Info = () => {
|
||||
variant={isCopied ? 'success' : 'tertiaryText'}
|
||||
aria-label={t('roomInformation.button.ariaLabel')}
|
||||
onPress={copyRoomToClipboard}
|
||||
data-attr="copy-info-sidepannel"
|
||||
data-attr="copy-info-sidepanel"
|
||||
style={{
|
||||
marginLeft: '-8px',
|
||||
}}
|
||||
|
||||
@@ -13,7 +13,6 @@ import { Effects } from './effects/Effects'
|
||||
import { Admin } from './Admin'
|
||||
import { Tools } from './Tools'
|
||||
import { Info } from './Info'
|
||||
import { SidePanelProvider } from '../contexts/SidePanelContext'
|
||||
import { useSidePanelRef } from '../hooks/useSidePanelRef'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
|
||||
@@ -173,6 +172,8 @@ 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}>
|
||||
<ParticipantsList />
|
||||
</Panel>
|
||||
@@ -196,9 +197,5 @@ const SidePanelContent = () => {
|
||||
}
|
||||
|
||||
export const SidePanel = () => {
|
||||
return (
|
||||
<SidePanelProvider>
|
||||
<SidePanelContent />
|
||||
</SidePanelProvider>
|
||||
)
|
||||
return <SidePanelContent />
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ReactNode } from 'react'
|
||||
import { SubPanelId, useSidePanel } from '../hooks/useSidePanel'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
import { useSidePanelRef } from '../hooks/useSidePanelRef'
|
||||
import { useSidePanelTriggers } from '../hooks/useSidePanelTriggers'
|
||||
import {
|
||||
useIsRecordingModeEnabled,
|
||||
RecordingMode,
|
||||
@@ -100,6 +101,7 @@ export const Tools = () => {
|
||||
useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'moreTools' })
|
||||
const panelRef = useSidePanelRef()
|
||||
const { getTrigger } = useSidePanelTriggers()
|
||||
|
||||
// Restore focus to the element that opened the Tools panel
|
||||
// following the same pattern as Chat.
|
||||
@@ -108,10 +110,10 @@ export const Tools = () => {
|
||||
// find the "more options" button ("Plus d'options") that opened the menu
|
||||
resolveTrigger: (activeEl) => {
|
||||
if (activeEl?.tagName === 'DIV') {
|
||||
return document.querySelector<HTMLElement>('#room-options-trigger')
|
||||
return getTrigger('options') ?? activeEl
|
||||
}
|
||||
// For direct button clicks (e.g. "Plus d'outils"), use the active element as is
|
||||
return activeEl
|
||||
return getTrigger('tools') ?? activeEl
|
||||
},
|
||||
// Focus the first focusable element when the panel opens
|
||||
onOpened: () => {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiInformationLine } from '@remixicon/react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { useSidePanelTriggers } from '../../hooks/useSidePanelTriggers'
|
||||
|
||||
export const InfoToggle = ({
|
||||
onPress,
|
||||
@@ -12,7 +14,14 @@ export const InfoToggle = ({
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.info' })
|
||||
|
||||
const { isInfoOpen, toggleInfo } = useSidePanel()
|
||||
const { setTrigger } = useSidePanelTriggers()
|
||||
const tooltipLabel = isInfoOpen ? 'open' : 'closed'
|
||||
const setInfoTriggerRef = useCallback(
|
||||
(el: HTMLElement | null) => {
|
||||
setTrigger('info', el)
|
||||
},
|
||||
[setTrigger]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -27,6 +36,7 @@ export const InfoToggle = ({
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isInfoOpen}
|
||||
ref={setInfoTriggerRef}
|
||||
onPress={(e) => {
|
||||
toggleInfo()
|
||||
onPress?.(e)
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import { Button, Menu } from '@/primitives'
|
||||
import { OptionsMenuItems } from './OptionsMenuItems'
|
||||
import { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers'
|
||||
|
||||
export const OptionsButton = () => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const { setTrigger } = useSidePanelTriggers()
|
||||
const setOptionsTriggerRef = useCallback(
|
||||
(el: HTMLElement | null) => {
|
||||
setTrigger('options', el)
|
||||
},
|
||||
[setTrigger]
|
||||
)
|
||||
|
||||
return (
|
||||
<Menu variant="dark">
|
||||
@@ -14,6 +23,7 @@ export const OptionsButton = () => {
|
||||
variant="primaryDark"
|
||||
aria-label={t('options.buttonLabel')}
|
||||
tooltip={t('options.buttonLabel')}
|
||||
ref={setOptionsTriggerRef}
|
||||
>
|
||||
<RiMoreFill />
|
||||
</Button>
|
||||
|
||||
+1
@@ -70,6 +70,7 @@ export function ParticipantsCollapsableList<T>({
|
||||
<ToggleHeader
|
||||
isSelected={isOpen}
|
||||
aria-label={label}
|
||||
data-focus-target="list-header"
|
||||
onPress={() => setIsOpen(!isOpen)}
|
||||
style={{
|
||||
borderRadius: !isOpen ? '7px' : undefined,
|
||||
|
||||
+4
-9
@@ -15,12 +15,14 @@ import { MuteEveryoneButton } from './MuteEveryoneButton'
|
||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
import { useSidePanelRef } from '../../../hooks/useSidePanelRef'
|
||||
import { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers'
|
||||
|
||||
// TODO: Optimize rendering performance, especially for longer participant lists, even though they are generally short.
|
||||
export const ParticipantsList = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
||||
const { isParticipantsOpen } = useSidePanel()
|
||||
const panelRef = useSidePanelRef()
|
||||
const { getTrigger } = useSidePanelTriggers()
|
||||
|
||||
// Preferred using the 'useParticipants' hook rather than the separate remote and local hooks,
|
||||
// because the 'useLocalParticipant' hook does not update the participant's information when their
|
||||
@@ -56,12 +58,7 @@ export const ParticipantsList = () => {
|
||||
// Restore focus to the element that opened the Participants panel
|
||||
useRestoreFocus(isParticipantsOpen, {
|
||||
resolveTrigger: (activeEl) => {
|
||||
// Find the Participants toggle button
|
||||
return (
|
||||
document.querySelector<HTMLElement>(
|
||||
'[data-attr*="controls-participants"]'
|
||||
) || activeEl
|
||||
)
|
||||
return getTrigger('participants') ?? activeEl
|
||||
},
|
||||
// Focus the first focusable element when the panel opens
|
||||
onOpened: () => {
|
||||
@@ -71,10 +68,8 @@ export const ParticipantsList = () => {
|
||||
const panel = panelRef.current
|
||||
if (panel) {
|
||||
// Find the first ToggleHeader (collapsable list header) in the participants panel
|
||||
// Look for buttons with aria-label containing "liste" (list headers)
|
||||
// Exclude close/back buttons
|
||||
const firstListHeader = panel.querySelector<HTMLElement>(
|
||||
'button[aria-label*="liste"]:not([aria-label*="Masquer les participants"])'
|
||||
'button[data-focus-target="list-header"]'
|
||||
)
|
||||
firstListHeader?.focus({ preventScroll: true })
|
||||
}
|
||||
|
||||
+10
@@ -1,3 +1,4 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiGroupLine, RiInfinityLine } from '@remixicon/react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
@@ -6,6 +7,7 @@ import { css } from '@/styled-system/css'
|
||||
import { useParticipants } from '@livekit/components-react'
|
||||
import { useSidePanel } from '../../../hooks/useSidePanel'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { useSidePanelTriggers } from '../../../hooks/useSidePanelTriggers'
|
||||
|
||||
export const ParticipantsToggle = ({
|
||||
onPress,
|
||||
@@ -24,8 +26,15 @@ export const ParticipantsToggle = ({
|
||||
numParticipants && numParticipants > 0 ? numParticipants : 1
|
||||
|
||||
const { isParticipantsOpen, toggleParticipants } = useSidePanel()
|
||||
const { setTrigger } = useSidePanelTriggers()
|
||||
|
||||
const tooltipLabel = isParticipantsOpen ? 'open' : 'closed'
|
||||
const setParticipantsTriggerRef = useCallback(
|
||||
(el: HTMLElement | null) => {
|
||||
setTrigger('participants', el)
|
||||
},
|
||||
[setTrigger]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -42,6 +51,7 @@ export const ParticipantsToggle = ({
|
||||
count: announcedCount,
|
||||
})}.`}
|
||||
isSelected={isParticipantsOpen}
|
||||
ref={setParticipantsTriggerRef}
|
||||
onPress={(e) => {
|
||||
toggleParticipants()
|
||||
onPress?.(e)
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useCallback } from 'react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { RiShapesLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { useSidePanelTriggers } from '../../hooks/useSidePanelTriggers'
|
||||
|
||||
export const ToolsToggle = ({
|
||||
variant = 'primaryTextDark',
|
||||
@@ -13,7 +15,14 @@ export const ToolsToggle = ({
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.tools' })
|
||||
|
||||
const { isToolsOpen, toggleTools } = useSidePanel()
|
||||
const { setTrigger } = useSidePanelTriggers()
|
||||
const tooltipLabel = isToolsOpen ? 'open' : 'closed'
|
||||
const setToolsTriggerRef = useCallback(
|
||||
(el: HTMLElement | null) => {
|
||||
setTrigger('tools', el)
|
||||
},
|
||||
[setTrigger]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -28,6 +37,7 @@ export const ToolsToggle = ({
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isToolsOpen}
|
||||
ref={setToolsTriggerRef}
|
||||
onPress={(e) => {
|
||||
toggleTools()
|
||||
onPress?.(e)
|
||||
|
||||
@@ -8,6 +8,7 @@ import { TrackSource } from '@livekit/protocol'
|
||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||
import { useRestoreFocus } from '@/hooks/useRestoreFocus'
|
||||
import { useSidePanelRef } from '../../hooks/useSidePanelRef'
|
||||
import { useSidePanelTriggers } from '../../hooks/useSidePanelTriggers'
|
||||
|
||||
export const Effects = () => {
|
||||
const { cameraTrack } = useLocalParticipant()
|
||||
@@ -15,13 +16,14 @@ export const Effects = () => {
|
||||
const { saveProcessorSerialized } = usePersistentUserChoices()
|
||||
const { isEffectsOpen } = useSidePanel()
|
||||
const panelRef = useSidePanelRef()
|
||||
const { getTrigger } = useSidePanelTriggers()
|
||||
|
||||
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
|
||||
|
||||
useRestoreFocus(isEffectsOpen, {
|
||||
resolveTrigger: (activeEl) => {
|
||||
if (activeEl?.tagName === 'DIV') {
|
||||
return document.querySelector<HTMLElement>('#room-options-trigger')
|
||||
return getTrigger('options') ?? activeEl
|
||||
}
|
||||
// For direct button clicks, use the active element as is
|
||||
return activeEl
|
||||
|
||||
@@ -1,11 +1,27 @@
|
||||
import { useRef, ReactNode } from 'react'
|
||||
import { SidePanelContext } from './sidePanelContextValue'
|
||||
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,
|
||||
})
|
||||
|
||||
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 }}>
|
||||
<SidePanelContext.Provider value={{ panelRef, setTrigger, getTrigger }}>
|
||||
{children}
|
||||
</SidePanelContext.Provider>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
import { createContext } from 'react'
|
||||
|
||||
export type SidePanelTriggerKey =
|
||||
| 'participants'
|
||||
| 'tools'
|
||||
| 'info'
|
||||
| 'admin'
|
||||
| 'options'
|
||||
| 'effects'
|
||||
|
||||
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>(
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useContext } from 'react'
|
||||
import { SidePanelContext } from '../contexts/sidePanelContextValue'
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ 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'
|
||||
@@ -259,75 +260,77 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
value={layoutContext}
|
||||
// onPinChange={handleFocusStateChange}
|
||||
>
|
||||
<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',
|
||||
}}
|
||||
>
|
||||
<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 }}>
|
||||
<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 />
|
||||
</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>
|
||||
</LayoutContextProvider>
|
||||
)}
|
||||
<RoomAudioRenderer />
|
||||
|
||||
@@ -58,20 +58,20 @@ export function useRestoreFocus(
|
||||
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')
|
||||
trigger.setAttribute('data-restore-focus-visible', '')
|
||||
// Remove focus ring only when the trigger loses focus
|
||||
const handleBlur = () => {
|
||||
if (document.contains(trigger)) {
|
||||
trigger.removeAttribute('data-restore-focus-visible')
|
||||
}
|
||||
}
|
||||
document.addEventListener('focusin', handleFocusChange, {
|
||||
once: true,
|
||||
})
|
||||
trigger.addEventListener('blur', handleBlur, { once: true })
|
||||
// Store cleanup for unmount case
|
||||
cleanupRef.current?.()
|
||||
cleanupRef.current = () => {
|
||||
trigger.removeEventListener('blur', handleBlur)
|
||||
if (document.contains(trigger)) {
|
||||
trigger.removeAttribute('data-focus-visible')
|
||||
trigger.removeAttribute('data-restore-focus-visible')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,11 +23,20 @@ body,
|
||||
}
|
||||
|
||||
[data-rac][data-focus-visible]:not(label, .react-aria-Select),
|
||||
[data-rac][data-restore-focus-visible]:not(label, .react-aria-Select),
|
||||
:is(a, button, input[type='text'], select, textarea):not(
|
||||
[data-rac]
|
||||
):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 {
|
||||
[data-focus-visible]:is(a, button, input[type='text'], select, textarea):focus,
|
||||
/* Show focus ring when restoring focus on react-aria buttons without overriding its focus state */
|
||||
[data-restore-focus-visible]:is(
|
||||
a,
|
||||
button,
|
||||
input[type='text'],
|
||||
select,
|
||||
textarea
|
||||
):focus {
|
||||
outline: 2px solid var(--colors-focus-ring);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user