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.
  • Loading branch information
abaevbog committed Sep 4, 2024
1 parent dbf4bef commit fe84482
Show file tree
Hide file tree
Showing 8 changed files with 190 additions and 34 deletions.
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
69 changes: 53 additions & 16 deletions src/common/reader.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ class Reader {
entireWord: false,
result: null
},
a11yVirtualCursorTarget: null
};

if (options.secondaryViewState) {
Expand Down Expand Up @@ -655,33 +656,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 +783,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 +849,27 @@ 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 !== this._state.a11yVirtualCursorTarget) {
this._updateState({ a11yVirtualCursorTarget: node });
}
}

// Announce the search index, page and snippet of the search result
let a11yAnnounceSearchMsg = (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 +916,9 @@ class Reader {
onTabOut,
onKeyDown,
onKeyUp,
onFocusAnnotation
onFocusAnnotation,
setA11yVirtualCursorTarget,
a11yAnnounceSearchMsg
};

if (this._type === 'pdf') {
Expand Down Expand Up @@ -939,6 +949,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 +976,32 @@ 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;
let doc = this._lastView._iframe.contentDocument;
// If the target is a text node, use its parent (e.g. <p> or <h>)
if (target?.nodeName == "#text") {
target = target.parentNode;
}
if (!target || !doc.contains(target)) return;
// Make it temporarily focusable
target.setAttribute("tabindex", "-1");
// On blur or keypress, blur it
target.addEventListener("blur", (event) => {
target.removeAttribute("tabindex");
});
target.addEventListener("keydown", (event) => {
target.blur();
});
target.focus();
this._updateState({ a11yVirtualCursorTarget: 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;
a11yAnnounceSearchMsg: (index: number, total: number, pageLabel: string | null, snippet:string) => void;
data: Data & {
buf?: Uint8Array,
url?: string
Expand Down
50 changes: 50 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 Down Expand Up @@ -56,6 +57,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 @@ -219,6 +221,8 @@ class EPUBView extends DOMView<EPUBViewState, EPUBViewData> {

// @ts-ignore
this.book.archive.zip = null;

this._addAriaNavigationLandmarks();
}

private async _isEncrypted() {
Expand Down Expand Up @@ -342,6 +346,25 @@ 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 textNodeWrapper = closestElement(node);
let nextPageElement = textNodeWrapper?.nextElementSibling;

if (!nextPageElement || !textNodeWrapper) 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.
textNodeWrapper.setAttribute("role", "navigation");

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

override toSelector(range: Range): FragmentSelector | null {
range = moveRangeEndsIntoTextNodes(range);
let cfi = this.getCFI(range);
Expand Down Expand Up @@ -911,6 +934,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 +947,31 @@ 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, 100);

let searchResult = getStartElement(range);
// @ts-ignore
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.a11yAnnounceSearchMsg(index, total, currentPageLabel, snippet);
}

protected _setScale(scale: number) {
this._keepPosition(() => {
this.scale = scale;
Expand Down Expand Up @@ -994,6 +1038,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 = view.container.querySelector("replaced-body section h1,h2,h3,h4,h5,h6,span,p");
debounceUntilScrollFinishes(this._iframeDocument, 100).then(() => {
this._options.setA11yVirtualCursorTarget(firstText || view.container);
});
}
}
else {
Expand Down
Loading

0 comments on commit fe84482

Please sign in to comment.