Skip to content

Commit

Permalink
miscellaneous a11y improvements
Browse files Browse the repository at this point in the history
- announce focused annotations in epub and snapshot
similar to pdf
- marking sidebar, toolbar and find popup with role="application"
so screen readers use focus mode instead of reading mode,
which is not appropriate to interact with clickable elements
- make outline values visible for screen readers
- improved aria-live message announced during search navigation
to include the page number as well as the snippet of the
result if provided. It relies on currentSnippet and currentPageLabel
variables from FindState.
- added currentSnippet and currentPageLabel to find results
in EPUB and snapshots. Need to have it added to pdf too.
- 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.
Semanticaly, it is not correct but this seems to be the best
approach without adding nodes into the DOM, which will break CFIs.
- views have a new _a11yVirtualCursorTarget variable, in which
we record what node to make focusable and focus to help
screen readers place virtual cursor there. It is set to the following:
search match, the top of the page after scrolling (if not snapshot)
or the node matching the outline value (for snapshot). When the focus
returns into the content from elsewhere, _a11yVirtualCursorTarget will
become focusable. It is removed on pointer down events to avoid interference
with mouse interactions.
  • Loading branch information
abaevbog committed Jan 30, 2025
1 parent 521b207 commit 9b4edae
Show file tree
Hide file tree
Showing 15 changed files with 233 additions and 40 deletions.
33 changes: 18 additions & 15 deletions src/common/components/sidebar/outline-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,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, unmatched: item.matched === false, expanded, active })}
data-id={id}
Expand Down Expand Up @@ -146,22 +146,23 @@ function OutlineView({ outline, currentOutlinePath, onNavigate, onOpenLink, onUp
}
}

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

