From 434aab69f3d438b291530152beea9e3080080cd5 Mon Sep 17 00:00:00 2001 From: Alphability Date: Tue, 21 Dec 2021 11:35:10 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=91=B6=20mia=20(make=20internet=20accessi?= =?UTF-8?q?ble)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/eva/__tests__/index.test.ts | 2 +- packages/eva/src/index.ts | 2 + packages/mia/README.md | 11 ++ packages/mia/__tests__/index.test.ts | 114 ++++++++++++++++++ packages/mia/jest.config.js | 11 ++ packages/mia/package.json | 26 ++++ packages/mia/package.ts | 4 + packages/mia/src/index.ts | 174 +++++++++++++++++++++++++++ packages/mia/tsconfig.json | 14 +++ 9 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 packages/mia/README.md create mode 100644 packages/mia/__tests__/index.test.ts create mode 100644 packages/mia/jest.config.js create mode 100644 packages/mia/package.json create mode 100644 packages/mia/package.ts create mode 100644 packages/mia/src/index.ts create mode 100644 packages/mia/tsconfig.json diff --git a/packages/eva/__tests__/index.test.ts b/packages/eva/__tests__/index.test.ts index b1b181b..9924e07 100644 --- a/packages/eva/__tests__/index.test.ts +++ b/packages/eva/__tests__/index.test.ts @@ -11,7 +11,7 @@ beforeEach(() => { }); afterEach(() => { - // Resetting Alice instance values before next test. + // Resetting Eva instance values before next test. global.innerWidth = 0; global.dispatchEvent(new Event('resize')); eva.destroy(); diff --git a/packages/eva/src/index.ts b/packages/eva/src/index.ts index 2970439..325cee9 100644 --- a/packages/eva/src/index.ts +++ b/packages/eva/src/index.ts @@ -168,6 +168,7 @@ export class Eva { * @author Alphability * @memberof Eva */ + public destroy(): void { this._detachListeners(); @@ -180,6 +181,7 @@ export class Eva { * @type {Calvin} * @memberof Eva */ + get view(): Calvin { return Eva._reactor; } diff --git a/packages/mia/README.md b/packages/mia/README.md new file mode 100644 index 0000000..a20b45a --- /dev/null +++ b/packages/mia/README.md @@ -0,0 +1,11 @@ +# MIA + +> MIA: Make Internet Accessible + +## Installation + +```sh +yarn add @endgame/mia +# or +npm i -S @endgame/mia +``` diff --git a/packages/mia/__tests__/index.test.ts b/packages/mia/__tests__/index.test.ts new file mode 100644 index 0000000..3d09bbe --- /dev/null +++ b/packages/mia/__tests__/index.test.ts @@ -0,0 +1,114 @@ +import { Mia } from '../src/index'; + +const mia = new Mia(); + +// Use modern in order to run time out functions +jest.useFakeTimers('modern'); + +const buttonId = 'button-id'; +const focusBlacklistElementId = 'focus-blacklist-element-id'; + +beforeAll(() => { + // Add button to the DOM + const button = global.document.createElement('button'); + button.id = buttonId; + global.document.body.appendChild(button); + + // Add p to the DOM + const p = global.document.createElement('p'); + p.id = focusBlacklistElementId; + global.document.body.appendChild(p); +}); + +beforeEach(() => { + // Init before each test + mia.initialize(); +}); + +afterEach(() => { + // Resetting Mia instance values before next test. + const button = global.document.getElementById(buttonId); + if (button) { + button.dispatchEvent(new Event('blur')); + + jest.runOnlyPendingTimers(); + } + + // Destroy before each new test + mia.destroy(); +}); + +describe('Success cases', () => { + test('Document element should have an a11y class', () => { + const { documentElement } = global.document; + + expect(documentElement.classList.contains('a11y')).toStrictEqual(true); + }); + + test("Document element shouldn't have an a11y class", () => { + const { documentElement } = global.document; + + mia.destroy(); + + expect(documentElement.classList.contains('a11y')).toStrictEqual(false); + }); + + test('It should have focusActive === true', () => { + const button = global.document.getElementById(buttonId); + if (button) { + // Trigger the button keyup event. + button.dispatchEvent( + new KeyboardEvent('keyup', { + key: 'Tab', + bubbles: true, + }) + ); + + expect(mia.reactor.data.focusActive).toStrictEqual(true); + } + }); + + test('It should have focusActive === true even if Mia is initialized two times', () => { + const button = global.document.getElementById(buttonId); + if (button) { + // Trigger the button keyup event. + button.dispatchEvent( + new KeyboardEvent('keyup', { + key: 'Tab', + bubbles: true, + }) + ); + + mia.initialize(); + + expect(mia.reactor.data.focusActive).toStrictEqual(true); + } + }); + + test('It should have focusActive === false if target is not whitelisted', () => { + const p = global.document.getElementById(focusBlacklistElementId); + if (p) { + // Trigger the paragraph keyup event. + p.dispatchEvent( + new KeyboardEvent('keyup', { + key: 'Tab', + bubbles: true, + }) + ); + + expect(mia.reactor.data.focusActive).toStrictEqual(false); + } + }); + + test('It should have focusActive === false if the key pressed is not Tab', () => { + // Trigger the document keyup event. + global.document.dispatchEvent( + new KeyboardEvent('keyup', { + key: 'a', + bubbles: true, + }) + ); + + expect(mia.reactor.data.focusActive).toStrictEqual(false); + }); +}); diff --git a/packages/mia/jest.config.js b/packages/mia/jest.config.js new file mode 100644 index 0000000..d855c07 --- /dev/null +++ b/packages/mia/jest.config.js @@ -0,0 +1,11 @@ +/* eslint-disable */ +const base = require('../../jest.config.base'); +const pkg = require('./package.json'); +/* eslint-enable */ + +module.exports = { + ...base, + testEnvironment: 'jsdom', + name: pkg.name, + displayName: pkg.name, +}; diff --git a/packages/mia/package.json b/packages/mia/package.json new file mode 100644 index 0000000..11bc9e1 --- /dev/null +++ b/packages/mia/package.json @@ -0,0 +1,26 @@ +{ + "name": "@endgame/mia", + "version": "1.0.7", + "description": "Make Internet Accessible", + "repository": "MBDW-Studio/endgame", + "author": "Mental Breakdown (https://mentalbreakdown.studio)", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist/**/*.js", + "dist/**/*.js.map", + "dist/**/*.d.ts" + ], + "scripts": { + "watch": "tsc -w", + "test": "jest __tests__ --collectCoverage --runInBand", + "test:watch": "jest __tests__ --watchAll --runInBand" + }, + "dependencies": { + "@endgame/calvin": "^1.0.7" + }, + "publishConfig": { + "access": "public" + }, + "license": "MIT" +} diff --git a/packages/mia/package.ts b/packages/mia/package.ts new file mode 100644 index 0000000..2c2ddaa --- /dev/null +++ b/packages/mia/package.ts @@ -0,0 +1,4 @@ +export default { + build: true, + giveExecutionRights: false, +}; diff --git a/packages/mia/src/index.ts b/packages/mia/src/index.ts new file mode 100644 index 0000000..5cac5e9 --- /dev/null +++ b/packages/mia/src/index.ts @@ -0,0 +1,174 @@ +import { Calvin } from '@endgame/calvin'; + +export class Mia { + /** + * @description The focus switch delay in ms. + * @author Alphability + * @static + * @memberof Mia + */ + + static _focusEndDelay = 500; + + /** + * @description Object allowing the use of reactive data. + * Storing a11y state values. + * @author Alphability + * @static + * @type {Calvin} + * @memberof Mia + */ + + static _reactor: Calvin = new Calvin({ focusActive: false }); + + /** + * @description The focus event's last target. + * @author Alphability + * @private + * @type {(HTMLElement | null)} + * @memberof Mia + */ + + private _target: HTMLElement | null = null; + + /** + * @description Boolean ensuring that we can't initialize multiple a11y listeners. + * @author Alphability + * @private + * @memberof Mia + */ + + private _isInitialized = false; + + /** + * Creates an instance of Mia. + * @author Alphability + * @memberof Mia + */ + + constructor() { + this._handleFocus = this._handleFocus.bind(this); + this._removeFocus = this._removeFocus.bind(this); + } + + /** + * @description Removing the focus on the last target. + * @author Alphability + * @private + * @param {(Event | undefined)} [event=undefined] + * @memberof Mia + */ + + private _removeFocus(event: Event | undefined = undefined): void { + document.documentElement.classList.remove('visible-focus-style'); + + if (event && event.target) { + const target = event.target as HTMLElement; + setTimeout(() => { + if (target === this._target) { + Mia._reactor.data.focusActive = false; + } + }, Mia._focusEndDelay); + } + } + + /** + * @description Adding focus on the new target. + * @author Alphability + * @private + * @param {(EventTarget | null)} target + * @return {*} {void} + * @memberof Mia + */ + private _handleNewTarget(target: EventTarget | null): void { + if (!target) { + return; + } + + // Registering new target + this._target = target; + + const nameLowercased = this._target.nodeName.toLowerCase(); + if ( + nameLowercased === 'input' || + nameLowercased === 'select' || + nameLowercased === 'textarea' || + nameLowercased === 'a' || + nameLowercased === 'button' + ) { + if (!Mia._reactor.data.focusActive) { + Mia._reactor.data.focusActive = true; + } + + this._target.addEventListener('blur', this._removeFocus, false); + document.documentElement.classList.add('visible-focus-style'); + } else { + this._removeFocus(); + } + } + + /** + * @description Handling global HTML elements focus. + * @author Alphability + * @private + * @param {KeyboardEvent} { key, target } + * @return {*} {void} + * @memberof Mia + */ + + private _handleFocus({ key, target }: KeyboardEvent): void { + if (!key || key !== 'Tab') { + return; + } + + // ⚡ Avoid memory leak by removing old listeners before registering new ones + if (this._target) { + this._target.removeEventListener('blur', this._removeFocus, false); + } + + this._handleNewTarget(target); + } + + /** + * @description Initializing the a11y abilities. + * @author Alphability + * @memberof Mia + */ + + public initialize(): void { + // No multiple init + // Avoid having multiple listeners at the same time. + if (this._isInitialized) { + return; + } + + this._isInitialized = true; + + document.documentElement.classList.add('a11y'); + document.addEventListener('keyup', this._handleFocus, false); + } + + /** + * @description Destroying the listeners. + * @author Alphability + * @memberof Mia + */ + + public destroy(): void { + document.removeEventListener('keyup', this._handleFocus, false); + document.documentElement.classList.remove('a11y'); + + this._isInitialized = false; + } + + /** + * @description Reactive properties object's getter. + * @readonly + * @type {Calvin} + * @memberof Mia + */ + + get reactor(): Calvin { + return Mia._reactor; + } +} diff --git a/packages/mia/tsconfig.json b/packages/mia/tsconfig.json new file mode 100644 index 0000000..a5d1dcf --- /dev/null +++ b/packages/mia/tsconfig.json @@ -0,0 +1,14 @@ +{ + "generated": true, + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "src", + }, + "include": [ + "src/**/*", + ], + "exclude": [ + "./dist" + ], +} \ No newline at end of file