diff --git a/addon-test-support/@ember/test-helpers/-utils.ts b/addon-test-support/@ember/test-helpers/-utils.ts index 35562a841..93d421514 100644 --- a/addon-test-support/@ember/test-helpers/-utils.ts +++ b/addon-test-support/@ember/test-helpers/-utils.ts @@ -8,6 +8,7 @@ const HAS_PROMISE = Promise !== RSVP.Promise; import PromisePolyfill from './-internal/promise-polyfill'; +import isFormControl from './dom/-is-form-control'; const _Promise: typeof Promise = HAS_PROMISE ? Promise @@ -53,3 +54,29 @@ export function runDestroyablesFor(object: any, property: string): void { export function isNumeric(n: string): boolean { return !isNaN(parseFloat(n)) && isFinite(Number(n)); } + +/** + Checks if an element is considered visible by the focus area spec. + + @private + @param {Element} element the element to check + @returns {boolean} `true` when the element is visible, `false` otherwise +*/ +export function isVisible(element: Element): boolean { + let styles = window.getComputedStyle(element); + return styles.display !== 'none' && styles.visibility !== 'hidden'; +} + +/** + Checks if an element is disabled. + + @private + @param {Element} element the element to check + @returns {boolean} `true` when the element is disabled, `false` otherwise +*/ +export function isDisabled(element: HTMLElement): boolean { + if (isFormControl(element)) { + return (element as HTMLInputElement).disabled; + } + return false; +} diff --git a/addon-test-support/@ember/test-helpers/dom/-is-focusable.ts b/addon-test-support/@ember/test-helpers/dom/-is-focusable.ts index a3fa1c6c6..b0563643d 100644 --- a/addon-test-support/@ember/test-helpers/dom/-is-focusable.ts +++ b/addon-test-support/@ember/test-helpers/dom/-is-focusable.ts @@ -1,7 +1,9 @@ import isFormControl from './-is-form-control'; import { isDocument, isContentEditable, isWindow } from './-target'; -const FOCUSABLE_TAGS = ['A']; +// For reference: +// https://html.spec.whatwg.org/multipage/interaction.html#the-tabindex-attribute +const FOCUSABLE_TAGS = ['A', 'SUMMARY']; type FocusableElement = HTMLAnchorElement; diff --git a/addon-test-support/@ember/test-helpers/dom/fire-event.ts b/addon-test-support/@ember/test-helpers/dom/fire-event.ts index 36bf97288..e83bee2b8 100644 --- a/addon-test-support/@ember/test-helpers/dom/fire-event.ts +++ b/addon-test-support/@ember/test-helpers/dom/fire-event.ts @@ -96,7 +96,7 @@ function fireEvent( let event; if (isKeyboardEventType(eventType)) { - event = buildKeyboardEvent(eventType, options); + event = _buildKeyboardEvent(eventType, options); } else if (isMouseEventType(eventType)) { let rect; if (element instanceof Window && element.document.documentElement) { @@ -185,8 +185,12 @@ function buildMouseEvent(type: MouseEventType, options: any = {}) { return event; } +// @private // eslint-disable-next-line require-jsdoc -function buildKeyboardEvent(type: KeyboardEventType, options: any = {}) { +export function _buildKeyboardEvent( + type: KeyboardEventType, + options: any = {} +) { let eventOpts: any = assign({}, DEFAULT_EVENT_OPTIONS, options); let event: Event | undefined; let eventMethodName: 'initKeyboardEvent' | 'initKeyEvent' | undefined; diff --git a/addon-test-support/@ember/test-helpers/dom/tab.ts b/addon-test-support/@ember/test-helpers/dom/tab.ts new file mode 100644 index 000000000..c17e97bc5 --- /dev/null +++ b/addon-test-support/@ember/test-helpers/dom/tab.ts @@ -0,0 +1,246 @@ +import getRootElement from './get-root-element'; +import settled from '../settled'; +import fireEvent, { _buildKeyboardEvent } from './fire-event'; +import { isDocument } from './-target'; +import { __blur__ } from './blur'; +import { __focus__ } from './focus'; +import { Promise, isVisible, isDisabled } from '../-utils'; + +const SUPPORTS_INERT = 'inert' in Element.prototype; +const FALLBACK_ELEMENTS = ['CANVAS', 'VIDEO', 'PICTURE']; + +/** + Gets the active element of a document. IE11 may return null instead of the body as + other user-agents does when there isn’t an active element. + @private + @param {Document} ownerDocument the element to check + @returns {HTMLElement} the active element of the document +*/ +function getActiveElement(ownerDocument: Document): HTMLElement { + return (ownerDocument.activeElement as HTMLElement) || ownerDocument.body; +} + +interface InertHTMLElement extends HTMLElement { + inert: boolean; +} + +/** + Compiles a list of nodes that can be focused. Walkes the tree, discardes hidden elements and a few edge cases. To calculate the right. + @private + @param {Element} root the root element to start traversing on + @returns {Array} list of focusable nodes +*/ +function compileFocusAreas(root: Element = document.body) { + let { ownerDocument } = root; + + if (!ownerDocument) { + throw new Error('Element must be in the DOM'); + } + + let activeElment = getActiveElement(ownerDocument); + let treeWalker = ownerDocument.createTreeWalker( + root, + NodeFilter.SHOW_ELEMENT, + { + acceptNode: (node: HTMLElement) => { + // Only visible nodes can be focused, with, at least, one exception; the "area" element. + // reference: https://html.spec.whatwg.org/multipage/interaction.html#data-model + if (node.tagName !== 'AREA' && isVisible(node) === false) { + return NodeFilter.FILTER_REJECT; + } + + // Reject any fallback elements. Fallback elements’s children are only rendered if the UA + // doesn’t support the element. We make an assumption that they are always supported, we + // could consider feature detecting every node type, or making it configurable. + let parentNode = node.parentNode as HTMLElement | null; + if ( + parentNode && + FALLBACK_ELEMENTS.indexOf(parentNode.tagName) !== -1 + ) { + return NodeFilter.FILTER_REJECT; + } + + // Rejects inert containers, if the user agent supports the feature (or if a polyfill is installed.) + if (SUPPORTS_INERT && (node as InertHTMLElement).inert) { + return NodeFilter.FILTER_REJECT; + } + + if (isDisabled(node)) { + return NodeFilter.FILTER_REJECT; + } + + // Always accept the 'activeElement' of the document, as it might fail the next check, elements with tabindex="-1" + // can be focused programtically, we'll therefor ensure the current active element is in the list. + if (node === activeElment) { + return NodeFilter.FILTER_ACCEPT; + } + + // UA parses the tabindex attribute and applies its default values, If the tabIndex is non negative, the UA can + // foucs it. + return node.tabIndex >= 0 + ? NodeFilter.FILTER_ACCEPT + : NodeFilter.FILTER_SKIP; + }, + }, + false + ); + + let node: Node | null; + let elements: HTMLElement[] = []; + + while ((node = treeWalker.nextNode())) { + elements.push(node as HTMLElement); + } + + return elements; +} + +/** + Sort elements by their tab indices. + As older browsers doesn't necessarily implement stabile sort, we'll have to + manually compare with the index in the original array. + @private + @param {Array} elements to sort + @returns {Array} list of sorted focusable nodes by their tab index +*/ +function sortElementsByTabIndices(elements: HTMLElement[]): HTMLElement[] { + return elements + .map((element, index) => { + return { index, element }; + }) + .sort((a, b) => { + if (a.element.tabIndex === b.element.tabIndex) { + return a.index - b.index; + } else if (a.element.tabIndex === 0 || b.element.tabIndex === 0) { + return b.element.tabIndex - a.element.tabIndex; + } + return a.element.tabIndex - b.element.tabIndex; + }) + .map((entity) => entity.element); +} + +/** + @private + @param {Element} root The root element or node to start traversing on. + @param {HTMLElement} activeElement The element to find the next and previous focus areas of + @returns {object} The next and previous focus areas of the active element + */ +function findNextResponders(root: Element, activeElement: HTMLElement) { + let focusAreas = compileFocusAreas(root); + let sortedFocusAreas = sortElementsByTabIndices(focusAreas); + let elements = activeElement.tabIndex === -1 ? focusAreas : sortedFocusAreas; + + let index = elements.indexOf(activeElement); + if (index === -1) { + return { + next: sortedFocusAreas[0], + previous: sortedFocusAreas[sortedFocusAreas.length - 1], + }; + } + + return { + next: elements[index + 1], + previous: elements[index - 1], + }; +} + +/** + Emulates the user pressing the tab button. + + Sends a number of events intending to simulate a "real" user pressing tab on their + keyboard. + + @public + @param {Object} [options] optional tab behaviors + @param {boolean} [options.backwards=false] indicates if the the user navigates backwards + @param {boolean} [options.unRestrainTabIndex=false] indicates if tabbing should throw an error when tabindex is greater than 0 + @return {Promise} resolves when settled + + @example + + Emulating pressing the `TAB` key + + tab(); + + @example + + Emulating pressing the `SHIFT`+`TAB` key combination + + tab({ backwards: true }); +*/ +export default function triggerTab(options?: { + backwards: boolean; + unRestrainTabIndex: boolean; +}): Promise { + return Promise.resolve() + .then(() => { + let backwards = (options && options.backwards) || false; + let unRestrainTabIndex = (options && options.unRestrainTabIndex) || false; + return triggerResponderChange(backwards, unRestrainTabIndex); + }) + .then(() => { + return settled(); + }); +} + +/** + @private + @param {boolean} backwards when `true` it selects the previous foucs area + @param {boolean} unRestrainTabIndex when `true`, will not throw an error if tabindex > 0 is encountered + @returns {Promise} resolves when all events are fired + */ +function triggerResponderChange( + backwards: boolean, + unRestrainTabIndex: boolean +): Promise { + let root = getRootElement(); + let ownerDocument: Document; + let rootElement: HTMLElement; + if (isDocument(root)) { + rootElement = root.body; + ownerDocument = root; + } else { + rootElement = root as HTMLElement; + ownerDocument = root.ownerDocument as Document; + } + + let keyboardEventOptions = { + keyCode: 9, + which: 9, + key: 'Tab', + code: 'Tab', + shiftKey: backwards, + }; + + return Promise.resolve() + .then(() => { + let activeElement = getActiveElement(ownerDocument); + let event = _buildKeyboardEvent('keydown', keyboardEventOptions); + let defaultNotPrevented = activeElement.dispatchEvent(event); + + if (defaultNotPrevented) { + // Query the active element again, as it might change during event phase + activeElement = getActiveElement(ownerDocument); + let target = findNextResponders(rootElement, activeElement); + if (target) { + if (backwards && target.previous) { + __focus__(target.previous); + } else if (!backwards && target.next) { + __focus__(target.next); + } else { + __blur__(activeElement); + } + } + } + }) + .then(() => { + let activeElement = getActiveElement(ownerDocument); + fireEvent(activeElement, 'keyup', keyboardEventOptions); + + if (!unRestrainTabIndex && activeElement.tabIndex > 0) { + throw new Error( + `tabindex of greater than 0 is not allowed. Found tabindex=${activeElement.tabIndex}` + ); + } + }); +} diff --git a/addon-test-support/@ember/test-helpers/index.ts b/addon-test-support/@ember/test-helpers/index.ts index 8614b5ad9..676d8ab73 100644 --- a/addon-test-support/@ember/test-helpers/index.ts +++ b/addon-test-support/@ember/test-helpers/index.ts @@ -39,6 +39,7 @@ export { // DOM Helpers export { default as click } from './dom/click'; export { default as doubleClick } from './dom/double-click'; +export { default as tab } from './dom/tab'; export { default as tap } from './dom/tap'; export { default as focus } from './dom/focus'; export { default as blur } from './dom/blur'; diff --git a/tests/helpers/events.js b/tests/helpers/events.js index 95c2de666..613a03a2b 100644 --- a/tests/helpers/events.js +++ b/tests/helpers/events.js @@ -1,3 +1,5 @@ +import { get } from '@ember/object'; + // from https://mdn.mozilla.org/en-US/docs/Web/Events export const KNOWN_EVENTS = Object.freeze([ 'abort', @@ -189,7 +191,8 @@ export function instrumentElement(element, logOptionsProperties) { if (!element.hasAttribute('data-skip-steps')) { if (logOptionsProperties) { for (var prop of logOptionsProperties) { - step += ` ${e[prop]}`; + // needs to be get so prop-paths can be used, such as "target.id" + step += ` ${get(e, prop)}`; } } assert.step(step); diff --git a/tests/unit/dom/tab-test.js b/tests/unit/dom/tab-test.js new file mode 100644 index 000000000..e13702c59 --- /dev/null +++ b/tests/unit/dom/tab-test.js @@ -0,0 +1,429 @@ +import { module, test } from 'qunit'; +import { + tab, + setupContext, + teardownContext, + settled, +} from '@ember/test-helpers'; +import { + buildInstrumentedElement, + insertElement, + instrumentElement, +} from '../../helpers/events'; +import { isIE11, isEdge } from '../../helpers/browser-detect'; +import hasEmberVersion from '@ember/test-helpers/has-ember-version'; + +let _focusSteps = ['focus', 'focusin']; +let _blurSteps = isEdge ? ['focusout', 'blur'] : ['blur', 'focusout']; + +function focusSteps(name) { + return [`${_focusSteps[0]} ${name}`, `${_focusSteps[1]} ${name}`]; +} + +function blurSteps(name) { + return [`${_blurSteps[0]} ${name}`, `${_blurSteps[1]} ${name}`]; +} + +function moveFocus(from, to) { + return [ + `keydown ${from}`, + ...blurSteps(from), + ...focusSteps(to), + `keyup ${to}`, + ]; +} + +module('DOM Helper: tab', function (hooks) { + if (!hasEmberVersion(2, 4)) { + return; + } + + if (isIE11) { + return; + } + + let context, element, elements; + + hooks.beforeEach(function () { + // used to simulate how `setupRenderingTest` (and soon `setupApplicationTest`) + // set context.element to the rootElement + context = { + element: document.querySelector('#qunit-fixture'), + }; + }); + + hooks.afterEach(async function () { + if (elements) { + elements.forEach((element) => { + element.setAttribute('data-skip-steps', true); + }); + elements.forEach((element) => { + element.parentNode.removeChild(element); + }); + elements = undefined; + } + + if (element) { + element.setAttribute('data-skip-steps', true); + element.parentNode.removeChild(element); + element = undefined; + } + + // only teardown if setupContext was called + if (context.owner) { + await teardownContext(context); + } + document.getElementById('ember-testing').innerHTML = ''; + }); + + test('tabs to focusable element', async function (assert) { + elements = [buildInstrumentedElement('input', ['target.id'])]; + + await setupContext(context); + await tab(); + + assert.verifySteps([ + ...focusSteps(elements[0].id), + `keyup ${elements[0].id}`, + ]); + }); + + test('tabs backwards to focusable element', async function (assert) { + elements = [buildInstrumentedElement('input', ['target.id'])]; + + await setupContext(context); + await tab({ backwards: true }); + + assert.verifySteps([ + ...focusSteps(elements[0].id), + `keyup ${elements[0].id}`, + ]); + }); + + test('blurs target when tabs through the last target', async function (assert) { + elements = [buildInstrumentedElement('input', ['target.id'])]; + + await setupContext(context); + await tab(); + await tab(); + + assert.verifySteps([ + ...focusSteps(elements[0].id), + `keyup ${elements[0].id}`, + + `keydown ${elements[0].id}`, + ...blurSteps(elements[0].id), + ]); + }); + + test('tabs between foucsable elements', async function (assert) { + elements = [ + buildInstrumentedElement('input', ['target.className']), + buildInstrumentedElement('input', ['target.className']), + ]; + + elements[0].className = 'a'; + elements[1].className = 'b'; + + await setupContext(context); + + await tab(); + await tab(); + + assert.verifySteps([...focusSteps('a'), `keyup a`, ...moveFocus('a', 'b')]); + }); + + test('ignores focusable elements with tab index = -1', async function (assert) { + elements = [ + buildInstrumentedElement('input', ['target.id']), + buildInstrumentedElement('input', ['target.id']), + ]; + + elements[0].tabIndex = -1; + + await setupContext(context); + + await tab(); + + assert.verifySteps([ + ...focusSteps(elements[1].id), + `keyup ${elements[1].id}`, + ]); + }); + + test('ignores focusable elements with tab index = -1', async function (assert) { + elements = [ + buildInstrumentedElement('input', ['target.id']), + buildInstrumentedElement('input', ['target.id']), + ]; + + elements[1].tabIndex = -1; + + await setupContext(context); + + await tab({ backwards: true }); + + assert.verifySteps([ + ...focusSteps(elements[0].id), + `keyup ${elements[0].id}`, + ]); + }); + + test('throws an error when elements have tab index > 0', async function (assert) { + elements = [ + buildInstrumentedElement('input', ['target.id']), + buildInstrumentedElement('input', ['target.id']), + ]; + + elements[1].tabIndex = 1; + + elements.forEach((element) => + element.setAttribute('data-skip-steps', true) + ); + + await setupContext(context); + + assert.rejects(tab(), 'tabindex of greater than 0 is not allowed'); + }); + + test('errors when tabbing without any focusable areas', async function (assert) { + elements = []; + await setupContext(context); + + assert.rejects(tab(), 'is not focusable'); + }); + + test('tabs an input that prevents defaults', async function (assert) { + elements = [ + buildInstrumentedElement('input', ['target.id']), + buildInstrumentedElement('input', ['target.id']), + ]; + + elements[0].addEventListener('keydown', (event) => { + event.preventDefault(); + }); + + await setupContext(context); + + await tab(); + await tab(); + + assert.verifySteps([ + ...focusSteps(elements[0].id), + `keyup ${elements[0].id}`, + `keydown ${elements[0].id}`, + `keyup ${elements[0].id}`, + ]); + }); + + test('tabs an input that moves focus during an event', async function (assert) { + elements = [ + document.createElement('input'), + document.createElement('input'), + document.createElement('input'), + ]; + + elements.forEach((element) => { + insertElement(element); + }); + + elements[0].addEventListener('keydown', () => { + elements[1].focus(); + }); + + await setupContext(context); + + elements[0].focus(); + await settled(); + await tab(); + + assert.equal(document.activeElement, elements[2]); + }); + + test('sorts focusable elements by their tab index', async function (assert) { + elements = [ + buildInstrumentedElement('input', ['target.className']), + buildInstrumentedElement('div', ['target.className']), + buildInstrumentedElement('input', ['target.className']), + buildInstrumentedElement('input', ['target.className']), + ]; + + elements[0].className = 'b'; + elements[1].className = 'c'; + elements[2].className = 'd'; + elements[3].className = 'a'; + + elements[0].tabIndex = 4; + elements[1].tabIndex = 4; + elements[2].tabIndex = 0; + elements[3].tabIndex = 1; + + await setupContext(context); + + await tab({ unRestrainTabIndex: true }); + await tab({ unRestrainTabIndex: true }); + await tab({ unRestrainTabIndex: true }); + await tab({ unRestrainTabIndex: true }); + + assert.verifySteps([ + ...focusSteps('a'), + 'keyup a', + ...moveFocus('a', 'b'), + ...moveFocus('b', 'c'), + ...moveFocus('c', 'd'), + ]); + }); + + module('programmatically focusable elements', function (hooks) { + hooks.beforeEach(async function () { + elements = [ + buildInstrumentedElement('input', ['target.id']), + buildInstrumentedElement('input', ['target.id']), + buildInstrumentedElement('input', ['target.id']), + ]; + + elements[0].className = 'c'; + elements[1].className = 'a'; + elements[2].className = 'b'; + + elements[0].tabIndex = 1; + elements[1].tabIndex = -1; + elements[2].tabIndex = 2; + + await setupContext(context); + + elements[1].focus(); + }); + + test('tabs backwards focuses previous node', async function (assert) { + await tab({ backwards: true, unRestrainTabIndex: true }); + assert.verifySteps([ + ...focusSteps(elements[1].id), + ...moveFocus(elements[1].id, elements[0].id), + ]); + }); + + test('tabs focuses next focus area', async function (assert) { + await tab({ unRestrainTabIndex: true }); + assert.verifySteps([ + ...focusSteps(elements[1].id), + ...moveFocus(elements[1].id, elements[2].id), + ]); + }); + }); + + module('invalid elements', function (hooks) { + hooks.beforeEach(async function () { + elements = [ + buildInstrumentedElement('input', ['target.id']), + buildInstrumentedElement('input', ['target.id']), + ]; + }); + + test('ignores disabled input elements', async function (assert) { + elements[0].disabled = true; + + await setupContext(context); + await tab(); + + assert.verifySteps([ + ...focusSteps(elements[1].id), + `keyup ${elements[1].id}`, + ]); + }); + + test('ignores invisible elements', async function (assert) { + elements[0].style.display = 'none'; + + await setupContext(context); + await tab(); + + assert.verifySteps([ + ...focusSteps(elements[1].id), + `keyup ${elements[1].id}`, + ]); + }); + }); + + test('ignores hidden parents', async function (assert) { + elements = [ + buildInstrumentedElement('input', ['target.id']), + buildInstrumentedElement('input', ['target.id']), + ]; + + let container = document.createElement('div'); + container.style.display = 'none'; + insertElement(container); + container.appendChild(elements[0]); + + await setupContext(context); + await tab(); + + assert.verifySteps([ + ...focusSteps(elements[1].id), + `keyup ${elements[1].id}`, + ]); + }); + + test('ignores children of disabled fieldset', async function (assert) { + elements = [ + buildInstrumentedElement('input', ['target.id']), + buildInstrumentedElement('input', ['target.id']), + ]; + + let container = document.createElement('fieldset'); + container.disabled = true; + insertElement(container); + container.appendChild(elements[0]); + + await setupContext(context); + await tab(); + + assert.verifySteps([ + ...focusSteps(elements[1].id), + `keyup ${elements[1].id}`, + ]); + }); + + test('first summary element of a details should be focusable', async function (assert) { + let firstSummary = document.createElement('summary'); + let secondSummary = document.createElement('summary'); + + instrumentElement(firstSummary, ['target.id']); + instrumentElement(secondSummary, ['target.id']); + + let container = document.createElement('details'); + container.appendChild(firstSummary); + container.appendChild(secondSummary); + insertElement(container); + + elements = [firstSummary, secondSummary]; + + assert.equal( + firstSummary.tabIndex, + 0, + 'first summary created successfully' + ); + assert.equal( + secondSummary.tabIndex, + -1, + 'second summary created successfully' + ); + + await setupContext(context); + + let active = document.activeElement; + await tab(); + assert.notEqual(document.activeElement, active); + active = document.activeElement; + await tab(); + assert.notEqual(document.activeElement, active); + + assert.verifySteps([ + ...focusSteps(firstSummary.id), + `keyup ${firstSummary.id}`, + `keydown ${firstSummary.id}`, + ...blurSteps(firstSummary.id), + ]); + }); +});