Skip to content

Commit

Permalink
Merge pull request #389 from bootstrapworld/pcardune-rework-announcem…
Browse files Browse the repository at this point in the history
…ents

Rework announcements to not use global SHARED object
  • Loading branch information
Emmanuel Schanzer authored Sep 7, 2021
2 parents 07929ad + bf54dc3 commit 168659f
Show file tree
Hide file tree
Showing 11 changed files with 132 additions and 53 deletions.
30 changes: 24 additions & 6 deletions src/actions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {say, poscmp, srcRangeIncludes, warn, createAnnouncement} from './utils';
import {poscmp, srcRangeIncludes, warn} from './utils';
import { say, cancelAnnouncement } from "./announcer";
import SHARED from './shared';
import {AppDispatch, store} from './store';
import {performEdits, edit_insert, edit_delete, edit_replace,
Expand Down Expand Up @@ -53,15 +54,32 @@ export function insert(text: string, target: Target, onSuccess?: OnSuccess, onEr
performEdits('cmb:insert', ast, edits, onSuccess, onError, annt);
}

/**
* Generates a description of an edit involving certain nodes
* that can be announced to the user.
*
* @param nodes the ast nodes that are involved
* @param editWord a word describing the edit like "copied"
* @returns the human readable description of the edit
* @internal
*/
function createEditAnnouncement(nodes: ASTNode[], editWord: string) {
nodes.sort((a,b) => poscmp(a.from, b.from)); // speak first-to-last
let annt = (editWord + " " +
nodes.map((node) => node.shortDescription())
.join(" and "));
return annt;
}

// Delete the given nodes.
export function delete_(nodes: ASTNode[], editWord?: string) { // 'delete' is a reserved word
if (nodes.length === 0) return;
const {ast} = store.getState();
nodes.sort((a, b) => poscmp(b.from, a.from)); // To focus before first deletion
const edits = nodes.map(node => edit_delete(node));
let annt: string|false = false;
let annt: string;
if (editWord) {
annt = createAnnouncement(nodes, editWord);
annt = createEditAnnouncement(nodes, editWord);
say(annt);
}
performEdits('cmb:delete-node', ast, edits, undefined, undefined, annt);
Expand All @@ -76,9 +94,9 @@ export function copy(nodes: ASTNode[], editWord?: string) {
// commented nodes (to prevent a comment from attaching itself to a
// different node after pasting).
nodes.sort((a, b) => poscmp(a.from, b.from));
let annt: string|false = false;
let annt: string;
if (editWord) {
annt = createAnnouncement(nodes, editWord);
annt = createEditAnnouncement(nodes, editWord);
say(annt);
}
let text = "";
Expand Down Expand Up @@ -205,7 +223,7 @@ export function activateByNid(nid:number|null, options?:{allowMove?: boolean, re
setTimeout(() => { if(newNode.element) newNode.element.focus(); }, 10);
}

clearTimeout(store.queuedAnnouncement); // clear any overrideable announcements
cancelAnnouncement(); // clear any overrideable announcements
// FIXME(Oak): if possible, let's not hard code like this
if (['blank', 'literal'].includes(newNode.type) && !collapsedList.includes(newNode.id)) {
say('Use enter to edit', 1250, true); // wait 1.25s, and allow to be overridden
Expand Down
97 changes: 97 additions & 0 deletions src/announcer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
class Announcer {
private queuedAnnouncement: ReturnType<typeof setTimeout>;
private announcerElement: HTMLElement;
muted: boolean;

constructor(mountPoint: HTMLElement) {
this.announcerElement = document.createElement('div');
this.announcerElement.setAttribute('aria-live', 'assertive');
this.announcerElement.setAttribute('aria-atomic', 'true');
mountPoint.appendChild(this.announcerElement);
}

/**
* Make a screenreader announce something to the user
*
* @param text the text to say
* @param delay how long to wait (in ms) before making the announcement
* @param allowOverride whether or not this announcement can be overridden
*
* @internal
*/
say(text: string, delay=200, allowOverride=false) {
if (this.muted) {
return;
}
const announcement = document.createTextNode(text);

clearTimeout(this.queuedAnnouncement); // clear anything overrideable

if (allowOverride) { // enqueue overrideable announcements
this.queuedAnnouncement = setTimeout(() => say('Use enter to edit', 0), delay);
} else { // otherwise write it to the DOM,
this.announcerElement.childNodes.forEach( c => c.remove() ); // remove the children
console.log('say:', text); // then erase it 10ms later
setTimeout(() => this.announcerElement.appendChild(announcement), delay);
}
}

/**
* Cancels a delayed announcement if there is one in the queue
* and it is allowed to be overridden.
* @internal
*/
cancelAnnouncement() {
clearTimeout(this.queuedAnnouncement);
}
}
let announcerSingleton:Announcer;
/**
* Create an announcer instance at the given mount point in the DOM.
* This function must be called before the {@link say} function will
* do anything.
*/
export function mountAnnouncer(mountPoint: HTMLElement) {
announcerSingleton = new Announcer(mountPoint);
}

/**
* Make a screenreader announce something to the user
*
* Note: {@link mountAnnouncer} must have been called first.
*
* Note: screenreaders will automatically speak items with aria-labels!
* This handles _everything_else_.
*
* @param text the text to say
* @param delay how long to wait (in ms) before making the announcement
* @param allowOverride whether or not this announcement can be overridden
*
* @internal
*/
export function say(text: string, delay=200, allowOverride=false) {
if (announcerSingleton) {
announcerSingleton.say(text, delay, allowOverride);
}
}

/**
* Cancels a delayed announcement if there is one in the queue
* and it is allowed to be overridden.
* @internal
*/
export function cancelAnnouncement() {
if (announcerSingleton) {
announcerSingleton.cancelAnnouncement();
}
}

/**
* Mute/unmute the announcer.
* @param muted whether or not the announcer should be muted
*/
export function setMuted(muted:boolean) {
if (announcerSingleton) {
announcerSingleton.muted = muted;
}
}
2 changes: 1 addition & 1 deletion src/components/NodeEditable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import ContentEditable, { ContentEditableProps } from './ContentEditable';
import SHARED from '../shared';
import classNames, {Argument as ClassNamesArgument} from 'classnames';
import {insert, activateByNid, Target} from '../actions';
import {say} from '../utils';
import { say } from "../announcer";
import CodeMirror from 'codemirror';
import { AppDispatch } from '../store';
import { RootState } from '../reducers';
Expand Down
2 changes: 1 addition & 1 deletion src/edits/performEdits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export function performEdits(
edits: Edit[],
onSuccess: OnSuccess=(r:{newAST:AST, focusId:string})=>{},
onError: OnError=(e:any)=>{},
annt?: string|false
annt?: string
) {
// Ensure that all of the edits are valid.
//console.log('XXX performEdits:55 doing performEdits');
Expand Down
3 changes: 2 additions & 1 deletion src/keymap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import CodeMirror, { Editor } from 'codemirror';
import SHARED from './shared';
import {delete_, copy, paste, InsertTarget,
ReplaceNodeTarget, OverwriteTarget, activateByNid} from './actions';
import {partition, getRoot, skipCollapsed, say, mac,
import {partition, getRoot, skipCollapsed, mac,
getLastVisibleNode, preambleUndoRedo, playSound, BEEP} from './utils';
import { say } from "./announcer";
import {findAdjacentDropTargetId as getDTid} from './components/DropTarget';

import type { AppDispatch } from './store';
Expand Down
1 change: 0 additions & 1 deletion src/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ type Shared = {
to: CodeMirror.Position;
options: CodeMirror.TextMarkerOptions;
}>;
announcer: HTMLElement;
}

export default {} as Shared;
4 changes: 0 additions & 4 deletions src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,6 @@ export type AppStore =
// reason. It's effectively a global variable and should be
// stored somewhere else.
onKeyDown?: (e:React.KeyboardEvent, env: InputEnv)=>void,

// used in say() function of util.ts
muteAnnouncements?: boolean,
queuedAnnouncement?: ReturnType<typeof setTimeout>,
};

export const store: AppStore = reduxStore
Expand Down
2 changes: 1 addition & 1 deletion src/ui/PrimitiveList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import PropTypes from 'prop-types';
import {PrimitiveGroup as PrimitiveGroupModel} from '../parsers/primitives';
import {Primitive as LanguagePrimitive} from '../parsers/primitives';
import {DragPrimitiveSource} from '../dnd';
import {say} from '../utils';
import { say } from "../announcer";
import {copy} from '../actions';
import CodeMirror from 'codemirror';
import {defaultKeyMap} from '../keymap';
Expand Down
3 changes: 2 additions & 1 deletion src/ui/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import React, {Component} from 'react';
import Dialog from '../components/Dialog';
import {Tab, Tabs, TabList, TabPanel} from 'react-tabs';
import 'react-tabs/style/react-tabs.less';
import {say, getBeginCursor, getEndCursor, playSound, WRAP} from '../utils';
import {getBeginCursor, getEndCursor, playSound, WRAP} from '../utils';
import { say } from "../announcer";
import { BlockEditorComponentClass, Search } from './BlockEditor';
import { Searcher } from './searchers/Searcher';
import { GetProps } from 'react-redux';
Expand Down
12 changes: 3 additions & 9 deletions src/ui/ToggleEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import ByBlock from './searchers/ByBlock';
import attachSearch from './Search';
import Toolbar from './Toolbar';
import { ToggleButton, BugButton } from './EditorButtons';
import { say } from '../utils';
import { mountAnnouncer, say } from "../announcer";
import TrashCan from './TrashCan';
import SHARED from '../shared';
import type { AST } from '../ast';
Expand Down Expand Up @@ -249,12 +249,6 @@ class ToggleEditor extends Component<ToggleEditorProps, ToggleEditorState> {

this.toolbarRef = createRef();

// construct announcer DOM node
const announcements = document.createElement('div');
announcements.setAttribute('aria-live', 'assertive');
announcements.setAttribute('aria-atomic', 'true');
SHARED.announcer = announcements;

SHARED.recordedMarks = new Map();
this.eventHandlers = {}; // blank event-handler record

Expand Down Expand Up @@ -328,12 +322,12 @@ class ToggleEditor extends Component<ToggleEditorProps, ToggleEditorState> {
* and (3) re-render any TextMarkers.
*/
handleEditorMounted = (ed: CodeMirror.Editor, api: API, ast: AST) => {
// set CM aria attributes, and add announcer
// set CM aria attributes, and mount announcer
const mode = this.state.blockMode ? 'Block' : 'Text';
const wrapper = ed.getWrapperElement();
ed.getScrollerElement().setAttribute('role', 'presentation');
wrapper.setAttribute('aria-label', mode+' Editor');
wrapper.appendChild(SHARED.announcer);
mountAnnouncer(wrapper);
// Rebuild the API and assign re-events
Object.assign(this.props.api, this.buildAPI(ed), api);
Object.keys(this.eventHandlers).forEach(type => {
Expand Down
29 changes: 1 addition & 28 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,33 +136,6 @@ export function partition<T>(arr: T[], f:(i:T)=>boolean) {
// };
// }

store.muteAnnouncements = false;
store.queuedAnnouncement = undefined;

// Note: screenreaders will automatically speak items with aria-labels!
// This handles _everything_else_.
export function say(text: string, delay=200, allowOverride=false) {
const announcement = document.createTextNode(text);
const announcer = SHARED.announcer;
if (store.muteAnnouncements || !announcer) return; // if nothing to do, bail
clearTimeout(store.queuedAnnouncement); // clear anything overrideable
if(allowOverride) { // enqueue overrideable announcements
store.queuedAnnouncement = setTimeout(() => say('Use enter to edit', 0), delay);
} else { // otherwise write it to the DOM,
announcer.childNodes.forEach( c => c.remove() ); // remove the children
console.log('say:', text); // then erase it 10ms later
setTimeout(() => announcer.appendChild(announcement), delay);
}
}

export function createAnnouncement(nodes: ASTNode[], action: string) {
nodes.sort((a,b) => poscmp(a.from, b.from)); // speak first-to-last
let annt = (action + " " +
nodes.map((node) => node.shortDescription())
.join(" and "));
return annt;
}

export function skipCollapsed(node: ASTNode, next: (node: ASTNode)=>ASTNode, state: RootState) {
const {collapsedList, ast} = state;
const collapsedNodeList = collapsedList.map(ast.getNodeById);
Expand Down Expand Up @@ -356,7 +329,6 @@ export function validateRanges(ranges: {anchor: Pos, head: Pos}[], ast: AST) {
return true;
}


export class BlockError extends Error {
type: string;
data: $TSFixMe;
Expand Down Expand Up @@ -409,6 +381,7 @@ class CustomAudio extends Audio {
import beepSound from './ui/beep.mp3';
export const BEEP = new CustomAudio(beepSound);
import wrapSound from './ui/wrap.mp3';
import { say } from './announcer';
export const WRAP = new CustomAudio(wrapSound);


Expand Down

0 comments on commit 168659f

Please sign in to comment.