mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 12:19:10 +00:00
♻️(refactor) Share reactions toggle and paginated toolbar
Unify main and PiP emoji UI via context; remove PiP-only duplicates.
This commit is contained in:
+1
-2
@@ -14,6 +14,7 @@ and this project adheres to
|
||||
- ✨(helm) add support multiple transcribe worker / endpoint #1247
|
||||
- ✨(backend) make LiveKit Egress recording encoding configurable #1288
|
||||
- ✨(summary) add speaker-to-participant assignment
|
||||
- ✨(feat) Introduce Picture-in-Picture (PiP) #890
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -235,8 +236,6 @@ and this project adheres to
|
||||
|
||||
- ✨(backend) monitor throttling rate failure through sentry #964
|
||||
- 🚀(paas) add PaaS deployment scripts, tested on Scalingo #957
|
||||
- ✨(feat) Introduce Picture-in-Picture (PiP) #890
|
||||
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { HandToggle } from '@/features/rooms/livekit/components/controls/HandTog
|
||||
import { StartMediaButton } from '@/features/rooms/livekit/components/controls/StartMediaButton'
|
||||
import { usePipElementSize } from '../hooks/usePipElementSize'
|
||||
import { PipOptionsMenu } from './controls/PipOptionsMenu'
|
||||
import { PipReactionsToggle } from './PipReactionsToggle'
|
||||
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
|
||||
|
||||
export const CollapsibleControls = {
|
||||
HAND: 'hand',
|
||||
@@ -85,8 +85,12 @@ export const PipControlBar = ({
|
||||
<PipControlsCenter>
|
||||
<AudioDevicesControl hideMenu />
|
||||
<VideoDeviceControl hideMenu />
|
||||
{!hidden.has(CollapsibleControls.REACTIONS) && <PipReactionsToggle />}
|
||||
{showScreenShare && !hidden.has(CollapsibleControls.SCREEN_SHARE) && <ScreenShareToggle />}
|
||||
{!hidden.has(CollapsibleControls.REACTIONS) && (
|
||||
<ReactionsToggle id="pip-reactions-toggle" />
|
||||
)}
|
||||
{showScreenShare && !hidden.has(CollapsibleControls.SCREEN_SHARE) && (
|
||||
<ScreenShareToggle />
|
||||
)}
|
||||
{!hidden.has(CollapsibleControls.HAND) && <HandToggle />}
|
||||
<PipOptionsMenu overflowControls={hidden} />
|
||||
<LeaveButton />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { useRoomPiP } from '../hooks/useRoomPiP'
|
||||
import pipIllustration from '/assets/pip.svg'
|
||||
|
||||
export const PipPlaceholder = () => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
@@ -11,7 +10,13 @@ export const PipPlaceholder = () => {
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Illustration src={pipIllustration} alt="" width={102} height={72} aria-hidden="true" />
|
||||
<Illustration
|
||||
src="/assets/pip.svg"
|
||||
alt=""
|
||||
width={102}
|
||||
height={72}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Title>{t('title')}</Title>
|
||||
<Description>{t('description')}</Description>
|
||||
<BringBackLink onClick={close}>{t('bringBack')}</BringBackLink>
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiEmotionLine } from '@remixicon/react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { pipLayoutStore } from '../stores/pipLayoutStore'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
|
||||
export const PipReactionsToggle = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||
const { showReactionsToolbar: isOpen } = useSnapshot(pipLayoutStore)
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
pipLayoutStore.showReactionsToolbar = !pipLayoutStore.showReactionsToolbar
|
||||
}, [])
|
||||
|
||||
useRegisterKeyboardShortcut({ id: 'reaction', handler: toggle })
|
||||
|
||||
return (
|
||||
<ToggleButton
|
||||
id="pip-reactions-toggle"
|
||||
data-attr="pip-reactions-toggle"
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={t('button')}
|
||||
aria-expanded={isOpen}
|
||||
tooltip={t('button')}
|
||||
isSelected={isOpen}
|
||||
onChange={toggle}
|
||||
>
|
||||
<RiEmotionLine />
|
||||
</ToggleButton>
|
||||
)
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { FocusScope } from '@react-aria/focus'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { pipLayoutStore } from '../stores/pipLayoutStore'
|
||||
import { useDelayUnmount } from '@/hooks/useDelayUnmount'
|
||||
import { usePipElementSize } from '../hooks/usePipElementSize'
|
||||
import { PipReactionsKeyboardNavigation } from './reactions/PipReactionsKeyboardNavigation'
|
||||
import { PipReactionsPill } from './reactions/PipReactionsPill'
|
||||
|
||||
/**
|
||||
* Reactions toolbar for the PiP window. Owns only the open/close orchestration;
|
||||
* layout and pagination live in `PipReactionsPill`, keyboard nav in
|
||||
* `PipReactionsKeyboardNavigation`.
|
||||
*/
|
||||
export const PipReactionsToolbar = () => {
|
||||
const { showReactionsToolbar: isOpen } = useSnapshot(pipLayoutStore)
|
||||
// Unmount content after the close transition so hidden emojis leave the tab order.
|
||||
const renderContent = useDelayUnmount(isOpen, 500)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const { width: availableWidth } = usePipElementSize(wrapperRef)
|
||||
|
||||
// Mark the subtree inert during the fade-out so Tab can't land on it.
|
||||
useEffect(() => {
|
||||
const el = contentRef.current
|
||||
if (!el) return
|
||||
if (isOpen) el.removeAttribute('inert')
|
||||
else el.setAttribute('inert', '')
|
||||
}, [isOpen, renderContent])
|
||||
|
||||
return (
|
||||
<Wrapper ref={wrapperRef} isOpen={isOpen}>
|
||||
{renderContent && (
|
||||
<div ref={contentRef}>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<FocusScope autoFocus>
|
||||
<PipReactionsKeyboardNavigation>
|
||||
<PipReactionsPill
|
||||
isOpen={isOpen}
|
||||
availableWidth={availableWidth}
|
||||
/>
|
||||
</PipReactionsKeyboardNavigation>
|
||||
</FocusScope>
|
||||
</div>
|
||||
)}
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const Wrapper = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
overflow: 'hidden',
|
||||
maxHeight: 0,
|
||||
padding: '0 0.5rem',
|
||||
transition:
|
||||
'max-height 0.5s cubic-bezier(0.4, 0, 0.2, 1), padding 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
variants: {
|
||||
isOpen: {
|
||||
true: {
|
||||
maxHeight: '60px',
|
||||
padding: '0.5rem 0.5rem 0.25rem',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -7,13 +7,14 @@ import {
|
||||
SidePanelStoreProvider,
|
||||
useSidePanel,
|
||||
} from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { ReactionsToolbarStoreProvider } from '@/features/reactions/hooks/useReactionsToolbar'
|
||||
import { pipLayoutStore } from '../stores/pipLayoutStore'
|
||||
import { useEscapeDismiss } from '../hooks/useEscapeDismiss'
|
||||
import { usePipKeyboardShortcuts } from '../hooks/usePipKeyboardShortcuts'
|
||||
import { usePipRestoreFocus } from '../hooks/usePipRestoreFocus'
|
||||
import { usePipFocusModality } from '../hooks/usePipFocusModality'
|
||||
import { PipControlBar } from './PipControlBar'
|
||||
import { PipReactionsToolbar } from './PipReactionsToolbar'
|
||||
import { ReactionsToolbar } from '@/features/reactions/components/toolbar/ReactionsToolbar'
|
||||
import { PipStage } from './layouts/PipStage'
|
||||
import { PipNotificationOverlay } from './notifications/PipNotificationOverlay'
|
||||
import { PipConnectionStateToast } from './notifications/PipConnectionStateToast'
|
||||
@@ -22,7 +23,9 @@ import { PipReactionPortals } from './PipReactionPortals'
|
||||
export const PipView = () => {
|
||||
return (
|
||||
<SidePanelStoreProvider value={pipLayoutStore}>
|
||||
<PipViewContent />
|
||||
<ReactionsToolbarStoreProvider value={pipLayoutStore}>
|
||||
<PipViewContent />
|
||||
</ReactionsToolbarStoreProvider>
|
||||
</SidePanelStoreProvider>
|
||||
)
|
||||
}
|
||||
@@ -62,7 +65,10 @@ const PipViewContent = () => {
|
||||
aria-label={t('windowLabel')}
|
||||
>
|
||||
<PipStage />
|
||||
<PipReactionsToolbar />
|
||||
<ReactionsToolbar
|
||||
toggleId="pip-reactions-toggle"
|
||||
controlBarId="pip-control-bar"
|
||||
/>
|
||||
<PipControlBar showScreenShare={browserSupportsScreenSharing} />
|
||||
<SidePanel />
|
||||
<PipReactionPortals />
|
||||
|
||||
@@ -14,10 +14,9 @@ type PipOptionsMenuItemsProps = {
|
||||
export const PipOptionsMenuItems = ({
|
||||
overflowControls,
|
||||
}: PipOptionsMenuItemsProps) => {
|
||||
|
||||
const hasOverflow = (overflowControls?.size ?? 0) > 0
|
||||
|
||||
const hasOverflowControls = hasOverflow && overflowControls
|
||||
const hasOverflowControls = hasOverflow && overflowControls
|
||||
|
||||
return (
|
||||
<RACMenu
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import React from 'react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import {
|
||||
RiHand,
|
||||
RiArrowUpLine,
|
||||
RiEmotionLine,
|
||||
} from '@remixicon/react'
|
||||
import { RiHand, RiArrowUpLine, RiEmotionLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Track } from 'livekit-client'
|
||||
import { pipLayoutStore } from '@/features/pip/stores/pipLayoutStore'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { useRoomContext, useTrackToggle } from '@livekit/components-react'
|
||||
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
||||
import { CollapsibleControls, type CollapsibleControl } from '../PipControlBar'
|
||||
|
||||
type PipOverflowItemsProps = {
|
||||
@@ -31,10 +26,7 @@ export const PipOverflowItems = ({
|
||||
source: Track.Source.ScreenShare,
|
||||
captureOptions: { audio: true, selfBrowserSurface: 'include' },
|
||||
})
|
||||
const pipLayoutSnap = useSnapshot(pipLayoutStore)
|
||||
const toggleReactions = () => {
|
||||
pipLayoutStore.showReactionsToolbar = !pipLayoutSnap.showReactionsToolbar
|
||||
}
|
||||
const { toggle: toggleReactions } = useReactionsToolbar()
|
||||
const itemClass = menuRecipe({ icon: true, variant: 'dark' }).item
|
||||
|
||||
return (
|
||||
@@ -47,11 +39,19 @@ export const PipOverflowItems = ({
|
||||
)}
|
||||
{overflowControls.has(CollapsibleControls.SCREEN_SHARE) && (
|
||||
<MenuItem
|
||||
onAction={() => screenShareProps.onClick?.({} as React.MouseEvent<HTMLButtonElement>)}
|
||||
onAction={() =>
|
||||
screenShareProps.onClick?.(
|
||||
{} as React.MouseEvent<HTMLButtonElement>
|
||||
)
|
||||
}
|
||||
className={itemClass}
|
||||
>
|
||||
<RiArrowUpLine size={20} />
|
||||
{t(isScreenSharing ? 'controls.screenShare.stop' : 'controls.screenShare.start')}
|
||||
{t(
|
||||
isScreenSharing
|
||||
? 'controls.screenShare.stop'
|
||||
: 'controls.screenShare.start'
|
||||
)}
|
||||
</MenuItem>
|
||||
)}
|
||||
{overflowControls.has(CollapsibleControls.HAND) && (
|
||||
|
||||
@@ -22,10 +22,7 @@ export const PipFocusLayout = memo(
|
||||
return (
|
||||
<FocusContainer>
|
||||
<MainSlot>
|
||||
<ParticipantTile
|
||||
key={getTrackKey(mainTrack)}
|
||||
trackRef={mainTrack}
|
||||
/>
|
||||
<ParticipantTile key={getTrackKey(mainTrack)} trackRef={mainTrack} />
|
||||
</MainSlot>
|
||||
{thumbnailTrack && (
|
||||
<Thumbnail>
|
||||
|
||||
@@ -36,7 +36,13 @@ export const PipNotificationOverlay = () => {
|
||||
<StyledToastContainer
|
||||
key={toast.key}
|
||||
aria-atomic="true"
|
||||
css={{ margin: 0, marginLeft: 0, display: 'flex', alignItems: 'center', paddingRight: '0.25rem' }}
|
||||
css={{
|
||||
margin: 0,
|
||||
marginLeft: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingRight: '0.25rem',
|
||||
}}
|
||||
>
|
||||
<PipToastBody toast={toast} />
|
||||
<Button
|
||||
@@ -63,4 +69,3 @@ const Region = styled('div', {
|
||||
width: '100%',
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiEmotionLine } from '@remixicon/react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
@@ -6,40 +6,37 @@ import { ToggleButton } from '@/primitives'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { REACTIONS_TOOLBAR_ID } from '../constants'
|
||||
import { useReactionsToolbar } from '../hooks/useReactionsToolbar'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { useRoomPiP } from '@/features/pip/hooks/useRoomPiP'
|
||||
|
||||
const focusReactionsToolbar = () => {
|
||||
document
|
||||
.getElementById(REACTIONS_TOOLBAR_ID)
|
||||
?.querySelector<HTMLElement>('button')
|
||||
?.focus()
|
||||
type ReactionsToggleProps = {
|
||||
id?: string
|
||||
}
|
||||
|
||||
export const ReactionsToggle = () => {
|
||||
export const ReactionsToggle = ({
|
||||
id = 'reactions-toggle',
|
||||
}: ReactionsToggleProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||
const { isOpen: isPiPOpen } = useRoomPiP()
|
||||
|
||||
const { isOpen, toggle } = useReactionsToolbar()
|
||||
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
const handleShortcut = useCallback(() => {
|
||||
if (layoutStore.showReactionsToolbar) {
|
||||
focusReactionsToolbar()
|
||||
if (isOpen) {
|
||||
const doc = buttonRef.current?.ownerDocument ?? document
|
||||
doc
|
||||
.getElementById(REACTIONS_TOOLBAR_ID)
|
||||
?.querySelector<HTMLElement>('button')
|
||||
?.focus()
|
||||
} else {
|
||||
layoutStore.showReactionsToolbar = true
|
||||
toggle()
|
||||
}
|
||||
}, [])
|
||||
}, [isOpen, toggle])
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'reaction',
|
||||
handler: handleShortcut,
|
||||
isDisabled: isPiPOpen,
|
||||
})
|
||||
useRegisterKeyboardShortcut({ id: 'reaction', handler: handleShortcut })
|
||||
|
||||
return (
|
||||
<ToggleButton
|
||||
id="reactions-toggle"
|
||||
data-attr="reactions-toggle"
|
||||
ref={buttonRef}
|
||||
id={id}
|
||||
data-attr={id}
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={t('button')}
|
||||
|
||||
+20
-20
@@ -2,31 +2,24 @@ import { useRef, type ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFocusManager } from '@react-aria/focus'
|
||||
import { findFirstFocusable } from '@/utils/dom'
|
||||
import { pipLayoutStore } from '@/features/pip/stores/pipLayoutStore'
|
||||
import { useEscapeDismiss } from '@/features/pip/hooks/useEscapeDismiss'
|
||||
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
|
||||
import { REACTIONS_TOOLBAR_ID } from '../../constants'
|
||||
|
||||
const REACTIONS_TOGGLE_ID = 'pip-reactions-toggle'
|
||||
const CONTROL_BAR_ID = 'pip-control-bar'
|
||||
|
||||
const closeToolbar = () => {
|
||||
pipLayoutStore.showReactionsToolbar = false
|
||||
type Props = {
|
||||
children: ReactNode
|
||||
toggleId?: string
|
||||
controlBarId?: string
|
||||
}
|
||||
|
||||
/** Keyboard navigation for the PiP reactions toolbar (mirrors the main app). */
|
||||
export const PipReactionsKeyboardNavigation = ({
|
||||
export const ReactionsKeyboardNavigation = ({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode
|
||||
}) => {
|
||||
toggleId = 'reactions-toggle',
|
||||
controlBarId = 'control-bar',
|
||||
}: Props) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||
const focusManager = useFocusManager()
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEscapeDismiss(rootRef, true, () => {
|
||||
const doc = rootRef.current?.ownerDocument ?? document
|
||||
doc.getElementById(REACTIONS_TOGGLE_ID)?.focus()
|
||||
closeToolbar()
|
||||
})
|
||||
const { close } = useReactionsToolbar()
|
||||
|
||||
const onFocus = (event: React.FocusEvent<HTMLDivElement>) => {
|
||||
const fromOutside = !event.currentTarget.contains(event.relatedTarget)
|
||||
@@ -34,6 +27,8 @@ export const PipReactionsKeyboardNavigation = ({
|
||||
}
|
||||
|
||||
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const doc = rootRef.current?.ownerDocument ?? document
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowRight':
|
||||
focusManager?.focusNext({ wrap: true })
|
||||
@@ -41,11 +36,15 @@ export const PipReactionsKeyboardNavigation = ({
|
||||
case 'ArrowLeft':
|
||||
focusManager?.focusPrevious({ wrap: true })
|
||||
break
|
||||
case 'Escape':
|
||||
event.preventDefault()
|
||||
doc.getElementById(toggleId)?.focus()
|
||||
close()
|
||||
break
|
||||
case 'Tab':
|
||||
if (!event.shiftKey) {
|
||||
event.preventDefault()
|
||||
const doc = rootRef.current?.ownerDocument ?? document
|
||||
findFirstFocusable(doc.getElementById(CONTROL_BAR_ID))?.focus()
|
||||
findFirstFocusable(doc.getElementById(controlBarId))?.focus()
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -54,6 +53,7 @@ export const PipReactionsKeyboardNavigation = ({
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
id={REACTIONS_TOOLBAR_ID}
|
||||
role="toolbar"
|
||||
aria-label={t('toolbar')}
|
||||
onFocus={onFocus}
|
||||
+4
-9
@@ -2,21 +2,17 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { ReactionButton } from '@/features/reactions/components/toolbar/ReactionButton'
|
||||
import { ReactionButton } from './ReactionButton'
|
||||
import {
|
||||
computeReactionsPage,
|
||||
getMaxPageStart,
|
||||
} from '../../utils/pipReactionsPagination'
|
||||
} from '../../utils/reactionsPagination'
|
||||
|
||||
type Props = { isOpen: boolean; availableWidth: number }
|
||||
|
||||
/**
|
||||
* Paginated emoji pill with animated entry/exit. Responsibility: layout the
|
||||
* visible emojis for the currently available width and expose prev/next arrows.
|
||||
*/
|
||||
export const PipReactionsPill = ({ isOpen, availableWidth }: Props) => {
|
||||
export const ReactionsPill = ({ isOpen, availableWidth }: Props) => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'options.items.pictureInPicture',
|
||||
keyPrefix: 'controls.reactions',
|
||||
})
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [pageStart, setPageStart] = useState(0)
|
||||
@@ -36,7 +32,6 @@ export const PipReactionsPill = ({ isOpen, availableWidth }: Props) => {
|
||||
[availableWidth, pageStart]
|
||||
)
|
||||
|
||||
// Clamp pageStart if the window was resized and the current page no longer fits.
|
||||
useEffect(() => {
|
||||
if (!hasOverflow) {
|
||||
setPageStart(0)
|
||||
@@ -1,155 +1,69 @@
|
||||
import { FocusScope, useFocusManager } from '@react-aria/focus'
|
||||
import { REACTIONS_TOOLBAR_ID } from '../../constants'
|
||||
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
|
||||
import { ReactionButton } from './ReactionButton'
|
||||
import { Emoji } from '../../types'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { FocusScope } from '@react-aria/focus'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { getFirstControlBarFocusable } from '@/utils/dom'
|
||||
import { useIsMobile } from '@/utils/useIsMobile'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
|
||||
import { useDelayUnmount } from '@/hooks/useDelayUnmount'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePipElementSize } from '@/features/pip/hooks/usePipElementSize'
|
||||
import { ReactionsKeyboardNavigation } from './ReactionsKeyboardNavigation'
|
||||
import { ReactionsPill } from './ReactionsPill'
|
||||
|
||||
const Container = styled('div', {
|
||||
type ReactionsToolbarProps = {
|
||||
toggleId?: string
|
||||
controlBarId?: string
|
||||
}
|
||||
|
||||
export const ReactionsToolbar = ({
|
||||
toggleId = 'reactions-toggle',
|
||||
controlBarId = 'control-bar',
|
||||
}: ReactionsToolbarProps) => {
|
||||
const { isOpen } = useReactionsToolbar()
|
||||
const renderContent = useDelayUnmount(isOpen, 500)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const { width: availableWidth } = usePipElementSize(wrapperRef)
|
||||
|
||||
useEffect(() => {
|
||||
const el = contentRef.current
|
||||
if (!el) return
|
||||
if (isOpen) el.removeAttribute('inert')
|
||||
else el.setAttribute('inert', '')
|
||||
}, [isOpen, renderContent])
|
||||
|
||||
return (
|
||||
<Wrapper ref={wrapperRef} isOpen={isOpen}>
|
||||
{renderContent && (
|
||||
<div ref={contentRef}>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||
<FocusScope autoFocus>
|
||||
<ReactionsKeyboardNavigation
|
||||
toggleId={toggleId}
|
||||
controlBarId={controlBarId}
|
||||
>
|
||||
<ReactionsPill isOpen={isOpen} availableWidth={availableWidth} />
|
||||
</ReactionsKeyboardNavigation>
|
||||
</FocusScope>
|
||||
</div>
|
||||
)}
|
||||
</Wrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const Wrapper = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
position: 'absolute',
|
||||
bottom: 'var(--sizes-room-control-bar)',
|
||||
left: 0,
|
||||
right: 0,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
})
|
||||
|
||||
const StyledStrip = styled('div', {
|
||||
base: {
|
||||
display: 'flex',
|
||||
gap: '0.2rem',
|
||||
borderRadius: '21px',
|
||||
padding: '0.15rem',
|
||||
backgroundColor: 'primaryDark.100',
|
||||
opacity: 0,
|
||||
transform: 'translateY(3.25rem)',
|
||||
transition: 'opacity, transform',
|
||||
transitionDuration: '0.5s',
|
||||
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
pointerEvents: 'none',
|
||||
overflow: 'hidden',
|
||||
maxHeight: 0,
|
||||
width: '100%',
|
||||
transition:
|
||||
'max-height 0.5s cubic-bezier(0.4, 0, 0.2, 1), padding 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
},
|
||||
variants: {
|
||||
isVisible: {
|
||||
isOpen: {
|
||||
true: {
|
||||
opacity: 1,
|
||||
transform: 'translateY(0)',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
},
|
||||
desktopOffset: {
|
||||
true: {
|
||||
// Ideally this value should be calculated dynamically in JavaScript to keep
|
||||
// the reaction toolbar perfectly centered relative to the reaction toggle.
|
||||
// However, for simplicity and to follow a pragmatic 80/20 approach,
|
||||
// this value is currently hardcoded in CSS.
|
||||
marginRight: '30px',
|
||||
maxHeight: '60px',
|
||||
padding: '0.5rem 0',
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const Strip = ({ children }: { children: React.ReactNode }) => {
|
||||
const { isOpen } = useReactionsToolbar()
|
||||
const isMobile = useIsMobile()
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// defer one frame so the browser paints opacity:0 first
|
||||
const id = requestAnimationFrame(() => setIsVisible(true))
|
||||
return () => cancelAnimationFrame(id)
|
||||
} else {
|
||||
setIsVisible(false)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
return (
|
||||
<StyledStrip
|
||||
ref={ref}
|
||||
aria-hidden={!isOpen}
|
||||
isVisible={isVisible}
|
||||
desktopOffset={!isMobile}
|
||||
>
|
||||
{children}
|
||||
</StyledStrip>
|
||||
)
|
||||
}
|
||||
|
||||
const KeyboardNavigation = ({ children }: { children: React.ReactNode }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||
const focusManager = useFocusManager()
|
||||
|
||||
const onFocus = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
const comingFromOutside = !e.currentTarget.contains(e.relatedTarget)
|
||||
if (comingFromOutside) {
|
||||
focusManager?.focusFirst()
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
switch (e.key) {
|
||||
case 'ArrowRight':
|
||||
focusManager?.focusNext({ wrap: true })
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
focusManager?.focusPrevious({ wrap: true })
|
||||
break
|
||||
case 'Escape':
|
||||
e.preventDefault()
|
||||
document.getElementById('reactions-toggle')?.focus()
|
||||
layoutStore.showReactionsToolbar = false
|
||||
break
|
||||
case 'Tab':
|
||||
if (!e.shiftKey) {
|
||||
e.preventDefault()
|
||||
getFirstControlBarFocusable('control-bar')?.focus()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id={REACTIONS_TOOLBAR_ID}
|
||||
role="toolbar"
|
||||
aria-label={t('toolbar')}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={onFocus}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ReactionsToolbar = () => {
|
||||
const { isOpen } = useReactionsToolbar()
|
||||
const shouldMount = useDelayUnmount(isOpen, 300)
|
||||
|
||||
if (!shouldMount) return null
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus*/}
|
||||
<FocusScope autoFocus>
|
||||
<KeyboardNavigation>
|
||||
<Strip>
|
||||
{Object.values(Emoji).map((emoji) => (
|
||||
<ReactionButton key={emoji} emoji={emoji} />
|
||||
))}
|
||||
</Strip>
|
||||
</KeyboardNavigation>
|
||||
</FocusScope>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import { createContext, useContext } from 'react'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
|
||||
export type ReactionsToolbarStore = {
|
||||
showReactionsToolbar: boolean
|
||||
}
|
||||
|
||||
const ReactionsToolbarStoreContext =
|
||||
createContext<ReactionsToolbarStore>(layoutStore)
|
||||
|
||||
export const ReactionsToolbarStoreProvider =
|
||||
ReactionsToolbarStoreContext.Provider
|
||||
|
||||
export const useReactionsToolbar = () => {
|
||||
const layoutSnap = useSnapshot(layoutStore)
|
||||
const store = useContext(ReactionsToolbarStoreContext)
|
||||
const snap = useSnapshot(store)
|
||||
|
||||
return {
|
||||
isOpen: layoutSnap.showReactionsToolbar,
|
||||
isOpen: snap.showReactionsToolbar,
|
||||
toggle: () => {
|
||||
layoutStore.showReactionsToolbar = !layoutSnap.showReactionsToolbar
|
||||
store.showReactionsToolbar = !store.showReactionsToolbar
|
||||
},
|
||||
close: () => {
|
||||
store.showReactionsToolbar = false
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,6 +219,8 @@
|
||||
"toolbar": "Reaktion senden",
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "du",
|
||||
"previousReactions": "Vorherige Reaktionen",
|
||||
"nextReactions": "Nächste Reaktionen",
|
||||
"emojis": {
|
||||
"thumbs-up": "Daumen hoch",
|
||||
"thumbs-down": "Daumen runter",
|
||||
@@ -249,8 +251,6 @@
|
||||
"windowLabel": "Bild-im-Bild Besprechung",
|
||||
"stage": "Teilnehmer",
|
||||
"controlBar": "Besprechungssteuerung",
|
||||
"previousReactions": "Vorherige Reaktionen",
|
||||
"nextReactions": "Nächste Reaktionen",
|
||||
"notificationsLabel": "Benachrichtigungen",
|
||||
"dismissNotification": "Benachrichtigung schließen",
|
||||
"connection": {
|
||||
|
||||
@@ -219,6 +219,8 @@
|
||||
"toolbar": "Send reaction",
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "you",
|
||||
"previousReactions": "Previous reactions",
|
||||
"nextReactions": "Next reactions",
|
||||
"emojis": {
|
||||
"thumbs-up": "thumbs up",
|
||||
"thumbs-down": "thumbs down",
|
||||
@@ -249,8 +251,6 @@
|
||||
"windowLabel": "Picture-in-picture meeting",
|
||||
"stage": "Participants",
|
||||
"controlBar": "Meeting controls",
|
||||
"previousReactions": "Previous reactions",
|
||||
"nextReactions": "Next reactions",
|
||||
"notificationsLabel": "Notifications",
|
||||
"dismissNotification": "Dismiss notification",
|
||||
"connection": {
|
||||
|
||||
@@ -219,6 +219,8 @@
|
||||
"toolbar": "Envoyer une réaction",
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "vous",
|
||||
"previousReactions": "Réactions précédentes",
|
||||
"nextReactions": "Réactions suivantes",
|
||||
"emojis": {
|
||||
"thumbs-up": "pouce levé",
|
||||
"thumbs-down": "pouce baissé",
|
||||
@@ -249,8 +251,6 @@
|
||||
"windowLabel": "Réunion en image dans l'image",
|
||||
"stage": "Participants",
|
||||
"controlBar": "Commandes de la réunion",
|
||||
"previousReactions": "Réactions précédentes",
|
||||
"nextReactions": "Réactions suivantes",
|
||||
"notificationsLabel": "Notifications",
|
||||
"dismissNotification": "Fermer la notification",
|
||||
"connection": {
|
||||
|
||||
@@ -219,6 +219,8 @@
|
||||
"toolbar": "Stuur reactie",
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "U",
|
||||
"previousReactions": "Vorige reacties",
|
||||
"nextReactions": "Volgende reacties",
|
||||
"emojis": {
|
||||
"thumbs-up": "duim omhoog",
|
||||
"thumbs-down": "duim omlaag",
|
||||
@@ -249,8 +251,6 @@
|
||||
"windowLabel": "Beeld-in-beeld vergadering",
|
||||
"stage": "Deelnemers",
|
||||
"controlBar": "Vergaderbesturing",
|
||||
"previousReactions": "Vorige reacties",
|
||||
"nextReactions": "Volgende reacties",
|
||||
"notificationsLabel": "Meldingen",
|
||||
"dismissNotification": "Melding sluiten",
|
||||
"connection": {
|
||||
|
||||
@@ -24,10 +24,7 @@ export const Menu = ({
|
||||
return (
|
||||
<MenuTrigger>
|
||||
{trigger}
|
||||
<StyledPopover
|
||||
placement={placement}
|
||||
boundaryElement={boundaryElement}
|
||||
>
|
||||
<StyledPopover placement={placement} boundaryElement={boundaryElement}>
|
||||
<Box size="sm" type="popover" variant={variant}>
|
||||
{menu}
|
||||
</Box>
|
||||
|
||||
@@ -7,5 +7,4 @@ import { createContext, useContext } from 'react'
|
||||
*/
|
||||
export const VisualOnlyTooltipsContext = createContext(false)
|
||||
|
||||
export const useVisualOnlyTooltips = () =>
|
||||
useContext(VisualOnlyTooltipsContext)
|
||||
export const useVisualOnlyTooltips = () => useContext(VisualOnlyTooltipsContext)
|
||||
|
||||
Reference in New Issue
Block a user