️(frontend) allow the reaction toolbar to be accessible by keyboard

Fix an accessibility issue. Reaction wasn't accessible using keyboard.
This fix is imperfect, the animation doesn't perflectly replicate the
menu one.
This commit is contained in:
lebaudantoine
2025-02-26 16:31:21 +01:00
committed by aleb_the_flash
parent 14885e0ffa
commit 38ab001bcf

View File

@@ -1,9 +1,9 @@
import { useTranslation } from 'react-i18next'
import { RiEmotionLine } from '@remixicon/react'
import { useState, useRef } from 'react'
import { useState, useRef, useEffect } from 'react'
import { css } from '@/styled-system/css'
import { useRoomContext } from '@livekit/components-react'
import { Menu, ToggleButton, Button } from '@/primitives'
import { ToggleButton, Button } from '@/primitives'
import { NotificationType } from '@/features/notifications/NotificationType'
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
import {
@@ -27,6 +27,8 @@ export const ReactionsToggle = () => {
const instanceIdRef = useRef(0)
const room = useRoomContext()
const [isVisible, setIsVisible] = useState(false)
const sendReaction = async (emoji: string) => {
const encoder = new TextEncoder()
const payload: NotificationPayload = {
@@ -53,42 +55,133 @@ export const ReactionsToggle = () => {
}, ANIMATION_DURATION)
}
// 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 (
<>
<Menu variant="dark" placement="top">
<div
className={css({
position: 'relative',
})}
>
<ToggleButton
square
variant="primaryDark"
aria-label={t('button')}
tooltip={t('button')}
onPress={() => setIsVisible(!isVisible)}
>
<RiEmotionLine />
</ToggleButton>
<RACToolbar
className={css({
display: 'flex',
})}
>
{EMOJIS.map((emoji, index) => (
<Button
key={index}
onPress={() => sendReaction(emoji)}
aria-label={t('send', { emoji })}
variant="quaternaryText"
size="sm"
{isRendered && (
<div
className={css({
position: 'absolute',
top: -55,
left: -114,
borderRadius: '8px',
padding: '0.25rem',
backgroundColor: 'primaryDark.50',
opacity: opacity,
transition: 'opacity 0.2s ease',
})}
onTransitionEnd={() => {
if (!isVisible) {
setIsRendered(false)
}
}}
>
<RACToolbar
className={css({
display: 'flex',
})}
>
<span
className={css({
fontSize: '20px',
})}
>
{emoji}
</span>
</Button>
))}
</RACToolbar>
</Menu>
{EMOJIS.map((emoji, index) => (
<Button
key={index}
onPress={() => sendReaction(emoji)}
aria-label={t('send', { emoji })}
variant="quaternaryText"
size="sm"
>
<span
className={css({
fontSize: '20px',
})}
>
{emoji}
</span>
</Button>
))}
</RACToolbar>
</div>
)}
</div>
<ReactionPortals reactions={reactions} />
</>
)
}
// export const ReactionsToolBar = () => {
// const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
//
// const [reactions, setReactions] = useState<Reaction[]>([])
// const instanceIdRef = useRef(0)
// const room = useRoomContext()
//
// const sendReaction = async (emoji: string) => {
// const encoder = new TextEncoder()
// const payload: NotificationPayload = {
// type: NotificationType.ReactionReceived,
// data: {
// emoji: emoji,
// },
// }
// const data = encoder.encode(JSON.stringify(payload))
// await room.localParticipant.publishData(data, { reliable: true })
//
// const newReaction = {
// id: instanceIdRef.current++,
// emoji,
// participant: room.localParticipant,
// }
// setReactions((prev) => [...prev, newReaction])
//
// // Remove this reaction after animation
// setTimeout(() => {
// setReactions((prev) =>
// prev.filter((instance) => instance.id !== newReaction.id)
// )
// }, ANIMATION_DURATION)
// }
//
// return <></>
// }