From 2a9e7a1932220d96b5a2143d4be72a07293b3e4b Mon Sep 17 00:00:00 2001 From: Bryan Date: Sun, 1 Nov 2026 19:12:45 -0600 Subject: [PATCH] feat(a11y): screen reader support for page turning and headings An invisible adjustable control exposes the current page and lets VoiceOver and TalkBack turn pages with swipe up/down (increment and decrement actions). The stacked previous/next layers used for the flip animation are hidden from the accessibility tree so only the visible pages are read, and headings get the header role. The spoken strings are overridable with the accessibilityLabels prop. Co-Authored-By: Claude Sonnet 5.5 --- src/flip/DocumentViewer.tsx | 22 +++++++++++ src/flip/PageControl.tsx | 65 +++++++++++++++++++++++++++++++++ src/flip/PageFlipper.tsx | 4 ++ src/flip/SpreadFlipper.tsx | 2 + src/flip/__tests__/a11y.test.ts | 39 ++++++++++++++++++++ src/flip/a11y.ts | 41 +++++++++++++++++++++ src/flip/index.ts | 2 + src/render/BlockList.tsx | 2 + 8 files changed, 177 insertions(+) create mode 100644 src/flip/PageControl.tsx create mode 100644 src/flip/__tests__/a11y.test.ts create mode 100644 src/flip/a11y.ts diff --git a/src/flip/DocumentViewer.tsx b/src/flip/DocumentViewer.tsx index 90a06b6..ba2f3e2 100644 --- a/src/flip/DocumentViewer.tsx +++ b/src/flip/DocumentViewer.tsx @@ -19,6 +19,9 @@ import type { ActiveRange, ColorTokens, DocColorScheme, DocTheme, Highlight } fr import { useDocTheme } from '../render'; import type { DocumentSource, ReadUri } from '../source'; import { useDocumentSource } from '../source'; +import type { ViewerAccessibilityLabels } from './a11y'; +import { mergeAccessibilityLabels } from './a11y'; +import PageControl from './PageControl'; import PageFlipper from './PageFlipper'; import SpreadFlipper from './SpreadFlipper'; @@ -42,6 +45,7 @@ export interface DocumentViewerProps { dualPage?: boolean; dualPageMinWidth?: number; spreadGap?: number; + accessibilityLabels?: Partial; onLoadComplete?: (document: MDDocument) => void; onLoadError?: (error: Error) => void; renderLoading?: () => React.ReactNode; @@ -61,6 +65,7 @@ interface ViewerCoreProps { dualPage?: boolean; dualPageMinWidth?: number; spreadGap?: number; + accessibilityLabels?: Partial; } function ViewerCore({ @@ -76,6 +81,7 @@ function ViewerCore({ dualPage = true, dualPageMinWidth = DUAL_PAGE_MIN_WIDTH, spreadGap = 0, + accessibilityLabels, }: ViewerCoreProps): React.JSX.Element { const [size, setSize] = useState({ width: 0, height: 0 }); const [pageIndex, setPageIndex] = useState(0); @@ -159,6 +165,10 @@ function ViewerCore({ }, []); const hasSize = size.width > 0 && size.height > 0; + const labels = useMemo( + () => mergeAccessibilityLabels(accessibilityLabels), + [accessibilityLabels], + ); return ( ) : null} + {hasSize && pagination.isMeasured ? ( + + ) : null} {hasSize && pagination.isMeasured && isSpread ? ( ); } @@ -290,6 +311,7 @@ function DocumentViewer(props: DocumentViewerProps): React.JSX.Element { dualPage={props.dualPage} dualPageMinWidth={props.dualPageMinWidth} spreadGap={props.spreadGap} + accessibilityLabels={props.accessibilityLabels} /> ); } diff --git a/src/flip/PageControl.tsx b/src/flip/PageControl.tsx new file mode 100644 index 0000000..1bfdb4b --- /dev/null +++ b/src/flip/PageControl.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { type AccessibilityActionEvent, StyleSheet, View } from 'react-native'; + +import { PAGE_PADDING } from '../pagination'; +import type { ViewerAccessibilityLabels } from './a11y'; +import { stepPageIndex, visiblePageRange } from './a11y'; + +export interface PageControlProps { + pageIndex: number; + pageCount: number; + pagesPerView: number; + labels: ViewerAccessibilityLabels; + onFlip: (nextIndex: number) => void; +} + +const CONTROL_HEIGHT = 44; + +function PageControl({ + pageIndex, + pageCount, + pagesPerView, + labels, + onFlip, +}: PageControlProps): React.JSX.Element { + const { first, last } = visiblePageRange(pageIndex, pagesPerView, pageCount); + const hasPrev = pageIndex > 0; + const hasNext = pageIndex + pagesPerView < pageCount; + + const handleAction = (event: AccessibilityActionEvent): void => { + const direction = event.nativeEvent.actionName === 'increment' ? 1 : -1; + const next = stepPageIndex(pageIndex, direction, pagesPerView, pageCount); + if (next !== pageIndex) { + onFlip(next); + } + }; + + return ( + + ); +} + +const styles = StyleSheet.create({ + control: { + position: 'absolute', + top: 0, + left: PAGE_PADDING, + right: PAGE_PADDING, + height: CONTROL_HEIGHT, + }, +}); + +export default PageControl; diff --git a/src/flip/PageFlipper.tsx b/src/flip/PageFlipper.tsx index fb5fd94..f63e53c 100644 --- a/src/flip/PageFlipper.tsx +++ b/src/flip/PageFlipper.tsx @@ -94,6 +94,8 @@ function PageFlipper({ { + assert.deepEqual(visiblePageRange(2, 1, 12), { first: 3, last: 3 }); +}); + +test('visiblePageRange reports both pages of a spread', () => { + assert.deepEqual(visiblePageRange(2, 2, 12), { first: 3, last: 4 }); +}); + +test('visiblePageRange clamps the last page of an odd-length spread', () => { + assert.deepEqual(visiblePageRange(10, 2, 11), { first: 11, last: 11 }); +}); + +test('stepPageIndex moves by the pages shown and stays inside the document', () => { + assert.equal(stepPageIndex(2, 1, 2, 12), 4); + assert.equal(stepPageIndex(2, -1, 2, 12), 0); + assert.equal(stepPageIndex(0, -1, 1, 12), 0); + assert.equal(stepPageIndex(11, 1, 1, 12), 11); +}); + +test('default labels read the page position', () => { + assert.equal(DEFAULT_ACCESSIBILITY_LABELS.pages(3, 3, 12), 'Page 3 of 12'); + assert.equal(DEFAULT_ACCESSIBILITY_LABELS.pages(3, 4, 12), 'Pages 3 and 4 of 12'); +}); + +test('mergeAccessibilityLabels overrides only the given labels', () => { + const labels = mergeAccessibilityLabels({ nextPage: 'Página siguiente' }); + assert.equal(labels.nextPage, 'Página siguiente'); + assert.equal(labels.previousPage, 'Previous page'); +}); diff --git a/src/flip/a11y.ts b/src/flip/a11y.ts new file mode 100644 index 0000000..a47ef14 --- /dev/null +++ b/src/flip/a11y.ts @@ -0,0 +1,41 @@ +export interface ViewerAccessibilityLabels { + /** Label of the invisible page control that screen readers swipe to turn pages. */ + control: string; + /** Spoken position, e.g. "Page 3 of 12" or "Pages 3 and 4 of 12". */ + pages: (first: number, last: number, total: number) => string; + nextPage: string; + previousPage: string; +} + +export const DEFAULT_ACCESSIBILITY_LABELS: ViewerAccessibilityLabels = { + control: 'Document pages', + pages: (first, last, total) => + first === last ? `Page ${first} of ${total}` : `Pages ${first} and ${last} of ${total}`, + nextPage: 'Next page', + previousPage: 'Previous page', +}; + +export function mergeAccessibilityLabels( + overrides?: Partial, +): ViewerAccessibilityLabels { + return { ...DEFAULT_ACCESSIBILITY_LABELS, ...overrides }; +} + +export function visiblePageRange( + pageIndex: number, + pagesPerView: number, + pageCount: number, +): { first: number; last: number } { + const first = Math.min(pageIndex, Math.max(0, pageCount - 1)) + 1; + return { first, last: Math.min(first + pagesPerView - 1, Math.max(1, pageCount)) }; +} + +export function stepPageIndex( + pageIndex: number, + direction: 1 | -1, + pagesPerView: number, + pageCount: number, +): number { + const next = pageIndex + direction * pagesPerView; + return Math.min(Math.max(0, next), Math.max(0, pageCount - 1)); +} diff --git a/src/flip/index.ts b/src/flip/index.ts index aca8cda..9710848 100644 --- a/src/flip/index.ts +++ b/src/flip/index.ts @@ -1,3 +1,5 @@ +export type { ViewerAccessibilityLabels } from './a11y'; +export { DEFAULT_ACCESSIBILITY_LABELS } from './a11y'; export type { DocumentViewerProps } from './DocumentViewer'; export { default as DocumentViewer } from './DocumentViewer'; export type { PageFlipperProps } from './PageFlipper'; diff --git a/src/render/BlockList.tsx b/src/render/BlockList.tsx index addfeb8..e557995 100644 --- a/src/render/BlockList.tsx +++ b/src/render/BlockList.tsx @@ -148,6 +148,8 @@ function renderBlock(block: Block, ctx: RenderContext, key: string): React.JSX.E return (