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 = ` +
+ + When should Notify send these messages? + +
+
+
+ + +
+
+
+ ${options()} +
+
+
`; + + 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;