️(frontend) announce slide position in presenter mode

Announce slide position via react-aria on change; drop bar live region.
This commit is contained in:
Cyril
2026-06-10 14:26:43 +02:00
parent ad33e9e0eb
commit a8705195fe
3 changed files with 55 additions and 13 deletions
@@ -97,18 +97,6 @@ export const PresenterFloatingBar = ({
<Text as="span" $size="sm" $color="neutral" aria-hidden="true">
{index + 1} / {total}
</Text>
<Text
as="span"
className="sr-only"
role="status"
aria-live="polite"
aria-atomic="true"
>
{t('Slide {{current}} of {{total}}', {
current: index + 1,
total,
})}
</Text>
<Button
size="small"
color="neutral"
@@ -1,3 +1,4 @@
import { announce } from '@react-aria/live-announcer';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
@@ -10,7 +11,7 @@ import { Doc } from '@/docs/doc-management';
import { PRESENTER_WINDOW_RADIUS } from '../constants';
import { useBrowserFullscreen } from '../hooks/useBrowserFullscreen';
import { usePresenterShortcuts } from '../hooks/usePresenterShortcuts';
import { useSlides } from '../hooks/useSlides';
import { getSlideTitle, useSlides } from '../hooks/useSlides';
import { PresenterFloatingBar } from './PresenterFloatingBar';
import { PresenterSlide } from './PresenterSlide';
@@ -105,6 +106,21 @@ export const PresenterOverlay = ({
const frameRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const title = getSlideTitle(slides[currentIndex] ?? []);
const message = title
? t('Slide {{current}} of {{total}}: {{title}}', {
current: currentIndex + 1,
total,
title,
})
: t('Slide {{current}} of {{total}}', {
current: currentIndex + 1,
total,
});
announce(message, 'polite');
}, [slides, currentIndex, total, t]);
if (typeof document === 'undefined') {
return null;
}
@@ -40,3 +40,41 @@ export const splitBlocksIntoSlides = <T extends Block>(blocks: T[]): T[][] => {
export const useSlides = <T extends Block>(blocks: T[]): T[][] => {
return useMemo(() => splitBlocksIntoSlides(blocks), [blocks]);
};
const extractInlineText = (content: unknown): string => {
if (typeof content === 'string') {
return content;
}
if (!Array.isArray(content)) {
return '';
}
return content
.map((node) => {
if (typeof node === 'string') {
return node;
}
if (node && typeof node === 'object') {
const inline = node as { text?: unknown; content?: unknown };
if (typeof inline.text === 'string') {
return inline.text;
}
if (inline.content !== undefined) {
return extractInlineText(inline.content);
}
}
return '';
})
.join('');
};
/** First heading text, or first block with text — for SR announcements. */
export const getSlideTitle = (
blocks: { type: string; content?: unknown }[],
): string => {
const heading = blocks.find((block) => block.type === 'heading');
const source =
heading ??
blocks.find((block) => extractInlineText(block.content).trim().length > 0);
return source ? extractInlineText(source.content).trim() : '';
};