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) <noreply@anthropic.com>

---------

Co-authored-by: leehuazhong <longsiyinyydds@gmail.com>
Co-authored-by: Huang Xin <chrox.huang@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
leehuazhong
2026-05-16 22:13:04 +08:00
committed by GitHub
parent 1a3d393e74
commit 2acd08202b
2 changed files with 86 additions and 3 deletions
@@ -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 <img>), not a
// trailing sibling of <body>, 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 <body> 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);
+46 -3
View File
@@ -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 <body>) 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 <body>, 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);
}
};