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); } };