mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-03 22:25:27 +00:00
♻️(frontend) PiP options menu and reactions toolbar refactor
PiP-native options popover; split reactions, keyboard nav, and pagination.
This commit is contained in:
@@ -65,7 +65,12 @@ export const PipControlBar = ({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PipControls ref={containerRef} id="pip-control-bar" role="toolbar" aria-label={t('controlBar')}>
|
<PipControls
|
||||||
|
ref={containerRef}
|
||||||
|
id="pip-control-bar"
|
||||||
|
role="toolbar"
|
||||||
|
aria-label={t('controlBar')}
|
||||||
|
>
|
||||||
<PipControlsCenter>
|
<PipControlsCenter>
|
||||||
<AudioDevicesControl hideMenu />
|
<AudioDevicesControl hideMenu />
|
||||||
<VideoDeviceControl hideMenu />
|
<VideoDeviceControl hideMenu />
|
||||||
|
|||||||
@@ -1,147 +1,54 @@
|
|||||||
import { useRef, useState, useEffect, useCallback, useMemo } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
|
import { FocusScope } from '@react-aria/focus'
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { pipLayoutStore } from '../stores/pipLayoutStore'
|
import { pipLayoutStore } from '../stores/pipLayoutStore'
|
||||||
import { ReactionButton } from '@/features/reactions/components/toolbar/ReactionButton'
|
import { useDelayUnmount } from '@/hooks/useDelayUnmount'
|
||||||
import { Emoji } from '@/features/reactions/types'
|
import { usePipElementSize } from '../hooks/usePipElementSize'
|
||||||
|
import { PipReactionsKeyboardNavigation } from './reactions/PipReactionsKeyboardNavigation'
|
||||||
const EMOJIS = Object.values(Emoji)
|
import { PipReactionsPill } from './reactions/PipReactionsPill'
|
||||||
const EMOJI_SLOT_WIDTH = 40
|
|
||||||
const ARROW_SLOT_WIDTH = 32
|
|
||||||
const PILL_HORIZONTAL_PADDING = 12
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = () => {
|
export const PipReactionsToolbar = () => {
|
||||||
const { showReactionsToolbar: isOpen } = useSnapshot(pipLayoutStore)
|
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 wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
const [isVisible, setIsVisible] = useState(false)
|
const { width: availableWidth } = usePipElementSize(wrapperRef)
|
||||||
const [availableWidth, setAvailableWidth] = useState(0)
|
|
||||||
const [pageStart, setPageStart] = useState(0)
|
|
||||||
|
|
||||||
|
// Mark the subtree inert during the fade-out so Tab can't land on it.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
const el = contentRef.current
|
||||||
const id = requestAnimationFrame(() => setIsVisible(true))
|
|
||||||
return () => cancelAnimationFrame(id)
|
|
||||||
} else {
|
|
||||||
setIsVisible(false)
|
|
||||||
}
|
|
||||||
}, [isOpen])
|
|
||||||
|
|
||||||
const updateWidth = useCallback(() => {
|
|
||||||
const el = wrapperRef.current
|
|
||||||
if (!el) return
|
if (!el) return
|
||||||
setAvailableWidth(el.getBoundingClientRect().width)
|
if (isOpen) el.removeAttribute('inert')
|
||||||
}, [])
|
else el.setAttribute('inert', '')
|
||||||
|
}, [isOpen, renderContent])
|
||||||
useEffect(() => {
|
|
||||||
const el = wrapperRef.current
|
|
||||||
if (!el) return
|
|
||||||
|
|
||||||
updateWidth()
|
|
||||||
|
|
||||||
const RO =
|
|
||||||
el.ownerDocument.defaultView?.ResizeObserver ?? window.ResizeObserver
|
|
||||||
const observer = new RO(() => updateWidth())
|
|
||||||
observer.observe(el)
|
|
||||||
|
|
||||||
return () => observer.disconnect()
|
|
||||||
}, [updateWidth])
|
|
||||||
|
|
||||||
const { visibleEmojis, hasOverflow, canGoLeft, canGoRight } = useMemo(() => {
|
|
||||||
const maxWithoutArrows = Math.max(
|
|
||||||
1,
|
|
||||||
Math.floor((availableWidth - PILL_HORIZONTAL_PADDING) / EMOJI_SLOT_WIDTH)
|
|
||||||
)
|
|
||||||
|
|
||||||
if (EMOJIS.length <= maxWithoutArrows) {
|
|
||||||
return {
|
|
||||||
visibleEmojis: EMOJIS,
|
|
||||||
hasOverflow: false,
|
|
||||||
canGoLeft: false,
|
|
||||||
canGoRight: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleCount = Math.max(
|
|
||||||
1,
|
|
||||||
Math.floor(
|
|
||||||
(availableWidth - PILL_HORIZONTAL_PADDING - ARROW_SLOT_WIDTH * 2) /
|
|
||||||
EMOJI_SLOT_WIDTH
|
|
||||||
)
|
|
||||||
)
|
|
||||||
const clampedStart = Math.min(pageStart, EMOJIS.length - visibleCount)
|
|
||||||
|
|
||||||
return {
|
|
||||||
visibleEmojis: EMOJIS.slice(clampedStart, clampedStart + visibleCount),
|
|
||||||
hasOverflow: true,
|
|
||||||
canGoLeft: clampedStart > 0,
|
|
||||||
canGoRight: clampedStart + visibleCount < EMOJIS.length,
|
|
||||||
}
|
|
||||||
}, [pageStart, availableWidth])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!hasOverflow) {
|
|
||||||
setPageStart(0)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const visibleCount = Math.max(
|
|
||||||
1,
|
|
||||||
Math.floor(
|
|
||||||
(availableWidth - PILL_HORIZONTAL_PADDING - ARROW_SLOT_WIDTH * 2) /
|
|
||||||
EMOJI_SLOT_WIDTH
|
|
||||||
)
|
|
||||||
)
|
|
||||||
const maxStart = Math.max(0, EMOJIS.length - visibleCount)
|
|
||||||
if (pageStart > maxStart) {
|
|
||||||
setPageStart(maxStart)
|
|
||||||
}
|
|
||||||
}, [hasOverflow, pageStart, availableWidth])
|
|
||||||
|
|
||||||
const paginate = useCallback((direction: 'left' | 'right') => {
|
|
||||||
setPageStart((current) =>
|
|
||||||
direction === 'left' ? Math.max(0, current - 1) : current + 1
|
|
||||||
)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolbarWrapper ref={wrapperRef} isOpen={isOpen}>
|
<Wrapper ref={wrapperRef} isOpen={isOpen}>
|
||||||
<ToolbarPill isVisible={isVisible}>
|
{renderContent && (
|
||||||
{hasOverflow && (
|
<div ref={contentRef}>
|
||||||
<ArrowSlot>
|
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||||
{canGoLeft && (
|
<FocusScope autoFocus>
|
||||||
<ArrowButton
|
<PipReactionsKeyboardNavigation>
|
||||||
onClick={() => paginate('left')}
|
<PipReactionsPill
|
||||||
aria-label="Previous reactions"
|
isOpen={isOpen}
|
||||||
>
|
availableWidth={availableWidth}
|
||||||
<RiArrowLeftSLine size={16} />
|
/>
|
||||||
</ArrowButton>
|
</PipReactionsKeyboardNavigation>
|
||||||
)}
|
</FocusScope>
|
||||||
</ArrowSlot>
|
</div>
|
||||||
)}
|
)}
|
||||||
<EmojiRow>
|
</Wrapper>
|
||||||
{visibleEmojis.map((emoji) => (
|
|
||||||
<ReactionButton key={emoji} emoji={emoji} />
|
|
||||||
))}
|
|
||||||
</EmojiRow>
|
|
||||||
{hasOverflow && (
|
|
||||||
<ArrowSlot>
|
|
||||||
{canGoRight && (
|
|
||||||
<ArrowButton
|
|
||||||
onClick={() => paginate('right')}
|
|
||||||
aria-label="Next reactions"
|
|
||||||
>
|
|
||||||
<RiArrowRightSLine size={16} />
|
|
||||||
</ArrowButton>
|
|
||||||
)}
|
|
||||||
</ArrowSlot>
|
|
||||||
)}
|
|
||||||
</ToolbarPill>
|
|
||||||
</ToolbarWrapper>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ToolbarWrapper = styled('div', {
|
const Wrapper = styled('div', {
|
||||||
base: {
|
base: {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@@ -160,82 +67,3 @@ const ToolbarWrapper = styled('div', {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const ToolbarPill = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '0.2rem',
|
|
||||||
borderRadius: '21px',
|
|
||||||
padding: '0.15rem',
|
|
||||||
backgroundColor: 'primaryDark.100',
|
|
||||||
maxWidth: '100%',
|
|
||||||
overflow: 'hidden',
|
|
||||||
width: 'fit-content',
|
|
||||||
opacity: 0,
|
|
||||||
transform: 'translateY(3.25rem)',
|
|
||||||
transition: 'opacity, transform',
|
|
||||||
transitionDuration: '0.5s',
|
|
||||||
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
},
|
|
||||||
variants: {
|
|
||||||
isVisible: {
|
|
||||||
true: {
|
|
||||||
opacity: 1,
|
|
||||||
transform: 'translateY(0)',
|
|
||||||
pointerEvents: 'auto',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const EmojiRow = styled('div', {
|
|
||||||
base: {
|
|
||||||
display: 'flex',
|
|
||||||
gap: '0.2rem',
|
|
||||||
'& > *': {
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const ArrowSlot = styled('div', {
|
|
||||||
base: {
|
|
||||||
width: '32px',
|
|
||||||
minWidth: '32px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const ArrowButton = ({
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={css({
|
|
||||||
flexShrink: 0,
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '28px',
|
|
||||||
height: '28px',
|
|
||||||
borderRadius: '50%',
|
|
||||||
border: 'none',
|
|
||||||
backgroundColor: 'primaryDark.200',
|
|
||||||
color: 'white',
|
|
||||||
cursor: 'pointer',
|
|
||||||
opacity: 0.85,
|
|
||||||
_hover: {
|
|
||||||
opacity: 1,
|
|
||||||
backgroundColor: 'primaryDark.300',
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -24,20 +24,21 @@ export const PipView = () => {
|
|||||||
|
|
||||||
// Side panels open via a menu item that unmounts on click; fall back to the
|
// Side panels open via a menu item that unmounts on click; fall back to the
|
||||||
// options button so focus returns somewhere visible.
|
// options button so focus returns somewhere visible.
|
||||||
const resolveTrigger = useCallback(
|
const resolveTrigger = useCallback((activeEl: HTMLElement | null) => {
|
||||||
(activeEl: HTMLElement | null) => {
|
if (activeEl?.tagName === 'DIV') {
|
||||||
if (activeEl?.tagName === 'DIV') {
|
const doc = containerRef.current?.ownerDocument ?? document
|
||||||
const doc = containerRef.current?.ownerDocument ?? document
|
return doc.getElementById('room-options-trigger')
|
||||||
return doc.getElementById('room-options-trigger')
|
}
|
||||||
}
|
return activeEl
|
||||||
return activeEl
|
}, [])
|
||||||
},
|
|
||||||
[]
|
|
||||||
)
|
|
||||||
usePipRestoreFocus(containerRef, isSidePanelOpen, { resolveTrigger })
|
usePipRestoreFocus(containerRef, isSidePanelOpen, { resolveTrigger })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PipContainer ref={containerRef} role="region" aria-label={t('windowLabel')}>
|
<PipContainer
|
||||||
|
ref={containerRef}
|
||||||
|
role="region"
|
||||||
|
aria-label={t('windowLabel')}
|
||||||
|
>
|
||||||
<PipStage />
|
<PipStage />
|
||||||
<PipReactionsToolbar />
|
<PipReactionsToolbar />
|
||||||
<PipControlBar showScreenShare={browserSupportsScreenSharing} />
|
<PipControlBar showScreenShare={browserSupportsScreenSharing} />
|
||||||
@@ -55,12 +56,21 @@ const PipContainer = styled('div', {
|
|||||||
backgroundColor: 'primaryDark.50',
|
backgroundColor: 'primaryDark.50',
|
||||||
'& .lk-participant-tile': {
|
'& .lk-participant-tile': {
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'primaryDark.100',
|
||||||
},
|
},
|
||||||
'& .lk-participant-media': {
|
'& .lk-participant-media': {
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
borderRadius: 0,
|
||||||
|
backgroundColor: 'primaryDark.100',
|
||||||
},
|
},
|
||||||
'& .lk-participant-media-video': {
|
'& .lk-participant-media-video': {
|
||||||
|
display: 'block',
|
||||||
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
|
borderRadius: 0,
|
||||||
|
backgroundColor: 'primaryDark.100',
|
||||||
objectFit: 'cover',
|
objectFit: 'cover',
|
||||||
},
|
},
|
||||||
'& .lk-grid-layout': {
|
'& .lk-grid-layout': {
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { RiMoreFill } from '@remixicon/react'
|
import { RiMoreFill } from '@remixicon/react'
|
||||||
|
import { FocusScope } from '@react-aria/focus'
|
||||||
import { Box, Button } from '@/primitives'
|
import { Box, Button } from '@/primitives'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { PipOptionsMenuItems } from './PipOptionsMenuItems'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { PipOptionsMenuItems } from './PipOptionsMenuItems'
|
||||||
|
import { useEscapeDismiss } from '@/features/pip/hooks/useEscapeDismiss'
|
||||||
import type { CollapsibleControl } from '../PipControlBar'
|
import type { CollapsibleControl } from '../PipControlBar'
|
||||||
|
|
||||||
type PipOptionsMenuProps = {
|
type PipOptionsMenuProps = {
|
||||||
@@ -11,16 +13,22 @@ type PipOptionsMenuProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PiP-specific options menu with absolute positioning for correct alignment in PiP window.
|
* PiP-native options menu. The shared `Menu` primitive mis-positions its
|
||||||
* Renders locally (unlike standard Menu) and closes automatically on item click or outside click.
|
* popover and loses focus across documents, so we drive open/close, focus
|
||||||
* Overflow controls from the toolbar are rendered as additional menu items.
|
* and dismissal ourselves.
|
||||||
*/
|
*/
|
||||||
export const PipOptionsMenu = ({ overflowControls }: PipOptionsMenuProps) => {
|
export const PipOptionsMenu = ({ overflowControls }: PipOptionsMenuProps) => {
|
||||||
const { t } = useTranslation('rooms')
|
const { t } = useTranslation('rooms')
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
const label = t('options.buttonLabel')
|
const label = t('options.buttonLabel')
|
||||||
|
|
||||||
|
useEscapeDismiss(wrapperRef, isOpen, () => {
|
||||||
|
setIsOpen(false)
|
||||||
|
requestAnimationFrame(() => triggerRef.current?.focus())
|
||||||
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return
|
if (!isOpen) return
|
||||||
const doc = wrapperRef.current?.ownerDocument ?? document
|
const doc = wrapperRef.current?.ownerDocument ?? document
|
||||||
@@ -29,19 +37,28 @@ export const PipOptionsMenu = ({ overflowControls }: PipOptionsMenuProps) => {
|
|||||||
const target = event.target as HTMLElement | null
|
const target = event.target as HTMLElement | null
|
||||||
const wrapper = wrapperRef.current
|
const wrapper = wrapperRef.current
|
||||||
if (!wrapper || !target) return
|
if (!wrapper || !target) return
|
||||||
|
|
||||||
if (wrapper.querySelector('button')?.contains(target)) return
|
if (wrapper.querySelector('button')?.contains(target)) return
|
||||||
|
|
||||||
if (target.closest('[role="menuitem"]')) {
|
if (target.closest('[role="menuitem"]')) {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
setIsOpen(false)
|
setIsOpen(false)
|
||||||
|
triggerRef.current?.focus()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleOutsideClick = (event: MouseEvent) => {
|
||||||
|
const target = event.target as HTMLElement | null
|
||||||
|
const wrapper = wrapperRef.current
|
||||||
|
if (!wrapper || !target) return
|
||||||
|
if (wrapper.contains(target)) return
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
doc.addEventListener('click', handleMenuItemClick, true)
|
doc.addEventListener('click', handleMenuItemClick, true)
|
||||||
|
doc.addEventListener('mousedown', handleOutsideClick, true)
|
||||||
return () => {
|
return () => {
|
||||||
doc.removeEventListener('click', handleMenuItemClick, true)
|
doc.removeEventListener('click', handleMenuItemClick, true)
|
||||||
|
doc.removeEventListener('mousedown', handleOutsideClick, true)
|
||||||
}
|
}
|
||||||
}, [isOpen])
|
}, [isOpen])
|
||||||
|
|
||||||
@@ -53,10 +70,13 @@ export const PipOptionsMenu = ({ overflowControls }: PipOptionsMenuProps) => {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
|
ref={triggerRef}
|
||||||
id="room-options-trigger"
|
id="room-options-trigger"
|
||||||
square
|
square
|
||||||
variant="primaryDark"
|
variant="primaryDark"
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={isOpen}
|
||||||
tooltip={label}
|
tooltip={label}
|
||||||
onPress={() => setIsOpen(!isOpen)}
|
onPress={() => setIsOpen(!isOpen)}
|
||||||
>
|
>
|
||||||
@@ -72,9 +92,12 @@ export const PipOptionsMenu = ({ overflowControls }: PipOptionsMenuProps) => {
|
|||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<Box size="sm" type="popover" variant="dark">
|
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
|
||||||
<PipOptionsMenuItems overflowControls={overflowControls} />
|
<FocusScope autoFocus>
|
||||||
</Box>
|
<Box size="sm" type="popover" variant="dark">
|
||||||
|
<PipOptionsMenuItems overflowControls={overflowControls} />
|
||||||
|
</Box>
|
||||||
|
</FocusScope>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
} from '@remixicon/react'
|
} from '@remixicon/react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Separator } from '@/primitives/Separator'
|
import { Separator } from '@/primitives/Separator'
|
||||||
import { SettingsMenuItem } from '@/features/rooms/livekit/components/controls/Options/SettingsMenuItem'
|
|
||||||
import { FeedbackMenuItem } from '@/features/rooms/livekit/components/controls/Options/FeedbackMenuItem'
|
import { FeedbackMenuItem } from '@/features/rooms/livekit/components/controls/Options/FeedbackMenuItem'
|
||||||
import { EffectsMenuItem } from '@/features/rooms/livekit/components/controls/Options/EffectsMenuItem'
|
import { EffectsMenuItem } from '@/features/rooms/livekit/components/controls/Options/EffectsMenuItem'
|
||||||
import { SupportMenuItem } from '@/features/rooms/livekit/components/controls/Options/SupportMenuItem'
|
import { SupportMenuItem } from '@/features/rooms/livekit/components/controls/Options/SupportMenuItem'
|
||||||
@@ -54,7 +53,6 @@ export const PipOptionsMenuItems = ({
|
|||||||
<MenuSection>
|
<MenuSection>
|
||||||
<SupportMenuItem />
|
<SupportMenuItem />
|
||||||
<FeedbackMenuItem />
|
<FeedbackMenuItem />
|
||||||
<SettingsMenuItem />
|
|
||||||
</MenuSection>
|
</MenuSection>
|
||||||
</RACMenu>
|
</RACMenu>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,10 +47,7 @@ export const PipGridLayout = memo(function PipGridLayout({
|
|||||||
return (
|
return (
|
||||||
<GridContainer ref={containerRef} style={gridStyle}>
|
<GridContainer ref={containerRef} style={gridStyle}>
|
||||||
{tracks.map((track, index) => (
|
{tracks.map((track, index) => (
|
||||||
<GridCell
|
<GridCell key={tileKeys[index]} style={placements[index]}>
|
||||||
key={tileKeys[index]}
|
|
||||||
style={placements[index]}
|
|
||||||
>
|
|
||||||
<ParticipantTile trackRef={track} disableMetadata />
|
<ParticipantTile trackRef={track} disableMetadata />
|
||||||
</GridCell>
|
</GridCell>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { PipGridLayout } from './PipGridLayout'
|
|||||||
* (main + thumbnail) to the adaptive grid layout.
|
* (main + thumbnail) to the adaptive grid layout.
|
||||||
*/
|
*/
|
||||||
const FOCUS_MAX_TILES = 2
|
const FOCUS_MAX_TILES = 2
|
||||||
|
|
||||||
// Handles which layout to render inside the PiP stage.
|
// Handles which layout to render inside the PiP stage.
|
||||||
|
|
||||||
export const PipStage = () => {
|
export const PipStage = () => {
|
||||||
@@ -32,10 +32,7 @@ export const PipStage = () => {
|
|||||||
{ onlySubscribed: false }
|
{ onlySubscribed: false }
|
||||||
)
|
)
|
||||||
|
|
||||||
const screenShareTrack = useMemo(
|
const screenShareTrack = useMemo(() => pickScreenShareTrack(tracks), [tracks])
|
||||||
() => pickScreenShareTrack(tracks),
|
|
||||||
[tracks]
|
|
||||||
)
|
|
||||||
|
|
||||||
// Order the list so the "focus target" (screen share when available,
|
// Order the list so the "focus target" (screen share when available,
|
||||||
// otherwise a remote camera) is first. Both layouts consume this order.
|
// otherwise a remote camera) is first. Both layouts consume this order.
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
const REACTIONS_TOGGLE_ID = 'pip-reactions-toggle'
|
||||||
|
const CONTROL_BAR_ID = 'pip-control-bar'
|
||||||
|
|
||||||
|
const closeToolbar = () => {
|
||||||
|
pipLayoutStore.showReactionsToolbar = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keyboard navigation for the PiP reactions toolbar (mirrors the main app). */
|
||||||
|
export const PipReactionsKeyboardNavigation = ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: ReactNode
|
||||||
|
}) => {
|
||||||
|
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 onFocus = (event: React.FocusEvent<HTMLDivElement>) => {
|
||||||
|
const fromOutside = !event.currentTarget.contains(event.relatedTarget)
|
||||||
|
if (fromOutside) focusManager?.focusFirst()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
switch (event.key) {
|
||||||
|
case 'ArrowRight':
|
||||||
|
focusManager?.focusNext({ wrap: true })
|
||||||
|
break
|
||||||
|
case 'ArrowLeft':
|
||||||
|
focusManager?.focusPrevious({ wrap: true })
|
||||||
|
break
|
||||||
|
case 'Tab':
|
||||||
|
if (!event.shiftKey) {
|
||||||
|
event.preventDefault()
|
||||||
|
const doc = rootRef.current?.ownerDocument ?? document
|
||||||
|
findFirstFocusable(doc.getElementById(CONTROL_BAR_ID))?.focus()
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
role="toolbar"
|
||||||
|
aria-label={t('toolbar')}
|
||||||
|
onFocus={onFocus}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
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 {
|
||||||
|
computeReactionsPage,
|
||||||
|
getMaxPageStart,
|
||||||
|
} from '../../utils/pipReactionsPagination'
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const { t } = useTranslation('rooms', {
|
||||||
|
keyPrefix: 'options.items.pictureInPicture',
|
||||||
|
})
|
||||||
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
|
const [pageStart, setPageStart] = useState(0)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setIsVisible(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const id = requestAnimationFrame(() => setIsVisible(true))
|
||||||
|
return () => cancelAnimationFrame(id)
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
const { visibleEmojis, hasOverflow, canGoLeft, canGoRight, visibleCount } =
|
||||||
|
useMemo(
|
||||||
|
() => computeReactionsPage(availableWidth, pageStart),
|
||||||
|
[availableWidth, pageStart]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clamp pageStart if the window was resized and the current page no longer fits.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasOverflow) {
|
||||||
|
setPageStart(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const maxStart = getMaxPageStart(visibleCount)
|
||||||
|
if (pageStart > maxStart) setPageStart(maxStart)
|
||||||
|
}, [hasOverflow, pageStart, visibleCount])
|
||||||
|
|
||||||
|
const paginate = useCallback((direction: 'left' | 'right') => {
|
||||||
|
setPageStart((current) =>
|
||||||
|
direction === 'left' ? Math.max(0, current - 1) : current + 1
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Pill isVisible={isVisible}>
|
||||||
|
{hasOverflow && (
|
||||||
|
<ArrowSlot>
|
||||||
|
{canGoLeft && (
|
||||||
|
<ArrowButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => paginate('left')}
|
||||||
|
aria-label={t('previousReactions')}
|
||||||
|
>
|
||||||
|
<RiArrowLeftSLine size={16} />
|
||||||
|
</ArrowButton>
|
||||||
|
)}
|
||||||
|
</ArrowSlot>
|
||||||
|
)}
|
||||||
|
<EmojiRow>
|
||||||
|
{visibleEmojis.map((emoji) => (
|
||||||
|
<ReactionButton key={emoji} emoji={emoji} />
|
||||||
|
))}
|
||||||
|
</EmojiRow>
|
||||||
|
{hasOverflow && (
|
||||||
|
<ArrowSlot>
|
||||||
|
{canGoRight && (
|
||||||
|
<ArrowButton
|
||||||
|
type="button"
|
||||||
|
onClick={() => paginate('right')}
|
||||||
|
aria-label={t('nextReactions')}
|
||||||
|
>
|
||||||
|
<RiArrowRightSLine size={16} />
|
||||||
|
</ArrowButton>
|
||||||
|
)}
|
||||||
|
</ArrowSlot>
|
||||||
|
)}
|
||||||
|
</Pill>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const Pill = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.2rem',
|
||||||
|
borderRadius: '21px',
|
||||||
|
padding: '0.15rem',
|
||||||
|
backgroundColor: 'primaryDark.100',
|
||||||
|
maxWidth: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
width: 'fit-content',
|
||||||
|
opacity: 0,
|
||||||
|
transform: 'translateY(3.25rem)',
|
||||||
|
transition: 'opacity, transform',
|
||||||
|
transitionDuration: '0.5s',
|
||||||
|
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
isVisible: {
|
||||||
|
true: {
|
||||||
|
opacity: 1,
|
||||||
|
transform: 'translateY(0)',
|
||||||
|
pointerEvents: 'auto',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const EmojiRow = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.2rem',
|
||||||
|
'& > *': {
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ArrowSlot = styled('div', {
|
||||||
|
base: {
|
||||||
|
width: '32px',
|
||||||
|
minWidth: '32px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ArrowButton = styled('button', {
|
||||||
|
base: {
|
||||||
|
flexShrink: 0,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
borderRadius: '50%',
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: 'primaryDark.200',
|
||||||
|
color: 'white',
|
||||||
|
cursor: 'pointer',
|
||||||
|
opacity: 0.85,
|
||||||
|
_hover: {
|
||||||
|
opacity: 1,
|
||||||
|
backgroundColor: 'primaryDark.300',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -34,8 +34,9 @@ export const usePipFlipAnimations = <T extends HTMLElement>(
|
|||||||
|
|
||||||
const doc = container.ownerDocument
|
const doc = container.ownerDocument
|
||||||
const view = doc.defaultView
|
const view = doc.defaultView
|
||||||
const reduceMotion = view?.matchMedia('(prefers-reduced-motion: reduce)')
|
const reduceMotion = view?.matchMedia(
|
||||||
.matches
|
'(prefers-reduced-motion: reduce)'
|
||||||
|
).matches
|
||||||
|
|
||||||
const children = Array.from(container.children) as HTMLElement[]
|
const children = Array.from(container.children) as HTMLElement[]
|
||||||
const nextRects = new Map<string, DOMRect>()
|
const nextRects = new Map<string, DOMRect>()
|
||||||
|
|||||||
@@ -42,7 +42,11 @@ const pickGridShape = (
|
|||||||
|
|
||||||
const minCols = count >= FORCE_TWO_COLS_COUNT ? 2 : 1
|
const minCols = count >= FORCE_TWO_COLS_COUNT ? 2 : 1
|
||||||
|
|
||||||
let best = { cols: minCols, rows: Math.ceil(count / minCols), score: -Infinity }
|
let best = {
|
||||||
|
cols: minCols,
|
||||||
|
rows: Math.ceil(count / minCols),
|
||||||
|
score: -Infinity,
|
||||||
|
}
|
||||||
for (let cols = minCols; cols <= count; cols++) {
|
for (let cols = minCols; cols <= count; cols++) {
|
||||||
const rows = Math.ceil(count / cols)
|
const rows = Math.ceil(count / cols)
|
||||||
const tileW = width / cols
|
const tileW = width / cols
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Emoji } from '@/features/reactions/types'
|
||||||
|
|
||||||
|
export const EMOJI_SLOT_WIDTH = 40
|
||||||
|
export const ARROW_SLOT_WIDTH = 32
|
||||||
|
export const PILL_HORIZONTAL_PADDING = 12
|
||||||
|
export const WRAPPER_HORIZONTAL_PADDING = 16
|
||||||
|
|
||||||
|
const EMOJIS = Object.values(Emoji)
|
||||||
|
|
||||||
|
export type ReactionsPage = {
|
||||||
|
visibleEmojis: Emoji[]
|
||||||
|
hasOverflow: boolean
|
||||||
|
canGoLeft: boolean
|
||||||
|
canGoRight: boolean
|
||||||
|
visibleCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute how many emojis fit in `availableWidth` and slice the visible page.
|
||||||
|
* Arrow slots are reserved only when the list overflows.
|
||||||
|
*/
|
||||||
|
export const computeReactionsPage = (
|
||||||
|
availableWidth: number,
|
||||||
|
pageStart: number
|
||||||
|
): ReactionsPage => {
|
||||||
|
const usableWidth =
|
||||||
|
availableWidth - WRAPPER_HORIZONTAL_PADDING - PILL_HORIZONTAL_PADDING
|
||||||
|
const maxWithoutArrows = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor(usableWidth / EMOJI_SLOT_WIDTH)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (EMOJIS.length <= maxWithoutArrows) {
|
||||||
|
return {
|
||||||
|
visibleEmojis: EMOJIS,
|
||||||
|
hasOverflow: false,
|
||||||
|
canGoLeft: false,
|
||||||
|
canGoRight: false,
|
||||||
|
visibleCount: EMOJIS.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleCount = Math.max(
|
||||||
|
1,
|
||||||
|
Math.floor((usableWidth - ARROW_SLOT_WIDTH * 2) / EMOJI_SLOT_WIDTH)
|
||||||
|
)
|
||||||
|
const clampedStart = Math.min(
|
||||||
|
Math.max(0, pageStart),
|
||||||
|
Math.max(0, EMOJIS.length - visibleCount)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
visibleEmojis: EMOJIS.slice(clampedStart, clampedStart + visibleCount),
|
||||||
|
hasOverflow: true,
|
||||||
|
canGoLeft: clampedStart > 0,
|
||||||
|
canGoRight: clampedStart + visibleCount < EMOJIS.length,
|
||||||
|
visibleCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMaxPageStart = (visibleCount: number): number =>
|
||||||
|
Math.max(0, EMOJIS.length - visibleCount)
|
||||||
Reference in New Issue
Block a user