♻️(frontend) replace custom reactions toolbar with react aria popover
use react aria primitives for escape, focus containment and restore
This commit is contained in:
@@ -8,6 +8,10 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ♻️(frontend) replace custom reactions toolbar with react aria popover #985
|
||||||
|
|
||||||
## [1.8.0] - 2026-02-20
|
## [1.8.0] - 2026-02-20
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { RiEmotionLine } from '@remixicon/react'
|
import { RiEmotionLine } from '@remixicon/react'
|
||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { useRoomContext } from '@livekit/components-react'
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
import { ToggleButton, Button } from '@/primitives'
|
import { ToggleButton, Button } from '@/primitives'
|
||||||
@@ -12,7 +12,12 @@ import {
|
|||||||
} from '@/features/rooms/livekit/components/ReactionPortal'
|
} from '@/features/rooms/livekit/components/ReactionPortal'
|
||||||
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
import { getEmojiLabel } from '@/features/rooms/livekit/utils/reactionUtils'
|
||||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
import { Toolbar as RACToolbar } from 'react-aria-components'
|
import {
|
||||||
|
Popover as RACPopover,
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
} from 'react-aria-components'
|
||||||
|
import { FocusScope } from '@react-aria/focus'
|
||||||
import { Participant } from 'livekit-client'
|
import { Participant } from 'livekit-client'
|
||||||
import useRateLimiter from '@/hooks/useRateLimiter'
|
import useRateLimiter from '@/hooks/useRateLimiter'
|
||||||
|
|
||||||
@@ -40,11 +45,11 @@ export const ReactionsToggle = () => {
|
|||||||
const instanceIdRef = useRef(0)
|
const instanceIdRef = useRef(0)
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
|
|
||||||
const [isVisible, setIsVisible] = useState(false)
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
|
||||||
useRegisterKeyboardShortcut({
|
useRegisterKeyboardShortcut({
|
||||||
id: 'reaction',
|
id: 'reaction',
|
||||||
handler: () => setIsVisible((prev) => !prev),
|
handler: () => setIsOpen((prev) => !prev),
|
||||||
})
|
})
|
||||||
|
|
||||||
const sendReaction = async (emoji: string) => {
|
const sendReaction = async (emoji: string) => {
|
||||||
@@ -79,100 +84,76 @@ export const ReactionsToggle = () => {
|
|||||||
windowMs: 1000,
|
windowMs: 1000,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Custom animation implementation for the emoji toolbar
|
|
||||||
// Could not use a menu and its animation, because a menu would make the toolbar inaccessible by keyboard
|
|
||||||
// animation isn't perfect
|
|
||||||
const [isRendered, setIsRendered] = useState(isVisible)
|
|
||||||
const [opacity, setOpacity] = useState(isVisible ? 1 : 0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isVisible) {
|
|
||||||
// Show: first render, then animate in
|
|
||||||
setIsRendered(true)
|
|
||||||
// Need to delay setting opacity to ensure CSS transition works
|
|
||||||
// (using requestAnimationFrame to ensure DOM has updated)
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setOpacity(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
} else if (isRendered) {
|
|
||||||
// Hide: first animate out, then unrender
|
|
||||||
setOpacity(0)
|
|
||||||
|
|
||||||
// Wait for animation to complete before removing from DOM
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
setIsRendered(false)
|
|
||||||
}, 200) // Match this to your animation duration
|
|
||||||
return () => clearTimeout(timer)
|
|
||||||
}
|
|
||||||
}, [isVisible, isRendered])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div className={css({ position: 'relative' })}>
|
||||||
className={css({
|
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
position: 'relative',
|
<ToggleButton
|
||||||
})}
|
square
|
||||||
>
|
variant="primaryDark"
|
||||||
<ToggleButton
|
aria-label={t('button')}
|
||||||
square
|
tooltip={t('button')}
|
||||||
variant="primaryDark"
|
isSelected={isOpen}
|
||||||
aria-label={t('button')}
|
onChange={setIsOpen}
|
||||||
tooltip={t('button')}
|
>
|
||||||
onPress={() => setIsVisible(!isVisible)}
|
<RiEmotionLine />
|
||||||
>
|
</ToggleButton>
|
||||||
<RiEmotionLine />
|
<RACPopover
|
||||||
</ToggleButton>
|
placement="top"
|
||||||
{isRendered && (
|
offset={8}
|
||||||
<div
|
isNonModal
|
||||||
|
shouldCloseOnInteractOutside={() => false}
|
||||||
className={css({
|
className={css({
|
||||||
position: 'absolute',
|
|
||||||
top: -63,
|
|
||||||
left: -162,
|
|
||||||
borderRadius: '8px',
|
borderRadius: '8px',
|
||||||
padding: '0.35rem',
|
padding: '0.35rem',
|
||||||
backgroundColor: 'primaryDark.50',
|
backgroundColor: 'primaryDark.50',
|
||||||
opacity: opacity,
|
'&[data-entering]': {
|
||||||
transition: 'opacity 0.2s ease',
|
animation: 'fade 200ms ease',
|
||||||
|
},
|
||||||
|
'&[data-exiting]': {
|
||||||
|
animation: 'fade 200ms ease-in reverse',
|
||||||
|
},
|
||||||
})}
|
})}
|
||||||
onTransitionEnd={() => {
|
|
||||||
if (!isVisible) {
|
|
||||||
setIsRendered(false)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<RACToolbar
|
<Dialog className={css({ outline: 'none' })}>
|
||||||
className={css({
|
{/* eslint-disable-next-line jsx-a11y/no-autofocus -- FocusScope autoFocus is programmatic focus for overlays, not the HTML autofocus attribute */}
|
||||||
display: 'flex',
|
<FocusScope contain autoFocus restoreFocus>
|
||||||
gap: '0.5rem',
|
<div
|
||||||
})}
|
role="toolbar"
|
||||||
>
|
aria-orientation="horizontal"
|
||||||
{Object.values(Emoji).map((emoji, index) => (
|
aria-label={t('button')}
|
||||||
<Button
|
className={css({
|
||||||
key={index}
|
display: 'flex',
|
||||||
onPress={() => debouncedSendReaction(emoji)}
|
gap: '0.5rem',
|
||||||
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
|
})}
|
||||||
variant="primaryTextDark"
|
|
||||||
size="sm"
|
|
||||||
square
|
|
||||||
data-attr={`send-reaction-${emoji}`}
|
|
||||||
>
|
>
|
||||||
<img
|
{Object.values(Emoji).map((emoji, index) => (
|
||||||
src={`/assets/reactions/${emoji}.png`}
|
<Button
|
||||||
alt=""
|
key={index}
|
||||||
className={css({
|
onPress={() => debouncedSendReaction(emoji)}
|
||||||
minHeight: '28px',
|
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
|
||||||
minWidth: '28px',
|
variant="primaryTextDark"
|
||||||
pointerEvents: 'none',
|
size="sm"
|
||||||
userSelect: 'none',
|
square
|
||||||
})}
|
data-attr={`send-reaction-${emoji}`}
|
||||||
/>
|
>
|
||||||
</Button>
|
<img
|
||||||
))}
|
src={`/assets/reactions/${emoji}.png`}
|
||||||
</RACToolbar>
|
alt=""
|
||||||
</div>
|
className={css({
|
||||||
)}
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FocusScope>
|
||||||
|
</Dialog>
|
||||||
|
</RACPopover>
|
||||||
|
</DialogTrigger>
|
||||||
</div>
|
</div>
|
||||||
<ReactionPortals reactions={reactions} />
|
<ReactionPortals reactions={reactions} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user