2acd08202b
* 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>
114 lines
3.9 KiB
TypeScript
114 lines
3.9 KiB
TypeScript
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,
|
|
options?: {
|
|
skipToLastPosCallback: () => void;
|
|
skipToLastPosLabel: string;
|
|
skipToNextSectionCallback: () => void;
|
|
skipToNextSectionLabel: string;
|
|
},
|
|
) => {
|
|
if (!view) return;
|
|
|
|
document.querySelectorAll('a').forEach((el) => {
|
|
el.setAttribute('tabindex', '-1');
|
|
});
|
|
|
|
// Inject a hidden "skip to reading position" link as the very first accessible
|
|
// element in the iframe body. NVDA's D-key landmark navigation fires no DOM
|
|
// events, so we cannot detect it; instead, when NVDA enters the landmark its
|
|
// virtual cursor lands on this link first. The user presses Enter to jump to
|
|
// their actual reading position.
|
|
const skipLinkId = 'readest-skip-link-last-pos';
|
|
if (document.body && !document.getElementById(skipLinkId)) {
|
|
const skipLink = document.createElement('div');
|
|
skipLink.id = skipLinkId;
|
|
skipLink.setAttribute('cfi-inert', '');
|
|
skipLink.setAttribute('tabindex', '0');
|
|
skipLink.setAttribute('aria-hidden', 'false');
|
|
skipLink.setAttribute('aria-label', options?.skipToLastPosLabel ?? '');
|
|
Object.assign(skipLink.style, {
|
|
position: 'absolute',
|
|
left: '0px',
|
|
top: 'auto',
|
|
width: '1px',
|
|
height: '1px',
|
|
overflow: 'hidden',
|
|
});
|
|
skipLink.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
options?.skipToLastPosCallback();
|
|
});
|
|
document.body.prepend(skipLink);
|
|
}
|
|
const skipNextSectionLinkId = 'readest-skip-link-next-section';
|
|
if (document.body && !document.getElementById(skipNextSectionLinkId)) {
|
|
const skipLink = document.createElement('div');
|
|
skipLink.id = skipNextSectionLinkId;
|
|
skipLink.setAttribute('cfi-inert', '');
|
|
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: 'absolute',
|
|
left: 'auto',
|
|
top: 'auto',
|
|
width: '1px',
|
|
height: '1px',
|
|
overflow: 'hidden',
|
|
});
|
|
skipLink.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
options?.skipToNextSectionCallback();
|
|
});
|
|
// 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);
|
|
}
|
|
};
|