let list = [];
function flatten(items) {
for (let item of items) {
if ((item.matched !== false) || (item.childMatched !== false)) {
list.push(item);
}
if (item.items && item.expanded && (item.childMatched !== false)) {
flatten(item.items);
}
function flatten(items, list = []) {
for (let item of items) {
if ((item.matched !== false) || (item.childMatched !== false)) {
list.push(item);
}
if (item.items && item.expanded && (item.childMatched !== false)) {
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 @@ -239,16 +240,18 @@ function OutlineView({ outline, currentOutlinePath, onNavigate, onOpenLink, onUp
);
}

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 @@ -105,7 +105,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 @@ -166,7 +166,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
4 changes: 3 additions & 1 deletion src/common/components/toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,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 Expand Up @@ -134,6 +134,7 @@ function Toolbar(props) {
tabIndex={-1}
disabled={!props.enableNavigateToPreviousPage}
onClick={props.onNavigateToPreviousPage}
aria-describedby='numPages'
><IconChevronUp/></button>
<button
className="toolbar-button pageDown"
Expand All @@ -142,6 +143,7 @@ function Toolbar(props) {
tabIndex={-1}
disabled={!props.enableNavigateToNextPage}
onClick={props.onNavigateToNextPage}
aria-describedby='numPages'
><IconChevronDown/></button>
</React.Fragment>
)}
Expand Down
2 changes: 1 addition & 1 deletion src/common/components/view-popup/find-popup.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ function FindPopup({ params, onChange, onFindNext, onFindPrevious, onAddAnnotati
}

return (
<div className="find-popup">
<div className="find-popup" role='application'>
<div className="row input">
<div className={cx('input-box', { loading: !params.result && params.active && params.query })}>
<input
Expand Down
1 change: 1 addition & 0 deletions src/common/defines.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ export const INK_ANNOTATION_WIDTH_STEPS = [
];

export const TEXT_ANNOTATION_FONT_SIZE_STEPS = [10, 12, 14, 18, 24, 36, 48, 64, 72, 96, 144, 192];
export const A11Y_VIRT_CURSOR_DEBOUNCE_LENGTH = 100; // ms
40 changes: 40 additions & 0 deletions src/common/lib/utilities.js
Original file line number Diff line number Diff line change
Expand Up @@ -367,3 +367,43 @@ export function sortTags(tags) {
});
return tags;
}

/**
* Explicitly focus a given node within the view to force screen readers to move
* their virtual cursors to that element. Screen readers just look at rendered content
* so without this any navigation done via outline/Find in/page input in toolbar gets
* undone by virtual cursor either remaining where it was or even jumping to the beginning of content.
* @param target - node to focus from the view. Views keep track of it in _a11yVirtualCursorTarget obj.
*/
export async function placeA11yVirtualCursor(target) {
// Can't focus a textnode, so grab its parent (e.g. <p>)
if (target?.nodeType === Node.TEXT_NODE) {
target = target.parentNode;
}
if (!target) return;
let doc = target.ownerDocument;
// Make it temporarily focusable
target.setAttribute("tabindex", "-1");
target.classList.add("a11y-cursor-target");
target.focus({ preventScroll: true });
// If focus didn't take, remove tabindex and stop
if (doc.activeElement != target) {
target.removeAttribute("tabindex");
return;
}
function blurHandler() {
target.removeAttribute("tabindex");
target.classList.remove("a11y-cursor-target");
target.removeEventListener("blur", blurHandler);
}
// Remove focus when element looses focus
target.addEventListener("blur", blurHandler);
// Blur the target on any keypress so that one can still scroll content with
// arrowUp/Down. Otherwise, all keydown events land on the target and
// nothing happens
function keydownHandler() {
target.blur();
target.removeEventListener("keydown", keydownHandler);
}
target.addEventListener("keydown", keydownHandler);
}
35 changes: 20 additions & 15 deletions src/common/reader.js
Original file line number Diff line number Diff line change
Expand Up @@ -700,33 +700,34 @@ 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}`);
}
// Announce info about current search result to screen readers.
// FindState is updated multiple times while navigating between results
// so debounce is used to fire only after the last update.
a11yAnnounceSearchMessage = debounce((findStateResult) => {
if (!findStateResult) return;
let { index, total, currentPageLabel, currentSnippet } = findStateResult;
if (total == 0) {
this.setA11yMessage(this._getString("pdfReader.phraseNotFound"));
return;
}
let searchIndex = `${this._getString("pdfReader.searchResultIndex")}: ${index + 1}.`;
let totalResults = `${this._getString("pdfReader.searchResultTotal")}: ${total}.`;
let page = currentPageLabel ? `${this._getString("pdfReader.page")}: ${currentPageLabel}.` : "";
this.setA11yMessage(`${searchIndex} ${totalResults} ${page} ${currentSnippet || ""}`);
}, 100);

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 @@ -885,6 +886,7 @@ class Reader {

let onSetFindState = (params) => {
this._updateState({ [primary ? 'primaryViewFindState' : 'secondaryViewFindState']: params });
this.a11yAnnounceSearchMessage(params.result);
};

let onSelectAnnotations = (ids, triggeringEvent) => {
Expand Down Expand Up @@ -923,6 +925,8 @@ class Reader {
this.setA11yMessage(annotationContent);
}

let getLocalizedString = (name) => this._getString(name);

let data;
if (this._type === 'pdf') {
data = this._data;
Expand Down Expand Up @@ -971,7 +975,8 @@ class Reader {
onTabOut,
onKeyDown,
onKeyUp,
onFocusAnnotation
onFocusAnnotation,
getLocalizedString
};

if (this._type === 'pdf') {
Expand Down
3 changes: 3 additions & 0 deletions src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ export type FindState = {
// Mobile app lists all results in a popup
snippets: string[],
annotation?: NewAnnotation
// Used for a11y notifications
currentSnippet: string,
currentPageLabel: string | null
} | null;
};

Expand Down
29 changes: 28 additions & 1 deletion src/dom/common/dom-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ import {
getCodeCombination,
getKeyCombination,
isMac,
isSafari
isSafari,
placeA11yVirtualCursor
} from "../../common/lib/utilities";
import {
closestElement,
Expand Down Expand Up @@ -144,6 +145,8 @@ abstract class DOMView<State extends DOMViewState, Data> {

protected _lastKeyboardFocusedAnnotationID: string | null = null;

protected _a11yVirtualCursorTarget: Node | null;

scale = 1;

protected constructor(options: DOMViewOptions<State, Data>) {
Expand All @@ -164,6 +167,7 @@ abstract class DOMView<State extends DOMViewState, Data> {
onUpdate: () => this._updateViewStats(),
onNavigate: location => this.navigate(location, { skipHistory: true, behavior: 'auto' }),
});
this._a11yVirtualCursorTarget = null;

this._iframe = document.createElement('iframe');
this._iframe.sandbox.add('allow-same-origin', 'allow-modals');
Expand Down Expand Up @@ -705,6 +709,7 @@ abstract class DOMView<State extends DOMViewState, Data> {
let annotationsStyle = this._iframeDocument.createElement('style');
annotationsStyle.innerHTML = annotationsCSS;
this._annotationShadowRoot.append(annotationsStyle);
this._annotationShadowRoot.addEventListener("focusin", this._handleAnnotationFocus.bind(this));

this._iframeDocument.documentElement.classList.toggle('is-safari', isSafari);

Expand Down Expand Up @@ -1348,6 +1353,9 @@ abstract class DOMView<State extends DOMViewState, Data> {

this._options.onSetOverlayPopup();

// If we marked a node as future focus target for screen readers, clear it to not interfere with focus
this._a11yVirtualCursorTarget = null;

// Create note annotation on pointer down event, if note tool is active.
// The note tool will be automatically deactivated in reader.js,
// because this is what we do in PDF reader
Expand Down Expand Up @@ -1466,6 +1474,11 @@ abstract class DOMView<State extends DOMViewState, Data> {
this._renderAnnotations();
this._repositionPopups();
});
// If there exists a focused node for screen readers, make sure it gets blurred
let cursorTarget = this._iframeDocument.querySelector(".a11y-cursor-target");
if (cursorTarget) {
(cursorTarget as HTMLElement).blur();
}
}

protected _handleScrollCapture(event: Event) {
Expand Down Expand Up @@ -1532,6 +1545,18 @@ abstract class DOMView<State extends DOMViewState, Data> {

private _handleFocus() {
this._options.onFocus();

// Help screen readers understand where to place virtual cursor
placeA11yVirtualCursor(this._a11yVirtualCursorTarget);
this._a11yVirtualCursorTarget = null;
}

private _handleAnnotationFocus(event: Event) {
let annotationID = (event.target as HTMLElement).dataset.annotationId;
let annotation = annotationID ? this._annotations.find(ann => ann.id == annotationID) : null;
if (annotation) {
this._options.onFocusAnnotation(annotation);
}
}

private _preventNextClickEvent() {
Expand Down Expand Up @@ -1762,6 +1787,8 @@ export type DOMViewOptions<State extends DOMViewState, Data> = {
onKeyUp: (event: KeyboardEvent) => void;
onKeyDown: (event: KeyboardEvent) => void;
onEPUBEncrypted: () => void;
onFocusAnnotation: (annotation: WADMAnnotation) => void;
getLocalizedString: (name: string) => string;
data: Data & {
buf?: Uint8Array,
url?: string
Expand Down
2 changes: 2 additions & 0 deletions src/dom/epub/defines.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,5 @@ export const DEFAULT_EPUB_APPEARANCE: EPUBAppearance = Object.freeze({
pageWidth: PageWidth.Normal,
useOriginalFont: false,
});

export const A11Y_VIRT_CURSOR_DEBOUNCE_LENGTH = 100;
Loading

0 comments on commit 9b4edae

Please sign in to comment.