diff --git a/app/assets/javascripts/radioSelect.js b/app/assets/javascripts/radioSelect.js
index 802382736..0dd32f252 100644
--- a/app/assets/javascripts/radioSelect.js
+++ b/app/assets/javascripts/radioSelect.js
@@ -1,7 +1,10 @@
-(function(Modules) {
+(function(global) {
"use strict";
+ var Modules = global.GOVUK.Modules;
+ var Hogan = global.Hogan;
+
let states = {
'initial': Hogan.compile(`
@@ -30,7 +33,7 @@
{{/choices}}
-
+
`),
'chosen': Hogan.compile(`
@@ -54,11 +57,8 @@
`)
};
- let focusSelected = function() {
- setTimeout(
- () => $('[type=radio]:checked').next('label').blur().trigger('focus').addClass('selected'),
- 50
- );
+ let focusSelected = function(component) {
+ $('[type=radio]:checked', component).focus();
};
Modules.RadioSelect = function() {
@@ -79,6 +79,35 @@
});
let categories = $component.data('categories').split(',');
let name = $component.find('input').eq(0).attr('name');
+ let mousedownOption = null;
+ const reset = () => {
+ render('initial', {
+ 'categories': categories,
+ 'name': name
+ });
+ };
+ const selectOption = (value) => {
+ render('chosen', {
+ 'choices': choices.filter(
+ element => element.value == value
+ ),
+ 'name': name
+ });
+ focusSelected(component);
+ };
+ const trackMouseup = (event) => {
+ const parentNode = event.target.parentNode;
+
+ if (parentNode === mousedownOption) {
+ const value = $('input', parentNode).attr('value');
+
+ selectOption(value);
+
+ // clear tracking
+ mousedownOption = null;
+ $(document).off('mouseup', trackMouseup);
+ }
+ };
$component
.on('click', '.js-category-button', function(event) {
@@ -92,51 +121,54 @@
),
'name': name
});
- focusSelected();
+ focusSelected(component);
})
- .on('click', '.js-option', function(event) {
-
- // stop click being triggered by keyboard events
- if (!event.pageX) return true;
-
- event.preventDefault();
- let value = $('input', this).attr('value');
- render('chosen', {
- 'choices': choices.filter(
- element => element.value == value
- ),
- 'name': name
- });
- focusSelected();
+ .on('mousedown', '.js-option', function(event) {
+ mousedownOption = this;
+ // mouseup on the same option completes the click action
+ $(document).on('mouseup', trackMouseup);
})
+ // space and enter, clicked on a radio confirm that option was selected
.on('keydown', 'input[type=radio]', function(event) {
- // intercept keypresses which aren’t enter or space
+ // allow keypresses which aren’t enter or space through
if (event.which !== 13 && event.which !== 32) {
return true;
}
event.preventDefault();
let value = $(this).attr('value');
- render('chosen', {
- 'choices': choices.filter(
- element => element.value == value
- ),
- 'name': name
- });
- focusSelected();
+ selectOption(value);
+
+ })
+ .on('click', '.js-done-button', function(event) {
+
+ event.preventDefault();
+ let $selection = $('input[type=radio]:checked', this.parentNode);
+ if ($selection.length) {
+
+ render('chosen', {
+ 'choices': choices.filter(
+ element => element.value == $selection.eq(0).attr('value')
+ ),
+ 'name': name
+ });
+
+ } else {
+
+ reset();
+
+ }
+ focusSelected(component);
})
.on('click', '.js-reset-button', function(event) {
event.preventDefault();
- render('initial', {
- 'categories': categories,
- 'name': name
- });
- focusSelected();
+ reset();
+ focusSelected(component);
});
@@ -151,4 +183,4 @@
};
-})(window.GOVUK.Modules);
+})(window);
diff --git a/app/assets/stylesheets/components/radios.scss b/app/assets/stylesheets/components/radios.scss
index eb632b62f..e0dc263d1 100644
--- a/app/assets/stylesheets/components/radios.scss
+++ b/app/assets/stylesheets/components/radios.scss
@@ -14,15 +14,16 @@
}
- .js-reset-button,
- .js-category-button {
+ .js-done-button,
+ .js-category-button,
+ .js-reset-button {
@include button($grey-3);
margin-right: $gutter-half;
}
- .js-reset-button-block {
+ .js-done-button-block {
display: block;
clear: both;
diff --git a/tests/javascripts/radioSelect.test.js b/tests/javascripts/radioSelect.test.js
new file mode 100644
index 000000000..918184ae0
--- /dev/null
+++ b/tests/javascripts/radioSelect.test.js
@@ -0,0 +1,413 @@
+const helpers = require('./support/helpers');
+
+beforeAll(() => {
+ window.Hogan = require('hogan.js');
+ require('../../app/assets/javascripts/radioSelect.js');
+});
+
+afterAll(() => {
+ require('./support/teardown.js');
+});
+
+describe('RadioSelect', () => {
+ const CATEGORIES = [
+ 'Later today',
+ 'Tomorrow',
+ 'Friday',
+ 'Saturday'
+ ];
+ const HOURS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24];
+ let originalOptionsForAllCategories;
+
+ const getDataFromOption = (option) => {
+ return {
+ value: option.querySelector('input').getAttribute('value'),
+ label: option.querySelector('label').textContent.trim()
+ };
+ };
+
+ const clickButtonForCategory = (category) => {
+
+ // click the button for this category
+ const categoryButton = document.querySelector(`.radio-select-column:nth-child(2) input[value="${category}"]`);
+ helpers.triggerEvent(categoryButton, 'click');
+
+ };
+
+ beforeEach(() => {
+ const options = () => {
+ let result = '';
+
+ const getHourLabel = (hour) => {
+ let label = hour;
+
+ if (hour === 12) {
+ return 'midday';
+ }
+
+ if (hour === 24) {
+ return 'midnight';
+ }
+
+ return `${hour}${hour > 12 ? 'am' : 'pm'}`;
+ };
+
+ const hours = (day, start) => {
+ let result = '';
+ let hours = HOURS;
+ let dayAsNumber = {
+ 'Later today': 22,
+ 'Tomorrow': 23,
+ 'Friday': 24,
+ 'Saturday': 25
+ }[day];
+
+ if (start !== undefined) {
+ hours = hours.slice(start - 1);
+ }
+
+ hours.forEach((hour, idx) => {
+ const hourLabel = getHourLabel(hour);
+
+ result +=
+ `
+
+
+
`;
+ });
+
+ return result;
+ };
+
+ CATEGORIES.forEach((day, idx) => {
+ if (idx === 0) {
+ result += hours(day, 11);
+ } else {
+ result += hours(day);
+ }
+
+ return result;
+ });
+
+ return result;
+ };
+
+ document.body.innerHTML = `
+ `;
+
+ originalOptionsForAllCategories = Array.from(document.querySelector('.radio-select-column:nth-child(2) .multiple-choice'))
+ .map(option => getDataFromOption(option));
+ });
+
+ afterEach(() => {
+ document.body.innerHTML = '';
+ });
+
+ describe("when the page has loaded it should have a button for each category", () => {
+
+ let categoryButtons;
+
+ beforeEach(() => {
+
+ // start module
+ window.GOVUK.modules.start();
+
+ categoryButtons = document.querySelectorAll('.radio-select-column:nth-child(2) .js-category-button');
+
+ });
+
+ test("the number of buttons should match the categories", () => {
+
+ expect(categoryButtons.length).toBe(CATEGORIES.length);
+
+ });
+
+ test("each button's text should match their category", () => {
+
+ // check the buttons have the right text
+ CATEGORIES.forEach((category, idx) => {
+ expect(categoryButtons[idx].getAttribute('value')).toEqual(category);
+ });
+
+ });
+
+ });
+
+ describe("category buttons", () => {
+
+ CATEGORIES.forEach((category, idx) => {
+
+ describe(`clicking the button for ${category} should`, () => {
+
+ beforeEach(() => {
+
+ // get all the options in the original page for this category
+ originalOptionsForCategory = originalOptionsForAllCategories.filter(option => option.label === category);
+
+ // start module
+ window.GOVUK.modules.start();
+
+ clickButtonForCategory(category);
+
+ });
+
+ test("show the options for it, with the right label and value", () => {
+
+ // check options this reveals against those originally in the page for this category
+ const options = document.querySelector('.radio-select-column:nth-child(2) .multiple-choice');
+
+ const optionsThatMatchOriginals = Array.from(options).filter((option, idx) => {
+ const optionData = getDataFromOption(option);
+ const originalOption = originalOptionsForCategory[idx];
+
+ return optionData.value === originalOption.value && optionData.label === originalOption.label;
+ });
+
+ expect(optionsThatMatchOriginals.length).toEqual(originalOptionsForCategory.length);
+
+ });
+
+ test("keep focus on the default time slot", () => {
+
+ expect(document.activeElement).toBe(document.getElementById('scheduled_for-0'));
+
+ });
+
+ });
+
+ });
+
+ test(`clicking the button for a category should add a 'Done' button below its options`, () => {
+
+ // start module
+ window.GOVUK.modules.start();
+
+ clickButtonForCategory(CATEGORIES[0]);
+
+ const button = document.querySelector('.radio-select-column:nth-child(2) input[type=button]');
+
+ expect(button).not.toBeNull();
+ expect(button.getAttribute('value')).toEqual('Done');
+
+ });
+
+ });
+
+ describe("after clicking the button to select that category", () => {
+
+ beforeEach(() => {
+
+ // start module
+ window.GOVUK.modules.start();
+
+ clickButtonForCategory(CATEGORIES[0]);
+
+ });
+
+ describe("clicking on an option with the mouse/trackpad should", () => {
+
+ let optionsColumn;
+ let firstOptionPositionSpy;
+ let firstOptionLabel;
+
+ beforeEach(() => {
+
+ optionsColumn = document.querySelector('.radio-select-column:nth-child(2)');
+
+ const firstOption = optionsColumn.querySelector('input[type=radio]');
+ firstOptionLabel = firstOption.parentNode.querySelector('label').textContent.trim();
+
+ helpers.clickElementWithMouse(firstOption);
+
+ });
+
+ test("remove all the other options", () => {
+
+ // module replaces the column node so this needs querying again
+ optionsColumn = document.querySelector('.radio-select-column:nth-child(2)');
+
+ expect(optionsColumn.querySelectorAll('input[type=radio]').length).toEqual(1);
+ expect(optionsColumn.querySelector('label').textContent.trim()).toEqual(firstOptionLabel);
+
+ });
+
+ test("add a button for choosing a different time", () => {
+
+ const button = document.querySelector('.radio-select-column:nth-child(3) input[type=button]');
+
+ expect(button).not.toBeNull();
+ expect(button.getAttribute('value')).toEqual('Choose a different time');
+
+ })
+
+ test("focus the selected option", () => {
+
+ selectedOption = document.querySelector('.radio-select-column:nth-child(2) input[checked=checked]');
+
+ expect(document.activeElement).toBe(selectedOption);
+
+ });
+
+ });
+
+ describe("selecting an option with the space key should", () => {
+
+ let optionsColumn;
+ let secondOptionLabel;
+
+ beforeEach(() => {
+
+ optionsColumn = document.querySelector('.radio-select-column:nth-child(2)');
+
+ const options = optionsColumn.querySelectorAll('input[type=radio]');
+ secondOptionLabel = options[1].parentNode.querySelector('label').textContent.trim();
+
+ helpers.moveSelectionToRadio(options[1], { 'direction': 'down' });
+ helpers.activateRadioWithSpace(options[1]);
+ });
+
+ test("remove all the other options", () => {
+
+ // module replaces the column node so this needs querying again
+ optionsColumn = document.querySelector('.radio-select-column:nth-child(2)');
+
+ expect(optionsColumn.querySelectorAll('input[type=radio]').length).toEqual(1);
+ expect(optionsColumn.querySelector('label').textContent.trim()).toEqual(secondOptionLabel);
+
+ });
+
+ test("add a button for choosing a different time", () => {
+
+ const button = document.querySelector('.radio-select-column:nth-child(3) input[type=button]');
+
+ expect(button).not.toBeNull();
+ expect(button.getAttribute('value')).toEqual('Choose a different time');
+
+ })
+
+ });
+
+ describe("selecting an option with the enter key should", () => {
+
+ let optionsColumn;
+ let secondOptionLabel;
+
+ beforeEach(() => {
+
+ optionsColumn = document.querySelector('.radio-select-column:nth-child(2)');
+
+ const options = optionsColumn.querySelectorAll('input[type=radio]');
+ secondOptionLabel = options[1].parentNode.querySelector('label').textContent.trim();
+
+ // simulate events for arrow key press moving selection to 2nd option
+ // event for down arrow key press
+ helpers.triggerEvent(options[1], 'keydown', {
+ eventInit: { which: 40 }
+ });
+ // click event fired from option radio being activated
+ helpers.triggerEvent(options[1], 'click', {
+ eventInit: { pageX: 0 }
+ });
+
+ // simulate events for enter key press to confirm selection
+ // event for enter key press
+ helpers.triggerEvent(options[1], 'keydown', {
+ eventInit: { which: 13 }
+ });
+
+ });
+
+ test("remove all the other options", () => {
+
+ // module replaces the column node so this needs querying again
+ optionsColumn = document.querySelector('.radio-select-column:nth-child(2)');
+
+ expect(optionsColumn.querySelectorAll('input[type=radio]').length).toEqual(1);
+ expect(optionsColumn.querySelector('label').textContent.trim()).toEqual(secondOptionLabel);
+
+ });
+
+ test("add a button for choosing a different time", () => {
+
+ const button = document.querySelector('.radio-select-column:nth-child(3) input[type=button]');
+
+ expect(button).not.toBeNull();
+ expect(button.getAttribute('value')).toEqual('Choose a different time');
+
+ })
+
+ });
+
+ test("clicking the 'Done' button should choose whatever time is selected", () => {
+
+ let optionsColumn = document.querySelector('.radio-select-column:nth-child(2)');
+ const secondOption = optionsColumn.querySelectorAll('input[type=radio]')[1];
+ const secondOptionLabel = document.querySelector('label[for=' + secondOption.getAttribute('id')).textContent.trim();
+ const doneButton = document.querySelector('.radio-select-column:nth-child(2) input[type=button]');
+
+ // select second option
+ secondOption.checked = true;
+ secondOption.setAttribute('checked', '');
+
+ helpers.triggerEvent(doneButton, 'click');
+
+ optionsColumn = document.querySelector('.radio-select-column:nth-child(2)');
+
+ expect(optionsColumn.querySelectorAll('input[type=radio]').length).toEqual(1);
+ expect(optionsColumn.querySelector('label').textContent.trim()).toEqual(secondOptionLabel);
+
+ });
+
+ describe("after selecting an option clicking the 'Choose a different time' button should", () => {
+
+ beforeEach(() => {
+
+ // select the first option
+ const firstOption = document.querySelector('.radio-select-column:nth-child(2) input[type=radio]');
+
+ helpers.clickElementWithMouse(firstOption);
+
+ // click the 'Choose a different time' button
+ const resetButton = document.querySelector('.radio-select-column:nth-child(3) input[type=button]');
+ helpers.triggerEvent(resetButton, 'click');
+
+ });
+
+ test("reset the module", () => {
+
+ categoryButtons = document.querySelectorAll('.radio-select-column:nth-child(2) .js-category-button');
+
+ expect(categoryButtons.length).toEqual(CATEGORIES.length);
+
+ });
+
+ test("focus the default option", () => {
+
+ expect(document.activeElement).toBe(document.getElementById('scheduled_for-0'));
+
+ });
+
+ });
+
+ });
+
+});
diff --git a/tests/javascripts/support/helpers.js b/tests/javascripts/support/helpers.js
index 52ce86cda..9e7b056a8 100644
--- a/tests/javascripts/support/helpers.js
+++ b/tests/javascripts/support/helpers.js
@@ -1,12 +1,104 @@
-const triggerEvent = (el, evtType) => {
- const evt = new Event(evtType, {
+const triggerEvent = (el, evtType, options) => {
+ const eventInit = {
bubbles: true,
cancelable: true
- });
+ };
+ let setPositionData = () => {
+ const browserUI = {
+ leftFrameBorder: 0,
+ topHeight: 100
+ };
+ const cursorOffset = {
+ x: 5,
+ y: 5
+ };
+ const elBoundingBox = el.getBoundingClientRect();
+
+ if (!eventInit.clientX) { eventInit.clientX = elBoundingBox.left + cursorOffset.x; }
+ if (!eventInit.clientY) { eventInit.clientY = elBoundingBox.top + cursorOffset.y; }
+ if (!eventInit.pageX) { eventInit.pageX = elBoundingBox.left + cursorOffset.x; }
+ if (!eventInit.pageY) { eventInit.pageY = elBoundingBox.top + cursorOffset.y; }
+ if (!eventInit.screenX) { eventInit.screenX = eventInit.clientX + browserUI.leftFrameBorder; }
+ if (!eventInit.screenY) { eventInit.screenY = eventInit.clientY + browserUI.topHeight; }
+ if (!eventInit.offsetX) { eventInit.offsetX = cursorOffset.x; }
+ if (!eventInit.offsetY) { eventInit.offsetY = cursorOffset.y; }
+ };
+ let Instance;
+
+ // mixin any specified event properties with the defaults
+ if (options && ('eventInit' in options)) {
+ Object.assign(eventInit, options.eventInit);
+ }
+
+ // use event interface if specified
+ if (options && ('interface' in options)) {
+ Instance = options.interface;
+ } else {
+
+ // otherwise, derive from the event type
+ switch (evtType) {
+ case 'click':
+ // click events are part of the MouseEvent interface
+ Instance = window.MouseEvent;
+ break;
+ case 'mousedown':
+ Instance = window.MouseEvent;
+ break;
+ case 'mouseup':
+ Instance = window.MouseEvent;
+ break;
+ case 'keydown':
+ Instance = window.KeyboardEvent;
+ break;
+ case 'keyup':
+ Instance = window.KeyboardEvent;
+ break;
+ default:
+ Instance = Event;
+ }
+
+ }
+
+ if (evtType === 'click') {
+ // hack for click events to simulate details of pointer interaction
+ setPositionData();
+ }
+
+ const evt = new Instance(evtType, eventInit);
el.dispatchEvent(evt);
};
+function clickElementWithMouse (el) {
+ triggerEvent(el, 'mousedown');
+ triggerEvent(el, 'mouseup');
+ triggerEvent(el, 'click');
+};
+
+function moveSelectionToRadio (el, options) {
+ // movement within a radio group with arrow keys fires no keyboard events
+
+ // click event fired from option radio being activated
+ triggerEvent(el, 'click', {
+ eventInit: { pageX: 0 }
+ });
+
+};
+
+function activateRadioWithSpace (el) {
+
+ // simulate events for space key press to confirm selection
+ // event for space key press
+ triggerEvent(el, 'keydown', {
+ eventInit: { which: 32 }
+ });
+ // click event fired from option radio being activated
+ triggerEvent(el, 'click', {
+ eventInit: { pageX: 0 }
+ });
+
+};
+
class ElementQuery {
constructor (el) {
this.el = el;
@@ -141,5 +233,8 @@ const element = function (el) {
};
exports.triggerEvent = triggerEvent;
+exports.clickElementWithMouse = clickElementWithMouse;
+exports.moveSelectionToRadio = moveSelectionToRadio;
+exports.activateRadioWithSpace = activateRadioWithSpace;
exports.element = element;
exports.WindowMock = WindowMock;