(frontend) add shortcuts settings tab ui

add a localized shortcuts tab with overrides UI and reusable focus trap/utils
This commit is contained in:
Cyril
2026-02-05 10:32:24 +01:00
parent 6b65e1772c
commit 27b5b956c6
7 changed files with 272 additions and 4 deletions
@@ -23,6 +23,7 @@ import { useRef } from 'react'
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import ShortcutTab from './tabs/ShortcutTab'
import AccessibilityTab from './tabs/AccessibilityTab'
const tabsStyle = css({
@@ -107,6 +108,10 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.NOTIFICATIONS}`)}
</Tab>
<Tab icon highlight id={SettingsDialogExtendedKey.SHORTCUTS}>
<span className="material-symbols">keyboard</span>
{isWideScreen && t(`tabs.${SettingsDialogExtendedKey.SHORTCUTS}`)}
</Tab>
{isAdminOrOwner && (
<Tab icon highlight id={SettingsDialogExtendedKey.TRANSCRIPTION}>
<Icon type="symbols" name="speech_to_text" />
@@ -130,6 +135,7 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
<VideoTab id={SettingsDialogExtendedKey.VIDEO} />
<GeneralTab id={SettingsDialogExtendedKey.GENERAL} />
<NotificationsTab id={SettingsDialogExtendedKey.NOTIFICATIONS} />
<ShortcutTab id={SettingsDialogExtendedKey.SHORTCUTS} />
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
@@ -0,0 +1,217 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Shortcut } from '@/features/shortcuts/types'
import { shortcutCatalog } from '@/features/shortcuts/catalog'
import {
formatLongPressLabel,
formatShortcutLabel,
formatShortcutLabelForSR,
getKeyLabelFromCode,
} from '@/features/shortcuts/formatLabels'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { text } from '@/primitives/Text'
import { buttonRecipe } from '@/primitives/buttonRecipe'
import { TabPanel, type TabPanelProps } from '@/primitives/Tabs'
type ShortcutOverrides = Record<string, Shortcut>
const STORAGE_KEY = 'shortcuts:overrides'
const loadOverrides = (): ShortcutOverrides => {
if (typeof window === 'undefined') return {}
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (!raw) return {}
return JSON.parse(raw) as ShortcutOverrides
} catch (e) {
console.warn('Failed to parse shortcut overrides', e)
return {}
}
}
const saveOverrides = (overrides: ShortcutOverrides) => {
if (typeof window === 'undefined') return
localStorage.setItem(STORAGE_KEY, JSON.stringify(overrides))
}
const rowStyle = css({
display: 'grid',
gridTemplateColumns: '1.25fr auto auto',
alignItems: 'center',
gap: '0.75rem',
padding: '0.65rem 0',
borderBottom: '1px solid rgba(255,255,255,0.08)',
})
const badgeStyle = css({
fontFamily: 'monospace',
backgroundColor: 'rgba(255,255,255,0.12)',
paddingInline: '0.4rem',
paddingBlock: '0.2rem',
borderRadius: '6px',
whiteSpace: 'nowrap',
minWidth: '5.5rem',
textAlign: 'center',
})
const buttonLink = buttonRecipe({ variant: 'secondary', size: 'sm' })
const ShortcutTab = ({ id }: Pick<TabPanelProps, 'id'>) => {
const { t } = useTranslation(['settings', 'rooms'])
const tRooms = useCallback(
(key: string, options?: Record<string, unknown>) =>
t(key, { ns: 'rooms', ...options }),
[t]
)
const [overrides, setOverrides] = useState<ShortcutOverrides>({})
const [editingId, setEditingId] = useState<string | null>(null)
useEffect(() => {
setOverrides(loadOverrides())
}, [])
const handleStartEdit = useCallback((shortcutId: string) => {
setEditingId(shortcutId)
}, [])
const handleReset = useCallback(
(shortcutId: string) => {
const next = { ...overrides }
delete next[shortcutId]
setOverrides(next)
saveOverrides(next)
},
[overrides]
)
const handleKeyCapture = useCallback(
(e: React.KeyboardEvent<HTMLButtonElement>, shortcutId: string) => {
e.preventDefault()
const { key, ctrlKey } = e
// Ignore modifier-only keys
if (!key || key === 'Control' || key === 'Meta' || key === 'Shift') return
const normalized: Shortcut = {
key,
ctrlKey,
}
const next = { ...overrides, [shortcutId]: normalized }
setOverrides(next)
saveOverrides(next)
setEditingId(null)
},
[overrides]
)
const rows = useMemo(() => {
return shortcutCatalog.map((item) => {
const override = overrides[item.id]
const effectiveShortcut = override ?? item.shortcut
const visualShortcut =
item.kind === 'longPress'
? formatLongPressLabel(
getKeyLabelFromCode(item.code),
tRooms('shortcutsPanel.visual.hold', { key: '{{key}}' })
)
: formatShortcutLabel(effectiveShortcut)
const srShortcut =
item.kind === 'longPress'
? formatLongPressLabel(
getKeyLabelFromCode(item.code),
tRooms('shortcutsPanel.sr.hold', { key: '{{key}}' })
)
: formatShortcutLabelForSR(effectiveShortcut, {
controlLabel: tRooms('shortcutsPanel.sr.control'),
commandLabel: tRooms('shortcutsPanel.sr.command'),
plusLabel: tRooms('shortcutsPanel.sr.plus'),
noShortcutLabel: tRooms('shortcutsPanel.sr.noShortcut'),
})
return {
item,
override,
visualShortcut,
srShortcut,
}
})
}, [overrides, tRooms])
return (
<TabPanel
id={id}
padding="md"
flex
className={css({
width: '100%',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
})}
>
<div className={text({ variant: 'h2' })}>{t('tabs.shortcuts')}</div>
<div className={text({ variant: 'body' })}>
{t('shortcutsEditor.description')}
</div>
<div
className={css({
display: 'grid',
gap: '0.25rem',
maxHeight: '420px',
overflowY: 'auto',
paddingRight: '0.35rem',
})}
>
{rows.map(({ item, override, visualShortcut, srShortcut }) => (
<div key={item.id} className={rowStyle}>
<div>
<div className={text({ variant: 'body' })}>
{tRooms(`shortcutsPanel.actions.${item.id}`)}
</div>
</div>
<div aria-label={srShortcut} className={badgeStyle}>
{visualShortcut}
{override && (
<span
className={text({ variant: 'smNote' })}
style={{ marginLeft: '0.4rem' }}
>
({t('shortcutsEditor.custom')})
</span>
)}
</div>
<div
className={css({
display: 'flex',
gap: '0.35rem',
justifyContent: 'flex-end',
})}
>
<button
type="button"
className={buttonLink}
onKeyDown={(e) => handleKeyCapture(e, item.id)}
onClick={() => handleStartEdit(item.id)}
aria-pressed={editingId === item.id}
>
{editingId === item.id
? t('shortcutsEditor.capture')
: t('shortcutsEditor.edit')}
</button>
<button
type="button"
className={buttonLink}
onClick={() => handleReset(item.id)}
disabled={!override}
>
{t('shortcutsEditor.reset')}
</button>
</div>
</div>
))}
</div>
<div className={text({ variant: 'smNote' })}>
{t('shortcutsEditor.limitations')}
</div>
</TabPanel>
)
}
export default ShortcutTab
@@ -5,5 +5,6 @@ export enum SettingsDialogExtendedKey {
GENERAL = 'general',
NOTIFICATIONS = 'notifications',
TRANSCRIPTION = 'transcription',
SHORTCUTS = 'shortcuts',
ACCESSIBILITY = 'accessibility',
}
+12 -1
View File
@@ -114,6 +114,17 @@
"video": "Video",
"general": "Allgemein",
"notifications": "Benachrichtigungen",
"transcription": "Transkription"
"transcription": "Transkription",
"shortcuts": "Tastenkürzel"
},
"shortcutsEditor": {
"description": "Tastenkürzel anpassen (lokal auf diesem Gerät).",
"pressHint": "Drücken Sie die gewünschten Tasten, um ein Kürzel zu setzen.",
"longPressHint": "Halten Sie eine Taste gedrückt, um ein Long-Press-Kürzel zu setzen.",
"edit": "Bearbeiten",
"capture": "Tasten drücken …",
"reset": "Zurücksetzen",
"custom": "benutzerdefiniert",
"limitations": "Die Kürzel werden lokal gespeichert und gelten nicht zwingend für alle Aktionen."
}
}
+12 -1
View File
@@ -120,6 +120,17 @@
"general": "General",
"notifications": "Notifications",
"accessibility": "Accessibility",
"transcription": "Transcription"
"transcription": "Transcription",
"shortcuts": "Shortcuts"
},
"shortcutsEditor": {
"description": "Customize keyboard shortcuts (local to this device).",
"pressHint": "Press the desired keys to set a new shortcut.",
"longPressHint": "Hold a key to set a long-press shortcut.",
"edit": "Edit",
"capture": "Press keys…",
"reset": "Reset",
"custom": "custom",
"limitations": "Shortcuts are saved locally and may not apply to all actions yet."
}
}
+12 -1
View File
@@ -120,6 +120,17 @@
"general": "Général",
"notifications": "Notifications",
"accessibility": "Accessibilité",
"transcription": "Transcription"
"transcription": "Transcription",
"shortcuts": "Raccourcis"
},
"shortcutsEditor": {
"description": "Personnaliser les raccourcis clavier (local à cet appareil).",
"pressHint": "Appuyez sur les touches souhaitées pour définir un raccourci.",
"longPressHint": "Maintenez une touche pour définir un raccourci long.",
"edit": "Modifier",
"capture": "Appuyez sur les touches…",
"reset": "Réinitialiser",
"custom": "personnalisé",
"limitations": "Les raccourcis sont enregistrés localement et peuvent ne pas sappliquer à toutes les actions."
}
}
+12 -1
View File
@@ -114,6 +114,17 @@
"video": "Video",
"general": "Algemeen",
"notifications": "Meldingen",
"transcription": "Transcriptie"
"transcription": "Transcriptie",
"shortcuts": "Sneltoetsen"
},
"shortcutsEditor": {
"description": "Sneltoetsen aanpassen (lokaal op dit apparaat).",
"pressHint": "Druk op de gewenste toetsen om een sneltoets in te stellen.",
"longPressHint": "Houd een toets ingedrukt voor een long-press sneltoets.",
"edit": "Bewerken",
"capture": "Toetsen indrukken…",
"reset": "Resetten",
"custom": "aangepast",
"limitations": "Sneltoetsen worden lokaal opgeslagen en gelden mogelijk niet voor alle acties."
}
}