♻️(frontend) replace custom reactions toolbar with react aria popover

use react aria primitives for escape, focus containment and restore
This commit is contained in:
Cyril
2026-02-20 09:36:33 +01:00
committed by aleb_the_flash
parent 9916ab7d7e
commit 3087dfe486
2 changed files with 74 additions and 89 deletions

View File

@@ -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

View File

@@ -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} />
</> </>