(frontend) add expandable shortcuts tooltip

show shortcut catalog in participant hint with open/close animation
This commit is contained in:
Cyril
2026-02-05 10:32:22 +01:00
parent 2565e57c50
commit e9f2f98269
5 changed files with 239 additions and 4 deletions
@@ -31,7 +31,7 @@ import { FullScreenShareWarning } from './FullScreenShareWarning'
import { ParticipantName } from './ParticipantName'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { useTranslation } from 'react-i18next'
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
import { ShortcutHelpTooltip } from './ShortcutHelpTooltip'
export function TrackRefContextIfNeeded(
props: React.PropsWithChildren<{
@@ -229,9 +229,10 @@ export const ParticipantTile: (
)}
</ParticipantContextIfNeeded>
</TrackRefContextIfNeeded>
{hasKeyboardFocus && (
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
)}
<ShortcutHelpTooltip
triggerLabel={t('toolbarHint')}
isVisible={hasKeyboardFocus}
/>
</div>
)
})
@@ -0,0 +1,197 @@
import React, { useCallback, useEffect, useMemo } from 'react'
import { css } from '@/styled-system/css'
import { shortcutCatalog } from '@/features/shortcuts/catalog'
import { isMacintosh } from '@/utils/livekit'
import {
closeShortcutHelp,
shortcutHelpStore,
toggleShortcutHelp,
} from '@/stores/shortcutHelp'
import { useSnapshot } from 'valtio'
import { Shortcut } from '@/features/shortcuts/types'
type ShortcutHelpTooltipProps = {
triggerLabel: string
isVisible?: boolean
}
const formatShortcutLabel = (shortcut?: Shortcut) => {
if (!shortcut) return '—'
const key = shortcut.key?.toUpperCase()
if (!key) return '—'
if (shortcut.ctrlKey) return `${isMacintosh() ? '⌘' : 'Ctrl'}+${key}`
return key
}
const formatLongPressLabel = (code?: string) => {
if (!code) return '—'
if (code.startsWith('Key') && code.length === 4) {
return `Hold ${code.slice(3)}`
}
return `Hold ${code}`
}
export const ShortcutHelpTooltip: React.FC<ShortcutHelpTooltipProps> = ({
triggerLabel,
isVisible = false,
}) => {
const { isOpen } = useSnapshot(shortcutHelpStore)
const isContainerVisible = isVisible || isOpen
const grouped = useMemo(() => {
return shortcutCatalog.reduce<Record<string, typeof shortcutCatalog>>(
(acc, item) => {
acc[item.category] = acc[item.category] || []
acc[item.category].push(item)
return acc
},
{}
)
}, [])
const handleToggle = useCallback(() => {
toggleShortcutHelp()
}, [])
useEffect(() => {
if (!isOpen) return
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
closeShortcutHelp()
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
}
}, [isOpen])
return (
<div
data-visible={isContainerVisible}
className={css({
position: 'absolute',
top: '0.75rem',
right: '0.75rem',
color: 'white',
borderRadius: 'calc(var(--lk-border-radius) / 2)',
backgroundColor: 'rgba(0,0,0,0.65)',
paddingInline: '0.5rem',
paddingBlock: '0.25rem',
fontSize: '0.875rem',
minWidth: '200px',
maxWidth: '320px',
boxShadow: '0 4px 10px rgba(0,0,0,0.25)',
zIndex: 10,
opacity: 0,
visibility: 'hidden',
pointerEvents: 'none',
transition: 'opacity 150ms ease',
'&[data-visible="true"]': {
opacity: 1,
visibility: 'visible',
pointerEvents: 'auto',
},
})}
>
<button
type="button"
aria-expanded={isOpen}
aria-controls="shortcut-help-panel"
onClick={handleToggle}
className={css({
all: 'unset',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
color: 'white',
fontWeight: 600,
gap: '0.5rem',
})}
>
<span>{triggerLabel}</span>
<span
aria-hidden
className={css({
fontSize: '0.75rem',
opacity: 0.8,
})}
>
{isOpen ? '' : '+'}
</span>
</button>
<div
id="shortcut-help-panel"
role="group"
aria-label="Keyboard shortcuts"
data-open={isOpen}
className={css({
overflow: 'hidden',
maxHeight: '0px',
opacity: 0,
mt: '0',
transition:
'max-height 200ms ease, opacity 200ms ease, margin-top 200ms ease',
'&[data-open="true"]': {
maxHeight: '400px',
opacity: 1,
mt: '0.35rem',
},
})}
>
{Object.entries(grouped).map(([category, items]) => (
<div
key={category}
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.25rem',
mb: '0.35rem',
})}
>
<div
className={css({
textTransform: 'capitalize',
fontSize: '0.75rem',
opacity: 0.8,
})}
>
{category}
</div>
{items.map((item) => (
<div
key={item.id}
className={css({
display: 'flex',
justifyContent: 'space-between',
gap: '0.5rem',
alignItems: 'center',
fontSize: '0.85rem',
})}
>
<span>{item.label}</span>
<span
className={css({
fontFamily: 'monospace',
backgroundColor: 'rgba(255,255,255,0.12)',
paddingInline: '0.35rem',
paddingBlock: '0.15rem',
borderRadius: '6px',
whiteSpace: 'nowrap',
})}
>
{item.kind === 'longPress'
? formatLongPressLabel(item.code)
: formatShortcutLabel(item.shortcut)}
</span>
</div>
))}
</div>
))}
</div>
</div>
)
}
@@ -12,6 +12,7 @@ import { StartMediaButton } from '../../components/controls/StartMediaButton'
import { MoreOptions } from './MoreOptions'
import { useRef } from 'react'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { openShortcutHelp } from '@/stores/shortcutHelp'
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
@@ -23,6 +24,14 @@ export function DesktopControlBar({
useRegisterKeyboardShortcut({
shortcut: { key: '?' },
handler: () => {
openShortcutHelp()
},
})
// Keep legacy behavior: F2 focuses the first button in the bottom toolbar.
useRegisterKeyboardShortcut({
shortcut: { key: 'F2' },
handler: () => {
const root = desktopControlBarEl.current
if (!root) return
@@ -7,6 +7,7 @@ export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
export type ShortcutId =
| 'open-shortcuts'
| 'focus-toolbar'
| 'toggle-microphone'
| 'toggle-camera'
| 'push-to-talk'
@@ -30,6 +31,12 @@ export const shortcutCatalog: ShortcutDescriptor[] = [
description:
'Currently focuses the bottom toolbar; will open the shortcuts panel next.',
},
{
id: 'focus-toolbar',
label: 'Focus bottom toolbar',
category: 'navigation',
shortcut: { key: 'F2' },
},
{
id: 'toggle-microphone',
label: 'Toggle microphone',
+21
View File
@@ -0,0 +1,21 @@
import { proxy } from 'valtio'
type State = {
isOpen: boolean
}
export const shortcutHelpStore = proxy<State>({
isOpen: false,
})
export const openShortcutHelp = () => {
shortcutHelpStore.isOpen = true
}
export const closeShortcutHelp = () => {
shortcutHelpStore.isOpen = false
}
export const toggleShortcutHelp = () => {
shortcutHelpStore.isOpen = !shortcutHelpStore.isOpen
}