From 24d3580d3edca8802c33535633a2b5b5ebc0d608 Mon Sep 17 00:00:00 2001 From: Jeremy Elbourn Date: Tue, 20 Sep 2016 17:48:14 -0700 Subject: [PATCH 1/2] feat(a11y): initial interactivity checker --- .../core/a11y/interactivity-checker.spec.ts | 282 ++++++++++++++++++ src/lib/core/a11y/interactivity-checker.ts | 121 ++++++++ 2 files changed, 403 insertions(+) create mode 100644 src/lib/core/a11y/interactivity-checker.spec.ts create mode 100644 src/lib/core/a11y/interactivity-checker.ts diff --git a/src/lib/core/a11y/interactivity-checker.spec.ts b/src/lib/core/a11y/interactivity-checker.spec.ts new file mode 100644 index 000000000000..a9e6bfc5bc08 --- /dev/null +++ b/src/lib/core/a11y/interactivity-checker.spec.ts @@ -0,0 +1,282 @@ +import {InteractivityChecker} from './interactivity-checker'; + +describe('InteractivityChecker', () => { + let testContainerElement: HTMLElement; + let checker: InteractivityChecker; + + beforeEach(() => { + testContainerElement = document.createElement('div'); + document.body.appendChild(testContainerElement); + + checker = new InteractivityChecker(); + }); + + afterEach(() => { + document.body.removeChild(testContainerElement); + testContainerElement.innerHTML = ''; + }); + + describe('isDisabled', () => { + it('should return true for disabled elements', () => { + let elements = createElements('input', 'textarea', 'select', 'button', 'md-checkbox'); + elements.forEach(el => el.setAttribute('disabled', '')); + appendElements(elements); + + elements.forEach(el => { + expect(checker.isDisabled(el)) + .toBe(true, `Expected <${el.nodeName} disabled> to be disabled`); + }); + }); + + it('should return false for elements without disabled', () => { + let elements = createElements('input', 'textarea', 'select', 'button', 'md-checkbox'); + appendElements(elements); + + elements.forEach(el => { + expect(checker.isDisabled(el)) + .toBe(false, `Expected <${el.nodeName}> not to be disabled`); + }); + }); + }); + + describe('isVisible', () => { + it('should return false for a `display: none` element', () => { + testContainerElement.innerHTML = + ``; + let input = testContainerElement.querySelector('input') as HTMLElement; + + expect(checker.isVisible(input)) + .toBe(false, 'Expected element with `display: none` to not be visible'); + }); + + it('should return false for the child of a `display: none` element', () => { + testContainerElement.innerHTML = + `
+ +
`; + let input = testContainerElement.querySelector('input') as HTMLElement; + + expect(checker.isVisible(input)) + .toBe(false, 'Expected element with `display: none` parent to not be visible'); + }); + + it('should return false for a `visibility: hidden` element', () => { + testContainerElement.innerHTML = + ``; + let input = testContainerElement.querySelector('input') as HTMLElement; + + expect(checker.isVisible(input)) + .toBe(false, 'Expected element with `visibility: hidden` to not be visible'); + }); + + it('should return false for the child of a `visibility: hidden` element', () => { + testContainerElement.innerHTML = + `
+ +
`; + let input = testContainerElement.querySelector('input') as HTMLElement; + + expect(checker.isVisible(input)) + .toBe(false, 'Expected element with `visibility: hidden` parent to not be visible'); + }); + + it('should return true for an element with `visibility: hidden` ancestor and *closer* ' + + '`visibility: visible` ancestor', () => { + testContainerElement.innerHTML = + `
+
+ +
+
`; + let input = testContainerElement.querySelector('input') as HTMLElement; + + expect(checker.isVisible(input)) + .toBe(true, 'Expected element with `visibility: hidden` ancestor and closer ' + + '`visibility: visible` ancestor to be visible'); + }); + + it('should return true for an element without visibility modifiers', () => { + let input = document.createElement('input'); + testContainerElement.appendChild(input); + + expect(checker.isVisible(input)) + .toBe(true, 'Expected element without visibility modifiers to be visible'); + }); + }); + + describe('isFocusable', () => { + it('should return true for native form controls', () => { + let elements = createElements('input', 'textarea', 'select', 'button'); + appendElements(elements); + + elements.forEach(el => { + expect(checker.isFocusable(el)).toBe(true, `Expected <${el.nodeName}> to be focusable`); + }); + }); + + it('should return true for an anchor with an href', () => { + let anchor = document.createElement('a'); + anchor.href = 'google.com'; + testContainerElement.appendChild(anchor); + + expect(checker.isFocusable(anchor)).toBe(true, `Expected with href to be focusable`); + }); + + it('should return false for an anchor without an href', () => { + let anchor = document.createElement('a'); + testContainerElement.appendChild(anchor); + + expect(checker.isFocusable(anchor)) + .toBe(false, `Expected without href not to be focusable`); + }); + + it('should return false for disabled form controls', () => { + let elements = createElements('input', 'textarea', 'select', 'button'); + elements.forEach(el => el.setAttribute('disabled', '')); + appendElements(elements); + + elements.forEach(el => { + expect(checker.isFocusable(el)) + .toBe(false, `Expected <${el.nodeName} disabled> not to be focusable`); + }); + }); + + it('should return false for a `display: none` element', () => { + testContainerElement.innerHTML = + ``; + let input = testContainerElement.querySelector('input') as HTMLElement; + + expect(checker.isFocusable(input)) + .toBe(false, 'Expected element with `display: none` to not be visible'); + }); + + it('should return false for the child of a `display: none` element', () => { + testContainerElement.innerHTML = + `
+ +
`; + let input = testContainerElement.querySelector('input') as HTMLElement; + + expect(checker.isFocusable(input)) + .toBe(false, 'Expected element with `display: none` parent to not be visible'); + }); + + it('should return false for a `visibility: hidden` element', () => { + testContainerElement.innerHTML = + ``; + let input = testContainerElement.querySelector('input') as HTMLElement; + + expect(checker.isFocusable(input)) + .toBe(false, 'Expected element with `visibility: hidden` not to be focusable'); + }); + + it('should return false for the child of a `visibility: hidden` element', () => { + testContainerElement.innerHTML = + `
+ +
`; + let input = testContainerElement.querySelector('input') as HTMLElement; + + expect(checker.isFocusable(input)) + .toBe(false, 'Expected element with `visibility: hidden` parent not to be focusable'); + }); + + it('should return true for an element with `visibility: hidden` ancestor and *closer* ' + + '`visibility: visible` ancestor', () => { + testContainerElement.innerHTML = + `
+
+ +
+
`; + let input = testContainerElement.querySelector('input') as HTMLElement; + + expect(checker.isFocusable(input)) + .toBe(true, 'Expected element with `visibility: hidden` ancestor and closer ' + + '`visibility: visible` ancestor to be focusable'); + }); + + it('should return false for an element with an empty tabindex', () => { + let element = document.createElement('div'); + element.setAttribute('tabindex', ''); + testContainerElement.appendChild(element); + + expect(checker.isFocusable(element)) + .toBe(false, `Expected element with tabindex="" not to be focusable`); + }); + + it('should return false for an element with a non-numeric tabindex', () => { + let element = document.createElement('div'); + element.setAttribute('tabindex', 'abba'); + testContainerElement.appendChild(element); + + expect(checker.isFocusable(element)) + .toBe(false, `Expected element with non-numeric tabindex not to be focusable`); + }); + + it('should return true for an element with contenteditable', () => { + let element = document.createElement('div'); + element.setAttribute('contenteditable', ''); + testContainerElement.appendChild(element); + + expect(checker.isFocusable(element)) + .toBe(true, `Expected element with contenteditable to be focusable`); + }); + }); + + describe('isTabbable', () => { + it('should return true for native form controls and anchor without tabindex attribute', () => { + let elements = createElements('input', 'textarea', 'select', 'button', 'a'); + appendElements(elements); + + elements.forEach(el => { + expect(checker.isTabbable(el)).toBe(true, `Expected <${el.nodeName}> to be tabbable`); + }); + }); + + it('should return false for native form controls and anchor with tabindex == -1', () => { + let elements = createElements('input', 'textarea', 'select', 'button', 'a'); + + elements.forEach(el => el.setAttribute('tabindex', '-1')); + appendElements(elements); + + elements.forEach(el => { + expect(checker.isTabbable(el)) + .toBe(false, `Expected <${el.nodeName} tabindex="-1"> not to be tabbable`); + }); + }); + + it('should return false for inert div and span', () => { + let elements = createElements('div', 'span'); + appendElements(elements); + + elements.forEach(el => { + expect(checker.isTabbable(el)).toBe(false, `Expected <${el.nodeName}> not to be tabbable`); + }); + }); + + it('should return true for div and span with tabindex == 0', () => { + let elements = createElements('div', 'span'); + + elements.forEach(el => el.setAttribute('tabindex', '0')); + appendElements(elements); + + elements.forEach(el => { + expect(checker.isTabbable(el)) + .toBe(true, `Expected <${el.nodeName} tabindex="0"> to be tabbable`); + }); + }); + }); + + /** Creates an array of elements with the given node names. */ + function createElements(...nodeNames: string[]) { + return nodeNames.map(name => document.createElement(name)); + } + + /** Appends elements to the testContainerElement. */ + function appendElements(elements: Element[]) { + for (let e of elements) { + testContainerElement.appendChild(e); + } + } +}); diff --git a/src/lib/core/a11y/interactivity-checker.ts b/src/lib/core/a11y/interactivity-checker.ts new file mode 100644 index 000000000000..25ea25a41149 --- /dev/null +++ b/src/lib/core/a11y/interactivity-checker.ts @@ -0,0 +1,121 @@ +/** + * Utility for checking the interactivity of an element, such as whether is is focusable or + * tabbable. + * + * NOTE: Currently does not capture any special element behaviors, browser quirks, or edge cases. + * This is a basic/naive starting point onto which further behavior will be added. + * + * This class uses instance methods instead of static functions so that alternate implementations + * can be injected. + * + * TODO(jelbourn): explore using ally.js directly for its significantly more robust + * checks (need to evaluate payload size, performance, and compatibility with tree-shaking). + */ +export class InteractivityChecker { + + /** Gets whether an element is disabled. */ + isDisabled(element: HTMLElement) { + // This does not capture some cases, such as a non-form control with a disabled attribute or + // a form control inside of a disabled form, but should capture the most common cases. + return element.hasAttribute('disabled'); + } + + /** + * Gets whether an element is visible for the purposes of interactivity. + * + * This will capture states like `display: none` and `visibility: hidden`, but not things like + * being clipped by an `overflow: hidden` parent or being outside the viewport. + */ + isVisible(element: HTMLElement) { + // There are additional special cases that this does not capture, but this will work for + // the most common cases. + + // Use logic from jQuery to check for `display: none`. + // See https://github.com/jquery/jquery/blob/master/src/css/hiddenVisibleSelectors.js#L12 + if (!(element.offsetWidth || element.offsetHeight || element.getClientRects().length)) { + return false; + } + + // Check for css `visibility` property. + // TODO(jelbourn): do any browsers we support return an empty string instead of 'visible'? + return getComputedStyle(element).getPropertyValue('visibility') == 'visible'; + } + + /** + * Gets whether an element can be reached via Tab key. + * Assumes that the element has already been checked with isFocusable. + */ + isTabbable(element: HTMLElement) { + // Again, naive approach that does not capture many special cases and browser quirks. + return element.tabIndex >= 0; + } + + /** Gets whether an element can be focused by the user. */ + isFocusable(element: HTMLElement): boolean { + // Perform checks in order of left to most expensive. + // Again, naive approach that does not capture many edge cases and browser quirks. + return isPotentiallyFocusable(element) && !this.isDisabled(element) && this.isVisible(element); + } +} + +/** Gets whether an element's */ +function isNativeFormElement(element: Node) { + let nodeName = element.nodeName.toLowerCase(); + return nodeName === 'input' || + nodeName === 'select' || + nodeName === 'button' || + nodeName === 'textarea'; +} + +/** Gets whether an element is an . */ +function isHiddenInput(element: HTMLElement): boolean { + return isInputElement(element) && element.type == 'hidden'; +} + +/** Gets whether an element is an anchor that has an href attribute. */ +function isAnchorWithHref(element: HTMLElement): boolean { + return isAnchorElement(element) && element.hasAttribute('href'); +} + +/** Gets whether an element is an input element. */ +function isInputElement(element: HTMLElement): element is HTMLInputElement { + return element.nodeName == 'input'; +} + +/** Gets whether an element is an anchor element. */ +function isAnchorElement(element: HTMLElement): element is HTMLAnchorElement { + return element.nodeName.toLowerCase() == 'a'; +} + +/** Gets whether an element has a valid tabindex. */ +function hasValidTabIndex(element: HTMLElement): boolean { + if (!element.hasAttribute('tabindex') || element.tabIndex === undefined) { + return false; + } + + let tabIndex = element.getAttribute('tabindex'); + + // IE11 parses tabindex="" as the value "-32768" + if (tabIndex == '-32768') { + return false; + } + + return !!(tabIndex && !isNaN(parseInt(tabIndex, 10))); +} + +/** + * Gets whether an element is potentially focusable without taking current visible/disabled state + * into account. + */ +function isPotentiallyFocusable(element: HTMLElement): boolean { + // Inputs are potentially focusable *unless* they're type="hidden". + if (isHiddenInput(element)) { + return false; + } + + return isNativeFormElement(element) || + isAnchorWithHref(element) || + element.hasAttribute('contenteditable') || + hasValidTabIndex(element); +} + From 8018e4204ae98dde792013b64850bfa16d3073b3 Mon Sep 17 00:00:00 2001 From: Jeremy Elbourn Date: Wed, 21 Sep 2016 10:50:10 -0700 Subject: [PATCH 2/2]
is actually tabbable in IE11 and Edge. Go figure. --- .../core/a11y/interactivity-checker.spec.ts | 32 +++++++++++++------ 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/src/lib/core/a11y/interactivity-checker.spec.ts b/src/lib/core/a11y/interactivity-checker.spec.ts index a9e6bfc5bc08..648499781aef 100644 --- a/src/lib/core/a11y/interactivity-checker.spec.ts +++ b/src/lib/core/a11y/interactivity-checker.spec.ts @@ -222,6 +222,29 @@ describe('InteractivityChecker', () => { expect(checker.isFocusable(element)) .toBe(true, `Expected element with contenteditable to be focusable`); }); + + + it('should return false for inert div and span', () => { + let elements = createElements('div', 'span'); + appendElements(elements); + + elements.forEach(el => { + expect(checker.isFocusable(el)) + .toBe(false, `Expected <${el.nodeName}> not to be focusable`); + }); + }); + + it('should return true for div and span with tabindex == 0', () => { + let elements = createElements('div', 'span'); + + elements.forEach(el => el.setAttribute('tabindex', '0')); + appendElements(elements); + + elements.forEach(el => { + expect(checker.isFocusable(el)) + .toBe(true, `Expected <${el.nodeName} tabindex="0"> to be focusable`); + }); + }); }); describe('isTabbable', () => { @@ -246,15 +269,6 @@ describe('InteractivityChecker', () => { }); }); - it('should return false for inert div and span', () => { - let elements = createElements('div', 'span'); - appendElements(elements); - - elements.forEach(el => { - expect(checker.isTabbable(el)).toBe(false, `Expected <${el.nodeName}> not to be tabbable`); - }); - }); - it('should return true for div and span with tabindex == 0', () => { let elements = createElements('div', 'span');