From f8a50afc1bafff89dec70000931e4d56db001dfa Mon Sep 17 00:00:00 2001 From: Bryan Date: Sun, 1 Nov 2026 21:16:11 -0600 Subject: [PATCH 1/5] chore(package): include CHANGELOG.md in the published files npm only adds the README and the license on its own, so the changelog returned 404 on unpkg and was not visible from the npm page. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 2 ++ package.json | 1 + 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 06b0af2..f557f33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Changed +- `CHANGELOG.md` is now included in the published package, so it is visible on npm and unpkg. + ### Fixed ### Removed diff --git a/package.json b/package.json index 7f7dbce..0becf0e 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "react-native.config.js", "react-native-md-document.podspec", "README.md", + "CHANGELOG.md", "LICENSE", "media" ], From 6d9b5457d8713c3061e4d4980017393ad83f50b9 Mon Sep 17 00:00:00 2001 From: Bryan Date: Mon, 2 Nov 2026 06:51:25 -0600 Subject: [PATCH 2/5] test(example): add XCUITest accessibility checks Four UI tests run the example on an iOS simulator and read the real accessibility tree: the invisible page control announces the position, only the visible page is exposed (the flip layers are hidden), headings are single elements, and turning the page updates both. A script adds the test target to the generated iOS project, since example/ios is not versioned. XCUITest cannot fire the increment action or read the header trait, so those two are not covered. Co-Authored-By: Claude Sonnet 5.5 --- CONTRIBUTING.md | 7 +++ example/package.json | 3 +- example/uitests/A11yUITests.swift | 69 +++++++++++++++++++++++++++ example/uitests/add-ui-test-target.rb | 36 ++++++++++++++ example/uitests/run.sh | 24 ++++++++++ 5 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 example/uitests/A11yUITests.swift create mode 100644 example/uitests/add-ui-test-target.rb create mode 100755 example/uitests/run.sh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 70dc7cf..d70112f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 -- +``` + ## Branching model Two permanent branches, both protected: diff --git a/example/package.json b/example/package.json index f9b6ed6..28802d5 100644 --- a/example/package.json +++ b/example/package.json @@ -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", diff --git a/example/uitests/A11yUITests.swift b/example/uitests/A11yUITests.swift new file mode 100644 index 0000000..de57fac --- /dev/null +++ b/example/uitests/A11yUITests.swift @@ -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") + } +} diff --git a/example/uitests/add-ui-test-target.rb b/example/uitests/add-ui-test-target.rb new file mode 100644 index 0000000..3a453af --- /dev/null +++ b/example/uitests/add-ui-test-target.rb @@ -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}" diff --git a/example/uitests/run.sh b/example/uitests/run.sh new file mode 100755 index 0000000..82d8e66 --- /dev/null +++ b/example/uitests/run.sh @@ -0,0 +1,24 @@ +#!/bin/sh +# Runs the accessibility UI tests on an iOS simulator. +# +# Usage: npm run test:a11y -- +# +# 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 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 -- }" +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 From 68aff2e3b1e111f5ed6fe0f0a7779d3bcdf5f3df Mon Sep 17 00:00:00 2001 From: Bryan Date: Mon, 2 Nov 2026 07:11:56 -0600 Subject: [PATCH 3/5] feat(pagination): keep headings with the block that follows A heading could end a page on its own while its content started the next one. paginate now treats a run of headings plus the first block after it as one unit and moves it to the next page when it does not fit; a unit taller than a page is packed block by block as before. usePagination takes the flags as an optional fifth argument and both viewers pass them, so existing callers are unaffected. Co-Authored-By: Claude Sonnet 5.5 --- src/flip/DocumentViewer.tsx | 10 ++++- src/pagination/PaginatedDocument.tsx | 12 +++++- src/pagination/__tests__/paginate.test.ts | 51 ++++++++++++++++++++++- src/pagination/index.ts | 2 +- src/pagination/paginate.ts | 49 +++++++++++++++++++--- src/pagination/usePagination.ts | 5 ++- 6 files changed, 116 insertions(+), 13 deletions(-) diff --git a/src/flip/DocumentViewer.tsx b/src/flip/DocumentViewer.tsx index ba2f3e2..2649766 100644 --- a/src/flip/DocumentViewer.tsx +++ b/src/flip/DocumentViewer.tsx @@ -4,6 +4,7 @@ import { type LayoutChangeEvent, StyleSheet, useWindowDimensions, View } from 'r import { blockIndexForOffset, DUAL_PAGE_MIN_WIDTH, + keepWithNextFlags, MeasureLayer, PAGE_PADDING, pageForBlock, @@ -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(() => { diff --git a/src/pagination/PaginatedDocument.tsx b/src/pagination/PaginatedDocument.tsx index 61ccd7e..4c45d2c 100644 --- a/src/pagination/PaginatedDocument.tsx +++ b/src/pagination/PaginatedDocument.tsx @@ -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'; @@ -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 { @@ -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(() => { diff --git a/src/pagination/__tests__/paginate.test.ts b/src/pagination/__tests__/paginate.test.ts index 96714d8..bf39210 100644 --- a/src/pagination/__tests__/paginate.test.ts +++ b/src/pagination/__tests__/paginate.test.ts @@ -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]]); @@ -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)); +}); diff --git a/src/pagination/index.ts b/src/pagination/index.ts index 306d0e8..12ad48d 100644 --- a/src/pagination/index.ts +++ b/src/pagination/index.ts @@ -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'; diff --git a/src/pagination/paginate.ts b/src/pagination/paginate.ts index a7d0b25..16cd606 100644 --- a/src/pagination/paginate.ts +++ b/src/pagination/paginate.ts @@ -1,17 +1,50 @@ -export function paginate(heights: number[], pageHeight: number): number[][] { +import type { Block } from '../parser/types'; + +/** + * Packs blocks into pages. A block flagged in `keepWithNext` (a heading) never ends a page + * on its own: it travels with the blocks that follow it, up to and including the first + * unflagged one. A group that is taller than a page is packed block by block instead. + */ +export function paginate( + heights: number[], + pageHeight: number, + keepWithNext: readonly boolean[] = [], +): number[][] { const pages: number[][] = []; let current: number[] = []; let used = 0; - for (let i = 0; i < heights.length; i++) { - const h = heights[i]!; - if (current.length > 0 && used + h > pageHeight) { + const place = (indices: number[], height: number): void => { + if (current.length > 0 && used + height > pageHeight) { pages.push(current); current = []; used = 0; } - current.push(i); - used += h; + current.push(...indices); + used += height; + }; + + let i = 0; + while (i < heights.length) { + let end = i; + while (end < heights.length - 1 && keepWithNext[end]) { + end++; + } + const group: number[] = []; + let groupHeight = 0; + for (let j = i; j <= end; j++) { + group.push(j); + groupHeight += heights[j]!; + } + + if (groupHeight <= pageHeight) { + place(group, groupHeight); + } else { + for (const index of group) { + place([index], heights[index]!); + } + } + i = end + 1; } if (current.length > 0) { @@ -20,3 +53,7 @@ export function paginate(heights: number[], pageHeight: number): number[][] { return pages; } + +export function keepWithNextFlags(blocks: readonly Block[]): boolean[] { + return blocks.map((block) => block.type === 'heading'); +} diff --git a/src/pagination/usePagination.ts b/src/pagination/usePagination.ts index 3b3fd53..d5eee85 100644 --- a/src/pagination/usePagination.ts +++ b/src/pagination/usePagination.ts @@ -15,6 +15,7 @@ export function usePagination( pageHeight: number, resetKey?: unknown, pageWidth?: number, + keepWithNext?: readonly boolean[], ): Pagination { const heightsRef = useRef([]); const [heights, setHeights] = useState(null); @@ -45,8 +46,8 @@ export function usePagination( ); const pages = useMemo( - () => (heights && pageHeight > 0 ? paginate(heights, pageHeight) : NO_PAGES), - [heights, pageHeight], + () => (heights && pageHeight > 0 ? paginate(heights, pageHeight, keepWithNext) : NO_PAGES), + [heights, pageHeight, keepWithNext], ); return { pages, isMeasured: heights !== null && pageHeight > 0, onBlockLayout }; From d6d18304b24be3660a93b09869db511e554d2d38 Mon Sep 17 00:00:00 2001 From: Bryan Date: Mon, 2 Nov 2026 07:11:56 -0600 Subject: [PATCH 4/5] docs: describe the heading pagination and the new exports Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 ++++ README.md | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f557f33..20b3407 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### 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. + ### Removed ## [1.3.0] - 2026-11-01 diff --git a/README.md b/README.md index bca475a..31ce0ff 100644 --- a/README.md +++ b/README.md @@ -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 From 2b29ccc31f8ce8346d01edf11bf1b331f0a7c0db Mon Sep 17 00:00:00 2001 From: Bryan Date: Mon, 2 Nov 2026 07:27:47 -0600 Subject: [PATCH 5/5] =?UTF-8?q?chore(release):=20v1.4.0=20=E2=80=94=20head?= =?UTF-8?q?ings=20stay=20with=20their=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 12 ++++++++++-- package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20b3407..0a6f5a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +### Changed + +### Fixed + +### 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 @@ -17,8 +27,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - 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. -### Removed - ## [1.3.0] - 2026-11-01 ### Added diff --git a/package-lock.json b/package-lock.json index 203cb10..8afa302 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@acertijosapps/react-native-md-document", - "version": "1.3.0", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@acertijosapps/react-native-md-document", - "version": "1.3.0", + "version": "1.4.0", "license": "MIT", "devDependencies": { "@react-native/eslint-config": "^0.76.5", diff --git a/package.json b/package.json index 0becf0e..be38a52 100644 --- a/package.json +++ b/package.json @@ -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": {