♻️(frontend) PiP options menu and reactions toolbar refactor

PiP-native options popover; split reactions, keyboard nav, and pagination.
This commit is contained in:
Cyril
2026-04-23 19:19:52 +02:00
parent f6510b7488
commit 8b1d200740
12 changed files with 393 additions and 243 deletions
@@ -65,7 +65,12 @@ export const PipControlBar = ({
)
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>
<AudioDevicesControl 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 { css } from '@/styled-system/css'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { useSnapshot } from 'valtio'
import { pipLayoutStore } from '../stores/pipLayoutStore'
import { ReactionButton } from '@/features/reactions/components/toolbar/ReactionButton'
import { Emoji } from '@/features/reactions/types'
const EMOJIS = Object.values(Emoji)
const EMOJI_SLOT_WIDTH = 40
const ARROW_SLOT_WIDTH = 32
const PILL_HORIZONTAL_PADDING = 12
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 [isVisible, setIsVisible] = useState(false)
const [availableWidth, setAvailableWidth] = useState(0)
const [pageStart, setPageStart] = useState(0)
const { width: availableWidth } = usePipElementSize(wrapperRef)
// Mark the subtree inert during the fade-out so Tab can't land on it.
useEffect(() => {
if (isOpen) {
const id = requestAnimationFrame(() => setIsVisible(true))
return () => cancelAnimationFrame(id)
} else {
setIsVisible(false)
}
}, [isOpen])
const updateWidth = useCallback(() => {
const el = wrapperRef.current
const el = contentRef.current
if (!el) return
setAvailableWidth(el.getBoundingClientRect().width)
}, [])
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
)
}, [])
if (isOpen) el.removeAttribute('inert')
else el.setAttribute('inert', '')
}, [isOpen, renderContent])
return (
<ToolbarWrapper ref={wrapperRef} isOpen={isOpen}>
<ToolbarPill isVisible={isVisible}>
{hasOverflow && (
<ArrowSlot>
{canGoLeft && (
<ArrowButton
onClick={() => paginate('left')}
aria-label="Previous reactions"
>
<RiArrowLeftSLine size={16} />
</ArrowButton>
)}
</ArrowSlot>
)}
<EmojiRow>
{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>
<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 ToolbarWrapper = styled('div', {
const Wrapper = styled('div', {
base: {
display: 'flex',
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
// options button so focus returns somewhere visible.
const resolveTrigger = useCallback(
(activeEl: HTMLElement | null) => {
if (activeEl?.tagName === 'DIV') {
const doc = containerRef.current?.ownerDocument ?? document
return doc.getElementById('room-options-trigger')
}
return activeEl
},
[]
)
const resolveTrigger = useCallback((activeEl: HTMLElement | null) => {
if (activeEl?.tagName === 'DIV') {
const doc = containerRef.current?.ownerDocument ?? document
return doc.getElementById('room-options-trigger')
}
return activeEl
}, [])
usePipRestoreFocus(containerRef, isSidePanelOpen, { resolveTrigger })
return (
<PipContainer ref={containerRef} role="region" aria-label={t('windowLabel')}>
<PipContainer
ref={containerRef}
role="region"
aria-label={t('windowLabel')}
>
<PipStage />
<PipReactionsToolbar />
<PipControlBar showScreenShare={browserSupportsScreenSharing} />
@@ -55,12 +56,21 @@ const PipContainer = styled('div', {
backgroundColor: 'primaryDark.50',
'& .lk-participant-tile': {
height: '100%',
overflow: 'hidden',
backgroundColor: 'primaryDark.100',
},
'& .lk-participant-media': {
height: '100%',
overflow: 'hidden',
borderRadius: 0,
backgroundColor: 'primaryDark.100',
},
'& .lk-participant-media-video': {
display: 'block',
width: '100%',
height: '100%',
borderRadius: 0,
backgroundColor: 'primaryDark.100',
objectFit: 'cover',
},
'& .lk-grid-layout': {
@@ -1,9 +1,11 @@
import { useEffect, useRef, useState } from 'react'
import { RiMoreFill } from '@remixicon/react'
import { FocusScope } from '@react-aria/focus'
import { Box, Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { PipOptionsMenuItems } from './PipOptionsMenuItems'
import { useTranslation } from 'react-i18next'
import { PipOptionsMenuItems } from './PipOptionsMenuItems'
import { useEscapeDismiss } from '@/features/pip/hooks/useEscapeDismiss'
import type { CollapsibleControl } from '../PipControlBar'
type PipOptionsMenuProps = {
@@ -11,16 +13,22 @@ type PipOptionsMenuProps = {
}
/**
* PiP-specific options menu with absolute positioning for correct alignment in PiP window.
* Renders locally (unlike standard Menu) and closes automatically on item click or outside click.
* Overflow controls from the toolbar are rendered as additional menu items.
* PiP-native options menu. The shared `Menu` primitive mis-positions its
* popover and loses focus across documents, so we drive open/close, focus
* and dismissal ourselves.
*/
export const PipOptionsMenu = ({ overflowControls }: PipOptionsMenuProps) => {
const { t } = useTranslation('rooms')
const wrapperRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const label = t('options.buttonLabel')
useEscapeDismiss(wrapperRef, isOpen, () => {
setIsOpen(false)
requestAnimationFrame(() => triggerRef.current?.focus())
})
useEffect(() => {
if (!isOpen) return
const doc = wrapperRef.current?.ownerDocument ?? document
@@ -29,19 +37,28 @@ export const PipOptionsMenu = ({ overflowControls }: PipOptionsMenuProps) => {
const target = event.target as HTMLElement | null
const wrapper = wrapperRef.current
if (!wrapper || !target) return
if (wrapper.querySelector('button')?.contains(target)) return
if (target.closest('[role="menuitem"]')) {
requestAnimationFrame(() => {
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('mousedown', handleOutsideClick, true)
return () => {
doc.removeEventListener('click', handleMenuItemClick, true)
doc.removeEventListener('mousedown', handleOutsideClick, true)
}
}, [isOpen])
@@ -53,10 +70,13 @@ export const PipOptionsMenu = ({ overflowControls }: PipOptionsMenuProps) => {
})}
>
<Button
ref={triggerRef}
id="room-options-trigger"
square
variant="primaryDark"
aria-label={label}
aria-haspopup="menu"
aria-expanded={isOpen}
tooltip={label}
onPress={() => setIsOpen(!isOpen)}
>
@@ -72,9 +92,12 @@ export const PipOptionsMenu = ({ overflowControls }: PipOptionsMenuProps) => {
zIndex: 10,
})}
>
<Box size="sm" type="popover" variant="dark">
<PipOptionsMenuItems overflowControls={overflowControls} />
</Box>
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<FocusScope autoFocus>
<Box size="sm" type="popover" variant="dark">
<PipOptionsMenuItems overflowControls={overflowControls} />
</Box>
</FocusScope>
</div>
)}
</div>
@@ -7,7 +7,6 @@ import {
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
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 { EffectsMenuItem } from '@/features/rooms/livekit/components/controls/Options/EffectsMenuItem'
import { SupportMenuItem } from '@/features/rooms/livekit/components/controls/Options/SupportMenuItem'
@@ -54,7 +53,6 @@ export const PipOptionsMenuItems = ({
<MenuSection>
<SupportMenuItem />
<FeedbackMenuItem />
<SettingsMenuItem />
</MenuSection>
</RACMenu>
)
@@ -47,10 +47,7 @@ export const PipGridLayout = memo(function PipGridLayout({
return (
<GridContainer ref={containerRef} style={gridStyle}>
{tracks.map((track, index) => (
<GridCell
key={tileKeys[index]}
style={placements[index]}
>
<GridCell key={tileKeys[index]} style={placements[index]}>
<ParticipantTile trackRef={track} disableMetadata />
</GridCell>
))}
@@ -17,7 +17,7 @@ import { PipGridLayout } from './PipGridLayout'
* (main + thumbnail) to the adaptive grid layout.
*/
const FOCUS_MAX_TILES = 2
// Handles which layout to render inside the PiP stage.
export const PipStage = () => {
@@ -32,10 +32,7 @@ export const PipStage = () => {
{ onlySubscribed: false }
)
const screenShareTrack = useMemo(
() => pickScreenShareTrack(tracks),
[tracks]
)
const screenShareTrack = useMemo(() => pickScreenShareTrack(tracks), [tracks])
// Order the list so the "focus target" (screen share when available,
// 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 view = doc.defaultView
const reduceMotion = view?.matchMedia('(prefers-reduced-motion: reduce)')
.matches
const reduceMotion = view?.matchMedia(
'(prefers-reduced-motion: reduce)'
).matches
const children = Array.from(container.children) as HTMLElement[]
const nextRects = new Map<string, DOMRect>()
@@ -42,7 +42,11 @@ const pickGridShape = (
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++) {
const rows = Math.ceil(count / 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)