1- import { useRef , useState , useEffect , useCallback , useMemo } from 'react'
1+ import { useEffect , useRef } from 'react'
2+ import { FocusScope } from '@react-aria/focus'
23import { styled } from '@/styled-system/jsx'
3- import { css } from '@/styled-system/css'
4- import { RiArrowLeftSLine , RiArrowRightSLine } from '@remixicon/react'
54import { useSnapshot } from 'valtio'
65import { 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+ */
1516export 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- )
0 commit comments