mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-01 05:07:56 +00:00
📱(frontend) collapse mobile control bar items on narrow viewports
Apply the same approach as the PiP screen to the mobile control bar: make sure it never overflows on small phone screens by collapsing some controls into the overflow menu when there is not enough horizontal room.
This commit is contained in:
@@ -8,6 +8,10 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 📱(frontend) collapse mobile control bar items on narrow viewports
|
||||||
|
|
||||||
## [1.28.0] - 2026-08-24
|
## [1.28.0] - 2026-08-24
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const controlBarRegion = cva({
|
|||||||
mobile: {
|
mobile: {
|
||||||
true: {
|
true: {
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
width: '330px',
|
width: '100%',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useCallback } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { RiEmotionLine } from '@remixicon/react'
|
import { RiEmotionLine } from '@remixicon/react'
|
||||||
import { ToggleButton } from '@/primitives'
|
import { ToggleButton } from '@/primitives'
|
||||||
@@ -7,6 +6,8 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey
|
|||||||
import { REACTIONS_TOOLBAR_ID } from '../constants'
|
import { REACTIONS_TOOLBAR_ID } from '../constants'
|
||||||
import { useReactionsToolbar } from '../hooks/useReactionsToolbar'
|
import { useReactionsToolbar } from '../hooks/useReactionsToolbar'
|
||||||
import { layoutStore } from '@/stores/layout'
|
import { layoutStore } from '@/stores/layout'
|
||||||
|
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||||
|
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
|
|
||||||
const focusReactionsToolbar = () => {
|
const focusReactionsToolbar = () => {
|
||||||
document
|
document
|
||||||
@@ -17,35 +18,44 @@ const focusReactionsToolbar = () => {
|
|||||||
|
|
||||||
export const REACTIONS_TOGGLE_ID = 'reactions-toggle'
|
export const REACTIONS_TOGGLE_ID = 'reactions-toggle'
|
||||||
|
|
||||||
export const ReactionsToggle = () => {
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
export const reactionShortcutHandler = () => {
|
||||||
|
if (layoutStore.showReactionsToolbar) {
|
||||||
|
focusReactionsToolbar()
|
||||||
|
} else {
|
||||||
|
layoutStore.showReactionsToolbar = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = Pick<NonNullable<ButtonRecipeProps>, 'variant'> & ToggleButtonProps
|
||||||
|
|
||||||
|
export const ReactionsToggle = ({
|
||||||
|
variant = 'primaryDark',
|
||||||
|
onPress,
|
||||||
|
...props
|
||||||
|
}: Props) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
|
||||||
|
|
||||||
const { isOpen, toggle } = useReactionsToolbar()
|
const { isOpen, toggle } = useReactionsToolbar()
|
||||||
|
|
||||||
const handleShortcut = useCallback(() => {
|
|
||||||
if (layoutStore.showReactionsToolbar) {
|
|
||||||
focusReactionsToolbar()
|
|
||||||
} else {
|
|
||||||
layoutStore.showReactionsToolbar = true
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useRegisterKeyboardShortcut({
|
useRegisterKeyboardShortcut({
|
||||||
id: 'reaction',
|
id: 'reaction',
|
||||||
handler: handleShortcut,
|
handler: reactionShortcutHandler,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
|
{...props}
|
||||||
id={REACTIONS_TOGGLE_ID}
|
id={REACTIONS_TOGGLE_ID}
|
||||||
data-attr="reactions-toggle"
|
data-attr="reactions-toggle"
|
||||||
square
|
square
|
||||||
variant="primaryDark"
|
variant={variant}
|
||||||
aria-label={t('button')}
|
aria-label={t('button')}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
tooltip={t('button')}
|
tooltip={t('button')}
|
||||||
isSelected={isOpen}
|
isSelected={isOpen}
|
||||||
onChange={toggle}
|
onChange={toggle}
|
||||||
|
onPress={onPress}
|
||||||
>
|
>
|
||||||
<RiEmotionLine />
|
<RiEmotionLine />
|
||||||
</ToggleButton>
|
</ToggleButton>
|
||||||
|
|||||||
@@ -10,10 +10,18 @@ import {
|
|||||||
showLowerHandToast,
|
showLowerHandToast,
|
||||||
} from '@/features/notifications/utils'
|
} from '@/features/notifications/utils'
|
||||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
|
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||||
|
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||||
|
|
||||||
const SPEAKING_DETECTION_DELAY = 3000
|
const SPEAKING_DETECTION_DELAY = 3000
|
||||||
|
|
||||||
export const HandToggle = () => {
|
type Props = Pick<NonNullable<ButtonRecipeProps>, 'variant'> & ToggleButtonProps
|
||||||
|
|
||||||
|
export const HandToggle = ({
|
||||||
|
variant = 'primaryDark',
|
||||||
|
onPress,
|
||||||
|
...props
|
||||||
|
}: Props) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.hand' })
|
||||||
|
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
@@ -74,12 +82,16 @@ export const HandToggle = () => {
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<ToggleButton
|
<ToggleButton
|
||||||
|
{...props}
|
||||||
square
|
square
|
||||||
variant="primaryDark"
|
variant={variant}
|
||||||
aria-label={t(tooltipLabel)}
|
aria-label={t(tooltipLabel)}
|
||||||
tooltip={t(tooltipLabel)}
|
tooltip={t(tooltipLabel)}
|
||||||
isSelected={isHandRaised}
|
isSelected={isHandRaised}
|
||||||
onPress={handleToggle}
|
onPress={(e) => {
|
||||||
|
handleToggle()
|
||||||
|
onPress?.(e)
|
||||||
|
}}
|
||||||
data-attr={`controls-hand-${tooltipLabel}`}
|
data-attr={`controls-hand-${tooltipLabel}`}
|
||||||
>
|
>
|
||||||
<RiHand />
|
<RiHand />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { supportsScreenSharing } from '@livekit/components-core'
|
import { supportsScreenSharing } from '@livekit/components-core'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import type { ControlBarAuxProps } from './ControlBar'
|
import type { ControlBarAuxProps } from './ControlBar'
|
||||||
import React from 'react'
|
import React, { useLayoutEffect, useRef, useState } from 'react'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { LeaveButton } from '../../components/controls/LeaveButton'
|
import { LeaveButton } from '../../components/controls/LeaveButton'
|
||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
@@ -26,7 +26,26 @@ import { AudioDevicesControl } from '../../components/controls/Device/AudioDevic
|
|||||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||||
import { openSettingsDialog } from '@/stores/settings'
|
import { openSettingsDialog } from '@/stores/settings'
|
||||||
import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion'
|
import { ControlBarRegion } from '@/features/layout/components/ControlBarRegion'
|
||||||
import { ReactionsToggle } from '@/features/reactions/components/ReactionsToggle'
|
import {
|
||||||
|
ReactionsToggle,
|
||||||
|
reactionShortcutHandler,
|
||||||
|
} from '@/features/reactions/components/ReactionsToggle'
|
||||||
|
import { useSize } from '../../hooks/useResizeObserver'
|
||||||
|
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||||
|
import { useRaisedHand } from '@/features/rooms/livekit/hooks/useRaisedHand'
|
||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
|
||||||
|
// Hand collapses first, then reactions; hidden toggles move into the menu.
|
||||||
|
const COLLAPSIBLE_COUNT = 2
|
||||||
|
|
||||||
|
// Layout-neutral measuring wrapper: inherits the region's gap and refuses to
|
||||||
|
// flex-shrink so measured widths are natural content widths.
|
||||||
|
const measuredRow = css({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 'inherit',
|
||||||
|
flexShrink: 0,
|
||||||
|
})
|
||||||
|
|
||||||
export function MobileControlBar({
|
export function MobileControlBar({
|
||||||
onDeviceError,
|
onDeviceError,
|
||||||
@@ -36,50 +55,107 @@ export function MobileControlBar({
|
|||||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||||
const { toggleEffects } = useSidePanel()
|
const { toggleEffects } = useSidePanel()
|
||||||
|
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
const { width } = useSize(containerRef)
|
||||||
|
const barRef = useRef<HTMLDivElement>(null)
|
||||||
|
const { width: barWidth } = useSize(barRef)
|
||||||
|
const collapsibleRef = useRef<HTMLDivElement>(null)
|
||||||
|
const { width: collapsibleWidth } = useSize(collapsibleRef)
|
||||||
|
|
||||||
|
const [hiddenCount, setHiddenCount] = useState(0)
|
||||||
|
const calibration = useRef<{ essential: number; slot: number }>()
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (hiddenCount === 0 && collapsibleWidth > 0 && barRef.current) {
|
||||||
|
const gap = parseFloat(getComputedStyle(barRef.current).columnGap) || 0
|
||||||
|
calibration.current = {
|
||||||
|
essential: barWidth - collapsibleWidth - gap,
|
||||||
|
slot: (collapsibleWidth + gap) / COLLAPSIBLE_COUNT,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!calibration.current || width <= 0) return
|
||||||
|
const { essential, slot } = calibration.current
|
||||||
|
const fits = Math.floor((width - essential) / slot)
|
||||||
|
const next = Math.min(
|
||||||
|
COLLAPSIBLE_COUNT,
|
||||||
|
Math.max(0, COLLAPSIBLE_COUNT - fits)
|
||||||
|
)
|
||||||
|
if (next !== hiddenCount) setHiddenCount(next)
|
||||||
|
}, [barWidth, collapsibleWidth, hiddenCount, width, setHiddenCount])
|
||||||
|
|
||||||
|
const hideHand = hiddenCount >= 1
|
||||||
|
const hideReactions = hiddenCount >= 2
|
||||||
|
|
||||||
|
const room = useRoomContext()
|
||||||
|
const { toggleRaisedHand } = useRaisedHand({
|
||||||
|
participant: room.localParticipant,
|
||||||
|
})
|
||||||
|
useRegisterKeyboardShortcut({
|
||||||
|
id: 'raise-hand',
|
||||||
|
handler: toggleRaisedHand,
|
||||||
|
})
|
||||||
|
useRegisterKeyboardShortcut({
|
||||||
|
id: 'reaction',
|
||||||
|
handler: reactionShortcutHandler,
|
||||||
|
})
|
||||||
|
|
||||||
const { data } = useConfig()
|
const { data } = useConfig()
|
||||||
|
|
||||||
|
const closeMenu = () => setIsMenuOpened(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
width: '100vw',
|
width: '100vw',
|
||||||
display: 'flex',
|
|
||||||
padding: '1.125rem',
|
padding: '1.125rem',
|
||||||
justifyContent: 'center',
|
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<ControlBarRegion mobile>
|
<div
|
||||||
<LeaveButton />
|
ref={containerRef}
|
||||||
<AudioDevicesControl
|
className={css({
|
||||||
onDeviceError={(error) =>
|
width: '100%',
|
||||||
onDeviceError?.({ source: Track.Source.Microphone, error })
|
display: 'flex',
|
||||||
}
|
justifyContent: 'center',
|
||||||
hideMenu={true}
|
})}
|
||||||
/>
|
>
|
||||||
<VideoDeviceControl
|
<ControlBarRegion mobile>
|
||||||
onDeviceError={(error) =>
|
<div ref={barRef} className={measuredRow}>
|
||||||
onDeviceError?.({ source: Track.Source.Camera, error })
|
<LeaveButton />
|
||||||
}
|
<AudioDevicesControl
|
||||||
hideMenu={true}
|
onDeviceError={(error) =>
|
||||||
/>
|
onDeviceError?.({ source: Track.Source.Microphone, error })
|
||||||
<ReactionsToggle />
|
}
|
||||||
<HandToggle />
|
hideMenu={true}
|
||||||
<Button
|
/>
|
||||||
id="room-options-trigger"
|
<VideoDeviceControl
|
||||||
square
|
onDeviceError={(error) =>
|
||||||
variant="primaryDark"
|
onDeviceError?.({ source: Track.Source.Camera, error })
|
||||||
aria-label={t('options.buttonLabel')}
|
}
|
||||||
tooltip={t('options.buttonLabel')}
|
hideMenu={true}
|
||||||
onPress={() => setIsMenuOpened(true)}
|
/>
|
||||||
>
|
{/* Unmounted when empty so it doesn't leave a stray gap. */}
|
||||||
<RiMore2Line />
|
{!hideReactions && (
|
||||||
</Button>
|
<div ref={collapsibleRef} className={measuredRow}>
|
||||||
</ControlBarRegion>
|
<ReactionsToggle />
|
||||||
|
{!hideHand && <HandToggle />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
id="room-options-trigger"
|
||||||
|
square
|
||||||
|
variant="primaryDark"
|
||||||
|
aria-label={t('options.buttonLabel')}
|
||||||
|
tooltip={t('options.buttonLabel')}
|
||||||
|
onPress={() => setIsMenuOpened(true)}
|
||||||
|
>
|
||||||
|
<RiMore2Line />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</ControlBarRegion>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ResponsiveMenu
|
<ResponsiveMenu isOpened={isMenuOpened} onClosed={closeMenu}>
|
||||||
isOpened={isMenuOpened}
|
|
||||||
onClosed={() => setIsMenuOpened(false)}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -98,6 +174,20 @@ export function MobileControlBar({
|
|||||||
},
|
},
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
|
{hideReactions && (
|
||||||
|
<ReactionsToggle
|
||||||
|
variant="primaryTextDark"
|
||||||
|
description={true}
|
||||||
|
onPress={closeMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{hideHand && (
|
||||||
|
<HandToggle
|
||||||
|
variant="primaryTextDark"
|
||||||
|
description={true}
|
||||||
|
onPress={closeMenu}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{browserSupportsScreenSharing && (
|
{browserSupportsScreenSharing && (
|
||||||
<ScreenShareToggle
|
<ScreenShareToggle
|
||||||
onDeviceError={(error) =>
|
onDeviceError={(error) =>
|
||||||
@@ -105,25 +195,16 @@ export function MobileControlBar({
|
|||||||
}
|
}
|
||||||
variant="primaryTextDark"
|
variant="primaryTextDark"
|
||||||
description={true}
|
description={true}
|
||||||
onPress={() => setIsMenuOpened(false)}
|
onPress={closeMenu}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ChatToggle
|
<ChatToggle description={true} onPress={closeMenu} />
|
||||||
description={true}
|
<ParticipantsToggle description={true} onPress={closeMenu} />
|
||||||
onPress={() => setIsMenuOpened(false)}
|
<ToolsToggle description={true} onPress={closeMenu} />
|
||||||
/>
|
|
||||||
<ParticipantsToggle
|
|
||||||
description={true}
|
|
||||||
onPress={() => setIsMenuOpened(false)}
|
|
||||||
/>
|
|
||||||
<ToolsToggle
|
|
||||||
description={true}
|
|
||||||
onPress={() => setIsMenuOpened(false)}
|
|
||||||
/>
|
|
||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
toggleEffects()
|
toggleEffects()
|
||||||
setIsMenuOpened(false)
|
closeMenu()
|
||||||
}}
|
}}
|
||||||
variant="primaryTextDark"
|
variant="primaryTextDark"
|
||||||
aria-label={t('options.items.effects')}
|
aria-label={t('options.items.effects')}
|
||||||
@@ -140,7 +221,7 @@ export function MobileControlBar({
|
|||||||
aria-label={t('options.items.feedback')}
|
aria-label={t('options.items.feedback')}
|
||||||
description={true}
|
description={true}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
onPress={() => setIsMenuOpened(false)}
|
onPress={closeMenu}
|
||||||
>
|
>
|
||||||
<RiMegaphoneLine size={20} />
|
<RiMegaphoneLine size={20} />
|
||||||
</LinkButton>
|
</LinkButton>
|
||||||
@@ -148,7 +229,7 @@ export function MobileControlBar({
|
|||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
openSettingsDialog()
|
openSettingsDialog()
|
||||||
setIsMenuOpened(false)
|
closeMenu()
|
||||||
}}
|
}}
|
||||||
variant="primaryTextDark"
|
variant="primaryTextDark"
|
||||||
aria-label={t('options.items.settings')}
|
aria-label={t('options.items.settings')}
|
||||||
@@ -157,7 +238,7 @@ export function MobileControlBar({
|
|||||||
>
|
>
|
||||||
<RiSettings3Line size={20} />
|
<RiSettings3Line size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
<CameraSwitchButton onPress={() => setIsMenuOpened(false)} />
|
<CameraSwitchButton onPress={closeMenu} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ResponsiveMenu>
|
</ResponsiveMenu>
|
||||||
|
|||||||
Reference in New Issue
Block a user