diff --git a/CHANGELOG.md b/CHANGELOG.md
index 14fb733b..b6767b11 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,7 @@ and this project adheres to
- ✨(frontend) let signed-out visitors start a meeting
- ✨(backend) expose `allow_unregistered_rooms` in the frontend configuration
- ✅(frontend) add vitest so the frontend can carry unit tests
+- ♿️(frontend) make participant pagination readable and keyboard reachable #1775
### Changed
@@ -94,7 +95,7 @@ and this project adheres to
### Fixed
-- 🐛(helm) probe liveness on __lbheartbeat__ and readiness on __heartbeat__
+- 🐛(helm) probe liveness on **lbheartbeat** and readiness on **heartbeat**
- 🐛(helm) render periodSeconds and failureThreshold on probes
- 🐛(backend) report the app release to Sentry instead of "NA"
- 🐛(frontend) play the waiting room notification sound on every arrival
@@ -166,6 +167,7 @@ and this project adheres to
### Added
- ✨(any) let any authenticated user manage the lobby on trusted rooms
+
### Changed
- 📱(frontend) collapse mobile control bar items on narrow viewports
diff --git a/src/frontend/src/features/layout/components/GridLayout.tsx b/src/frontend/src/features/layout/components/GridLayout.tsx
index fe51f7a3..60e463d6 100644
--- a/src/frontend/src/features/layout/components/GridLayout.tsx
+++ b/src/frontend/src/features/layout/components/GridLayout.tsx
@@ -7,7 +7,6 @@ import {
useSwipe,
} from '@livekit/components-react'
import { mergeProps } from '@/utils/mergeProps'
-import { PaginationIndicator } from './PaginationIndicator'
import { useGridLayout } from '../hooks/useGridLayout'
import { PaginationControl } from './PaginationControl'
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
@@ -62,13 +61,7 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
>
{props.children}
{tracks.length > layout.maxTiles && (
- <>
-
-
- >
+
)}
)
diff --git a/src/frontend/src/features/layout/components/PaginationControl.tsx b/src/frontend/src/features/layout/components/PaginationControl.tsx
index ed64af42..bda2ae32 100644
--- a/src/frontend/src/features/layout/components/PaginationControl.tsx
+++ b/src/frontend/src/features/layout/components/PaginationControl.tsx
@@ -1,99 +1,147 @@
-import * as React from 'react'
-import { createInteractingObservable } from '@livekit/components-core'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { Button } from '@/primitives'
-import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
-import { css } from '@/styled-system/css'
+import { css, cva, type RecipeVariantProps } from '@/styled-system/css'
+import { useCallback, useRef } from 'react'
+import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
-export interface PaginationControlProps {
+const paginationToolbar = cva({
+ base: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.125rem',
+ backgroundColor: 'primaryDark.50',
+ borderRadius: '2rem',
+ border: '1px solid',
+ borderColor: 'primaryDark.200',
+ padding: '0.375rem',
+ },
+ variants: {
+ placement: {
+ overlay: {
+ position: 'absolute',
+ bottom: '1rem',
+ left: '50%',
+ zIndex: 2,
+ transform: 'translateX(-50%)',
+ boxShadow: '0 2px 10px rgba(0, 0, 0, 0.45)',
+ },
+ inline: {
+ alignSelf: 'center',
+ flexShrink: 0,
+ marginTop: '1rem',
+ },
+ },
+ },
+ defaultVariants: {
+ placement: 'overlay',
+ },
+})
+
+export type PaginationControlProps = RecipeVariantProps<
+ typeof paginationToolbar
+> & {
totalPageCount: number
nextPage: () => void
prevPage: () => void
currentPage: number
- pagesContainer?: React.RefObject
+ // The shortcut listens on the main window only, so a single instance may own it.
+ focusShortcut?: boolean
}
+const arrowButtonClass = css({
+ _disabled: {
+ cursor: 'default',
+ backgroundColor: 'transparent !important',
+ '& svg': {
+ opacity: 0.35,
+ },
+ _focusVisible: {
+ outline: '2px solid',
+ outlineColor: 'focusRing',
+ outlineOffset: '2px',
+ },
+ },
+})
+
export function PaginationControl({
totalPageCount,
nextPage,
prevPage,
currentPage,
- pagesContainer: connectedElement,
+ placement,
+ focusShortcut = false,
}: PaginationControlProps) {
const { t } = useTranslation('rooms', { keyPrefix: 'pagination' })
- const [interactive, setInteractive] = useState(false)
+ const prevButtonRef = useRef(null)
+ const nextButtonRef = useRef(null)
+ const isSinglePage = totalPageCount <= 1
+ const isFirstPage = currentPage <= 1
+ const isLastPage = currentPage >= totalPageCount
- useEffect(() => {
- let subscription:
- | ReturnType['subscribe']>
- | undefined
- if (connectedElement) {
- subscription = createInteractingObservable(
- connectedElement.current,
- 2000
- ).subscribe(setInteractive)
- }
- return () => {
- if (subscription) {
- subscription.unsubscribe()
- }
- }
- }, [connectedElement])
+ const focusPagination = useCallback(() => {
+ const target = isLastPage ? prevButtonRef.current : nextButtonRef.current
+ target?.focus()
+ }, [isLastPage])
- if (totalPageCount <= 1) return null
+ useRegisterKeyboardShortcut({
+ id: focusShortcut ? 'focus-pagination' : undefined,
+ handler: focusPagination,
+ isDisabled: isSinglePage,
+ unregisterOnUnmount: true,
+ })
+
+ if (isSinglePage) return null
+
+ const pageCount = t('count', { currentPage, totalPageCount })
return (
-
+
)
}
diff --git a/src/frontend/src/features/layout/components/PaginationIndicator.tsx b/src/frontend/src/features/layout/components/PaginationIndicator.tsx
deleted file mode 100644
index 7e7640d4..00000000
--- a/src/frontend/src/features/layout/components/PaginationIndicator.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import * as React from 'react'
-
-export interface PaginationIndicatorProps {
- totalPageCount: number
- currentPage: number
-}
-
-export const PaginationIndicator: (
- props: PaginationIndicatorProps & React.RefAttributes
-) => React.ReactNode = /* @__PURE__ */ React.forwardRef<
- HTMLDivElement,
- PaginationIndicatorProps
->(function PaginationIndicator(
- { totalPageCount, currentPage }: PaginationIndicatorProps,
- ref
-) {
- const bubbles = new Array(totalPageCount).fill('').map((_, index) => {
- if (index + 1 === currentPage) {
- return
- } else {
- return
- }
- })
-
- return (
-
- {bubbles}
-
- )
-})
diff --git a/src/frontend/src/features/pip/components/layout/PipPagination.tsx b/src/frontend/src/features/pip/components/layout/PipPagination.tsx
deleted file mode 100644
index 228d332a..00000000
--- a/src/frontend/src/features/pip/components/layout/PipPagination.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
-import { useTranslation } from 'react-i18next'
-import { styled } from '@/styled-system/jsx'
-
-interface PipPaginationProps {
- totalPageCount: number
- currentPage: number
- nextPage: () => void
- prevPage: () => void
-}
-
-export const PipPagination = ({
- totalPageCount,
- currentPage,
- nextPage,
- prevPage,
-}: PipPaginationProps) => {
- const { t } = useTranslation('rooms', { keyPrefix: 'pagination' })
-
- if (totalPageCount <= 1) return null
-
- return (
-
- )
-}
-
-const Nav = styled('nav', {
- base: {
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- gap: '0.25rem',
- marginTop: '1rem',
- flexShrink: 0,
- },
-})
-
-const ArrowButton = styled('button', {
- base: {
- display: 'inline-flex',
- alignItems: 'center',
- justifyContent: 'center',
- width: '1.75rem',
- height: '1.75rem',
- borderRadius: '4px',
- border: 'none',
- cursor: 'pointer',
- color: 'white',
- backgroundColor: 'primaryDark.100',
- transition: 'opacity 0.15s, background-color 0.15s',
- '&:hover:not(:disabled)': {
- backgroundColor: 'primaryDark.75',
- },
- '&:focus-visible': {
- outline: '2px solid',
- outlineColor: 'white',
- outlineOffset: '2px',
- },
- '&:disabled': {
- opacity: 0.3,
- cursor: 'default',
- },
- },
-})
-
-const Counter = styled('span', {
- base: {
- fontSize: '0.75rem',
- color: 'white',
- opacity: 0.8,
- whiteSpace: 'nowrap',
- padding: '0 0.25rem',
- minWidth: '3rem',
- textAlign: 'center',
- },
-})
diff --git a/src/frontend/src/features/pip/components/layout/PipStage.tsx b/src/frontend/src/features/pip/components/layout/PipStage.tsx
index 1102c453..35c6cc96 100644
--- a/src/frontend/src/features/pip/components/layout/PipStage.tsx
+++ b/src/frontend/src/features/pip/components/layout/PipStage.tsx
@@ -4,7 +4,7 @@ import { RoomEvent, Track } from 'livekit-client'
import { styled } from '@/styled-system/jsx'
import { PipFocusLayout } from './PipFocusLayout'
import { PipGridLayout } from './PipGridLayout'
-import { PipPagination } from './PipPagination'
+import { PaginationControl } from '@/features/layout/components/PaginationControl'
import { PipScreenShareLayout } from './PipScreenShareLayout'
import { StageFrame } from './StageFrame'
import { MAX_PIP_TILES } from '../../utils/pipGrid'
@@ -120,12 +120,7 @@ const PaginatedStage = ({
}) => (
{children}
-
+
)
diff --git a/src/frontend/src/features/shortcuts/catalog.ts b/src/frontend/src/features/shortcuts/catalog.ts
index ce7ce40e..462f9a1d 100644
--- a/src/frontend/src/features/shortcuts/catalog.ts
+++ b/src/frontend/src/features/shortcuts/catalog.ts
@@ -7,6 +7,7 @@ export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
export type ShortcutId =
| 'open-shortcuts'
| 'focus-toolbar'
+ | 'focus-pagination'
| 'toggle-microphone'
| 'toggle-camera'
| 'push-to-talk'
@@ -44,6 +45,11 @@ export const shortcutCatalog: ShortcutDescriptor[] = [
category: 'navigation',
shortcut: { key: 'F2' },
},
+ {
+ id: 'focus-pagination',
+ category: 'navigation',
+ shortcut: { key: 'G', ctrlKey: true, shiftKey: true },
+ },
{
id: 'toggle-microphone',
category: 'media',
diff --git a/src/frontend/src/features/shortcuts/useRegisterKeyboardShortcut.ts b/src/frontend/src/features/shortcuts/useRegisterKeyboardShortcut.ts
index 9ec148a4..5d0f8553 100644
--- a/src/frontend/src/features/shortcuts/useRegisterKeyboardShortcut.ts
+++ b/src/frontend/src/features/shortcuts/useRegisterKeyboardShortcut.ts
@@ -7,12 +7,16 @@ export type useRegisterKeyboardShortcutProps = {
id?: ShortcutId
handler: () => Promise | void
isDisabled?: boolean
+ // Opt-in: controls rendered inside menus unmount when the menu closes, and
+ // their shortcuts must keep working.
+ unregisterOnUnmount?: boolean
}
export const useRegisterKeyboardShortcut = ({
id,
handler,
isDisabled = false,
+ unregisterOnUnmount = false,
}: useRegisterKeyboardShortcutProps) => {
useEffect(() => {
if (!id) return
@@ -21,8 +25,14 @@ export const useRegisterKeyboardShortcut = ({
const formattedKey = formatShortcutKey(descriptor.shortcut)
if (isDisabled) {
keyboardShortcutsStore.shortcuts.delete(formattedKey)
- } else {
- keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
+ return
}
- }, [handler, id, isDisabled])
+ keyboardShortcutsStore.shortcuts.set(formattedKey, handler)
+ if (!unregisterOnUnmount) return
+ return () => {
+ if (keyboardShortcutsStore.shortcuts.get(formattedKey) === handler) {
+ keyboardShortcutsStore.shortcuts.delete(formattedKey)
+ }
+ }
+ }, [handler, id, isDisabled, unregisterOnUnmount])
}
diff --git a/src/frontend/src/locales/de/rooms.json b/src/frontend/src/locales/de/rooms.json
index 9c79907f..f9f714fa 100644
--- a/src/frontend/src/locales/de/rooms.json
+++ b/src/frontend/src/locales/de/rooms.json
@@ -784,6 +784,7 @@
"actions": {
"open-shortcuts": "Tastenkürzel-Hilfe öffnen",
"focus-toolbar": "Fokus auf die untere Symbolleiste",
+ "focus-pagination": "Fokus auf die Teilnehmerseiten",
"toggle-microphone": "Mikrofon umschalten",
"toggle-camera": "Kamera umschalten",
"push-to-talk": "Push-to-talk",
diff --git a/src/frontend/src/locales/en/rooms.json b/src/frontend/src/locales/en/rooms.json
index 09c3a6b8..35f5d423 100644
--- a/src/frontend/src/locales/en/rooms.json
+++ b/src/frontend/src/locales/en/rooms.json
@@ -784,6 +784,7 @@
"actions": {
"open-shortcuts": "Open shortcuts help",
"focus-toolbar": "Focus bottom toolbar",
+ "focus-pagination": "Focus participant pagination",
"toggle-microphone": "Toggle microphone",
"toggle-camera": "Toggle camera",
"push-to-talk": "Push-to-talk (hold to unmute)",
diff --git a/src/frontend/src/locales/es/rooms.json b/src/frontend/src/locales/es/rooms.json
index 631fb104..caa239c8 100644
--- a/src/frontend/src/locales/es/rooms.json
+++ b/src/frontend/src/locales/es/rooms.json
@@ -783,6 +783,7 @@
"actions": {
"open-shortcuts": "Abrir la ayuda de atajos",
"focus-toolbar": "Poner el foco en la barra de herramientas inferior",
+ "focus-pagination": "Poner el foco en la paginación de participantes",
"toggle-microphone": "Activar o desactivar el micrófono",
"toggle-camera": "Activar o desactivar la cámara",
"push-to-talk": "Pulsar para hablar (mantener para reactivar)",
diff --git a/src/frontend/src/locales/fr/rooms.json b/src/frontend/src/locales/fr/rooms.json
index 24e16156..bbfb5e06 100644
--- a/src/frontend/src/locales/fr/rooms.json
+++ b/src/frontend/src/locales/fr/rooms.json
@@ -784,6 +784,7 @@
"actions": {
"open-shortcuts": "Ouvrir l’aide des raccourcis",
"focus-toolbar": "Mettre le focus sur la barre d’outils du bas",
+ "focus-pagination": "Mettre le focus sur la pagination des participants",
"toggle-microphone": "Activer ou désactiver le micro",
"toggle-camera": "Activer ou désactiver la caméra",
"push-to-talk": "Appuyer pour parler (maintenir pour réactiver)",
diff --git a/src/frontend/src/locales/nl/rooms.json b/src/frontend/src/locales/nl/rooms.json
index 86b25763..07db7bfc 100644
--- a/src/frontend/src/locales/nl/rooms.json
+++ b/src/frontend/src/locales/nl/rooms.json
@@ -784,6 +784,7 @@
"actions": {
"open-shortcuts": "Sneltoetsenhulp openen",
"focus-toolbar": "Focus op de onderste werkbalk",
+ "focus-pagination": "Focus op de paginering van deelnemers",
"toggle-microphone": "Microfoon aan/uit",
"toggle-camera": "Camera aan/uit",
"push-to-talk": "Push-to-talk (ingedrukt houden om te activeren)",