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) {