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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Removed

## [1.4.0] - 2026-11-02

### Added

- `keepWithNextFlags(blocks)` export and an optional fifth `keepWithNext` argument for `usePagination`, plus a third `keepWithNext` argument for `paginate`, to control which blocks stay with the next one.

### Changed

- `CHANGELOG.md` is now included in the published package, so it is visible on npm and unpkg.

### Fixed

- A heading is no longer left alone at the bottom of a page: it moves to the next page together with the block that follows it (consecutive headings travel together). A group taller than a page is still split block by block.

## [1.3.0] - 2026-11-01

### Added
Expand Down
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,13 @@ npm run format:check # check formatting with Prettier
npm test # run the unit tests (node:test)
```

The accessibility checks run as XCUITests against the example app on an iOS simulator (see
[`example/uitests/run.sh`](./example/uitests/run.sh) for the setup):

```sh
cd example && npm run test:a11y -- <simulator-udid>
```

## Branching model

Two permanent branches, both protected:
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,8 @@ export function Viewer() {
```

`DocumentViewer` measures the content, paginates it to the viewport size and enables flip +
zoom. `parse(source)` returns an immutable `MDDocument` with `plainText` (the text projection
zoom. A heading is never left alone at the bottom of a page: it moves to the next page together
with the block that follows it. `parse(source)` returns an immutable `MDDocument` with `plainText` (the text projection
that highlights and TTS rely on).

## Document sources
Expand Down
3 changes: 2 additions & 1 deletion example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
"scripts": {
"start": "expo start",
"ios": "expo run:ios",
"android": "expo run:android"
"android": "expo run:android",
"test:a11y": "sh uitests/run.sh"
},
"dependencies": {
"@react-native-async-storage/async-storage": "^2.1.0",
Expand Down
69 changes: 69 additions & 0 deletions example/uitests/A11yUITests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import XCTest

/// Checks what VoiceOver sees in the example app: the invisible page control,
/// the pages hidden behind the flip animation and the headings.
///
/// Runs on a phone-sized simulator, where the demo document has several pages and a
/// single page is shown at a time. XCUITest does not expose accessibility traits, so
/// the heading check is structural (see `testHeadingIsASingleAccessibilityElement`).
final class A11yUITests: XCTestCase {
private var app: XCUIApplication!

private let firstHeading = "1. What this document shows"
private let laterHeading = "6. Long content to paginate"

override func setUpWithError() throws {
continueAfterFailure = false
app = XCUIApplication(bundleIdentifier: "com.example.rnddoc")
app.launch()
XCTAssertTrue(element(label: firstHeading).waitForExistence(timeout: 90), "document did not render")
}

private func element(label: String) -> XCUIElement {
app.descendants(matching: .any)
.matching(NSPredicate(format: "label == %@", label))
.firstMatch
}

private var pageControl: XCUIElement { element(label: "Document pages") }

func testPageControlAnnouncesTheCurrentPage() throws {
XCTAssertTrue(pageControl.exists, "the invisible page control is missing")
let value = try XCTUnwrap(pageControl.value as? String)
XCTAssertNotNil(
value.range(of: #"^Page 1 of \d+$"#, options: .regularExpression),
"unexpected spoken position: \(value)"
)
XCTAssertGreaterThanOrEqual(pageControl.frame.height, 44, "the control is too small to focus")
}

func testOnlyTheVisiblePageIsInTheAccessibilityTree() {
XCTAssertTrue(element(label: firstHeading).exists)
XCTAssertFalse(
element(label: laterHeading).exists,
"text from a page hidden behind the flip animation is exposed to screen readers"
)
}

/// XCUITest cannot read the `header` trait, so this checks the structure it produces:
/// a heading is an accessible container that swallows its text, while a paragraph
/// keeps its own static text element.
func testHeadingIsASingleAccessibilityElement() {
let heading = element(label: firstHeading)
XCTAssertEqual(heading.descendants(matching: .staticText).count, 0)
}

/// XCUITest can only `adjust` sliders and pickers, so the `increment` action itself
/// cannot be fired here; this turns the page with a real drag instead and checks
/// that the spoken position and the exposed content follow.
func testTurningThePageUpdatesTheControlAndTheTree() throws {
let from = app.coordinate(withNormalizedOffset: CGVector(dx: 0.85, dy: 0.5))
let to = app.coordinate(withNormalizedOffset: CGVector(dx: 0.15, dy: 0.5))
from.press(forDuration: 0.1, thenDragTo: to)

let turned = NSPredicate(format: "value == %@", "Page 2 of 4")
expectation(for: turned, evaluatedWith: pageControl)
waitForExpectations(timeout: 15)
XCTAssertFalse(element(label: firstHeading).exists, "page 1 is still exposed after turning")
}
}
36 changes: 36 additions & 0 deletions example/uitests/add-ui-test-target.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Adds the accessibility UI test target to the generated iOS project (example/ios is
# produced by `expo prebuild` and is not versioned). Safe to run more than once.
require 'xcodeproj'

TARGET_NAME = 'rndexampleA11yUITests'
ROOT = File.expand_path('..', __dir__)
PROJECT_PATH = File.join(ROOT, 'ios', 'rndexample.xcodeproj')

project = Xcodeproj::Project.open(PROJECT_PATH)
app_target = project.targets.find { |t| t.name == 'rndexample' } or abort('app target not found')
target = project.targets.find { |t| t.name == TARGET_NAME }

unless target
target = project.new_target(:ui_test_bundle, TARGET_NAME, :ios, '15.1')
target.add_dependency(app_target)
group = project.main_group.new_group(TARGET_NAME, File.join(ROOT, 'uitests'))
ref = group.new_file('A11yUITests.swift')
target.add_file_references([ref])
target.build_configurations.each do |config|
config.build_settings['PRODUCT_NAME'] = '$(TARGET_NAME)'
config.build_settings['PRODUCT_BUNDLE_IDENTIFIER'] = 'com.example.rnddoc.a11yuitests'
config.build_settings['TEST_TARGET_NAME'] = 'rndexample'
config.build_settings['GENERATE_INFOPLIST_FILE'] = 'YES'
config.build_settings['SWIFT_VERSION'] = '5.0'
config.build_settings['TARGETED_DEVICE_FAMILY'] = '1,2'
config.build_settings['CODE_SIGN_STYLE'] = 'Automatic'
end
project.save
end

scheme = Xcodeproj::XCScheme.new
scheme.add_build_target(app_target)
scheme.add_test_target(target)
scheme.set_launch_target(app_target)
scheme.save_as(PROJECT_PATH, TARGET_NAME, true)
puts "Configured #{TARGET_NAME}"
24 changes: 24 additions & 0 deletions example/uitests/run.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
#!/bin/sh
# Runs the accessibility UI tests on an iOS simulator.
#
# Usage: npm run test:a11y -- <simulator-udid>
#
# Requirements: the example app was built once for the simulator (`npm run ios`) and Metro is
# serving it on port 8090 (`npx expo start --port 8090 --localhost`), with the simulator
# pointed at it: `xcrun simctl spawn <udid> defaults write com.example.rnddoc RCT_jsLocation localhost:8090`.
# Use a phone-sized simulator: the demo document has several pages there and shows one at a time.
set -eu

UDID="${1:?usage: npm run test:a11y -- <simulator-udid>}"
cd "$(dirname "$0")/.."

export LANG=en_US.UTF-8 LC_ALL=en_US.UTF-8
ruby uitests/add-ui-test-target.rb
cd ios
xcodebuild test \
-workspace rndexample.xcworkspace \
-scheme rndexampleA11yUITests \
-sdk iphonesimulator \
-destination "id=$UDID" \
-configuration Debug \
-derivedDataPath build
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@acertijosapps/react-native-md-document",
"version": "1.3.0",
"version": "1.4.0",
"description": "Immutable Markdown-as-document viewer for React Native: page-flip, zoom, text-to-speech and persistent highlights.",
"homepage": "https://acertijosapps.com/librerias/react-native-md-document",
"repository": {
Expand All @@ -26,6 +26,7 @@
"react-native.config.js",
"react-native-md-document.podspec",
"README.md",
"CHANGELOG.md",
"LICENSE",
"media"
],
Expand Down
10 changes: 9 additions & 1 deletion src/flip/DocumentViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { type LayoutChangeEvent, StyleSheet, useWindowDimensions, View } from 'r
import {
blockIndexForOffset,
DUAL_PAGE_MIN_WIDTH,
keepWithNextFlags,
MeasureLayer,
PAGE_PADDING,
pageForBlock,
Expand Down Expand Up @@ -96,7 +97,14 @@ function ViewerCore({
screenShortSide: Math.min(screen.width, screen.height),
});
const contentHeight = size.height - PAGE_PADDING * 2;
const pagination = usePagination(document.blocks.length, contentHeight, document, pageWidth);
const keepWithNext = useMemo(() => keepWithNextFlags(document.blocks), [document]);
const pagination = usePagination(
document.blocks.length,
contentHeight,
document,
pageWidth,
keepWithNext,
);

const onPageChangeRef = useRef(onPageChange);
useEffect(() => {
Expand Down
12 changes: 10 additions & 2 deletions src/pagination/PaginatedDocument.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { type LayoutChangeEvent, StyleSheet, View } from 'react-native';

import type { MDDocument } from '../parser/types';
Expand All @@ -7,6 +7,7 @@ import { useDocTheme } from '../render';
import { PAGE_PADDING } from './constants';
import MeasureLayer from './MeasureLayer';
import Page from './Page';
import { keepWithNextFlags } from './paginate';
import { usePagination } from './usePagination';

export interface PaginatedDocumentProps {
Expand Down Expand Up @@ -35,7 +36,14 @@ function PaginatedDocument({
const [size, setSize] = useState({ width: 0, height: 0 });
const resolvedTheme = useDocTheme(colorScheme, theme, colorTokens);
const contentHeight = size.height - PAGE_PADDING * 2;
const pagination = usePagination(document.blocks.length, contentHeight, document, size.width);
const keepWithNext = useMemo(() => keepWithNextFlags(document.blocks), [document]);
const pagination = usePagination(
document.blocks.length,
contentHeight,
document,
size.width,
keepWithNext,
);

const onPagesChangeRef = useRef(onPagesChange);
useEffect(() => {
Expand Down
51 changes: 50 additions & 1 deletion src/pagination/__tests__/paginate.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';

import { paginate } from '../paginate';
import { parse } from '../../parser';
import { keepWithNextFlags, paginate } from '../paginate';

test('packs blocks greedily until the page height is exceeded', () => {
assert.deepEqual(paginate([100, 100, 100], 250), [[0, 1], [2]]);
Expand Down Expand Up @@ -38,3 +39,51 @@ test('no page exceeds the height unless a single block does', () => {
assert.ok(single || total <= pageHeight, `page ${page} overflows`);
}
});

test('a heading that would end a page moves to the next one with its block', () => {
// heights: [text, heading, text]; the heading fits on page 1 but its text does not
assert.deepEqual(paginate([100, 30, 100], 150, [false, true, false]), [[0], [1, 2]]);
});

test('without the flag the same heading stays orphaned at the end of the page', () => {
assert.deepEqual(paginate([100, 30, 100], 150), [[0, 1], [2]]);
});

test('consecutive headings travel together with the block that follows', () => {
assert.deepEqual(paginate([100, 20, 20, 100], 150, [false, true, true, false]), [
[0],
[1, 2, 3],
]);
});

test('a heading and its block stay on the same page when they fit', () => {
assert.deepEqual(paginate([30, 30, 30, 30], 150, [false, true, false, false]), [[0, 1, 2, 3]]);
});

test('a group taller than the page is packed block by block', () => {
assert.deepEqual(paginate([60, 60, 60], 100, [true, false, false]), [[0], [1], [2]]);
});

test('a heading that is the last block of the document stays where it is', () => {
assert.deepEqual(paginate([100, 30], 150, [false, true]), [[0, 1]]);
assert.deepEqual(paginate([100, 100, 30], 150, [false, false, true]), [[0], [1, 2]]);
});

test('keeping headings with their block still emits every index once, in order', () => {
const heights = [30, 70, 120, 45, 200, 15, 25, 90];
const flags = [false, true, false, true, true, false, true, false];
const flat = paginate(heights, 150, flags).flat();
assert.deepEqual(
flat,
heights.map((_, i) => i),
);
});

test('keepWithNextFlags flags headings only', () => {
const doc = parse('# Title\n\nParagraph\n\n## Section\n\nMore text\n\n> quote');
assert.deepEqual(
keepWithNextFlags(doc.blocks),
doc.blocks.map((block) => block.type === 'heading'),
);
assert.ok(keepWithNextFlags(doc.blocks).some(Boolean));
});
2 changes: 1 addition & 1 deletion src/pagination/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export {
} from './offsets';
export type { PageProps } from './Page';
export { default as Page } from './Page';
export { paginate } from './paginate';
export { keepWithNextFlags, paginate } from './paginate';
export type { PaginatedDocumentProps } from './PaginatedDocument';
export { default as PaginatedDocument } from './PaginatedDocument';
export type { SpreadInput, SpreadLayout } from './spread';
Expand Down
Loading
Loading