Skip to content

Commit

Permalink
Cleanup, move types, add comments
Browse files Browse the repository at this point in the history
  • Loading branch information
marcysutton committed Nov 15, 2024
1 parent fc86083 commit 23f00ef
Show file tree
Hide file tree
Showing 3 changed files with 131 additions and 74 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import * as React from "react";
import {render, screen} from "@testing-library/react";
import {SendMessageButton} from "../components/send-message-button";
import {screen} from "@testing-library/react";
import {sendMessage, clearMessages} from "../index";

describe("Announcer.clearMessages", () => {
Expand Down
16 changes: 16 additions & 0 deletions packages/wonder-blocks-announcer/src/announcer.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export type PolitenessLevel = "polite" | "assertive";

export type RegionFactory = {
count: number;
aIndex: number;
pIndex: number;
};

export type RegionDef = {
id: string;
levelIndex: number;
level: PolitenessLevel;
element: HTMLElement;
};

export type RegionDictionary = Map<string, RegionDef>;
185 changes: 114 additions & 71 deletions packages/wonder-blocks-announcer/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,50 @@
// TODO: publish wonder-blocks-style package WB-1776
// import {srOnly} from "../../wonder-blocks-style/src/styles/a11y";

export type PolitenessLevel = "polite" | "assertive";
import type {
PolitenessLevel,
RegionDef,
RegionDictionary,
RegionFactory,
} from "./announcer.types";

const TIMEOUT_DELAY = 5000;
let announcer: Announcer | null = null;

export type SendMessageProps = {
message: string;
level?: PolitenessLevel;
timeoutDelay?: number;
removalDelay?: number;
};

/**
* Public API method to send screen reader messages.
* @param {string} message The message to send.
* @param {PolitenessLevel} level Polite or assertive announcements
* @param {number} removalDelay Optional duration to remove a message after sending. Defaults to 5000ms.
* @returns {string} IDREF for targeted live region element
*/
export function sendMessage({
message,
level = "polite", // TODO: decide whether to allow role=`timer`
timeoutDelay,
level = "polite", // TODO: decide whether to allow other roles, i.e. role=`timer`
removalDelay,
}: SendMessageProps): string {
announcer = Announcer.getInstance();

if (typeof jest === "undefined") {
setTimeout(() => {
return announcer?.announce(message, level, timeoutDelay);
return announcer?.announce(message, level, removalDelay);
}, 100);
} else {
// If we are in a test environment, announce without waiting
return announcer.announce(message, level, timeoutDelay);
return announcer.announce(message, level, removalDelay);
}
return "";
}

/**
* Public API method to clear screen reader messages after sending.
* Clears all regions by default.
* @param {string} id Optional id of live region element to clear.
*/
export function clearMessages(id?: string) {
if (id && document?.getElementById(id)) {
announcer?.clear(id);
Expand All @@ -38,36 +53,9 @@ export function clearMessages(id?: string) {
}
}

type RegionFactory = {
count: number;
aIndex: number;
pIndex: number;
};

interface RegionSet {
polite: HTMLElement[];
assertive: HTMLElement[];
} //deprecate

type RegionList = {[K in keyof RegionSet]: HTMLElement[]}; //deprecate
type RegionDef = {
id: string;
levelIndex: number;
level: PolitenessLevel;
element: HTMLElement;
};
type RegionDictionary = Map<string, RegionDef>;

/* {
wbARegion-polite0: {id: 0, level: polite, element: HTMLElement}
wbARegion-polite1: {id: 1, level: polite, element: HTMLElement}
wbARegion-assertive0: {id: 0, level: assertive, element: HTMLElement}
wbARegion-assertive1: {id: 1, level: assertive, element: HTMLElement}
}
{
assertive: [element0, element1]
polite: [element0, element1]
} */
/**
* Internal class to manage screen reader announcements.
*/
class Announcer {
private static _instance: Announcer;
node: HTMLElement | null = null;
Expand All @@ -76,34 +64,43 @@ class Announcer {
aIndex: 0,
pIndex: 0,
};
regionList: RegionList = {polite: [], assertive: []};
dictionary: RegionDictionary = new Map();

private constructor() {
if (typeof document !== "undefined") {
const topLevelId = `wbAnnounce`;

// Prevent duplicates in HMR
// Check if our top level element already exists
const announcerCheck = document.getElementById(topLevelId);

// Init new structure if the coast is clear
if (announcerCheck === null) {
this.init(topLevelId);
}
// The structure exists but references are lost, so help HMR recover
else {
this.rebootForHMR();
}
}
}

/**
* Singleton handler to ensure we only have one Announcer instance
* @returns {Announcer}
*/
static getInstance() {
if (!Announcer._instance) {
Announcer._instance = new Announcer();
}

Announcer._instance.rebootForHMR();
return Announcer._instance;
}

/**
* Internal initializer method to create live region elements
* Prepends regions to document body
* @param {string} id ID of the top level node (wbAnnounce)
*/
init(id: string) {
this.node = document.createElement("div");
this.node.id = id;
this.node.setAttribute("data-testid", `wbAnnounce`);
this.node.setAttribute("data-testid", id);

Object.assign(this.node.style, srOnly);

Expand All @@ -117,11 +114,12 @@ class Announcer {

document.body.prepend(this.node);
}

/**
* Recover in the event regions get lost
* This happens in Storybook when saving a file:
* Announcer exists, but it loses the connection to element Refs
*/
rebootForHMR() {
// Recover in the event regions get lost
// This happens in Storybook when saving a file:
// Announcer exists, but it loses the connection to element Refs
const announcerCheck = document.getElementById(`wbAnnounce`);
if (announcerCheck !== null) {
this.node = announcerCheck;
Expand All @@ -143,16 +141,23 @@ class Announcer {
}
}

isAttached() {
return this.node?.isConnected;
}

/**
* Create a wrapper element to group regions for a given level
* @param {string} level Politeness level for grouping
* @returns {HTMLElement} Wrapper DOM element reference
*/
createRegionWrapper(level: PolitenessLevel) {
const wrapper = document.createElement("div");
wrapper.id = `wbAWrap-${level}`;
return wrapper;
}

/**
* Create multiple live regions for a given level
* @param {HTMLElement} wrapper Parent DOM element reference to append into
* @param {string} level Politeness level for grouping
* @returns {HTMLElement[]} Array of region elements
*/
createDuplicateRegions(
wrapper: HTMLElement,
level: PolitenessLevel,
Expand All @@ -167,6 +172,13 @@ class Announcer {
return result;
}

/**
* Create live region element for a given level
* @param {string} level Politeness level for grouping
* @param {number} index Incrementor for duplicate regions
* @param {string} role Role attribute for live regions, defaults to log
* @returns {HTMLElement} DOM element reference for live region
*/
createRegion(level: PolitenessLevel, index: number, role = "log") {
const region = document.createElement("div");
// TODO: test combinations of attrs
Expand All @@ -185,10 +197,17 @@ class Announcer {
return region;
}

/**
* Announce a live region message for a given level
* @param {string} message The message to be announced
* @param {string} level Politeness level: should it interrupt?
* @param {number} removalDelay How long to wait before removing message
* @returns {string} IDREF for targeted element or empty string if it failed
*/
announce(
message: string,
level: PolitenessLevel,
timeoutDelay?: number,
removalDelay?: number,
): string {
if (!this.node) {
return "";
Expand All @@ -203,7 +222,7 @@ class Announcer {
message,
level,
regions,
timeoutDelay,
removalDelay,
);

// overwrite central index for the given level
Expand All @@ -216,11 +235,38 @@ class Announcer {
return regions[newIndex].id || "";
}

/**
* Clear messages on demand.
* This could be useful for clearing immediately, rather than waiting for the removalDelay.
* Defaults to clearing all live region elements
* @param {string} id Optional IDREF of specific element to empty
*/
clear(id?: string) {
if (!this.node) {
return;
}
if (id) {
this.dictionary.get(id)?.element.replaceChildren();
} else {
this.dictionary.forEach((region) => {
region.element.replaceChildren();
});
}
}

/**
* Append message to alternating element for a given level
* @param {string} message The message to be appended
* @param {string} level Which level to alternate
* @param {RegionDef[]} regionList Filtered dictionary of regions for level
* @param {number} removalDelay How long to wait before removing message
* @returns {number} Index of targeted region for updating central register
*/
appendMessage(
message: string,
level: PolitenessLevel, // level
regionList: RegionDef[], // list of relevant elements
timeoutDelay: number = TIMEOUT_DELAY,
removalDelay: number = TIMEOUT_DELAY,
): number {
// Starting index for a given level
let index =
Expand All @@ -243,33 +289,30 @@ class Announcer {

setTimeout(() => {
messageEl.remove();
}, timeoutDelay);
}, removalDelay);

return index;
}

/**
* Alternate index for cycling through elements
* @param {number} index Previous element index (0 or 1)
* @returns {number} New index
*/
alternateIndex(index: number): number {
index += 1;
index = index % this.regionFactory.count;
return index;
}

clear(id?: string) {
if (!this.node) {
return;
}
if (id) {
this.dictionary.get(id)?.element.replaceChildren();
} else {
this.dictionary.forEach((region) => {
region.element.replaceChildren();
});
}
}
}

export default Announcer;

/**
* Styling for live region.
* TODO: move to wonder-blocks-style package.
* Note: This style is overridden in Storybook for testing.
*/
export const srOnly = {
border: 0,
clip: "rect(0,0,0,0)",
Expand Down

0 comments on commit 23f00ef

Please sign in to comment.