diff --git a/src/frontend/panda.config.ts b/src/frontend/panda.config.ts index 2ab3e8b0..c98804ac 100644 --- a/src/frontend/panda.config.ts +++ b/src/frontend/panda.config.ts @@ -278,6 +278,7 @@ const config: Config = { 'room-side-panel': { value: '360px' }, 'room-side-panel-margin': { value: '1.5rem' }, 'room-control-bar': { value: '80px' }, + 'room-reaction-toolbar-height': { value: '42px' }, }, spacing, }), diff --git a/src/frontend/src/features/layout/components/RoomContentArea.tsx b/src/frontend/src/features/layout/components/RoomContentArea.tsx index bf3f2eff..7ff699e9 100644 --- a/src/frontend/src/features/layout/components/RoomContentArea.tsx +++ b/src/frontend/src/features/layout/components/RoomContentArea.tsx @@ -7,6 +7,7 @@ import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles' import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel' import { Subtitles } from '@/features/subtitle/component/Subtitles' import { MainNotificationToast } from '@/features/notifications/MainNotificationToast' +import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar' const RoomViewport = styled( 'div', @@ -14,15 +15,25 @@ const RoomViewport = styled( base: { position: 'absolute', maxHeight: '100%', - transition: 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms', + transition: + 'inset .5s cubic-bezier(0.4,0,0.2,1) 5ms, padding .5s cubic-bezier(0.4,0,0.2,1) 5ms', }, variants: { isSidePanelOpen: { true: { - inset: `var(--lk-grid-gap) calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin) * 2) calc(var(--sizes-room-control-bar) + var(--lk-grid-gap)) 16px`, + inset: `var(--lk-grid-gap) calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin) * 2) calc(var(--sizes-room-control-bar)) 16px`, }, false: { - inset: `var(--lk-grid-gap) var(--lk-grid-gap) calc(var(--sizes-room-control-bar) + var(--lk-grid-gap))`, + inset: `var(--lk-grid-gap) var(--lk-grid-gap) calc(var(--sizes-room-control-bar))`, + }, + }, + isReactionToolbarOpen: { + true: { + paddingBottom: + 'calc(var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))', + }, + false: { + paddingBottom: '0', }, }, }, @@ -58,9 +69,13 @@ interface RoomContentAreaProps { export function RoomContentArea({ children }: RoomContentAreaProps) { const { isSidePanelOpen } = useSidePanel() const { areSubtitlesOpen } = useSubtitles() + const { isOpen: isReactionToolbarOpen } = useReactionsToolbar() return ( - + {children} diff --git a/src/frontend/src/features/reactions/components/ReactionsToggle.tsx b/src/frontend/src/features/reactions/components/ReactionsToggle.tsx index 24d4abe5..002b7c1b 100644 --- a/src/frontend/src/features/reactions/components/ReactionsToggle.tsx +++ b/src/frontend/src/features/reactions/components/ReactionsToggle.tsx @@ -1,102 +1,32 @@ import { useTranslation } from 'react-i18next' import { RiEmotionLine } from '@remixicon/react' -import { useState } from 'react' -import { css } from '@/styled-system/css' -import { ToggleButton, Button } from '@/primitives' +import { ToggleButton } from '@/primitives' import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut' -import { - Popover as RACPopover, - Dialog, - DialogTrigger, -} from 'react-aria-components' -import { FocusScope } from '@react-aria/focus' -import { useReactions } from '../hooks/useReactions' -import { Emoji } from '../types' -import { getEmojiLabel } from '../utils' +import { useReactionsToolbar } from '../hooks/useReactionsToolbar' export const ReactionsToggle = () => { const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' }) - const [isOpen, setIsOpen] = useState(false) - const { sendReaction } = useReactions() + const { isOpen, toggle } = useReactionsToolbar() useRegisterKeyboardShortcut({ id: 'reaction', - handler: () => setIsOpen((prev) => !prev), + handler: toggle, }) return ( - <> -
- - - - - false} - className={css({ - borderRadius: '8px', - padding: '0.35rem', - backgroundColor: 'primaryDark.50', - '&[data-entering]': { - animation: 'fade 200ms ease', - }, - '&[data-exiting]': { - animation: 'fade 200ms ease-in reverse', - }, - })} - > - - {/* eslint-disable-next-line jsx-a11y/no-autofocus -- FocusScope autoFocus is programmatic focus for overlays, not the HTML autofocus attribute */} - -
- {Object.values(Emoji).map((emoji, index) => ( - - ))} -
-
-
-
-
-
- + + + ) } diff --git a/src/frontend/src/features/reactions/components/toolbar/ReactionButton.tsx b/src/frontend/src/features/reactions/components/toolbar/ReactionButton.tsx new file mode 100644 index 00000000..9664a244 --- /dev/null +++ b/src/frontend/src/features/reactions/components/toolbar/ReactionButton.tsx @@ -0,0 +1,32 @@ +import { useTranslation } from 'react-i18next' +import { css } from '@/styled-system/css' +import { getEmojiLabel } from '../../utils' +import { Emoji } from '../../types' +import { useReactions } from '../../hooks/useReactions' +import { Button } from '@/primitives' + +export const ReactionButton = ({ emoji }: { emoji: Emoji }) => { + const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' }) + const { sendReaction } = useReactions() + return ( + + ) +} diff --git a/src/frontend/src/features/reactions/components/toolbar/ReactionsToolbar.tsx b/src/frontend/src/features/reactions/components/toolbar/ReactionsToolbar.tsx new file mode 100644 index 00000000..21d38153 --- /dev/null +++ b/src/frontend/src/features/reactions/components/toolbar/ReactionsToolbar.tsx @@ -0,0 +1,146 @@ +import { FocusScope, useFocusManager } from '@react-aria/focus' +import { useReactionsToolbar } from '../../hooks/useReactionsToolbar' +import { ReactionButton } from './ReactionButton' +import { Emoji } from '../../types' +import { styled } from '@/styled-system/jsx' +import { layoutStore } from '@/stores/layout' +import { getFirstControlBarFocusable } from '@/utils/dom' +import { useIsMobile } from '@/utils/useIsMobile' +import { useEffect, useRef, useState } from 'react' +import { useDelayUnmount } from '@/hooks/useDelayUnmount' + +const Container = styled('div', { + base: { + display: 'flex', + justifyContent: 'center', + position: 'absolute', + bottom: 'var(--sizes-room-control-bar)', + left: 0, + right: 0, + pointerEvents: 'none', + }, +}) + +const StyledStrip = styled('div', { + base: { + display: 'flex', + gap: '0.2rem', + borderRadius: '21px', + padding: '0.15rem', + backgroundColor: 'primaryDark.100', + opacity: 0, + transform: 'translateY(3.25rem)', + transition: 'opacity, transform', + transitionDuration: '0.5s', + transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', + pointerEvents: 'none', + }, + variants: { + isVisible: { + true: { + opacity: 1, + transform: 'translateY(0)', + pointerEvents: 'auto', + }, + }, + desktopOffset: { + true: { + // Ideally this value should be calculated dynamically in JavaScript to keep + // the reaction toolbar perfectly centered relative to the reaction toggle. + // However, for simplicity and to follow a pragmatic 80/20 approach, + // this value is currently hardcoded in CSS. + marginRight: '30px', + }, + }, + }, +}) + +const Strip = ({ children }: { children: React.ReactNode }) => { + const { isOpen } = useReactionsToolbar() + const isMobile = useIsMobile() + const ref = useRef(null) + + const [isVisible, setIsVisible] = useState(false) + + useEffect(() => { + if (isOpen) { + // defer one frame so the browser paints opacity:0 first + const id = requestAnimationFrame(() => setIsVisible(true)) + return () => cancelAnimationFrame(id) + } else { + setIsVisible(false) + } + }, [isOpen]) + + return ( + + {children} + + ) +} + +const KeyboardNavigation = ({ children }: { children: React.ReactNode }) => { + const focusManager = useFocusManager() + + const onFocus = (e: React.FocusEvent) => { + const comingFromOutside = !e.currentTarget.contains(e.relatedTarget) + if (comingFromOutside) { + focusManager?.focusFirst() + } + } + + const onKeyDown = (e: React.KeyboardEvent) => { + switch (e.key) { + case 'ArrowRight': + focusManager?.focusNext({ wrap: true }) + break + case 'ArrowLeft': + focusManager?.focusPrevious({ wrap: true }) + break + case 'Escape': + e.preventDefault() + document.getElementById('reactions-toggle')?.focus() + layoutStore.showReactionsToolbar = false + break + case 'Tab': + if (!e.shiftKey) { + e.preventDefault() + getFirstControlBarFocusable('control-bar')?.focus() + } + break + } + } + + return ( +
+ {children} +
+ ) +} + +export const ReactionsToolbar = () => { + const { isOpen } = useReactionsToolbar() + const shouldMount = useDelayUnmount(isOpen, 300) + + if (!shouldMount) return null + + return ( + + {/* eslint-disable-next-line jsx-a11y/no-autofocus*/} + + + + {Object.values(Emoji).map((emoji) => ( + + ))} + + + + + ) +} diff --git a/src/frontend/src/features/reactions/hooks/useReactionsToolbar.ts b/src/frontend/src/features/reactions/hooks/useReactionsToolbar.ts new file mode 100644 index 00000000..2b16f7f6 --- /dev/null +++ b/src/frontend/src/features/reactions/hooks/useReactionsToolbar.ts @@ -0,0 +1,13 @@ +import { useSnapshot } from 'valtio' +import { layoutStore } from '@/stores/layout' + +export const useReactionsToolbar = () => { + const layoutSnap = useSnapshot(layoutStore) + + return { + isOpen: layoutSnap.showReactionsToolbar, + toggle: () => { + layoutStore.showReactionsToolbar = !layoutSnap.showReactionsToolbar + }, + } +} diff --git a/src/frontend/src/features/reactions/utils.ts b/src/frontend/src/features/reactions/utils.ts index 42ff9c09..4098353f 100644 --- a/src/frontend/src/features/reactions/utils.ts +++ b/src/frontend/src/features/reactions/utils.ts @@ -1,7 +1,8 @@ import { useTranslation } from 'react-i18next' +import { Emoji } from './types' export const getEmojiLabel = ( - emoji: string, + emoji: string | Emoji, t: ReturnType['t'] ) => { const emojiLabels: Record = { diff --git a/src/frontend/src/features/rooms/livekit/components/SidePanel.tsx b/src/frontend/src/features/rooms/livekit/components/SidePanel.tsx index e2ddb1d4..900ca9f2 100644 --- a/src/frontend/src/features/rooms/livekit/components/SidePanel.tsx +++ b/src/frontend/src/features/rooms/livekit/components/SidePanel.tsx @@ -14,6 +14,7 @@ import { Admin } from './Admin' import { Tools } from './Tools' import { Info } from './Info' import { HStack } from '@/styled-system/jsx' +import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar' type StyledSidePanelProps = { title: string @@ -25,6 +26,7 @@ type StyledSidePanelProps = { isSubmenu: boolean onBack: () => void backButtonLabel: string + isReactionToolbarOpen?: boolean } const StyledSidePanel = ({ @@ -33,6 +35,7 @@ const StyledSidePanel = ({ children, onClose, isClosed, + isReactionToolbarOpen, closeButtonTooltip, isSubmenu = false, onBack, @@ -53,11 +56,11 @@ const StyledSidePanel = ({ flexDirection: 'column', margin: 'var(--sizes-room-side-panel-margin)', marginLeft: 0, + marginBottom: 0, padding: 0, gap: 0, right: 0, top: 0, - bottom: 'var(--sizes-room-control-bar)', width: 'var(--sizes-room-side-panel)', transition: '.5s cubic-bezier(.4,0,.2,1) 5ms', })} @@ -65,6 +68,9 @@ const StyledSidePanel = ({ transform: isClosed ? 'translateX(calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin)))' : 'none', + bottom: isReactionToolbarOpen + ? 'calc( var(--sizes-room-control-bar) + var(--sizes-room-reaction-toolbar-height) + calc(var(--lk-grid-gap) / 2))' + : 'var(--sizes-room-control-bar)', }} aria-hidden={isClosed} aria-label={ariaLabel} @@ -154,6 +160,8 @@ export const SidePanel = () => { const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' }) const title = t(`heading.${activeSubPanelId || activePanelId}`) + const { isOpen: isReactionToolbarOpen } = useReactionsToolbar() + return ( { })} isClosed={!isSidePanelOpen} isSubmenu={isSubPanelOpen} + isReactionToolbarOpen={isReactionToolbarOpen} backButtonLabel={t('backToTools')} onBack={() => (layoutStore.activeSubPanelId = null)} > diff --git a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/ControlBar.tsx b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/ControlBar.tsx index 75ae1f43..b329a238 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/ControlBar.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/ControlBar.tsx @@ -4,6 +4,8 @@ import * as React from 'react' import { MobileControlBar } from './MobileControlBar' import { DesktopControlBar } from './DesktopControlBar' import { useIsMobile } from '@/utils/useIsMobile' +import { ReactionsToolbar } from '@/features/reactions/components/toolbar/ReactionsToolbar' +import { css } from '@/styled-system/css' export interface ControlBarProps extends React.HTMLAttributes { onDeviceError?: (error: { source: Track.Source; error: Error }) => void @@ -16,10 +18,32 @@ export interface ControlBarProps extends React.HTMLAttributes { export function ControlBar({ onDeviceError }: ControlBarProps) { const isMobile = useIsMobile() - if (isMobile) { - return - } - return + return ( +
+ +
+ {isMobile ? ( + + ) : ( + + )} +
+
+ ) } - export type ControlBarAuxProps = Pick diff --git a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/DesktopControlBar.tsx b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/DesktopControlBar.tsx index 67bce97c..846ab49b 100644 --- a/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/DesktopControlBar.tsx +++ b/src/frontend/src/features/rooms/livekit/prefabs/ControlBar/DesktopControlBar.tsx @@ -48,11 +48,7 @@ export function DesktopControlBar({ className={css({ width: '100vw', display: 'flex', - position: 'absolute', padding: '1.125rem', - bottom: 0, - left: 0, - right: 0, })} >
{ + const [shouldRender, setShouldRender] = useState(false) + useEffect(() => { + let timeoutId: ReturnType | undefined + if (isMounted && !shouldRender) { + setShouldRender(true) + } else if (!isMounted && shouldRender) { + timeoutId = setTimeout(() => setShouldRender(false), delayTime) + } + return () => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId) + } + } + }, [isMounted, delayTime, shouldRender]) + return shouldRender +} diff --git a/src/frontend/src/primitives/buttonRecipe.ts b/src/frontend/src/primitives/buttonRecipe.ts index 43ba5b36..4de58749 100644 --- a/src/frontend/src/primitives/buttonRecipe.ts +++ b/src/frontend/src/primitives/buttonRecipe.ts @@ -44,6 +44,13 @@ export const buttonRecipe = cva({ paddingY: 'var(--square-padding)', }, }, + round: { + true: { + borderRadius: '50%', + paddingX: 'var(--square-padding)', + paddingY: 'var(--square-padding)', + }, + }, variant: { primary: { backgroundColor: 'primary.800', diff --git a/src/frontend/src/stores/layout.ts b/src/frontend/src/stores/layout.ts index b04a24b6..f69b010a 100644 --- a/src/frontend/src/stores/layout.ts +++ b/src/frontend/src/stores/layout.ts @@ -10,6 +10,7 @@ type State = { showSubtitles: boolean activePanelId: PanelId | null activeSubPanelId: SubPanelId | null + showReactionsToolbar: boolean } export const layoutStore = proxy({ @@ -18,4 +19,5 @@ export const layoutStore = proxy({ showSubtitles: false, activePanelId: null, activeSubPanelId: null, + showReactionsToolbar: false, }) diff --git a/src/frontend/src/utils/dom.ts b/src/frontend/src/utils/dom.ts new file mode 100644 index 00000000..c18bccfa --- /dev/null +++ b/src/frontend/src/utils/dom.ts @@ -0,0 +1,6 @@ +export const getFirstControlBarFocusable = (id: string): HTMLElement | null => + document + .getElementById(id) + ?.querySelector( + 'input, select, textarea, button, object, a, area[href], [tabindex]' + ) ?? null