Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions src/flip/DocumentViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -42,6 +45,7 @@ export interface DocumentViewerProps {
dualPage?: boolean;
dualPageMinWidth?: number;
spreadGap?: number;
accessibilityLabels?: Partial<ViewerAccessibilityLabels>;
onLoadComplete?: (document: MDDocument) => void;
onLoadError?: (error: Error) => void;
renderLoading?: () => React.ReactNode;
Expand All @@ -61,6 +65,7 @@ interface ViewerCoreProps {
dualPage?: boolean;
dualPageMinWidth?: number;
spreadGap?: number;
accessibilityLabels?: Partial<ViewerAccessibilityLabels>;
}

function ViewerCore({
Expand All @@ -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);
Expand Down Expand Up @@ -159,6 +165,10 @@ function ViewerCore({
}, []);

const hasSize = size.width > 0 && size.height > 0;
const labels = useMemo(
() => mergeAccessibilityLabels(accessibilityLabels),
[accessibilityLabels],
);

return (
<View
Expand All @@ -175,6 +185,15 @@ function ViewerCore({
onBlockLayout={pagination.onBlockLayout}
/>
) : null}
{hasSize && pagination.isMeasured ? (
<PageControl
pageIndex={safeIndex}
pageCount={pageCount}
pagesPerView={pagesPerView}
labels={labels}
onFlip={handleFlip}
/>
) : null}
{hasSize && pagination.isMeasured && isSpread ? (
<SpreadFlipper
key={safeIndex}
Expand Down Expand Up @@ -230,6 +249,7 @@ function SourcedDocumentViewer({
dualPage,
dualPageMinWidth,
spreadGap,
accessibilityLabels,
}: DocumentViewerProps & { source: DocumentSource }): React.JSX.Element {
const { document, isLoading, error, reload } = useDocumentSource(source, {
readUri,
Expand All @@ -252,6 +272,7 @@ function SourcedDocumentViewer({
dualPage={dualPage}
dualPageMinWidth={dualPageMinWidth}
spreadGap={spreadGap}
accessibilityLabels={accessibilityLabels}
/>
);
}
Expand Down Expand Up @@ -290,6 +311,7 @@ function DocumentViewer(props: DocumentViewerProps): React.JSX.Element {
dualPage={props.dualPage}
dualPageMinWidth={props.dualPageMinWidth}
spreadGap={props.spreadGap}
accessibilityLabels={props.accessibilityLabels}
/>
);
}
Expand Down
65 changes: 65 additions & 0 deletions src/flip/PageControl.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<View
accessible
accessibilityRole="adjustable"
accessibilityLabel={labels.control}
accessibilityValue={{ text: labels.pages(first, last, pageCount) }}
accessibilityState={{ disabled: !hasPrev && !hasNext }}
accessibilityActions={[
{ name: 'increment', label: labels.nextPage },
{ name: 'decrement', label: labels.previousPage },
]}
onAccessibilityAction={handleAction}
pointerEvents="none"
style={styles.control}
/>
);
}

const styles = StyleSheet.create({
control: {
position: 'absolute',
top: 0,
left: PAGE_PADDING,
right: PAGE_PADDING,
height: CONTROL_HEIGHT,
},
});

export default PageControl;
4 changes: 4 additions & 0 deletions src/flip/PageFlipper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,8 @@ function PageFlipper({
<Animated.View
style={[styles.layer, nextStyle]}
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Page
blocks={next}
Expand All @@ -111,6 +113,8 @@ function PageFlipper({
<Animated.View
style={[styles.layer, prevStyle]}
pointerEvents="none"
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
>
<Page
blocks={prev}
Expand Down
2 changes: 2 additions & 0 deletions src/flip/SpreadFlipper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ function SpreadFlipper({
<Animated.View
style={[styles.layer, { left: x, width: pageWidth, height }, style]}
pointerEvents={interactive ? 'auto' : 'none'}
accessibilityElementsHidden={!interactive}
importantForAccessibility={interactive ? 'auto' : 'no-hide-descendants'}
>
<Page
blocks={pageBlocks}
Expand Down
39 changes: 39 additions & 0 deletions src/flip/__tests__/a11y.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';

import {
DEFAULT_ACCESSIBILITY_LABELS,
mergeAccessibilityLabels,
stepPageIndex,
visiblePageRange,
} from '../a11y';

test('visiblePageRange reports one page in single view', () => {
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');
});
41 changes: 41 additions & 0 deletions src/flip/a11y.ts
Original file line number Diff line number Diff line change
@@ -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>,
): 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));
}
2 changes: 2 additions & 0 deletions src/flip/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
2 changes: 2 additions & 0 deletions src/render/BlockList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ function renderBlock(block: Block, ctx: RenderContext, key: string): React.JSX.E
return (
<View
key={key}
accessible
accessibilityRole="header"
style={[spacing, blockBox(block.style, theme)]}
>
<InlineText
Expand Down
Loading