Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 309ce0989d | |||
| a6c154374f | |||
| b0e27b38e2 | |||
| 9bdc68f9c9 | |||
| 4545e9fa1e | |||
| 3f1edbf134 | |||
| 4f2764eef4 | |||
| b11cc6e9da | |||
| 0a7eb97c90 | |||
| db188075af |
+6
-2
@@ -1,4 +1,3 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
@@ -9,4 +8,9 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
-
|
||||
### Changed
|
||||
|
||||
- ♿(frontend) improve accessibility:
|
||||
- ♿️(frontend) hover controls, focus, SR #803
|
||||
- ♿️(frontend) change ptt keybinding from space to v #813
|
||||
- ♿(frontend) indicate external link opens in new window on feedback #816
|
||||
|
||||
@@ -159,6 +159,8 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
configuration=configuration,
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
)
|
||||
else:
|
||||
del output["pin_code"]
|
||||
|
||||
output["is_administrable"] = is_admin_or_owner
|
||||
|
||||
|
||||
@@ -32,7 +32,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -52,7 +51,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -71,7 +69,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -88,7 +85,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -105,7 +101,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -347,7 +342,6 @@ def test_api_rooms_retrieve_authenticated():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ export const Avatar = ({
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={css({
|
||||
marginTop: '-0.3rem',
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import React, { ReactNode } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
export interface KeyboardShortcutHintProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Small reusable bubble used to display and announce keyboard shortcuts,
|
||||
* typically when an element receives keyboard focus.
|
||||
*/
|
||||
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '0.75rem',
|
||||
right: '0.75rem',
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
color: 'white',
|
||||
borderRadius: 'calc(var(--lk-border-radius) / 2)',
|
||||
paddingInline: '0.5rem',
|
||||
paddingBlock: '0.1rem',
|
||||
fontSize: '0.875rem',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export const ParticipantName = ({
|
||||
style={{
|
||||
paddingBottom: '0.1rem',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{displayedName}
|
||||
</Text>
|
||||
|
||||
@@ -29,6 +29,9 @@ import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
|
||||
|
||||
export function TrackRefContextIfNeeded(
|
||||
props: React.PropsWithChildren<{
|
||||
@@ -102,9 +105,31 @@ export const ParticipantTile: (
|
||||
})
|
||||
|
||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
||||
|
||||
const participantName = getParticipantName(trackReference.participant)
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
|
||||
const interactiveProps = {
|
||||
...elementProps,
|
||||
// Ensure the tile is focusable to expose contextual controls to keyboard users.
|
||||
tabIndex: 0,
|
||||
'aria-label': t('containerLabel', { name: participantName }),
|
||||
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
||||
elementProps.onFocus?.(event)
|
||||
setHasKeyboardFocus(true)
|
||||
},
|
||||
onBlur: (event: React.FocusEvent<HTMLDivElement>) => {
|
||||
elementProps.onBlur?.(event)
|
||||
const nextTarget = event.relatedTarget as Node | null
|
||||
if (!event.currentTarget.contains(nextTarget)) {
|
||||
setHasKeyboardFocus(false)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative' }} {...elementProps}>
|
||||
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
|
||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||
<FullScreenShareWarning trackReference={trackReference} />
|
||||
@@ -195,10 +220,16 @@ export const ParticipantTile: (
|
||||
</>
|
||||
)}
|
||||
{!disableMetadata && (
|
||||
<ParticipantTileFocus trackRef={trackReference} />
|
||||
<ParticipantTileFocus
|
||||
trackRef={trackReference}
|
||||
hasKeyboardFocus={hasKeyboardFocus}
|
||||
/>
|
||||
)}
|
||||
</ParticipantContextIfNeeded>
|
||||
</TrackRefContextIfNeeded>
|
||||
{hasKeyboardFocus && (
|
||||
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -131,8 +131,10 @@ const MOUSE_IDLE_TIME = 3000
|
||||
|
||||
export const ParticipantTileFocus = ({
|
||||
trackRef,
|
||||
hasKeyboardFocus,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
hasKeyboardFocus: boolean
|
||||
}) => {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [opacity, setOpacity] = useState(0)
|
||||
@@ -140,8 +142,10 @@ export const ParticipantTileFocus = ({
|
||||
const idleTimerRef = useRef<number | null>(null)
|
||||
const [isIdleRef, setIsIdleRef] = useState(false)
|
||||
|
||||
const isVisible = hasKeyboardFocus || (hovered && !isIdleRef)
|
||||
|
||||
useEffect(() => {
|
||||
if (hovered && !isIdleRef) {
|
||||
if (isVisible) {
|
||||
// Wait for next frame to ensure element is mounted
|
||||
requestAnimationFrame(() => {
|
||||
setOpacity(0.6)
|
||||
@@ -149,7 +153,7 @@ export const ParticipantTileFocus = ({
|
||||
} else {
|
||||
setOpacity(0)
|
||||
}
|
||||
}, [hovered, isIdleRef])
|
||||
}, [isVisible])
|
||||
|
||||
const handleMouseMove = () => {
|
||||
if (idleTimerRef.current) {
|
||||
@@ -180,11 +184,12 @@ export const ParticipantTileFocus = ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
})}
|
||||
aria-hidden={!isVisible}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
{hovered && (
|
||||
{isVisible && (
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'primaryDark.50',
|
||||
|
||||
@@ -93,7 +93,7 @@ export const ToggleDevice = <T extends ToggleSource>({
|
||||
isDisabled: cannotUseDevice,
|
||||
})
|
||||
useLongPress({
|
||||
keyCode: kind === 'audioinput' ? 'Space' : undefined,
|
||||
keyCode: kind === 'audioinput' ? 'KeyV' : undefined,
|
||||
onKeyDown,
|
||||
onKeyUp,
|
||||
isDisabled: cannotUseDevice,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { OptionsButton } from '../../components/controls/Options/OptionsButton'
|
||||
import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
||||
import { MoreOptions } from './MoreOptions'
|
||||
import { useRef } from 'react'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
||||
|
||||
@@ -19,6 +20,18 @@ export function DesktopControlBar({
|
||||
}: Readonly<ControlBarAuxProps>) {
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
shortcut: { key: 'F2' },
|
||||
handler: () => {
|
||||
const root = desktopControlBarEl.current
|
||||
if (!root) return
|
||||
const firstButton = root.querySelector<HTMLButtonElement>(
|
||||
'button, [role="button"], [tabindex="0"]'
|
||||
)
|
||||
firstButton?.focus()
|
||||
},
|
||||
})
|
||||
return (
|
||||
<div
|
||||
ref={desktopControlBarEl}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"feedback": {
|
||||
"context": "Produkt in Entwicklung — Ihr Feedback ist wichtig!",
|
||||
"cta": "Teilen Sie uns Ihre Meinung mit"
|
||||
"cta": "Teilen Sie uns Ihre Meinung mit - neues Fenster"
|
||||
},
|
||||
"forbidden": {
|
||||
"heading": "Zugriff verweigert"
|
||||
|
||||
@@ -512,6 +512,8 @@
|
||||
}
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Optionen für {{name}}",
|
||||
"toolbarHint": "F2: zur Symbolleiste unten.",
|
||||
"pin": {
|
||||
"enable": "Anheften",
|
||||
"disable": "Lösen"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"feedback": {
|
||||
"context": "Product under development — your input matters!",
|
||||
"cta": "Share your feedback"
|
||||
"cta": "Share your feedback - new tab"
|
||||
},
|
||||
"forbidden": {
|
||||
"heading": "You don't have the permission to view this page"
|
||||
|
||||
@@ -512,6 +512,8 @@
|
||||
}
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Options for {{name}}",
|
||||
"toolbarHint": "F2: go to the bottom toolbar.",
|
||||
"pin": {
|
||||
"enable": "Pin",
|
||||
"disable": "Unpin"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"feedback": {
|
||||
"context": "Produit en cours de développement — votre avis compte !",
|
||||
"cta": "Partagez votre avis"
|
||||
"cta": "Partagez votre avis - nouvelle fenêtre"
|
||||
},
|
||||
"forbidden": {
|
||||
"heading": "Accès interdit"
|
||||
|
||||
@@ -512,6 +512,8 @@
|
||||
}
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Options pour {{name}}",
|
||||
"toolbarHint": "F2 : raccourci barre d'outils en bas.",
|
||||
"pin": {
|
||||
"enable": "Épingler",
|
||||
"disable": "Annuler l'épinglage"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
},
|
||||
"feedback": {
|
||||
"context": "Product in ontwikkeling - uw input is belangrijk!",
|
||||
"cta": "Deel uw feedback"
|
||||
"cta": "Deel uw feedback - nieuw tabblad"
|
||||
},
|
||||
"forbidden": {
|
||||
"heading": "U hebt geen toestemming om deze pagina te bekijken"
|
||||
|
||||
@@ -512,6 +512,8 @@
|
||||
}
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Opties voor {{name}}",
|
||||
"toolbarHint": "F2: naar de werkbalk onderaan.",
|
||||
"pin": {
|
||||
"enable": "Pinnen",
|
||||
"disable": "Losmaken"
|
||||
|
||||
Reference in New Issue
Block a user