diff --git a/test/unit/ui/picker.js b/test/unit/ui/picker.js index 04e42754c7..b45d0ba12a 100644 --- a/test/unit/ui/picker.js +++ b/test/unit/ui/picker.js @@ -1,13 +1,34 @@ +import Keyboard from '../../../modules/keyboard'; import Picker from '../../../ui/picker'; +function createKeydownEvent(keyCode) { + let event; + if (typeof Event === 'function') { + event = new Event('keydown', { + bubbles: true, + cancelable: true, + }); + Object.defineProperty(event, 'keyCode', {'value': keyCode}); + } else if (typeof Event === 'object') { // IE11 + event = document.createEvent('Event'); + Object.defineProperty(event, 'keyCode', {'value': keyCode}); + event.initEvent('keydown', true, true); + } + return event; +} + describe('Picker', function() { - it('initialization', function() { + beforeEach(function () { this.container.innerHTML = ''; - let picker = new Picker(this.container.firstChild); // eslint-disable-line no-unused-vars + this.pickerSelectorInstance = new Picker(this.container.firstChild); + this.pickerSelector = this.container.querySelector('.ql-picker'); + }); + + it('initialization', function() { expect(this.container.querySelector('.ql-picker')).toBeTruthy(); expect(this.container.querySelector('.ql-active')).toBeFalsy(); - expect(this.container.querySelector('.ql-picker-item.ql-selected').outerHTML).toEqualHTML(''); - expect(this.container.querySelector('.ql-picker-item:not(.ql-selected)').outerHTML).toEqualHTML(''); + expect(this.container.querySelector('.ql-picker-item.ql-selected').outerHTML).toEqualHTML(''); + expect(this.container.querySelector('.ql-picker-item:not(.ql-selected)').outerHTML).toEqualHTML(''); }); it('escape charcters', function() { @@ -20,4 +41,100 @@ describe('Picker', function() { value = value.replace(/\"/g, '\\"'); expect(select.querySelector(`option[value="${value}"]`)).toEqual(option); }); + + it('label is initialized with the correct aria attributes', function() { + expect(this.pickerSelector.querySelector('.ql-picker-label').getAttribute('aria-expanded')).toEqual('false'); + const optionsId = this.pickerSelector.querySelector('.ql-picker-options').id; + expect(this.pickerSelector.querySelector('.ql-picker-label').getAttribute('aria-controls')).toEqual(optionsId); + }); + + it('options container is initialized with the correct aria attributes', function() { + expect(this.pickerSelector.querySelector('.ql-picker-options').getAttribute('aria-hidden')).toEqual('true'); + + const ariaControlsLabel = this.pickerSelector.querySelector('.ql-picker-label').getAttribute('aria-controls'); + expect(this.pickerSelector.querySelector('.ql-picker-options').id).toEqual(ariaControlsLabel); + expect(this.pickerSelector.querySelector('.ql-picker-options').tabIndex).toEqual(-1); + }); + + it('aria attributes toggle correctly when the picker is opened via enter key', function() { + const pickerLabel = this.pickerSelector.querySelector('.ql-picker-label'); + + // Select picker via enter key + const e = createKeydownEvent(Keyboard.keys.ENTER); + pickerLabel.dispatchEvent(e); + + expect(pickerLabel.getAttribute('aria-expanded')).toEqual('true'); + expect(this.pickerSelector.querySelector('.ql-picker-options').getAttribute('aria-hidden')).toEqual('false'); + }); + + it('aria attributes toggle correctly when the picker is opened via mousedown', function() { + const pickerLabel = this.pickerSelector.querySelector('.ql-picker-label'); + + // Select picker via enter key + let e; + if (typeof Event === 'function') { + e = new Event('mousedown', { + bubbles: true, + cancelable: true, + }); + } else if (typeof Event === 'object') { // IE11 + e = document.createEvent('Event'); + e.initEvent('mousedown', true, true); + } + + pickerLabel.dispatchEvent(e); + + expect(pickerLabel.getAttribute('aria-expanded')).toEqual('true'); + expect(this.pickerSelector.querySelector('.ql-picker-options').getAttribute('aria-hidden')).toEqual('false'); + }); + + it('aria attributes toggle correctly when an item is selected via click', function() { + const pickerLabel = this.pickerSelector.querySelector('.ql-picker-label'); + pickerLabel.click(); + + const pickerItem = this.pickerSelector.querySelector('.ql-picker-item'); + pickerItem.click(); + + expect(pickerLabel.getAttribute('aria-expanded')).toEqual('false'); + expect(this.pickerSelector.querySelector('.ql-picker-options').getAttribute('aria-hidden')).toEqual('true'); + expect(pickerLabel.textContent.trim()).toEqual(pickerItem.textContent.trim()); + }); + + it('aria attributes toggle correctly when an item is selected via enter', function() { + const pickerLabel = this.pickerSelector.querySelector('.ql-picker-label'); + pickerLabel.click(); + + const pickerItem = this.pickerSelector.querySelector('.ql-picker-item'); + // Select picker item via enter key + const e = createKeydownEvent(Keyboard.keys.ENTER); + pickerItem.dispatchEvent(e); + + expect(pickerLabel.getAttribute('aria-expanded')).toEqual('false'); + expect(this.pickerSelector.querySelector('.ql-picker-options').getAttribute('aria-hidden')).toEqual('true'); + expect(pickerLabel.textContent.trim()).toEqual(pickerItem.textContent.trim()); + }); + + it('aria attributes toggle correctly when the picker is closed via clicking on the label again', function() { + const pickerLabel = this.pickerSelector.querySelector('.ql-picker-label'); + pickerLabel.click(); + pickerLabel.click(); + expect(pickerLabel.getAttribute('aria-expanded')).toEqual('false'); + expect(this.pickerSelector.querySelector('.ql-picker-options').getAttribute('aria-hidden')).toEqual('true'); + }); + + it('aria attributes toggle correctly when the picker is closed via escaping out of it', function() { + const pickerLabel = this.pickerSelector.querySelector('.ql-picker-label'); + pickerLabel.click(); + + // Escape out of the picker + const e = createKeydownEvent(Keyboard.keys.ESCAPE); + pickerLabel.dispatchEvent(e); + + expect(pickerLabel.getAttribute('aria-expanded')).toEqual('false'); + expect(this.pickerSelector.querySelector('.ql-picker-options').getAttribute('aria-hidden')).toEqual('true'); + }); + + afterEach(function() { + this.pickerSelectorInstance = null; + }); }); diff --git a/ui/picker.js b/ui/picker.js index 6597c653cf..957a614e92 100644 --- a/ui/picker.js +++ b/ui/picker.js @@ -1,5 +1,11 @@ +import Keyboard from '../modules/keyboard'; import DropdownIcon from '../assets/icons/dropdown.svg'; +let optionsCounter = 0; + +function toggleAriaAttribute(element, attribute) { + element.setAttribute(attribute, !(element.getAttribute(attribute) === 'true')); +} class Picker { constructor(select) { @@ -8,14 +14,40 @@ class Picker { this.buildPicker(); this.select.style.display = 'none'; this.select.parentNode.insertBefore(this.container, this.select); + this.label.addEventListener('mousedown', () => { - this.container.classList.toggle('ql-expanded'); + this.togglePicker(); + }); + this.label.addEventListener('keydown', (event) => { + switch(event.keyCode) { + // Allows the "Enter" key to open the picker + case Keyboard.keys.ENTER: + this.togglePicker(); + break; + + // Allows the "Escape" key to close the picker + case Keyboard.keys.ESCAPE: + this.escape(); + event.preventDefault(); + break; + default: + } }); this.select.addEventListener('change', this.update.bind(this)); } + togglePicker() { + this.container.classList.toggle('ql-expanded'); + // Toggle aria-expanded and aria-hidden to make the picker accessible + toggleAriaAttribute(this.label, 'aria-expanded'); + toggleAriaAttribute(this.options, 'aria-hidden'); + } + buildItem(option) { let item = document.createElement('span'); + item.tabIndex = '0'; + item.setAttribute('role', 'button'); + item.classList.add('ql-picker-item'); if (option.hasAttribute('value')) { item.setAttribute('data-value', option.getAttribute('value')); @@ -26,6 +58,23 @@ class Picker { item.addEventListener('click', () => { this.selectItem(item, true); }); + item.addEventListener('keydown', (event) => { + switch(event.keyCode) { + // Allows the "Enter" key to select an item + case Keyboard.keys.ENTER: + this.selectItem(item, true); + event.preventDefault(); + break; + + // Allows the "Escape" key to close the picker + case Keyboard.keys.ESCAPE: + this.escape(); + event.preventDefault(); + break; + default: + } + }); + return item; } @@ -33,6 +82,9 @@ class Picker { let label = document.createElement('span'); label.classList.add('ql-picker-label'); label.innerHTML = DropdownIcon; + label.tabIndex = '0'; + label.setAttribute('role', 'button'); + label.setAttribute('aria-expanded', 'false'); this.container.appendChild(label); return label; } @@ -40,6 +92,18 @@ class Picker { buildOptions() { let options = document.createElement('span'); options.classList.add('ql-picker-options'); + + // Don't want screen readers to read this until options are visible + options.setAttribute('aria-hidden', 'true'); + options.tabIndex = '-1'; + + // Need a unique id for aria-controls + options.id = `ql-picker-options-${optionsCounter}`; + optionsCounter += 1; + this.label.setAttribute('aria-controls', options.id); + + this.options = options; + [].slice.call(this.select.options).forEach((option) => { let item = this.buildItem(option); options.appendChild(item); @@ -59,8 +123,18 @@ class Picker { this.buildOptions(); } + escape() { + // Close menu and return focus to trigger label + this.close(); + // Need setTimeout for accessibility to ensure that the browser executes + // focus on the next process thread and after any DOM content changes + setTimeout(() => this.label.focus(), 1); + } + close() { this.container.classList.remove('ql-expanded'); + this.label.setAttribute('aria-expanded', 'false'); + this.options.setAttribute('aria-hidden', 'true'); } selectItem(item, trigger = false) {