Skip to content

Commit

Permalink
feat: capture-announcements
Browse files Browse the repository at this point in the history
  • Loading branch information
AriPerkkio committed Jun 19, 2021
1 parent 2b4e664 commit dd6471a
Show file tree
Hide file tree
Showing 12 changed files with 768 additions and 2 deletions.
3 changes: 1 addition & 2 deletions .eslintrc.js
Expand Up @@ -19,8 +19,7 @@ module.exports = {
'plugin:prettier/recommended',
'plugin:@typescript-eslint/eslint-recommended',
'plugin:@typescript-eslint/recommended',
'prettier/@typescript-eslint',
'plugin:testing-library/recommended',
'plugin:testing-library/dom',
'plugin:jest-dom/recommended',
],
rules: {
Expand Down
23 changes: 23 additions & 0 deletions .github/workflows/ci.yml
@@ -0,0 +1,23 @@
name: CI

on: [push]

jobs:
build:
runs-on: ubuntu-latest

strategy:
matrix:
node-version: [13.2.x, 14.x, 15.x]

steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- run: |
yarn install
yarn build
- run: yarn lint
- run: yarn test
24 changes: 24 additions & 0 deletions .github/workflows/publish.yml
@@ -0,0 +1,24 @@
name: Publish

on:
release:
types: [created]
workflow_dispatch:

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
with:
node-version: '14.x'
registry-url: 'https://registry.npmjs.org'
- run: yarn install
- run: yarn build
- run: yarn test
- run: yarn lint
- run: npm publish
working-directory: ./dist
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
1 change: 1 addition & 0 deletions jest.config.js
@@ -1,4 +1,5 @@
module.exports = {
testEnvironment: 'jsdom',
preset: 'ts-jest',
verbose: true,
setupFilesAfterEnv: ['./test/jest.setup.ts'],
Expand Down
4 changes: 4 additions & 0 deletions package.json
Expand Up @@ -3,6 +3,9 @@
"version": "0.0.1",
"description": "Capture announcements of ARIA-live regions",
"main": "dist/index.js",
"files": [
"dist"
],
"author": "Ari Perkkio <ari.perkkio@gmail.com>",
"license": "MIT",
"homepage": "https://github.com/AriPerkkio/aria-live-capture",
Expand All @@ -14,6 +17,7 @@
"scripts": {
"prebuild": "rimraf dist",
"build": "tsc --project tsconfig.prod.json",
"lint": "eslint . --max-warnings 0 --ext .js,.ts,.tsx",
"test": "jest"
},
"devDependencies": {
Expand Down
202 changes: 202 additions & 0 deletions src/capture-announcements.ts
@@ -0,0 +1,202 @@
import {
getClosestElement,
getParentLiveRegion,
isElement,
isInDOM,
isLiveRegionAttribute,
LIVE_REGION_QUERY,
PolitenessSetting,
resolvePolitenessSetting,
} from './utils';
import { interceptMethod, interceptSetter, Restore } from './interceptors';

interface Options {
onCapture: (
textContent: string,
politenessSetting: Exclude<PolitenessSetting, 'off'>
) => void;
onIncorrectStatusMessage?: (textContent: string) => void;
}

const WHITE_SPACE_REGEXP = /\s+/g;

// Map of live regions to previous textContent
const liveRegions = new Map<Node, string | null>();

export default function CaptureAnnouncements({
onCapture: __onCapture,
onIncorrectStatusMessage,
}: Options): Restore {
const onCapture: typeof __onCapture = (textContent, politenessSetting) =>
__onCapture(trimWhiteSpace(textContent), politenessSetting);

/**
* Check whether given node should trigger announcement
* - Node should be inside live region
* - Politeness setting should not be off
* - `textContent` of live region should have changed
*/
function updateAnnouncements(node: Node) {
const element = getClosestElement(node);
if (!element) return;

const parentLiveRegion = getParentLiveRegion(element);

if (parentLiveRegion) {
const politenessSetting = resolvePolitenessSetting(
parentLiveRegion
);

if (politenessSetting !== 'off' && isInDOM(parentLiveRegion)) {
const previousText = liveRegions.get(node);
const newText = node.textContent || '';

if (previousText !== newText) {
onCapture(newText, politenessSetting);
liveRegions.set(node, newText);
}
}
}
}

function addLiveRegion(liveRegion: Element) {
if (liveRegions.has(liveRegion)) return;

const politenessSetting = resolvePolitenessSetting(liveRegion);
if (politenessSetting === 'off') return;

liveRegions.set(liveRegion, liveRegion.textContent);

// Content of assertive live regions is announced on initial mount
if (liveRegion.textContent) {
if (politenessSetting === 'assertive') {
onCapture(liveRegion.textContent, politenessSetting);
} else if (
politenessSetting === 'polite' &&
onIncorrectStatusMessage
) {
onIncorrectStatusMessage(
trimWhiteSpace(liveRegion.textContent)
);
}
}
}

/**
* Check DOM for live regions and update `liveRegions` store
* - TODO: Could be optimized based on appended/updated child
*/
function updateLiveRegions() {
for (const liveRegion of document.querySelectorAll(LIVE_REGION_QUERY)) {
addLiveRegion(liveRegion);
}
}

function onTextContentChange(this: Node) {
updateAnnouncements(this);
}

// https://github.com/facebook/react/blob/9198a5cec0936a21a5ba194a22fcbac03eba5d1d/packages/react-dom/src/client/setTextContent.js#L12-L35
function onNodeValueChange(this: Node) {
updateAnnouncements(this);
}

/**
* Shared handler for methods which mount new nodes on DOM, e.g. appendChild, insertBefore
*/
function onNodeMount(node: Node) {
updateLiveRegions();
updateAnnouncements(node);
}

function onInsertAdjacent(
this: Node,
position: string,
elementOrText: Element | string
) {
if (!this.parentNode) {
const log =
typeof elementOrText === 'string'
? elementOrText
: elementOrText.outerHTML;

throw new Error(
`Unable to find parentNode for element/text ${log}`
);
}

onNodeMount(this.parentNode);
}

function onSetAttribute(
this: Element,
...args: Parameters<Element['setAttribute']>
): void {
if (!isElement(this)) return;
if (!isInDOM(this)) return;
if (args[0] !== 'role' && args[0] !== 'aria-live') return;

const isAlreadyTracked = liveRegions.has(this);
const liveRegionAttribute = isLiveRegionAttribute(args[1]);

// Attribute value was changed from live region attribute to something else.
// Stop tracking this element.
if (isAlreadyTracked && !liveRegionAttribute) {
liveRegions.delete(this);
return;
}

// Previous value was not live region attribute value
if (!isAlreadyTracked && liveRegionAttribute) {
return addLiveRegion(this);
}

// Value was changed to assertive - announce content immediately
if (
isAlreadyTracked &&
liveRegionAttribute &&
resolvePolitenessSetting(this) === 'assertive'
) {
return updateAnnouncements(this);
}
}

// prettier-ignore
const cleanups: Restore[] = [
interceptMethod(Element.prototype, 'setAttribute', onSetAttribute),
interceptMethod(Element.prototype, 'removeAttribute', onRemoveAttribute),
interceptMethod(Element.prototype, 'insertAdjacentElement', onInsertAdjacent),
interceptMethod(Element.prototype, 'insertAdjacentHTML', onInsertAdjacent),
interceptMethod(Element.prototype, 'insertAdjacentText', onInsertAdjacent),
interceptMethod(Element.prototype, 'before', onNodeMount),
interceptMethod(Element.prototype, 'append', onNodeMount),
interceptMethod(Element.prototype, 'prepend', onNodeMount),
interceptMethod(Node.prototype, 'appendChild', onNodeMount),
interceptMethod(Node.prototype, 'insertBefore', onNodeMount),
interceptMethod(Node.prototype, 'replaceChild', onNodeMount),

interceptSetter(Node.prototype, 'textContent', onTextContentChange),
interceptSetter(Node.prototype, 'nodeValue', onNodeValueChange)
];

return function restore() {
cleanups.splice(0).forEach(cleanup => cleanup());
liveRegions.clear();
};
}

function onRemoveAttribute(
this: Element,
...args: Parameters<Element['removeAttribute']>
) {
if (!isElement(this)) return;
if (args[0] !== 'role' && args[0] !== 'aria-live') return;

if (liveRegions.has(this)) {
liveRegions.delete(this);
}
}

function trimWhiteSpace(text: string) {
return text.trim().replace(WHITE_SPACE_REGEXP, ' ');
}
1 change: 1 addition & 0 deletions src/index.ts
@@ -0,0 +1 @@
export { default } from './capture-announcements';
71 changes: 71 additions & 0 deletions src/interceptors.ts
@@ -0,0 +1,71 @@
export type Restore = () => void;

/**
* Intercept objects setters of property
* - Original setter is invoked first
*/
export function interceptSetter<
T extends Object = Object,
P extends keyof T = keyof T,
K extends T[P] = T[P]
>(obj: T, property: P, method: (value: K) => void): Restore {
const descriptor = Object.getOwnPropertyDescriptor(obj, property);

if (!descriptor || !descriptor.set) {
throw new Error(
`Unable to intercept ${property}. No descriptor available.`
);
}

const originalSetter = descriptor.set;

descriptor.set = function interceptedSet(value: K) {
const output = originalSetter.call(this, value);
method.call(this, value);

return output;
};

Object.defineProperty(obj, property, descriptor);

return function restore() {
descriptor.set = originalSetter;
Object.defineProperty(obj, property, descriptor);
};
}

/**
* Intercept method calls of given object
* - Original method is invoked first
*/
export function interceptMethod<
T extends Object = Object,
P extends keyof T = keyof T
>(object: T, methodName: P, method: (...args: any[]) => void): Restore {
const original = (object[methodName] as unknown) as Function;

if (typeof original !== 'function') {
throw new Error(
`Expected ${methodName} to be a function. Received ${typeof original}: ${original}`
);
}

if (typeof method !== 'function') {
throw new Error(
`Expected method to be a function. Received ${typeof method}: ${method}`
);
}

function interceptedMethod(this: T, ...args: any) {
const output = original.call(this, ...args);
method.call(this, ...args);

return output;
}

object[methodName] = interceptedMethod as any;

return function restore() {
object[methodName] = original as any;
};
}

0 comments on commit dd6471a

Please sign in to comment.