From aa01733f6b913610d35852760be62850dbe2fb5b Mon Sep 17 00:00:00 2001 From: Ovgodd Date: Mon, 5 Oct 2026 14:06:02 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BF=EF=B8=8F(frontend)=20make=20participa?= =?UTF-8?q?nt=20pagination=20readable=20and=20keyboard=20reachable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improves PaginationControl: clearer structure, keyboard nav, a11y improved. The main room and the picture-in-picture window share this control. Ctrl+Shift+G focuses the pagination. --- CHANGELOG.md | 4 +- .../features/layout/components/GridLayout.tsx | 9 +- .../layout/components/PaginationControl.tsx | 170 +++++++++++------- .../layout/components/PaginationIndicator.tsx | 30 ---- .../pip/components/layout/PipPagination.tsx | 96 ---------- .../pip/components/layout/PipStage.tsx | 9 +- .../src/features/shortcuts/catalog.ts | 6 + .../shortcuts/useRegisterKeyboardShortcut.ts | 16 +- src/frontend/src/locales/de/rooms.json | 1 + src/frontend/src/locales/en/rooms.json | 1 + src/frontend/src/locales/es/rooms.json | 1 + src/frontend/src/locales/fr/rooms.json | 1 + src/frontend/src/locales/nl/rooms.json | 1 + 13 files changed, 139 insertions(+), 206 deletions(-) delete mode 100644 src/frontend/src/features/layout/components/PaginationIndicator.tsx delete mode 100644 src/frontend/src/features/pip/components/layout/PipPagination.tsx 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 ( - - ) -}) 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)",