✨(frontend) add shortcuts settings tab ui
add a localized shortcuts tab with overrides UI and reusable focus trap/utils
This commit is contained in:
@@ -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',
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 s’appliquer à toutes les actions."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user