Skip to content

Commit 77f7726

Browse files
committed
♻️(frontend) PiP options menu and reactions toolbar refactor
PiP-native options popover; split reactions, keyboard nav, and pagination.
1 parent e2f6ddb commit 77f7726

12 files changed

Lines changed: 385 additions & 244 deletions

src/frontend/src/features/pip/components/PipControlBar.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,12 @@ export const PipControlBar = ({
6565
)
6666

6767
return (
68-
<PipControls ref={containerRef} id="pip-control-bar" role="toolbar" aria-label={t('controlBar')}>
68+
<PipControls
69+
ref={containerRef}
70+
id="pip-control-bar"
71+
role="toolbar"
72+
aria-label={t('controlBar')}
73+
>
6974
<PipControlsCenter>
7075
<AudioDevicesControl hideMenu />
7176
<VideoDeviceControl hideMenu />
Lines changed: 37 additions & 209 deletions
Original file line numberDiff line numberDiff line change
@@ -1,147 +1,54 @@
1-
import { useRef, useState, useEffect, useCallback, useMemo } from 'react'
1+
import { useEffect, useRef } from 'react'
2+
import { FocusScope } from '@react-aria/focus'
23
import { styled } from '@/styled-system/jsx'
3-
import { css } from '@/styled-system/css'
4-
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
54
import { useSnapshot } from 'valtio'
65
import { pipLayoutStore } from '../stores/pipLayoutStore'
7-
import { ReactionButton } from '@/features/reactions/components/toolbar/ReactionButton'
8-
import { Emoji } from '@/features/reactions/types'
9-
10-
const EMOJIS = Object.values(Emoji)
11-
const EMOJI_SLOT_WIDTH = 40
12-
const ARROW_SLOT_WIDTH = 32
13-
const PILL_HORIZONTAL_PADDING = 12
14-
6+
import { useDelayUnmount } from '@/hooks/useDelayUnmount'
7+
import { usePipElementSize } from '../hooks/usePipElementSize'
8+
import { PipReactionsKeyboardNavigation } from './reactions/PipReactionsKeyboardNavigation'
9+
import { PipReactionsPill } from './reactions/PipReactionsPill'
10+
11+
/**
12+
* Reactions toolbar for the PiP window. Owns only the open/close orchestration;
13+
* layout and pagination live in `PipReactionsPill`, keyboard nav in
14+
* `PipReactionsKeyboardNavigation`.
15+
*/
1516
export const PipReactionsToolbar = () => {
1617
const { showReactionsToolbar: isOpen } = useSnapshot(pipLayoutStore)
18+
// Unmount content after the close transition so hidden emojis leave the tab order.
19+
const renderContent = useDelayUnmount(isOpen, 500)
20+
const contentRef = useRef<HTMLDivElement>(null)
1721
const wrapperRef = useRef<HTMLDivElement>(null)
18-
const [isVisible, setIsVisible] = useState(false)
19-
const [availableWidth, setAvailableWidth] = useState(0)
20-
const [pageStart, setPageStart] = useState(0)
21-
22-
useEffect(() => {
23-
if (isOpen) {
24-
const id = requestAnimationFrame(() => setIsVisible(true))
25-
return () => cancelAnimationFrame(id)
26-
} else {
27-
setIsVisible(false)
28-
}
29-
}, [isOpen])
30-
31-
const updateWidth = useCallback(() => {
32-
const el = wrapperRef.current
33-
if (!el) return
34-
setAvailableWidth(el.getBoundingClientRect().width)
35-
}, [])
22+
const { width: availableWidth } = usePipElementSize(wrapperRef)
3623

24+
// Mark the subtree inert during the fade-out so Tab can't land on it.
3725
useEffect(() => {
38-
const el = wrapperRef.current
26+
const el = contentRef.current
3927
if (!el) return
40-
41-
updateWidth()
42-
43-
const RO =
44-
el.ownerDocument.defaultView?.ResizeObserver ?? window.ResizeObserver
45-
const observer = new RO(() => updateWidth())
46-
observer.observe(el)
47-
48-
return () => observer.disconnect()
49-
}, [updateWidth])
50-
51-
const { visibleEmojis, hasOverflow, canGoLeft, canGoRight } = useMemo(() => {
52-
const maxWithoutArrows = Math.max(
53-
1,
54-
Math.floor((availableWidth - PILL_HORIZONTAL_PADDING) / EMOJI_SLOT_WIDTH)
55-
)
56-
57-
if (EMOJIS.length <= maxWithoutArrows) {
58-
return {
59-
visibleEmojis: EMOJIS,
60-
hasOverflow: false,
61-
canGoLeft: false,
62-
canGoRight: false,
63-
}
64-
}
65-
66-
const visibleCount = Math.max(
67-
1,
68-
Math.floor(
69-
(availableWidth - PILL_HORIZONTAL_PADDING - ARROW_SLOT_WIDTH * 2) /
70-
EMOJI_SLOT_WIDTH
71-
)
72-
)
73-
const clampedStart = Math.min(pageStart, EMOJIS.length - visibleCount)
74-
75-
return {
76-
visibleEmojis: EMOJIS.slice(clampedStart, clampedStart + visibleCount),
77-
hasOverflow: true,
78-
canGoLeft: clampedStart > 0,
79-
canGoRight: clampedStart + visibleCount < EMOJIS.length,
80-
}
81-
}, [pageStart, availableWidth])
82-
83-
useEffect(() => {
84-
if (!hasOverflow) {
85-
setPageStart(0)
86-
return
87-
}
88-
const visibleCount = Math.max(
89-
1,
90-
Math.floor(
91-
(availableWidth - PILL_HORIZONTAL_PADDING - ARROW_SLOT_WIDTH * 2) /
92-
EMOJI_SLOT_WIDTH
93-
)
94-
)
95-
const maxStart = Math.max(0, EMOJIS.length - visibleCount)
96-
if (pageStart > maxStart) {
97-
setPageStart(maxStart)
98-
}
99-
}, [hasOverflow, pageStart, availableWidth])
100-
101-
const paginate = useCallback((direction: 'left' | 'right') => {
102-
setPageStart((current) =>
103-
direction === 'left' ? Math.max(0, current - 1) : current + 1
104-
)
105-
}, [])
28+
if (isOpen) el.removeAttribute('inert')
29+
else el.setAttribute('inert', '')
30+
}, [isOpen, renderContent])
10631

10732
return (
108-
<ToolbarWrapper ref={wrapperRef} isOpen={isOpen}>
109-
<ToolbarPill isVisible={isVisible}>
110-
{hasOverflow && (
111-
<ArrowSlot>
112-
{canGoLeft && (
113-
<ArrowButton
114-
onClick={() => paginate('left')}
115-
aria-label="Previous reactions"
116-
>
117-
<RiArrowLeftSLine size={16} />
118-
</ArrowButton>
119-
)}
120-
</ArrowSlot>
121-
)}
122-
<EmojiRow>
123-
{visibleEmojis.map((emoji) => (
124-
<ReactionButton key={emoji} emoji={emoji} />
125-
))}
126-
</EmojiRow>
127-
{hasOverflow && (
128-
<ArrowSlot>
129-
{canGoRight && (
130-
<ArrowButton
131-
onClick={() => paginate('right')}
132-
aria-label="Next reactions"
133-
>
134-
<RiArrowRightSLine size={16} />
135-
</ArrowButton>
136-
)}
137-
</ArrowSlot>
138-
)}
139-
</ToolbarPill>
140-
</ToolbarWrapper>
33+
<Wrapper ref={wrapperRef} isOpen={isOpen}>
34+
{renderContent && (
35+
<div ref={contentRef}>
36+
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
37+
<FocusScope autoFocus>
38+
<PipReactionsKeyboardNavigation>
39+
<PipReactionsPill
40+
isOpen={isOpen}
41+
availableWidth={availableWidth}
42+
/>
43+
</PipReactionsKeyboardNavigation>
44+
</FocusScope>
45+
</div>
46+
)}
47+
</Wrapper>
14148
)
14249
}
14350

