Skip to content

Commit

Permalink
miscellaneous a11y improvements
Browse files Browse the repository at this point in the history
- set role="application" on toolbar and sidebar to force
screen readers to use focus mode in those areas since
reading mode is not applicable here and one should not have
to manually switch to focus mode
- made outline values visible to screen readers
- improved aria-live message announced during search navigation
to include the page number as well as the snippet of the
result
- added role="navigation" to start containers of epub ranges
so that screen readers indicate when one moves to a new page.
It also enabled navigation via d/shift-d for NVDA and r/shift-r
for JAWS to go to next/previous page as with PDFs.
- added a state variable a11yVirtualCursorTarget to record
which node the screen readers should place its virtual cursor
on next time the focus enters the reader.
It forces virtual cursor to be moved onto that node, as
opposed to landing in the beginning of the document.
It is currently used to make sure screen readers begin reading
the chapter/section selected in the outline, as well as to
place virtual cursor on the last search result. On scroll,
a11yVirtualCursorTarget is cleared to not interfere with
mouse navigation. To make sure that scroll events of document
that fire when outline is navigated don't clear the a11yVirtualCursorTarget
that was just set, we wait for scrolling to finish and do
not allow a11yVirtualCursorTarget to be cleared if it was added
within the last 0.5 second.
  • Loading branch information
