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 (