144-
const ToolbarWrapper = styled('div', {
51+
const Wrapper = styled('div', {
14552
base: {
14653
display: 'flex',
14754
justifyContent: 'center',
@@ -160,82 +67,3 @@ const ToolbarWrapper = styled('div', {
16067
},
16168
},
16269
})
163-
164-
const ToolbarPill = styled('div', {
165-
base: {
166-
display: 'flex',
167-
alignItems: 'center',
168-
gap: '0.2rem',
169-
borderRadius: '21px',
170-
padding: '0.15rem',
171-
backgroundColor: 'primaryDark.100',
172-
maxWidth: '100%',
173-
overflow: 'hidden',
174-
width: 'fit-content',
175-
opacity: 0,
176-
transform: 'translateY(3.25rem)',
177-
transition: 'opacity, transform',
178-
transitionDuration: '0.5s',
179-
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
180-
pointerEvents: 'none',
181-
},
182-
variants: {
183-
isVisible: {
184-
true: {
185-
opacity: 1,
186-
transform: 'translateY(0)',
187-
pointerEvents: 'auto',
188-
},
189-
},
190-
},
191-
})
192-
193-
const EmojiRow = styled('div', {
194-
base: {
195-
display: 'flex',
196-
gap: '0.2rem',
197-
'& > *': {
198-
flexShrink: 0,
199-
},
200-
},
201-
})
202-
203-
const ArrowSlot = styled('div', {
204-
base: {
205-
width: '32px',
206-
minWidth: '32px',
207-
display: 'flex',
208-
alignItems: 'center',
209-
justifyContent: 'center',
210-
},
211-
})
212-
213-
const ArrowButton = ({
214-
children,
215-
...props
216-
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => (
217-
<button
218-
type="button"
219-
className={css({
220-
flexShrink: 0,
221-
display: 'flex',
222-
alignItems: 'center',
223-
justifyContent: 'center',
224-
width: '28px',
225-
height: '28px',
226-
borderRadius: '50%',
227-
border: 'none',
228-
backgroundColor: 'primaryDark.200',
229-
color: 'white',
230-
cursor: 'pointer',
231-
opacity: 0.85,
232-
_hover: {
233-
opacity: 1,
234-
backgroundColor: 'primaryDark.300',
235-
},
236-
})}
237-
{...props}
238-
>
239-
{children}
240-
</button>
241-
)

src/frontend/src/features/pip/components/PipView.tsx

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,20 +24,21 @@ export const PipView = () => {
2424

2525
// Side panels open via a menu item that unmounts on click; fall back to the
2626
// options button so focus returns somewhere visible.
27-
const resolveTrigger = useCallback(
28-
(activeEl: HTMLElement | null) => {
29-
if (activeEl?.tagName === 'DIV') {
30-
const doc = containerRef.current?.ownerDocument ?? document
31-
return doc.getElementById('room-options-trigger')
32-
}
33-
return activeEl
34-
},
35-
[]
36-
)
27+
const resolveTrigger = useCallback((activeEl: HTMLElement | null) => {
28+
if (activeEl?.tagName === 'DIV') {
29+
const doc = containerRef.current?.ownerDocument ?? document
30+
return doc.getElementById('room-options-trigger')
31+
}
32+
return activeEl
33+
}, [])
3734
usePipRestoreFocus(containerRef, isSidePanelOpen, { resolveTrigger })
3835

3936
return (
40-
<PipContainer ref={containerRef} role="region" aria-label={t('windowLabel')}>
37+
<PipContainer
38+
ref={containerRef}
39+
role="region"
40+
aria-label={t('windowLabel')}
41+
>
4142
<PipStage />
4243
<PipReactionsToolbar />
4344
<PipControlBar showScreenShare={browserSupportsScreenSharing} />

0 commit comments

Comments
 (0)