diff --git a/apps/readest-app/src/__tests__/utils/a11y.browser.test.ts b/apps/readest-app/src/__tests__/utils/a11y.browser.test.ts new file mode 100644 index 00000000..cc060593 --- /dev/null +++ b/apps/readest-app/src/__tests__/utils/a11y.browser.test.ts @@ -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
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
paragraph styled by the paragraph-layout rule', () => { + const { doc, win } = renderSection( + `
First paragraph.
` + + `
Last paragraph with an inline span.
`, + ); + 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'); + }); +}); diff --git a/apps/readest-app/src/utils/a11y.ts b/apps/readest-app/src/utils/a11y.ts index fac0284b..66921f85 100644 --- a/apps/readest-app/src/utils/a11y.ts +++ b/apps/readest-app/src/utils/a11y.ts @@ -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 , not a
: 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
would make the enclosing paragraph fail the + // `:has()` test and silently lose its line-spacing/indent overrides. + // 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');