From 2acd08202bc88af27df7850c20de3b19d9c225c2 Mon Sep 17 00:00:00 2001 From: leehuazhong <167761613+leehuazhong@users.noreply.github.com> Date: Sat, 16 May 2026 22:13:04 +0800 Subject: [PATCH] fix(a11y): use position absolute for skip-next-section link to prevent blank page (#4182) * fix(a11y): use position absolute for skip-next-section link to prevent blank page * fix(a11y): nest next-section skip link inside last content element position:absolute alone does not fix the blank-page bug: a full-page illustration wrapper commonly carries `column-break-after: always`, and the skip link's static position after that break still renders in a fresh, blank column. Nest the link inside the deepest last content element so it shares the final content column, while remaining the last node in document order for NVDA's virtual cursor. Also use left:auto so it keeps its static position instead of pinning to the viewport edge. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: leehuazhong Co-authored-by: Huang Xin Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/__tests__/utils/a11y.test.ts | 40 +++++++++++++++ apps/readest-app/src/utils/a11y.ts | 49 +++++++++++++++++-- 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/apps/readest-app/src/__tests__/utils/a11y.test.ts b/apps/readest-app/src/__tests__/utils/a11y.test.ts index 1c191e65..f35faa67 100644 --- a/apps/readest-app/src/__tests__/utils/a11y.test.ts +++ b/apps/readest-app/src/__tests__/utils/a11y.test.ts @@ -83,6 +83,46 @@ describe('handleA11yNavigation', () => { expect(document.body.lastElementChild).toBe(skipLink); }); + test('next-section skip link is absolutely positioned at its static position', () => { + handleA11yNavigation(createMockView(), document, makeOptions()); + + const skipLink = document.getElementById(NEXT_SECTION_ID); + expect(skipLink).not.toBeNull(); + // position:absolute removes it from flow so its own box cannot trigger an + // extra column break; left/top:auto keep it at its static position. + expect(skipLink!.style.position).toBe('absolute'); + expect(skipLink!.style.left).toBe('auto'); + expect(skipLink!.style.top).toBe('auto'); + }); + + test('next-section skip link nests inside the deepest last content element', () => { + const section = document.createElement('section'); + const wrapper = document.createElement('div'); + wrapper.className = 'kuchie'; + wrapper.appendChild(document.createElement('img')); + section.appendChild(wrapper); + document.body.appendChild(section); + + handleA11yNavigation(createMockView(), document, makeOptions()); + + const skipLink = document.getElementById(NEXT_SECTION_ID); + expect(skipLink).not.toBeNull(); + // nested inside the final content block (after the void ), not a + // trailing sibling of , so a `column-break-after` on that block + // cannot push the link into a blank column. + expect(skipLink!.parentElement).toBe(wrapper); + + section.remove(); + }); + + test('next-section skip link falls back to when there is no content element', () => { + handleA11yNavigation(createMockView(), document, makeOptions()); + + const skipLink = document.getElementById(NEXT_SECTION_ID); + expect(skipLink).not.toBeNull(); + expect(skipLink!.parentElement).toBe(document.body); + }); + test('last-pos skip link click calls skipToLastPosCallback', () => { const options = makeOptions(); handleA11yNavigation(createMockView(), document, options); diff --git a/apps/readest-app/src/utils/a11y.ts b/apps/readest-app/src/utils/a11y.ts index bb06ebef..fac0284b 100644 --- a/apps/readest-app/src/utils/a11y.ts +++ b/apps/readest-app/src/utils/a11y.ts @@ -1,5 +1,40 @@ import { FoliateView } from '@/types/view'; +const VOID_ELEMENT_TAGS = new Set([ + 'area', + 'base', + 'br', + 'col', + 'embed', + 'hr', + 'img', + 'input', + 'link', + 'meta', + 'param', + 'source', + 'track', + 'wbr', +]); + +// Walk down the last-element-child chain to find the deepest element that the +// next-section skip link can be nested inside. Appending the link there (rather +// than as a trailing sibling of ) keeps it within the final content +// column: a full-page illustration wrapper often carries +// `column-break-after: always`, and any sibling placed after that break lands +// in a fresh, blank column/page (#4126). Stops before void elements (which +// cannot host children) and existing skip links. +const findSectionEndHost = (root: Element, excludeIds: string[]): Element => { + let host: Element = root; + for (;;) { + const last = host.lastElementChild; + if (!last || excludeIds.includes(last.id) || VOID_ELEMENT_TAGS.has(last.localName)) { + return host; + } + host = last; + } +}; + export const handleA11yNavigation = ( view: FoliateView | null, document: Document, @@ -52,9 +87,12 @@ export const handleA11yNavigation = ( skipLink.setAttribute('tabindex', '0'); skipLink.setAttribute('aria-hidden', 'false'); skipLink.setAttribute('aria-label', options?.skipToNextSectionLabel ?? ''); + // position:absolute keeps the link out of flow so its own box cannot + // trigger an extra column break (the blank-page bug, #4126); left/top:auto + // leave it at its static position. Object.assign(skipLink.style, { - position: 'relative', - left: '0px', + position: 'absolute', + left: 'auto', top: 'auto', width: '1px', height: '1px', @@ -65,6 +103,11 @@ export const handleA11yNavigation = ( e.stopPropagation(); options?.skipToNextSectionCallback(); }); - document.body.appendChild(skipLink); + // Nest the link inside the last content element instead of appending it as + // a trailing sibling of , so a `column-break-after` on that block + // cannot push it into a blank column. It stays the last node in document + // order, so NVDA's virtual cursor still reaches it at the section end. + const host = findSectionEndHost(document.body, [skipLinkId, skipNextSectionLinkId]); + host.appendChild(skipLink); } };