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:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user