abaevbog committed Sep 5, 2024
1 parent f228509 commit a80e9e9
Show file tree
Hide file tree
Showing 10 changed files with 218 additions and 35 deletions.
2 changes: 1 addition & 1 deletion epubjs/epub.js
28 changes: 15 additions & 13 deletions src/common/components/sidebar/outline-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ function Item({ item, id, children, onOpenLink, onUpdate, onSelect }) {
let { expanded, active } = item;

return (
<li>
<li id={`outline-${id}`} aria-label={item.title}>
<div
className={cx('item', { expandable: !!item.items?.length, expanded, active })}
data-id={id}
Expand Down Expand Up @@ -76,20 +76,20 @@ function OutlineView({ outline, onNavigate, onOpenLink, onUpdate}) {
}
}

function handleKeyDown(event) {
let { key } = event;

let list = [];
function flatten(items) {
for (let item of items) {
list.push(item);
if (item.items && item.expanded) {
flatten(item.items);
}
function flatten(items, list = []) {
for (let item of items) {
list.push(item);
if (item.items && item.expanded) {
flatten(item.items, list);
}
}
return list;
}

function handleKeyDown(event) {
let { key } = event;

flatten(outline);
let list = flatten(outline);

let currentIndex = list.findIndex(x => x.active);
let currentItem = list[currentIndex];
Expand Down Expand Up @@ -166,16 +166,18 @@ function OutlineView({ outline, onNavigate, onOpenLink, onUpdate}) {
);
}

let active = flatten(outline || []).findIndex(item => item.active);
return (
<div
ref={containerRef}
className={cx('outline-view', { loading: outline === null })}
data-tabstop="1"
tabIndex={-1}
id="outlineView"
role="tabpanel"
role="listbox"
aria-labelledby="viewOutline"
onKeyDown={handleKeyDown}
aria-activedescendant={active !== -1 ? `outline-${active}` : null}
>
{outline === null ? <div className="spinner"/> : renderItems(outline)}
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/common/components/sidebar/sidebar.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function Sidebar(props) {
}

return (
<div id="sidebarContainer" className="sidebarOpen">
<div id="sidebarContainer" className="sidebarOpen" role="application">
<div className="sidebar-toolbar">
<div className="start" data-tabstop={1} role="tablist">
{props.type === 'pdf' &&
Expand Down Expand Up @@ -72,7 +72,7 @@ function Sidebar(props) {
<div id="annotationsView" role="tabpanel" aria-labelledby="viewAnnotations" className={cx("viewWrapper", { hidden: props.view !== 'annotations'})}>
{props.annotationsView}
</div>
<div className={cx("viewWrapper", { hidden: props.view !== 'outline'})}>
<div className={cx("viewWrapper", { hidden: props.view !== 'outline' })} role="tabpanel">
{props.outlineView}
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/common/components/toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ function Toolbar(props) {
}

return (
<div className="toolbar" data-tabstop={1}>
<div className="toolbar" data-tabstop={1} role="application">
<div className="start">
<button
id="sidebarToggle"
Expand Down
94 changes: 78 additions & 16 deletions src/common/reader.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ class Reader {
entireWord: false,
result: null
},
a11yVirtualCursorTarget: {
node: null,
ts: null
}
};

if (options.secondaryViewState) {
Expand Down Expand Up @@ -655,33 +659,18 @@ class Reader {
this._onTextSelectionAnnotationModeChange(mode);
}

// Announce the index of current search result to screen readers
setA11ySearchResultMessage(primaryView) {
let result = (primaryView ? this._state.primaryViewFindState : this._state.secondaryViewFindState).result;
if (!result) return;
let searchIndex = `${this._getString("pdfReader.searchResultIndex")}: ${result.index + 1}`;
let totalResults = `${this._getString("pdfReader.searchResultTotal")}: ${result.total}`;
this.setA11yMessage(`${searchIndex}. ${totalResults}`);
}

findNext(primary) {
if (primary === undefined) {
primary = this._lastViewPrimary;
}
(primary ? this._primaryView : this._secondaryView).findNext();
setTimeout(() => {
this.setA11ySearchResultMessage(primary);
});
}

findPrevious(primary) {
if (primary === undefined) {
primary = this._lastViewPrimary;
}
(primary ? this._primaryView : this._secondaryView).findPrevious();
setTimeout(() => {
this.setA11ySearchResultMessage(primary);
});
}

toggleEPUBAppearancePopup({ open }) {
Expand Down Expand Up @@ -797,6 +786,7 @@ class Reader {
this.focusView(primary);
// A workaround for Firefox/Zotero because iframe focusing doesn't trigger 'focusin' event
this._focusManager._closeFindPopupIfEmpty();
this.placeA11yVirtualCursor();
};

let onRequestPassword = () => {
Expand Down Expand Up @@ -862,6 +852,35 @@ class Reader {
this.setA11yMessage(annotationContent);
}

// Add page number as aria-label to provided node to improve screen reader navigation
let setA11yNavContent = (node, pageIndex) => {
node.setAttribute('aria-label', `${this._getString("pdfReader.page")}: ${pageIndex}`);
};

// Set which node should receive focus when the focus enters the reader to
// help screen readers place virtual cursor at the right location
let setA11yVirtualCursorTarget = (node) => {
if (node && node !== this._state.a11yVirtualCursorTarget.node) {
this._updateState({ a11yVirtualCursorTarget: { node, ts: Date.now() } });
}
// Clear the cursor only half a second after it was set. It ensures the
// target is not cleared by scrolling of the document during outline navigation.
// Particularly important for snapshots where a random scroll event would fire after
// debounceUntilScrollFinishes is done. In all other instances of scrolling,
// the virtual cursor target is cleared
if (node === null && Date.now() - this._state.a11yVirtualCursorTarget.ts > 500) {
this._updateState({ a11yVirtualCursorTarget: { node: null, ts: null } });
}
};

// Announce the search index, page and snippet of the search result
let a11yAnnounceSearchMessage = (index, total, pageLabel, snippet) => {
let searchIndex = `${this._getString("pdfReader.searchResultIndex")}: ${index + 1}.`;
let totalResults = `${this._getString("pdfReader.searchResultTotal")}: ${total}.`;
let page = pageLabel !== null ? `${this._getString("pdfReader.page")}: ${pageLabel}.` : "";
this.setA11yMessage(`${searchIndex} ${totalResults} ${snippet || ""} ${page}`);
};

let data;
if (this._type === 'pdf') {
data = this._data;
Expand Down Expand Up @@ -908,7 +927,9 @@ class Reader {
onTabOut,
onKeyDown,
onKeyUp,
onFocusAnnotation
onFocusAnnotation,
setA11yVirtualCursorTarget,
a11yAnnounceSearchMessage
};

if (this._type === 'pdf') {
Expand Down Expand Up @@ -939,6 +960,7 @@ class Reader {
fontFamily: this._state.fontFamily,
hyphenate: this._state.hyphenate,
onEPUBEncrypted,
setA11yNavContent,
});
} else if (this._type === 'snapshot') {
view = new SnapshotView({
Expand All @@ -965,6 +987,46 @@ class Reader {
document.getElementById("a11yAnnouncement").innerText = a11yMessage;
}

// Make a11yVirtualCursorTarget node set previously focusable and
// focus it to help screen readers understand where the virtual cursor needs to
// be positioned. This is required because screen readers are not aware of
// scroll positioning, so without this, the virtual cursor will always land
// at the start of the document.
placeA11yVirtualCursor() {
let target = this._state.a11yVirtualCursorTarget.node;
let doc = this._lastView._iframe.contentDocument;
// If the target is a text node, use its parent (e.g. <p> or <h>)
if (target?.nodeType === Node.TEXT_NODE) {
target = target.parentNode;
}
if (!target || !doc.contains(target)) return;
// Make it temporarily focusable
target.setAttribute("tabindex", "-1");
target.focus();

// On blur or keypress, blur it
if (doc.activeElement == target) {
target.addEventListener("blur", (_) => {
target.removeAttribute("tabindex");
});
target.addEventListener("keydown", (_) => {
target.blur();
});
// Keypress may not fire if screen reader is being used, in which
// case remove tabindex next time the page content is scrolled
let cleanUpOnScroll = (_) => {
target.blur();
doc.removeEventListener("scroll", cleanUpOnScroll);
};
doc.addEventListener("scroll", cleanUpOnScroll);
}
// If the focus didn't take, make sure temp tabindex is removed
else {
target.removeAttribute("tabindex");
}
this._updateState({ a11yVirtualCursorTarget: { node: null, ts: null } });
}

getUnsavedAnnotations() {

}
Expand Down
6 changes: 6 additions & 0 deletions src/dom/common/dom-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1138,6 +1138,9 @@ abstract class DOMView<State extends DOMViewState, Data> {
this._renderAnnotations();
this._repositionPopups();
});
// Clear whatever node we may have planned to focus for screen readers
// to not interfere with mouse navigation
this._options.setA11yVirtualCursorTarget(null);
}

protected _handleScrollCapture(event: Event) {
Expand Down Expand Up @@ -1412,6 +1415,9 @@ export type DOMViewOptions<State extends DOMViewState, Data> = {
onKeyUp: (event: KeyboardEvent) => void;
onKeyDown: (event: KeyboardEvent) => void;
onEPUBEncrypted: () => void;
setA11yVirtualCursorTarget: (node: Node | null) => void;
setA11yNavContent: (node: Node, pageIndex: string) => void;
a11yAnnounceSearchMessage: (index: number, total: number, pageLabel: string | null, snippet: string) => void;
data: Data & {
buf?: Uint8Array,
url?: string
Expand Down
3 changes: 3 additions & 0 deletions src/dom/common/lib/range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,14 @@ export class PersistentRange {

endOffset: number;

collapsed: boolean;

constructor(range: AbstractRange) {
this.startContainer = range.startContainer;
this.startOffset = range.startOffset;
this.endContainer = range.endContainer;
this.endOffset = range.endOffset;
this.collapsed = range.collapsed;
}

toRange(): Range {
Expand Down
48 changes: 48 additions & 0 deletions src/dom/epub/epub-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import Epub, {
NavItem,
} from "epubjs";
import {
getStartElement,
moveRangeEndsIntoTextNodes,
PersistentRange,
splitRangeToTextNodes
Expand All @@ -42,6 +43,7 @@ import SectionRenderer from "./section-renderer";
import Section from "epubjs/types/section";
import {
closestElement,
getVisibleTextNodes,
getContainingBlock
} from "../common/lib/nodes";
import { StyleScoper } from "./lib/sanitize-and-render";
Expand All @@ -56,6 +58,7 @@ import {
ScrolledFlow
} from "./flow";
import { DEFAULT_EPUB_APPEARANCE, RTL_SCRIPTS } from "./defines";
import { debounceUntilScrollFinishes } from "../../common/lib/utilities";

class EPUBView extends DOMView<EPUBViewState, EPUBViewData> {
protected _find: EPUBFindProcessor | null = null;
Expand Down Expand Up @@ -177,6 +180,7 @@ class EPUBView extends DOMView<EPUBViewState, EPUBViewData> {
this._sectionsContainer.hidden = false;
this.pageMapping = this._initPageMapping(viewState.savedPageMapping);
this._initOutline();
this._addAriaNavigationLandmarks();

// Validate viewState and its properties
// Also make sure this doesn't trigger _updateViewState
Expand Down Expand Up @@ -342,6 +346,24 @@ class EPUBView extends DOMView<EPUBViewState, EPUBViewData> {
}
}

// Add landmarks with page labels for screen reader navigation
private async _addAriaNavigationLandmarks() {
for (let [key, value] of this.pageMapping.tree.entries()) {
let node = key.startContainer;
let containingElement = closestElement(node);

if (!containingElement) continue;

// This is semantically not correct, as we are assigning
// navigation role to <p> and <h> nodes but this is the
// best solution to avoid adding nodes into the DOM, which
// will break CFIs.
containingElement.setAttribute("role", "navigation");

this._options.setA11yNavContent(containingElement, value);
}
}

override toSelector(range: Range): FragmentSelector | null {
range = moveRangeEndsIntoTextNodes(range);
let cfi = this.getCFI(range);
Expand Down Expand Up @@ -911,6 +933,7 @@ class EPUBView extends DOMView<EPUBViewState, EPUBViewData> {
let result = await processor.next();
if (result) {
this.flow.scrollIntoView(result.range);
this.a11yHandleSearchResultUpdate(result.range);
}
this._renderAnnotations();
}
Expand All @@ -923,11 +946,30 @@ class EPUBView extends DOMView<EPUBViewState, EPUBViewData> {
let result = await processor.prev();
if (result) {
this.flow.scrollIntoView(result.range);
this.a11yHandleSearchResultUpdate(result.range);
}
this._renderAnnotations();
}
}

// After the search result is switched to, record which node the
// search result is in to place screen readers' virtual cursor on it
// + announce the result.
async a11yHandleSearchResultUpdate(range: PersistentRange) {
await debounceUntilScrollFinishes(this._iframeDocument);

let searchResult = getStartElement(range);
let currentPageLabel = this.pageMapping.getPageLabel(range);
if (!searchResult || !this._findState?.result || !currentPageLabel) return;

this._options.setA11yVirtualCursorTarget(searchResult);

let { index, total } = this._findState.result;

let snippet = this._findState.result.snippets[this._findState.result.index];
this._options.a11yAnnounceSearchMessage(index, total, currentPageLabel, snippet);
}

protected _setScale(scale: number) {
this._keepPosition(() => {
this.scale = scale;
Expand Down Expand Up @@ -994,6 +1036,12 @@ class EPUBView extends DOMView<EPUBViewState, EPUBViewData> {
return;
}
this.flow.scrollIntoView(view.container, options);
// Once scrolling is done, tell screen readers to focus the first textual element of
// the section. Used when you navigate to a new section via outline in the sidebar.
let firstText = getVisibleTextNodes(view.body)[0];
debounceUntilScrollFinishes(this._iframeDocument).then(() => {
this._options.setA11yVirtualCursorTarget(firstText || view.body);
});
}
}
else {
Expand Down
Loading

0 comments on commit a80e9e9

Please sign in to comment.