mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-24 17:27:02 +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:
committed by
aleb_the_flash
parent
f18d784615
commit
24404e4ea2
@@ -8,6 +8,10 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- 📱(frontend) collapse mobile control bar items on narrow viewports
|
||||
|
||||
## [1.28.0] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
@@ -13,7 +13,7 @@ const controlBarRegion = cva({
|
||||
mobile: {
|
||||
true: {
|
||||
justifyContent: 'center',
|
||||
width: '330px',
|
||||
width: '100%',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiEmotionLine } from '@remixicon/react'
|
||||
import { ToggleButton } from '@/primitives'
|
||||
@@ -7,6 +6,8 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey
|
||||
import { REACTIONS_TOOLBAR_ID } from '../constants'
|
||||
import { useReactionsToolbar } from '../hooks/useReactionsToolbar'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
|
||||
const focusReactionsToolbar = () => {
|
||||
document
|
||||
@@ -17,35 +18,44 @@ const focusReactionsToolbar = () => {
|
||||
|
||||
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 { isOpen, toggle } = useReactionsToolbar()
|
||||
|
||||
const handleShortcut = useCallback(() => {
|
||||
if (layoutStore.showReactionsToolbar) {
|
||||
focusReactionsToolbar()
|
||||
} else {
|
||||
layoutStore.showReactionsToolbar = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'reaction',
|
||||
handler: handleShortcut,
|
||||
handler: reactionShortcutHandler,
|
||||
})
|
||||
|
||||
return (
|
||||
<ToggleButton
|
||||
{...props}
|
||||
id={REACTIONS_TOGGLE_ID}
|
||||
data-attr="reactions-toggle"
|
||||
square
|
||||
variant="primaryDark"
|
||||
variant={variant}
|
||||
aria-label={t('button')}
|
||||
aria-expanded={isOpen}
|
||||
tooltip={t('button')}
|
||||
isSelected={isOpen}
|
||||
onChange={toggle}
|
||||
onPress={onPress}
|
||||
>
|
||||
<RiEmotionLine />
|
||||
</ToggleButton>
|
||||
|
||||
@@ -10,10 +10,18 @@ import {
|
||||
showLowerHandToast,
|
||||
} from '@/features/notifications/utils'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { type ButtonRecipeProps } from '@/primitives/buttonRecipe'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
|
||||
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 room = useRoomContext()
|
||||
@@ -74,12 +82,16 @@ export const HandToggle = () => {
|
||||
})}
|
||||
>
|
||||
<ToggleButton
|
||||
{...props}
|
||||
square
|
||||
variant="primaryDark"
|
||||
variant={variant}
|
||||
aria-label={t(tooltipLabel)}
|
||||
tooltip={t(tooltipLabel)}
|
||||
isSelected={isHandRaised}
|
||||
onPress={handleToggle}
|
||||
onPress={(e) => {
|
||||
handleToggle()
|
||||
onPress?.(e)
|
||||
}}
|
||||
data-attr={`controls-hand-${tooltipLabel}`}
|
||||
>
|
||||
<RiHand />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { supportsScreenSharing } from '@livekit/components-core'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { ControlBarAuxProps } from './ControlBar'
|
||||
import React from 'react'
|
||||
import React, { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { LeaveButton } from '../../components/controls/LeaveButton'
|
||||
import { Track } from 'livekit-client'
|
||||
@@ -26,7 +26,26 @@ import { AudioDevicesControl } from '../../components/controls/Device/AudioDevic
|
||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||
import { openSettingsDialog } from '@/stores/settings'
|
||||
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({
|
||||
onDeviceError,
|
||||
@@ -36,50 +55,107 @@ export function MobileControlBar({
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
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 closeMenu = () => setIsMenuOpened(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={css({
|
||||
width: '100vw',
|
||||
display: 'flex',
|
||||
padding: '1.125rem',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
<ControlBarRegion mobile>
|
||||
<LeaveButton />
|
||||
<AudioDevicesControl
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Microphone, error })
|
||||
}
|
||||
hideMenu={true}
|
||||
/>
|
||||
<VideoDeviceControl
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Camera, error })
|
||||
}
|
||||
hideMenu={true}
|
||||
/>
|
||||
<ReactionsToggle />
|
||||
<HandToggle />
|
||||
<Button
|
||||
id="room-options-trigger"
|
||||
square
|
||||
variant="primaryDark"
|
||||
aria-label={t('options.buttonLabel')}
|
||||
tooltip={t('options.buttonLabel')}
|
||||
onPress={() => setIsMenuOpened(true)}
|
||||
>
|
||||
<RiMore2Line />
|
||||
</Button>
|
||||
</ControlBarRegion>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={css({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
<ControlBarRegion mobile>
|
||||
<div ref={barRef} className={measuredRow}>
|
||||
<LeaveButton />
|
||||
<AudioDevicesControl
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Microphone, error })
|
||||
}
|
||||
hideMenu={true}
|
||||
/>
|
||||
<VideoDeviceControl
|
||||
onDeviceError={(error) =>
|
||||
onDeviceError?.({ source: Track.Source.Camera, error })
|
||||
}
|
||||
hideMenu={true}
|
||||
/>
|
||||
{/* Unmounted when empty so it doesn't leave a stray gap. */}
|
||||
{!hideReactions && (
|
||||
<div ref={collapsibleRef} className={measuredRow}>
|
||||
<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>
|
||||
<ResponsiveMenu
|
||||
isOpened={isMenuOpened}
|
||||
onClosed={() => setIsMenuOpened(false)}
|
||||
>
|
||||
<ResponsiveMenu isOpened={isMenuOpened} onClosed={closeMenu}>
|
||||
<div
|
||||
className={css({
|
||||
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 && (
|
||||
<ScreenShareToggle
|
||||
onDeviceError={(error) =>
|
||||
@@ -105,25 +195,16 @@ export function MobileControlBar({
|
||||
}
|
||||
variant="primaryTextDark"
|
||||
description={true}
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
onPress={closeMenu}
|
||||
/>
|
||||
)}
|
||||
<ChatToggle
|
||||
description={true}
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
/>
|
||||
<ParticipantsToggle
|
||||
description={true}
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
/>
|
||||
<ToolsToggle
|
||||
description={true}
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
/>
|
||||
<ChatToggle description={true} onPress={closeMenu} />
|
||||
<ParticipantsToggle description={true} onPress={closeMenu} />
|
||||
<ToolsToggle description={true} onPress={closeMenu} />
|
||||
<Button
|
||||
onPress={() => {
|
||||
toggleEffects()
|
||||
setIsMenuOpened(false)
|
||||
closeMenu()
|
||||
}}
|
||||
variant="primaryTextDark"
|
||||
aria-label={t('options.items.effects')}
|
||||
@@ -140,7 +221,7 @@ export function MobileControlBar({
|
||||
aria-label={t('options.items.feedback')}
|
||||
description={true}
|
||||
target="_blank"
|
||||
onPress={() => setIsMenuOpened(false)}
|
||||
onPress={closeMenu}
|
||||
>
|
||||
<RiMegaphoneLine size={20} />
|
||||
</LinkButton>
|
||||
@@ -148,7 +229,7 @@ export function MobileControlBar({
|
||||
<Button
|
||||
onPress={() => {
|
||||
openSettingsDialog()
|
||||
setIsMenuOpened(false)
|
||||
closeMenu()
|
||||
}}
|
||||
variant="primaryTextDark"
|
||||
aria-label={t('options.items.settings')}
|
||||
@@ -157,7 +238,7 @@ export function MobileControlBar({
|
||||
>
|
||||
<RiSettings3Line size={20} />
|
||||
</Button>
|
||||
<CameraSwitchButton onPress={() => setIsMenuOpened(false)} />
|
||||
<CameraSwitchButton onPress={closeMenu} />
|
||||
</div>
|
||||
</div>
|
||||
</ResponsiveMenu>
|
||||
|
||||
Reference in New Issue
Block a user