fix(reader): keep last paragraph's line spacing by making the section skip link a <span> (#4642)

The next-section accessibility skip link is injected nested inside each
section's last content element (findSectionEndHost in a11y.ts, added for
#4126). The paragraph-layout rule in getParagraphLayoutStyles() targets
`div:not(:has(*:not(b,a,em,i,strong,u,span)))`, so nesting a <div> made the
enclosing paragraph fail the :has() test and silently lose its line-spacing,
word/letter-spacing, text-indent, and hyphenation overrides — but only for the
last paragraph of every section, and only in <div>-based EPUBs (common in
Chinese-source books). <p>-based books were unaffected because the bare `p`
clause matches regardless of children.

Create the next-section skip link as a <span> instead. <span> is in the
selector's allow-list, so the enclosing paragraph keeps matching. The link is
still position:absolute (an out-of-flow 1x1px box) and focusable, so layout and
NVDA focus behavior are unchanged.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-06-18 16:39:44 +08:00
committed by GitHub
parent bcd9ed724b
commit 6626db967c
2 changed files with 71 additions and 1 deletions
@@ -0,0 +1,62 @@
import { describe, test, expect, afterEach } from 'vitest';
import type { FoliateView } from '@/types/view';
import { handleA11yNavigation } from '@/utils/a11y';
// The exact paragraph-layout selector emitted by getParagraphLayoutStyles() in
// src/utils/style.ts. Its <div> clause only matches paragraph-like divs whose
// descendants are all inline formatting tags — so nesting any other element
// (e.g. the next-section skip link) inside such a paragraph drops the match.
// This needs the real :has() engine, so it runs as a browser test.
const PARAGRAPH_SELECTOR = 'p, blockquote, dd, div:not(:has(*:not(b, a, em, i, strong, u, span)))';
const NEXT_SECTION_ID = 'readest-skip-link-next-section';
const makeOptions = () => ({
skipToLastPosCallback: () => {},
skipToLastPosLabel: 'last',
skipToNextSectionCallback: () => {},
skipToNextSectionLabel: 'next',
});
const iframes: HTMLIFrameElement[] = [];
// Render an isolated, laid-out document in an iframe (mirrors how foliate
// renders each EPUB section) so getComputedStyle and :has() use the real engine.
const renderSection = (bodyHtml: string) => {
const iframe = document.createElement('iframe');
document.body.appendChild(iframe);
iframes.push(iframe);
const doc = iframe.contentDocument!;
const style = doc.createElement('style');
style.textContent =
`body { font-size: 16px; }` + `${PARAGRAPH_SELECTOR} { line-height: 3 !important; }`;
doc.head.appendChild(style);
doc.body.innerHTML = bodyHtml;
return { doc, win: iframe.contentWindow! };
};
describe('handleA11yNavigation paragraph-layout interaction', () => {
afterEach(() => {
while (iframes.length) iframes.pop()!.remove();
});
test('keeps the last <div> paragraph styled by the paragraph-layout rule', () => {
const { doc, win } = renderSection(
`<div class="para">First paragraph.</div>` +
`<div class="para">Last paragraph <span>with an inline span</span>.</div>`,
);
const lastPara = doc.body.lastElementChild as HTMLElement;
// 3 × 16px — the overridden line spacing, applied before any injection.
expect(win.getComputedStyle(lastPara).lineHeight).toBe('48px');
handleA11yNavigation({} as FoliateView, doc, makeOptions());
// The skip link is still injected at the section end (accessibility intact)...
const skipLink = doc.getElementById(NEXT_SECTION_ID);
expect(skipLink).not.toBeNull();
expect(lastPara.contains(skipLink)).toBe(true);
// ...and the last paragraph still receives the overridden line spacing
// instead of reverting to the book default (the bug: #last-paragraph).
expect(win.getComputedStyle(lastPara).lineHeight).toBe('48px');
});
});
+9 -1
View File
@@ -81,7 +81,15 @@ export const handleA11yNavigation = (
}
const skipNextSectionLinkId = 'readest-skip-link-next-section';
if (document.body && !document.getElementById(skipNextSectionLinkId)) {
const skipLink = document.createElement('div');
// Use a <span>, not a <div>: this link is nested inside the section's last
// content element (see findSectionEndHost below). The paragraph-layout rule
// in getParagraphLayoutStyles() targets `div:not(:has(*:not(b,a,em,i,strong,
// u,span)))`, so a nested <div> would make the enclosing paragraph fail the
// `:has()` test and silently lose its line-spacing/indent overrides. <span>
// is in that allow-list, so the paragraph keeps matching. position:absolute
// still makes the inline span an out-of-flow 1×1px box, so layout/focus are
// unchanged.
const skipLink = document.createElement('span');
skipLink.id = skipNextSectionLinkId;
skipLink.setAttribute('cfi-inert', '');
skipLink.setAttribute('tabindex', '0');