diff --git a/CHANGELOG.md b/CHANGELOG.md index 06b0af2..0a6f5a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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/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 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 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 7f7dbce..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": { @@ -26,6 +26,7 @@ "react-native.config.js", "react-native-md-document.podspec", "README.md", + "CHANGELOG.md", "LICENSE", "media" ], 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 };