mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-30 12:09:08 +00:00
♻️(frontend) introduce persistent reaction toolbar
Inspired by proprietary solutions. Replace the dialog-based reaction UI with a toolbar integrated directly into the DOM. Allow it to remain open and support proper keyboard interaction, improving accessibility and user experience.
This commit is contained in:
@@ -278,6 +278,7 @@ const config: Config = {
|
|||||||
'room-side-panel': { value: '360px' },
|
'room-side-panel': { value: '360px' },
|
||||||
'room-side-panel-margin': { value: '1.5rem' },
|
'room-side-panel-margin': { value: '1.5rem' },
|
||||||
'room-control-bar': { value: '80px' },
|
'room-control-bar': { value: '80px' },
|
||||||
|
'room-reaction-toolbar-height': { value: '42px' },
|
||||||
},
|
},
|
||||||
spacing,
|
spacing,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
|
|||||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import { Subtitles } from '@/features/subtitle/component/Subtitles'
|
import { Subtitles } from '@/features/subtitle/component/Subtitles'
|
||||||
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
|
import { MainNotificationToast } from '@/features/notifications/MainNotificationToast'
|
||||||
|
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
||||||
|
|
||||||
const RoomViewport = styled(
|
const RoomViewport = styled(
|
||||||
'div',
|
'div',
|
||||||
@@ -14,15 +15,25 @@ const RoomViewport = styled(
|
|||||||
base: {
|
base: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
maxHeight: '100%',
|
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: {
|
variants: {
|
||||||
isSidePanelOpen: {
|
isSidePanelOpen: {
|
||||||
true: {
|
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: {
|
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) {
|
export function RoomContentArea({ children }: RoomContentAreaProps) {
|
||||||
const { isSidePanelOpen } = useSidePanel()
|
const { isSidePanelOpen } = useSidePanel()
|
||||||
const { areSubtitlesOpen } = useSubtitles()
|
const { areSubtitlesOpen } = useSubtitles()
|
||||||
|
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RoomViewport isSidePanelOpen={isSidePanelOpen}>
|
<RoomViewport
|
||||||
|
isSidePanelOpen={isSidePanelOpen}
|
||||||
|
isReactionToolbarOpen={isReactionToolbarOpen}
|
||||||
|
>
|
||||||
<TrackAreaContainer areSubtitlesOpen={areSubtitlesOpen}>
|
<TrackAreaContainer areSubtitlesOpen={areSubtitlesOpen}>
|
||||||
{children}
|
{children}
|
||||||
</TrackAreaContainer>
|
</TrackAreaContainer>
|
||||||
|
|||||||
@@ -1,102 +1,32 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { RiEmotionLine } from '@remixicon/react'
|
import { RiEmotionLine } from '@remixicon/react'
|
||||||
import { useState } from 'react'
|
import { ToggleButton } from '@/primitives'
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { ToggleButton, Button } from '@/primitives'
|
|
||||||
|
|
||||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
import {
|
import { useReactionsToolbar } from '../hooks/useReactionsToolbar'
|
||||||
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'
|
|
||||||
|
|
||||||
export const ReactionsToggle = () => {
|
export const ReactionsToggle = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||||
const [isOpen, setIsOpen] = useState(false)
|
|
||||||
|
|
||||||
const { sendReaction } = useReactions()
|
const { isOpen, toggle } = useReactionsToolbar()
|
||||||
|
|
||||||
useRegisterKeyboardShortcut({
|
useRegisterKeyboardShortcut({
|
||||||
id: 'reaction',
|
id: 'reaction',
|
||||||
handler: () => setIsOpen((prev) => !prev),
|
handler: toggle,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<ToggleButton
|
||||||
<div className={css({ position: 'relative' })}>
|
id="reactions-toggle"
|
||||||
<DialogTrigger isOpen={isOpen} onOpenChange={setIsOpen}>
|
data-attr="reactions-toggle"
|
||||||
<ToggleButton
|
square
|
||||||
square
|
variant="primaryDark"
|
||||||
variant="primaryDark"
|
aria-label={t('button')}
|
||||||
aria-label={t('button')}
|
tooltip={t('button')}
|
||||||
tooltip={t('button')}
|
isSelected={isOpen}
|
||||||
isSelected={isOpen}
|
onChange={toggle}
|
||||||
onChange={setIsOpen}
|
>
|
||||||
>
|
<RiEmotionLine />
|
||||||
<RiEmotionLine />
|
</ToggleButton>
|
||||||
</ToggleButton>
|
|
||||||
<RACPopover
|
|
||||||
placement="top"
|
|
||||||
offset={8}
|
|
||||||
isNonModal
|
|
||||||
shouldCloseOnInteractOutside={() => 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',
|
|
||||||
},
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Dialog className={css({ outline: 'none' })}>
|
|
||||||
{/* eslint-disable-next-line jsx-a11y/no-autofocus -- FocusScope autoFocus is programmatic focus for overlays, not the HTML autofocus attribute */}
|
|
||||||
<FocusScope contain autoFocus restoreFocus>
|
|
||||||
<div
|
|
||||||
role="toolbar"
|
|
||||||
aria-orientation="horizontal"
|
|
||||||
aria-label={t('button')}
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
gap: '0.5rem',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{Object.values(Emoji).map((emoji, index) => (
|
|
||||||
<Button
|
|
||||||
key={index}
|
|
||||||
onPress={() => sendReaction(emoji)}
|
|
||||||
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
|
|
||||||
variant="primaryTextDark"
|
|
||||||
size="sm"
|
|
||||||
square
|
|
||||||
data-attr={`send-reaction-${emoji}`}
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={`/assets/reactions/${emoji}.png`}
|
|
||||||
alt=""
|
|
||||||
className={css({
|
|
||||||
width: '28px',
|
|
||||||
height: '28px',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
userSelect: 'none',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</FocusScope>
|
|
||||||
</Dialog>
|
|
||||||
</RACPopover>
|
|
||||||
</DialogTrigger>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Button
|
||||||
|
onPress={() => sendReaction(emoji)}
|
||||||
|
aria-label={t('send', { emoji: getEmojiLabel(emoji, t) })}
|
||||||
|
variant="primaryTextDark"
|
||||||
|
size="sm"
|
||||||
|
round
|
||||||
|
data-attr={`send-reaction-${emoji}`}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`/assets/reactions/${emoji}.png`}
|
||||||
|
alt=""
|
||||||
|
className={css({
|
||||||
|
width: '28px',
|
||||||
|
height: '28px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
userSelect: 'none',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<StyledStrip
|
||||||
|
ref={ref}
|
||||||
|
aria-hidden={!isOpen}
|
||||||
|
isVisible={isVisible}
|
||||||
|
desktopOffset={!isMobile}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</StyledStrip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const KeyboardNavigation = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
const focusManager = useFocusManager()
|
||||||
|
|
||||||
|
const onFocus = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||||
|
const comingFromOutside = !e.currentTarget.contains(e.relatedTarget)
|
||||||
|
if (comingFromOutside) {
|
||||||
|
focusManager?.focusFirst()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||||
|
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 (
|
||||||
|
<div role="toolbar" onKeyDown={onKeyDown} onFocus={onFocus}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ReactionsToolbar = () => {
|
||||||
|
const { isOpen } = useReactionsToolbar()
|
||||||
|
const shouldMount = useDelayUnmount(isOpen, 300)
|
||||||
|
|
||||||
|
if (!shouldMount) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/no-autofocus*/}
|
||||||
|
<FocusScope autoFocus>
|
||||||
|
<KeyboardNavigation>
|
||||||
|
<Strip>
|
||||||
|
{Object.values(Emoji).map((emoji) => (
|
||||||
|
<ReactionButton key={emoji} emoji={emoji} />
|
||||||
|
))}
|
||||||
|
</Strip>
|
||||||
|
</KeyboardNavigation>
|
||||||
|
</FocusScope>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Emoji } from './types'
|
||||||
|
|
||||||
export const getEmojiLabel = (
|
export const getEmojiLabel = (
|
||||||
emoji: string,
|
emoji: string | Emoji,
|
||||||
t: ReturnType<typeof useTranslation>['t']
|
t: ReturnType<typeof useTranslation>['t']
|
||||||
) => {
|
) => {
|
||||||
const emojiLabels: Record<string, string> = {
|
const emojiLabels: Record<string, string> = {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { Admin } from './Admin'
|
|||||||
import { Tools } from './Tools'
|
import { Tools } from './Tools'
|
||||||
import { Info } from './Info'
|
import { Info } from './Info'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
|
import { useReactionsToolbar } from '@/features/reactions/hooks/useReactionsToolbar'
|
||||||
|
|
||||||
type StyledSidePanelProps = {
|
type StyledSidePanelProps = {
|
||||||
title: string
|
title: string
|
||||||
@@ -25,6 +26,7 @@ type StyledSidePanelProps = {
|
|||||||
isSubmenu: boolean
|
isSubmenu: boolean
|
||||||
onBack: () => void
|
onBack: () => void
|
||||||
backButtonLabel: string
|
backButtonLabel: string
|
||||||
|
isReactionToolbarOpen?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledSidePanel = ({
|
const StyledSidePanel = ({
|
||||||
@@ -33,6 +35,7 @@ const StyledSidePanel = ({
|
|||||||
children,
|
children,
|
||||||
onClose,
|
onClose,
|
||||||
isClosed,
|
isClosed,
|
||||||
|
isReactionToolbarOpen,
|
||||||
closeButtonTooltip,
|
closeButtonTooltip,
|
||||||
isSubmenu = false,
|
isSubmenu = false,
|
||||||
onBack,
|
onBack,
|
||||||
@@ -53,11 +56,11 @@ const StyledSidePanel = ({
|
|||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
margin: 'var(--sizes-room-side-panel-margin)',
|
margin: 'var(--sizes-room-side-panel-margin)',
|
||||||
marginLeft: 0,
|
marginLeft: 0,
|
||||||
|
marginBottom: 0,
|
||||||
padding: 0,
|
padding: 0,
|
||||||
gap: 0,
|
gap: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 'var(--sizes-room-control-bar)',
|
|
||||||
width: 'var(--sizes-room-side-panel)',
|
width: 'var(--sizes-room-side-panel)',
|
||||||
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
|
transition: '.5s cubic-bezier(.4,0,.2,1) 5ms',
|
||||||
})}
|
})}
|
||||||
@@ -65,6 +68,9 @@ const StyledSidePanel = ({
|
|||||||
transform: isClosed
|
transform: isClosed
|
||||||
? 'translateX(calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin)))'
|
? 'translateX(calc(var(--sizes-room-side-panel) + var(--sizes-room-side-panel-margin)))'
|
||||||
: 'none',
|
: '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-hidden={isClosed}
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
@@ -154,6 +160,8 @@ export const SidePanel = () => {
|
|||||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||||
const title = t(`heading.${activeSubPanelId || activePanelId}`)
|
const title = t(`heading.${activeSubPanelId || activePanelId}`)
|
||||||
|
|
||||||
|
const { isOpen: isReactionToolbarOpen } = useReactionsToolbar()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledSidePanel
|
<StyledSidePanel
|
||||||
title={title}
|
title={title}
|
||||||
@@ -167,6 +175,7 @@ export const SidePanel = () => {
|
|||||||
})}
|
})}
|
||||||
isClosed={!isSidePanelOpen}
|
isClosed={!isSidePanelOpen}
|
||||||
isSubmenu={isSubPanelOpen}
|
isSubmenu={isSubPanelOpen}
|
||||||
|
isReactionToolbarOpen={isReactionToolbarOpen}
|
||||||
backButtonLabel={t('backToTools')}
|
backButtonLabel={t('backToTools')}
|
||||||
onBack={() => (layoutStore.activeSubPanelId = null)}
|
onBack={() => (layoutStore.activeSubPanelId = null)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import * as React from 'react'
|
|||||||
import { MobileControlBar } from './MobileControlBar'
|
import { MobileControlBar } from './MobileControlBar'
|
||||||
import { DesktopControlBar } from './DesktopControlBar'
|
import { DesktopControlBar } from './DesktopControlBar'
|
||||||
import { useIsMobile } from '@/utils/useIsMobile'
|
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<HTMLDivElement> {
|
export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
|
onDeviceError?: (error: { source: Track.Source; error: Error }) => void
|
||||||
@@ -16,10 +18,32 @@ export interface ControlBarProps extends React.HTMLAttributes<HTMLDivElement> {
|
|||||||
export function ControlBar({ onDeviceError }: ControlBarProps) {
|
export function ControlBar({ onDeviceError }: ControlBarProps) {
|
||||||
const isMobile = useIsMobile()
|
const isMobile = useIsMobile()
|
||||||
|
|
||||||
if (isMobile) {
|
return (
|
||||||
return <MobileControlBar onDeviceError={onDeviceError} />
|
<div
|
||||||
}
|
className={css({
|
||||||
return <DesktopControlBar onDeviceError={onDeviceError} />
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<ReactionsToolbar />
|
||||||
|
<div
|
||||||
|
id="control-bar"
|
||||||
|
className={css({
|
||||||
|
zIndex: 100,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{isMobile ? (
|
||||||
|
<MobileControlBar onDeviceError={onDeviceError} />
|
||||||
|
) : (
|
||||||
|
<DesktopControlBar onDeviceError={onDeviceError} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ControlBarAuxProps = Pick<ControlBarProps, 'onDeviceError'>
|
export type ControlBarAuxProps = Pick<ControlBarProps, 'onDeviceError'>
|
||||||
|
|||||||
@@ -48,11 +48,7 @@ export function DesktopControlBar({
|
|||||||
className={css({
|
className={css({
|
||||||
width: '100vw',
|
width: '100vw',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
position: 'absolute',
|
|
||||||
padding: '1.125rem',
|
padding: '1.125rem',
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -43,12 +43,8 @@ export function MobileControlBar({
|
|||||||
className={css({
|
className={css({
|
||||||
width: '100vw',
|
width: '100vw',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
position: 'absolute',
|
|
||||||
padding: '1.125rem',
|
padding: '1.125rem',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
bottom: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
|
||||||
|
export const useDelayUnmount = (isMounted: boolean, delayTime: number) => {
|
||||||
|
const [shouldRender, setShouldRender] = useState(false)
|
||||||
|
useEffect(() => {
|
||||||
|
let timeoutId: ReturnType<typeof setTimeout> | 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
|
||||||
|
}
|
||||||
@@ -44,6 +44,13 @@ export const buttonRecipe = cva({
|
|||||||
paddingY: 'var(--square-padding)',
|
paddingY: 'var(--square-padding)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
round: {
|
||||||
|
true: {
|
||||||
|
borderRadius: '50%',
|
||||||
|
paddingX: 'var(--square-padding)',
|
||||||
|
paddingY: 'var(--square-padding)',
|
||||||
|
},
|
||||||
|
},
|
||||||
variant: {
|
variant: {
|
||||||
primary: {
|
primary: {
|
||||||
backgroundColor: 'primary.800',
|
backgroundColor: 'primary.800',
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type State = {
|
|||||||
showSubtitles: boolean
|
showSubtitles: boolean
|
||||||
activePanelId: PanelId | null
|
activePanelId: PanelId | null
|
||||||
activeSubPanelId: SubPanelId | null
|
activeSubPanelId: SubPanelId | null
|
||||||
|
showReactionsToolbar: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export const layoutStore = proxy<State>({
|
export const layoutStore = proxy<State>({
|
||||||
@@ -18,4 +19,5 @@ export const layoutStore = proxy<State>({
|
|||||||
showSubtitles: false,
|
showSubtitles: false,
|
||||||
activePanelId: null,
|
activePanelId: null,
|
||||||
activeSubPanelId: null,
|
activeSubPanelId: null,
|
||||||
|
showReactionsToolbar: false,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user