From 20a94910cbef90478850d671aff9a9b6ce0c8d7f Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Tue, 7 May 2019 16:02:47 +0100 Subject: [PATCH 01/19] Give permissions form legend heading styles Also includes putting the text at the foot of the checkboxes into a hint below the legend. --- app/assets/stylesheets/app.scss | 10 ++++++++++ app/templates/views/manage-users/permissions.html | 9 ++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/app/assets/stylesheets/app.scss b/app/assets/stylesheets/app.scss index 4bd8db24b..3cb2aa81b 100644 --- a/app/assets/stylesheets/app.scss +++ b/app/assets/stylesheets/app.scss @@ -124,10 +124,20 @@ td { .form-label { margin-bottom: 5px; + + &.heading-small { + @include bold-19(); + } } .hint { color: $secondary-text-colour; + + .form-label + & { + display: block; + margin-top: -5px; + margin-bottom: 5px; + } } .list-bullet { diff --git a/app/templates/views/manage-users/permissions.html b/app/templates/views/manage-users/permissions.html index 50648da5e..801424c89 100644 --- a/app/templates/views/manage-users/permissions.html +++ b/app/templates/views/manage-users/permissions.html @@ -2,18 +2,17 @@ {% from "components/radios.html" import radio, radios, conditional_radio_panel %}
- + Permissions + + All team members can see sent messages. + {% for field in form.permissions_fields %} {{ checkbox(field) }} {% endfor %}
-

- All team members can see sent messages. -

- {% if current_service.has_permission("edit_folder_permissions") and form.folder_permissions.all_template_folders %} {{ checkboxes_nested(form.folder_permissions, form.folder_permissions.children()) }} {% endif %} From 33d074c00aaf330b105a81a1bb39c3a6df62e7d1 Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Tue, 7 May 2019 16:08:17 +0100 Subject: [PATCH 02/19] Allow nested checkboxes to be collapsible Expands the API of the macro to allow nested checkboxes to have a summary tracking the current selection, the fieldset to expand/collapse and buttons to be added to allow jumping between states. Includes making 'Done' button inline on mobile. Helps differentiate it form the form submit. --- .../javascripts/collapsibleCheckboxes.js | 142 ++++++++++++++++++ .../stylesheets/components/checkboxes.scss | 52 +++++++ app/templates/components/checkbox.html | 8 +- app/templates/components/select-input.html | 17 ++- .../views/manage-users/permissions.html | 2 +- .../templates/manage-template-folder.html | 2 +- gulpfile.js | 1 + 7 files changed, 210 insertions(+), 14 deletions(-) create mode 100644 app/assets/javascripts/collapsibleCheckboxes.js diff --git a/app/assets/javascripts/collapsibleCheckboxes.js b/app/assets/javascripts/collapsibleCheckboxes.js new file mode 100644 index 000000000..97957b3d1 --- /dev/null +++ b/app/assets/javascripts/collapsibleCheckboxes.js @@ -0,0 +1,142 @@ +(function (Modules) { + "use strict"; + + Modules.CollapsibleCheckboxes = function() { + const _focusTextElement = ($el) => { + $el + .attr('tabindex', '-1') + .focus(); + }; + + this.start = function(component) { + this.$formGroup = $(component); + this.$fieldset = this.$formGroup.find('fieldset'); + this.$checkboxes = this.$fieldset.find('input[type=checkbox]'); + this.summary.$el = this.$formGroup.find('.selection-summary'); + this.fieldLabel = this.$formGroup.data('fieldLabel'); + this.total = this.$checkboxes.length; + + this.addHeadingHideLegend(); + this.addFooterAndDoneButton(); + + // create summary from component pieces and match text to current selection + this.summary.$el = this.summary.create(this.getSelection(), this.total); + this.summary.update(this.getSelection(), this.total, this.fieldLabel); + this.$fieldset.before(this.summary.$el); + + // hide checkboxes + this.$fieldset.hide(); + this.expanded = false; + + // set semantic relationships with aria attributes + this.addARIAToButtons(); + + this.bindEvents(); + }; + this.getSelection = function() { return this.$checkboxes.filter(':checked').length; }; + this.addHeadingHideLegend = function() { + const headingLevel = this.$formGroup.data('heading-level') || '2'; + const $legend = this.$fieldset.find('legend'); + + this.$heading = $(`${$legend.text().trim()}`); + this.$fieldset.before(this.$heading); + + $legend.addClass('visuallyhidden'); + }; + this.summary = { + templates: { + all: (selection, total, field) => `All ${field}s`, + some: (selection, total, field) => `${selection} of ${total} ${field}s`, + none: (selection, total, field) => { + if (field === 'folder') { + return "No folders (only templates outside a folder)"; + } else { + return `No ${field}s`; + } + } + }, + create: function() { + const $el = $(`

+ +

`); + + this.$text = $el.find('span'); + this.$changeButton = $el.find('button'); + + return $el; + }, + update: function(selection, total, field) { + let template; + + if (selection === total) { + template = 'all'; + } else if (selection > 0) { + template = 'some'; + } else { + template = 'none'; + } + + this.$text.html(this.templates[template](selection, total, field)); + } + }; + this.addFooterAndDoneButton = function () { + this.$footer = $(``); + + this.$doneButton = this.$footer.find('.button'); + this.$fieldset.append(this.$footer); + }; + this.addARIAToButtons = function () { + const aria = { + 'aria-expanded': this.expanded, + 'aria-controls': this.$fieldset.attr('id') + }; + + this.summary.$changeButton.attr(aria); + this.$doneButton.attr(aria); + }; + this.expand = function(e) { + if (e !== undefined) { e.preventDefault(); } + + if (!this.expanded) { + this.$fieldset.show(); + this.summary.$changeButton.attr('aria-expanded', true); + this.$doneButton.attr('aria-expanded', true); + this.expanded = true; + } + + // shift focus whether expanded or not + _focusTextElement(this.$fieldset); + }; + this.collapse = function(e) { + if (e !== undefined) { e.preventDefault(); } + + if (this.expanded) { + this.$fieldset.hide(); + this.summary.$changeButton.attr('aria-expanded', false); + this.$doneButton.attr('aria-expanded', false); + this.expanded = false; + } + + // shift focus whether expanded or not + _focusTextElement(this.summary.$text); + }; + this.handleSelection = function(e) { + this.summary.update(this.getSelection(), this.total, this.fieldLabel); + }; + this.bindEvents = function() { + const self = this; + + this.summary.$changeButton.on('click', this.expand.bind(this)); + this.$doneButton.on('click', this.collapse.bind(this)); + this.$checkboxes.on('click', this.handleSelection.bind(this)); + + // take summary out of tab order when focus moves + this.summary.$el.on('blur', (e) => $(this).attr('tabindex', '-1')); + }; + }; + +}(window.GOVUK.Modules)); diff --git a/app/assets/stylesheets/components/checkboxes.scss b/app/assets/stylesheets/components/checkboxes.scss index 40ca600d4..6e6b5414c 100644 --- a/app/assets/stylesheets/components/checkboxes.scss +++ b/app/assets/stylesheets/components/checkboxes.scss @@ -1,3 +1,31 @@ +.selection-summary { + + .selection-summary__text { + display: inline-block; + padding: .526315em .789473em .263157em 40px; + padding-left: 40px; + background-image: file-url('folder-black-bold.svg'); + background-repeat: no-repeat; + background-size: auto 20px; + background-position: 0px 8px; + + &:focus { + outline: none; + } + } + + @include ie-lte(8) { + background-image: file-url('folder-blue-bold.png'); + } + + // revert full-width button for smaller screens + .button { + display: inline-block; + width: auto; + } + +} + .checkboxes-nested { margin-bottom: 10px; @@ -42,3 +70,27 @@ } } + +.selection-footer { + clear: both; + padding-top: $gutter / 6; + + @include media(tablet) { + padding-top: $gutter / 3; + } + + .button-secondary { + // revert full-width button for smaller screens + display: inline-block; + width: auto; + } +} + +// styles specific to the collapsible checkboxes module +[data-module=collapsible-checkboxes] { + + fieldset:focus { + outline: none; + } + +} diff --git a/app/templates/components/checkbox.html b/app/templates/components/checkbox.html index 51594eea6..3647c4f1c 100644 --- a/app/templates/components/checkbox.html +++ b/app/templates/components/checkbox.html @@ -19,13 +19,13 @@ {% endmacro %} -{% macro checkboxes_nested(field, child_map, hint=None, disable=[], option_hints={}, hide_legend=False) %} - {{ select_nested(field, child_map, hint, disable, option_hints, hide_legend, input="checkbox") }} +{% macro checkboxes_nested(field, child_map, hint=None, disable=[], option_hints={}, hide_legend=False, collapsible_opts={}, legend_style="text") %} + {{ select_nested(field, child_map, hint, disable, option_hints, hide_legend, collapsible_opts, legend_style, input="checkbox") }} {% endmacro %} -{% macro checkboxes(field, hint=None, disable=[], option_hints={}, hide_legend=False) %} - {{ select(field, hint, disable, option_hints, hide_legend, input="checkbox") }} +{% macro checkboxes(field, hint=None, disable=[], option_hints={}, hide_legend=False, collapsible_opts={}) %} + {{ select(field, hint, disable, option_hints, hide_legend, collapsible_opts, input="checkbox") }} {% endmacro %} diff --git a/app/templates/components/select-input.html b/app/templates/components/select-input.html index b3cfbb9e4..a42f00a9f 100644 --- a/app/templates/components/select-input.html +++ b/app/templates/components/select-input.html @@ -1,6 +1,6 @@ -{% macro select(field, hint=None, disable=[], option_hints={}, hide_legend=False, input="radio") %} +{% macro select(field, hint=None, disable=[], option_hints={}, hide_legend=False, collapsible_opts={}, legend_style="text", input="radio") %} {% call select_wrapper( - field, hint, disable, option_hints, hide_legend + field, hint, disable, option_hints, hide_legend, collapsible_opts, legend_style ) %} {% for option in field %} {{ select_input(option, disable, option_hints, input=input) }} @@ -24,9 +24,9 @@ {% endmacro %} -{% macro select_nested(field, child_map, hint=None, disable=[], option_hints={}, hide_legend=False, input="radio") %} +{% macro select_nested(field, child_map, hint=None, disable=[], option_hints={}, hide_legend=False, collapsible_opts={}, legend_style="text", input="radio") %} {% call select_wrapper( - field, hint, disable, option_hints, hide_legend + field, hint, disable, option_hints, hide_legend, collapsible_opts, legend_style ) %}
{{ select_list(child_map[None], child_map, disable, option_hints, input=input) }} @@ -35,10 +35,11 @@ {% endmacro %} -{% macro select_wrapper(field, hint=None, disable=[], option_hints={}, hide_legend=False) %} -
-
- +{% macro select_wrapper(field, hint=None, disable=[], option_hints={}, hide_legend=False, collapsible_opts={}, legend_style="text") %} + {% set is_collapsible = collapsible_opts|length %} +
+
+ {% if hide_legend %}{% endif %} {{ field.label.text|safe }} {% if hide_legend %}{% endif %} diff --git a/app/templates/views/manage-users/permissions.html b/app/templates/views/manage-users/permissions.html index 801424c89..8d839b9aa 100644 --- a/app/templates/views/manage-users/permissions.html +++ b/app/templates/views/manage-users/permissions.html @@ -14,7 +14,7 @@
{% if current_service.has_permission("edit_folder_permissions") and form.folder_permissions.all_template_folders %} - {{ checkboxes_nested(form.folder_permissions, form.folder_permissions.children()) }} + {{ checkboxes_nested(form.folder_permissions, form.folder_permissions.children(), hide_legend=True, collapsible_opts={ 'field': 'folder' }) }} {% endif %} {% if service_has_email_auth %} diff --git a/app/templates/views/templates/manage-template-folder.html b/app/templates/views/templates/manage-template-folder.html index f7bbb37a0..56367fac6 100644 --- a/app/templates/views/templates/manage-template-folder.html +++ b/app/templates/views/templates/manage-template-folder.html @@ -27,7 +27,7 @@ {{ textbox(form.name) }} {% if current_service.has_permission("edit_folder_permissions") %} {% if current_user.has_permissions("manage_service") and form.users_with_permission.all_service_users %} - {{ checkboxes(form.users_with_permission) }} + {{ checkboxes(form.users_with_permission, collapsible_opts={ 'field': 'team member' }) }} {% endif %} {% endif %} diff --git a/gulpfile.js b/gulpfile.js index 648bf86eb..305323158 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -95,6 +95,7 @@ const javascripts = () => { paths.src + 'javascripts/previewPane.js', paths.src + 'javascripts/colourPreview.js', paths.src + 'javascripts/templateFolderForm.js', + paths.src + 'javascripts/collapsibleCheckboxes.js', paths.src + 'javascripts/main.js' ]) .pipe(plugins.prettyerror()) From 90ad8b4ed0bd40aa17c3cec864fcb6bcdaf2f14c Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Tue, 7 May 2019 16:12:26 +0100 Subject: [PATCH 03/19] Add helpers for JS tests To help with asserting some things which were repeated in the tests. --- tests/javascripts/support/helpers.js | 84 ++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/javascripts/support/helpers.js diff --git a/tests/javascripts/support/helpers.js b/tests/javascripts/support/helpers.js new file mode 100644 index 000000000..f951f317b --- /dev/null +++ b/tests/javascripts/support/helpers.js @@ -0,0 +1,84 @@ +const triggerEvent = (el, evtType) => { + const evt = new Event(evtType, { + bubbles: true, + cancelable: true + }); + + el.dispatchEvent(evt); +}; + +class ElementQuery { + constructor (el) { + this.el = el; + } + + get nodeName () { + return this.el.nodeName.toLowerCase(); + } + + get firstTextNodeValue () { + const textNodes = Array.from(this.el.childNodes).filter(el => el.nodeType === 3); + + return textNodes.length ? textNodes[0].nodeValue : undefined; + }; + // returns the elements attributes as an object + hasAttributesSetTo (mappings) { + if (!this.el.hasAttributes()) { return false; } + + const keys = Object.keys(mappings); + let matches = 0; + + keys.forEach(key => { + if (this.el.hasAttribute(key) && (this.el.attributes[key].value === mappings[key])) { + matches++; + } + }); + + return matches === keys.length; + } + + hasClass (classToken) { + return Array.from(this.el.classList).includes(classToken); + } + + is (state) { + const test = `_is${state.charAt(0).toUpperCase()}${state.slice(1)}`; + + if (ElementQuery.prototype.hasOwnProperty(test)) { + return this[test](); + } + } + + // looks for a sibling before the el that matches the supplied test function + // the test function gets sent each sibling, wrapped in an Element instance + getPreviousSibling (test) { + let node = this.el.previousElementSibling; + let el; + + while(node) { + el = element(node); + + if (test(el)) { + return node; + } + + node = node.previousElementSibling; + } + + return null; + } + + _isHidden () { + const display = window.getComputedStyle(this.el).getPropertyValue('display'); + + return display === 'none'; + } +}; + +// function to ask certain questions of a DOM Element +const element = function (el) { + return new ElementQuery(el); +}; + +exports.triggerEvent = triggerEvent; +exports.element = element; From 089ebf2c7a0b6f96ad32dc9f90363b6b2d9c21c1 Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Tue, 7 May 2019 16:13:20 +0100 Subject: [PATCH 04/19] Add tests for collapsible checkboxes --- .../javascripts/collapsibleCheckboxes.test.js | 443 ++++++++++++++++++ 1 file changed, 443 insertions(+) create mode 100644 tests/javascripts/collapsibleCheckboxes.test.js diff --git a/tests/javascripts/collapsibleCheckboxes.test.js b/tests/javascripts/collapsibleCheckboxes.test.js new file mode 100644 index 000000000..6a8e266fe --- /dev/null +++ b/tests/javascripts/collapsibleCheckboxes.test.js @@ -0,0 +1,443 @@ +const helpers = require('./support/helpers'); + +beforeAll(() => { + // set up jQuery + window.jQuery = require('jquery'); + $ = window.jQuery; + + // load module code + require('govuk_frontend_toolkit/javascripts/govuk/modules.js'); + require('../../app/assets/javascripts/collapsibleCheckboxes.js'); +}); + +afterAll(() => { + window.jQuery = null; + $ = null; + + delete window.GOVUK; +}); + + +describe('Collapsible fieldset', () => { + + let fieldset; + let checkboxes; + + beforeEach(() => { + const _checkboxes = (start, end, descendents) => { + result = ''; + + for (let num = start; num <= end; num++) { + let id = `folder-permissions-${num}`; + + if (!descendents) { descendents = ''; } + + result += `
  • + + + ${descendents} +
  • `; + } + + return result; + }; + + // set up DOM + document.body.innerHTML = + `
    +
    + + Folders this team member can see + +
    +
      + ${_checkboxes(1, 10)} +
    +
    +
    +
    `; + + formGroup = document.querySelector('.form-group'); + fieldset = formGroup.querySelector('fieldset'); + checkboxesContainer = fieldset.querySelector('.checkboxes-nested'); + checkboxes = checkboxesContainer.querySelectorAll('input[type=checkbox]'); + + }); + + afterEach(() => { + + document.body.innerHTML = ''; + + }); + + describe('when started', () => { + + beforeEach(() => { + + // start module + window.GOVUK.modules.start(); + + }); + + afterEach(() => { + + // reset checkboxes to default state + checkboxes.forEach(el => el.removeAttribute('checked')); + + }); + + test("adds a heading before the selected fieldset", () => { + + const heading = helpers.element(fieldset).getPreviousSibling( + el => (el.nodeName === 'h2') && (el.hasClass('heading-small')) + ); + + expect(heading).not.toBeNull(); + + }); + + test("has a summary added before the selected fieldset", () => { + + const summary = helpers.element(fieldset).getPreviousSibling( + el => (el.nodeName === 'p') && (el.hasClass('selection-summary')) + ); + + expect(summary).not.toBeNull(); + + }); + + test("the legend of the fieldset is visually hidden", () => { + + const legend = helpers.element(fieldset.querySelector('legend')); + + expect(legend.hasClass('visuallyhidden')).toBe(true); + + }); + + test("has a change button", () => { + + const changeButton = document.querySelector('.selection-summary .button'); + + expect(changeButton).not.toBeNull(); + expect(changeButton.textContent).toEqual('Change Folders this team member can see'); + + }); + + test("has a 'Done' button", () => { + + const nextEl = fieldset.querySelector('.button'); + + expect(helpers.element(nextEl).nodeName).toEqual('button'); + + }); + + test("has the correct aria attributes on both buttons", () => { + + const changeButton = document.querySelector('.selection-summary .button'); + const doneButton = fieldset.querySelector('.button'); + + // check change button + expect(helpers.element(changeButton).hasAttributesSetTo({ + 'aria-controls': fieldset.getAttribute('id'), + 'aria-expanded': 'false' + })).toBe(true); + + // check done button + expect(helpers.element(doneButton).hasAttributesSetTo({ + 'aria-controls': fieldset.getAttribute('id'), + 'aria-expanded': 'false' + })).toBe(true); + + }); + + test("hides the checkboxes", () => { + + expect(helpers.element(fieldset).is('hidden')).toEqual(true); + + }); + + }); + + test('has the right summary text when started with no checkboxes selected', () => { + + // start module + window.GOVUK.modules.start(); + + const summaryText = document.querySelector('.selection-summary__text'); + + // default state is for none to be selected + expect(summaryText.textContent).toEqual("No folders (only templates outside a folder)"); + + }); + + test('has the right summary text when started with some checkboxes selected', () => { + + // select the first 3 checkboxes + checkboxes.forEach((el, idx) => { + if ([0,1,2].includes(idx)) { el.setAttribute('checked', ''); } + }); + + // start module + window.GOVUK.modules.start(); + + const summaryText = document.querySelector('.selection-summary__text'); + + expect(summaryText.textContent).toEqual("3 of 10 folders"); + + }); + + test('has the right summary text when started with all checkboxes selected', () => { + + // select all the checkboxes + checkboxes.forEach(el => el.setAttribute('checked', '')); + + // start module + window.GOVUK.modules.start(); + + const summaryText = document.querySelector('.selection-summary__text'); + + expect(summaryText.textContent).toEqual("All folders"); + + }); + + describe("when 'change' is clicked", () => { + + let changeButton; + let doneButton; + + beforeEach(() => { + + // start module + window.GOVUK.modules.start(); + + doneButton = fieldset.querySelector('.button'); + changeButton = document.querySelector('.selection-summary .button'); + + helpers.triggerEvent(changeButton, 'click'); + + }); + + test("it shows the checkboxes", () => { + + expect(helpers.element(fieldset).is('hidden')).toBe(false); + + }); + test("it focuses the fieldset", () => { + + expect(document.activeElement).toBe(fieldset); + + }); + test("it uses ARIA to mark the checkboxes as expanded", () => { + + expect(changeButton.getAttribute('aria-expanded')).toEqual('true'); + expect(doneButton.getAttribute('aria-expanded')).toEqual('true'); + + }); + }); + + describe("when 'done' is clicked", () => { + + let changeButton; + let doneButton; + + beforeEach(() => { + + // start module + window.GOVUK.modules.start(); + + doneButton = fieldset.querySelector('.button'); + changeButton = document.querySelector('.selection-summary .button'); + + // show the checkboxes + helpers.triggerEvent(changeButton, 'click'); + + // click the done button + helpers.triggerEvent(doneButton, 'click'); + + }); + + test("it hides the checkboxes", () => { + + expect(helpers.element(fieldset).is('hidden')).toBe(true); + + }); + + test("it focuses the summary text", () => { + + expect(document.activeElement).toBe(document.querySelector('.selection-summary__text')); + + }); + + test("it uses ARIA to mark the checkboxes as collapsed", () => { + + expect(changeButton.getAttribute('aria-expanded')).toEqual('false'); + expect(doneButton.getAttribute('aria-expanded')).toEqual('false'); + + }); + }); + + describe("when the selection changes", () => { + + const showCheckboxes = () => { + changeButton = document.querySelector('.selection-summary .button'); + helpers.triggerEvent(changeButton, 'click'); + }; + + const checkFirstCheckbox = () => { + checkboxes[0].setAttribute('checked', ''); + checkboxes[0].checked = true; + }; + + const checkAllCheckboxes = () => { + Array.from(checkboxes).forEach(checkbox => { + checkbox.setAttribute('checked', ''); + checkbox.checked = true; + }); + }; + + const checkAllCheckboxesButTheLast = () => { + Array.from(checkboxes).forEach((checkbox, idx) => { + if (idx > 0) { + checkbox.setAttribute('checked', ''); + checkbox.checked = true; + } + }); + }; + + let changeButton; + + describe("from some to none the summary updates to reflect that", () => { + + test("if fields are called 'folders'", () => { + + formGroup.dataset.fieldLabel = 'folder'; + + checkFirstCheckbox(); + + // start module + window.GOVUK.modules.start(); + + showCheckboxes(); + + const summaryText = document.querySelector('.selection-summary__text'); + + // click the first checkbox + helpers.triggerEvent(checkboxes[0], 'click'); + + expect(summaryText.textContent).toEqual("No folders (only templates outside a folder)"); + + }); + + test("if fields are called 'team member'", () => { + + formGroup.dataset.fieldLabel = 'team member'; + + checkFirstCheckbox(); + + // start module + window.GOVUK.modules.start(); + + showCheckboxes(); + + const summaryText = document.querySelector('.selection-summary__text'); + + // click the first checkbox + helpers.triggerEvent(checkboxes[0], 'click'); + + expect(summaryText.textContent).toEqual("No team members"); + + }); + + }); + + describe("from all to some the summary updates to reflect that", () => { + + test("if fields are called 'folder'", () => { + + formGroup.dataset.fieldLabel = 'folder'; + + checkAllCheckboxes(); + + // start module + window.GOVUK.modules.start(); + + showCheckboxes(); + + const summaryText = document.querySelector('.selection-summary__text'); + + // click the first checkbox + helpers.triggerEvent(checkboxes[1], 'click'); + + expect(summaryText.textContent).toEqual("9 of 10 folders"); + + }); + + test("if fields are called 'team member'", () => { + + formGroup.dataset.fieldLabel = 'team member'; + + checkAllCheckboxes(); + + // start module + window.GOVUK.modules.start(); + + showCheckboxes(); + + const summaryText = document.querySelector('.selection-summary__text'); + + // click the first checkbox + helpers.triggerEvent(checkboxes[1], 'click'); + + expect(summaryText.textContent).toEqual("9 of 10 team members"); + + }); + + }); + + describe("from some to all the summary updates to reflect that", () => { + + test("if fields are called 'folder'", () => { + + formGroup.dataset.fieldLabel = 'folder'; + + checkAllCheckboxesButTheLast(); + + // start module + window.GOVUK.modules.start(); + + showCheckboxes(); + + const summaryText = document.querySelector('.selection-summary__text'); + + helpers.triggerEvent(checkboxes[0], 'click'); + + expect(summaryText.textContent).toEqual("All folders"); + + }); + + test("if fields are called 'team member'", () => { + + formGroup.dataset.fieldLabel = 'team member'; + + checkAllCheckboxesButTheLast(); + + // start module + window.GOVUK.modules.start(); + + showCheckboxes(); + + const summaryText = document.querySelector('.selection-summary__text'); + + helpers.triggerEvent(checkboxes[0], 'click'); + + expect(summaryText.textContent).toEqual("All team members"); + + }); + + }); + + }); + +}); From 42a9a0cf23330ad4f4bfd3ac3cb787a8ad4ba57b Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Tue, 7 May 2019 11:44:12 +0100 Subject: [PATCH 05/19] Make selection summary a live region Live regions need to be in the original HTML of the page to work. We were generating the summary in JS. This changes the JS to only generate the contents of the summary so changes to its contents are announces by the existing live-region. --- app/assets/javascripts/collapsibleCheckboxes.js | 16 ++++++++-------- app/templates/components/select-input.html | 3 +++ tests/javascripts/collapsibleCheckboxes.test.js | 10 +++++----- 3 files changed, 16 insertions(+), 13 deletions(-) diff --git a/app/assets/javascripts/collapsibleCheckboxes.js b/app/assets/javascripts/collapsibleCheckboxes.js index 97957b3d1..7c8b5bae0 100644 --- a/app/assets/javascripts/collapsibleCheckboxes.js +++ b/app/assets/javascripts/collapsibleCheckboxes.js @@ -20,7 +20,7 @@ this.addFooterAndDoneButton(); // create summary from component pieces and match text to current selection - this.summary.$el = this.summary.create(this.getSelection(), this.total); + this.summary.addContent(); this.summary.update(this.getSelection(), this.total, this.fieldLabel); this.$fieldset.before(this.summary.$el); @@ -55,15 +55,15 @@ } } }, - create: function() { - const $el = $(`

    - -

    `); + addContent: function() { + const $content = $(`

    + +

    `); - this.$text = $el.find('span'); - this.$changeButton = $el.find('button'); + this.$text = $content.find('span'); + this.$changeButton = $content.find('button'); - return $el; + this.$el.append($content); }, update: function(selection, total, field) { let template; diff --git a/app/templates/components/select-input.html b/app/templates/components/select-input.html index a42f00a9f..f65647647 100644 --- a/app/templates/components/select-input.html +++ b/app/templates/components/select-input.html @@ -38,6 +38,9 @@ {% macro select_wrapper(field, hint=None, disable=[], option_hints={}, hide_legend=False, collapsible_opts={}, legend_style="text") %} {% set is_collapsible = collapsible_opts|length %}
    + {% if is_collapsible %} +
    + {% endif %}
    {% if hide_legend %}{% endif %} diff --git a/tests/javascripts/collapsibleCheckboxes.test.js b/tests/javascripts/collapsibleCheckboxes.test.js index 6a8e266fe..f56917ffa 100644 --- a/tests/javascripts/collapsibleCheckboxes.test.js +++ b/tests/javascripts/collapsibleCheckboxes.test.js @@ -47,6 +47,7 @@ describe('Collapsible fieldset', () => { // set up DOM document.body.innerHTML = `
    +
    Folders this team member can see @@ -98,13 +99,12 @@ describe('Collapsible fieldset', () => { }); - test("has a summary added before the selected fieldset", () => { + test("adds the right content to the summary", () => { - const summary = helpers.element(fieldset).getPreviousSibling( - el => (el.nodeName === 'p') && (el.hasClass('selection-summary')) - ); + const summary = formGroup.querySelector('.selection-summary'); - expect(summary).not.toBeNull(); + expect(summary.querySelector('p')).not.toBeNull(); + expect(summary.querySelector('p .selection-summary__text')).not.toBeNull(); }); From db12ec3a5c4ef2c62cb2157953356b1d4d3f13b1 Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Tue, 7 May 2019 17:07:17 +0100 Subject: [PATCH 06/19] Make buttons work outside of context This is important for users who find buttons via interfaces that present them out of the context of the page, like screenreaders. --- .../javascripts/collapsibleCheckboxes.js | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/app/assets/javascripts/collapsibleCheckboxes.js b/app/assets/javascripts/collapsibleCheckboxes.js index 7c8b5bae0..7b154ae84 100644 --- a/app/assets/javascripts/collapsibleCheckboxes.js +++ b/app/assets/javascripts/collapsibleCheckboxes.js @@ -16,11 +16,13 @@ this.fieldLabel = this.$formGroup.data('fieldLabel'); this.total = this.$checkboxes.length; - this.addHeadingHideLegend(); - this.addFooterAndDoneButton(); + const legendText = this.$fieldset.find('legend').text().trim(); + + this.addHeadingHideLegend(legendText); + this.addFooterAndDoneButton(legendText); // create summary from component pieces and match text to current selection - this.summary.addContent(); + this.summary.addContent(legendText); this.summary.update(this.getSelection(), this.total, this.fieldLabel); this.$fieldset.before(this.summary.$el); @@ -34,14 +36,13 @@ this.bindEvents(); }; this.getSelection = function() { return this.$checkboxes.filter(':checked').length; }; - this.addHeadingHideLegend = function() { + this.addHeadingHideLegend = function(legendText) { const headingLevel = this.$formGroup.data('heading-level') || '2'; - const $legend = this.$fieldset.find('legend'); - this.$heading = $(`${$legend.text().trim()}`); + this.$heading = $(`${legendText}`); this.$fieldset.before(this.$heading); - $legend.addClass('visuallyhidden'); + this.$fieldset.find('legend').addClass('visuallyhidden'); }; this.summary = { templates: { @@ -55,12 +56,13 @@ } } }, - addContent: function() { + addContent: function(legendText) { const $content = $(`

    - + +

    `); - this.$text = $content.find('span'); + this.$text = $content.find('.selection-summary__text'); this.$changeButton = $content.find('button'); this.$el.append($content); @@ -79,9 +81,10 @@ this.$text.html(this.templates[template](selection, total, field)); } }; - this.addFooterAndDoneButton = function () { + this.addFooterAndDoneButton = function (legendText) { this.$footer = $(``); From f274759cd871f84bab554beac3effc103ca6fa22 Mon Sep 17 00:00:00 2001 From: Chris Hill-Scott Date: Thu, 9 May 2019 10:21:30 +0100 Subject: [PATCH 07/19] Add larger version of black folder icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is for use in the folder permissions UI. It’s designed to be sized at the same width as a GOV.UK style checkbox. The CSS to render it is something like: ```css background-image: file-url('folder-black.svg'); background-repeat: no-repeat; background-size: 39px auto; background-position: 0px 4px; ``` --- app/assets/images/folder-black.svg | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 app/assets/images/folder-black.svg diff --git a/app/assets/images/folder-black.svg b/app/assets/images/folder-black.svg new file mode 100644 index 000000000..85f7456fa --- /dev/null +++ b/app/assets/images/folder-black.svg @@ -0,0 +1,13 @@ + + + + + + + + From 89478ac2ca6edbdca78861f655f1fee8c20084e2 Mon Sep 17 00:00:00 2001 From: Chris Hill-Scott Date: Thu, 9 May 2019 10:27:14 +0100 Subject: [PATCH 08/19] Use larger folder icon --- app/assets/stylesheets/components/checkboxes.scss | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/assets/stylesheets/components/checkboxes.scss b/app/assets/stylesheets/components/checkboxes.scss index 6e6b5414c..bb35893c5 100644 --- a/app/assets/stylesheets/components/checkboxes.scss +++ b/app/assets/stylesheets/components/checkboxes.scss @@ -3,11 +3,11 @@ .selection-summary__text { display: inline-block; padding: .526315em .789473em .263157em 40px; - padding-left: 40px; - background-image: file-url('folder-black-bold.svg'); + padding-left: 51px; + background-image: file-url('folder-black.svg'); background-repeat: no-repeat; - background-size: auto 20px; - background-position: 0px 8px; + background-size: 39px auto; + background-position: 0px 4px; &:focus { outline: none; From 3dc2130926e6cc21a902a61a4f3527f47de86cc2 Mon Sep 17 00:00:00 2001 From: Chris Hill-Scott Date: Thu, 9 May 2019 10:29:00 +0100 Subject: [PATCH 09/19] Align baseline of summary text with button text --- app/assets/stylesheets/components/checkboxes.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/assets/stylesheets/components/checkboxes.scss b/app/assets/stylesheets/components/checkboxes.scss index bb35893c5..385671406 100644 --- a/app/assets/stylesheets/components/checkboxes.scss +++ b/app/assets/stylesheets/components/checkboxes.scss @@ -22,6 +22,8 @@ .button { display: inline-block; width: auto; + position: relative; + top: 1px; } } From 1ffa8c8915229b1b94dae489816e6042d006863d Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Thu, 9 May 2019 11:04:43 +0100 Subject: [PATCH 10/19] Only show the folder icon if fields are folders --- .../javascripts/collapsibleCheckboxes.js | 6 ++++-- .../stylesheets/components/checkboxes.scss | 19 +++++++++++-------- .../javascripts/collapsibleCheckboxes.test.js | 17 ++++++++++++++++- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/app/assets/javascripts/collapsibleCheckboxes.js b/app/assets/javascripts/collapsibleCheckboxes.js index 7b154ae84..5bcdcef5f 100644 --- a/app/assets/javascripts/collapsibleCheckboxes.js +++ b/app/assets/javascripts/collapsibleCheckboxes.js @@ -22,7 +22,7 @@ this.addFooterAndDoneButton(legendText); // create summary from component pieces and match text to current selection - this.summary.addContent(legendText); + this.summary.addContent(legendText, this.fieldLabel); this.summary.update(this.getSelection(), this.total, this.fieldLabel); this.$fieldset.before(this.summary.$el); @@ -56,7 +56,7 @@ } } }, - addContent: function(legendText) { + addContent: function(legendText, fieldLabel) { const $content = $(`

    @@ -65,6 +65,8 @@ this.$text = $content.find('.selection-summary__text'); this.$changeButton = $content.find('button'); + if (fieldLabel === 'folder') { this.$text.addClass('selection-summary__text--folders'); } + this.$el.append($content); }, update: function(selection, total, field) { diff --git a/app/assets/stylesheets/components/checkboxes.scss b/app/assets/stylesheets/components/checkboxes.scss index 385671406..f2ea72826 100644 --- a/app/assets/stylesheets/components/checkboxes.scss +++ b/app/assets/stylesheets/components/checkboxes.scss @@ -2,20 +2,23 @@ .selection-summary__text { display: inline-block; - padding: .526315em .789473em .263157em 40px; - padding-left: 51px; - background-image: file-url('folder-black.svg'); - background-repeat: no-repeat; - background-size: 39px auto; - background-position: 0px 4px; + padding: .526315em .789473em .263157em 0px; &:focus { outline: none; } } - @include ie-lte(8) { - background-image: file-url('folder-blue-bold.png'); + .selection-summary__text--folders { + padding-left: 51px; + background-image: file-url('folder-black.svg'); + background-repeat: no-repeat; + background-size: 39px auto; + background-position: 0px 4px; + + @include ie-lte(8) { + background-image: file-url('folder-blue-bold.png'); + } } // revert full-width button for smaller screens diff --git a/tests/javascripts/collapsibleCheckboxes.test.js b/tests/javascripts/collapsibleCheckboxes.test.js index f56917ffa..e8b73dfa9 100644 --- a/tests/javascripts/collapsibleCheckboxes.test.js +++ b/tests/javascripts/collapsibleCheckboxes.test.js @@ -99,12 +99,14 @@ describe('Collapsible fieldset', () => { }); - test("adds the right content to the summary", () => { + test("adds the right content and classes to the summary", () => { const summary = formGroup.querySelector('.selection-summary'); expect(summary.querySelector('p')).not.toBeNull(); expect(summary.querySelector('p .selection-summary__text')).not.toBeNull(); + debugger; + expect(summary.querySelector('p .selection-summary__text').classList.contains('selection-summary__text--folders')).toBe(true); }); @@ -202,6 +204,19 @@ describe('Collapsible fieldset', () => { }); + test("the summary doesn't have a folder icon if fields aren't called 'folder'", () => { + + formGroup.dataset.fieldLabel = 'team member'; + + // start module + window.GOVUK.modules.start(); + + const summaryText = document.querySelector('.selection-summary__text'); + + expect(summaryText.classList.contains('.selection-summary__text-label')).toBe(false); + + }); + describe("when 'change' is clicked", () => { let changeButton; From 923ac7190cfa6e6c1c1a8e0b35ace2a24207291b Mon Sep 17 00:00:00 2001 From: Chris Hill-Scott Date: Thu, 9 May 2019 13:33:33 +0100 Subject: [PATCH 11/19] Add a hint when no team members are selected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This helps clarify that you will still be able to see the folder. Follows the phrasing used for the "No folders…" message. --- .../javascripts/collapsibleCheckboxes.js | 11 ++++----- .../javascripts/collapsibleCheckboxes.test.js | 24 +++++++++++++++++-- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/app/assets/javascripts/collapsibleCheckboxes.js b/app/assets/javascripts/collapsibleCheckboxes.js index 5bcdcef5f..43689115c 100644 --- a/app/assets/javascripts/collapsibleCheckboxes.js +++ b/app/assets/javascripts/collapsibleCheckboxes.js @@ -48,13 +48,10 @@ templates: { all: (selection, total, field) => `All ${field}s`, some: (selection, total, field) => `${selection} of ${total} ${field}s`, - none: (selection, total, field) => { - if (field === 'folder') { - return "No folders (only templates outside a folder)"; - } else { - return `No ${field}s`; - } - } + none: (selection, total, field) => ({ + "folder": "No folders (only templates outside a folder)", + "team member": "No team members (only you)" + }[field] || `No ${field}s`) }, addContent: function(legendText, fieldLabel) { const $content = $(`

    diff --git a/tests/javascripts/collapsibleCheckboxes.test.js b/tests/javascripts/collapsibleCheckboxes.test.js index e8b73dfa9..e52b4d8f8 100644 --- a/tests/javascripts/collapsibleCheckboxes.test.js +++ b/tests/javascripts/collapsibleCheckboxes.test.js @@ -205,7 +205,7 @@ describe('Collapsible fieldset', () => { }); test("the summary doesn't have a folder icon if fields aren't called 'folder'", () => { - + formGroup.dataset.fieldLabel = 'team member'; // start module @@ -361,7 +361,27 @@ describe('Collapsible fieldset', () => { // click the first checkbox helpers.triggerEvent(checkboxes[0], 'click'); - expect(summaryText.textContent).toEqual("No team members"); + expect(summaryText.textContent).toEqual("No team members (only you)"); + + }); + + test("if fields are called 'arbitrary thing'", () => { + + formGroup.dataset.fieldLabel = 'arbitrary thing'; + + checkFirstCheckbox(); + + // start module + window.GOVUK.modules.start(); + + showCheckboxes(); + + const summaryText = document.querySelector('.selection-summary__text'); + + // click the first checkbox + helpers.triggerEvent(checkboxes[0], 'click'); + + expect(summaryText.textContent).toEqual("No arbitrary things"); }); From 7328649537ad2d015d6e2475f080bd472038a8f1 Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Thu, 9 May 2019 16:36:31 +0100 Subject: [PATCH 12/19] Combine 'Change' and 'Done' buttons into one Includes addition of classes by JS to ensure CSS selectors don't have to reference the data attributes. --- .../javascripts/collapsibleCheckboxes.js | 90 +++++++------- .../stylesheets/components/checkboxes.scss | 18 +-- .../javascripts/collapsibleCheckboxes.test.js | 115 ++++++++---------- 3 files changed, 108 insertions(+), 115 deletions(-) diff --git a/app/assets/javascripts/collapsibleCheckboxes.js b/app/assets/javascripts/collapsibleCheckboxes.js index 43689115c..f6bb41fab 100644 --- a/app/assets/javascripts/collapsibleCheckboxes.js +++ b/app/assets/javascripts/collapsibleCheckboxes.js @@ -12,34 +12,37 @@ this.$formGroup = $(component); this.$fieldset = this.$formGroup.find('fieldset'); this.$checkboxes = this.$fieldset.find('input[type=checkbox]'); - this.summary.$el = this.$formGroup.find('.selection-summary'); this.fieldLabel = this.$formGroup.data('fieldLabel'); this.total = this.$checkboxes.length; + this.legendText = this.$fieldset.find('legend').text().trim(); + this.expanded = false; - const legendText = this.$fieldset.find('legend').text().trim(); + this.addHeadingHideLegend(); - this.addHeadingHideLegend(legendText); - this.addFooterAndDoneButton(legendText); + // generate summary and footer + this.summary.$el = this.$formGroup.find('.selection-summary'); + this.footer.$el = this.footer.getEl(this); + this.footer.update(this); // create summary from component pieces and match text to current selection - this.summary.addContent(legendText, this.fieldLabel); + this.summary.addContent(this.legendText, this.fieldLabel); this.summary.update(this.getSelection(), this.total, this.fieldLabel); this.$fieldset.before(this.summary.$el); + // add custom classes + this.$formGroup.addClass('selection-wrapper'); + this.$fieldset.addClass('selection-content'); + // hide checkboxes this.$fieldset.hide(); - this.expanded = false; - - // set semantic relationships with aria attributes - this.addARIAToButtons(); this.bindEvents(); }; this.getSelection = function() { return this.$checkboxes.filter(':checked').length; }; - this.addHeadingHideLegend = function(legendText) { + this.addHeadingHideLegend = function() { const headingLevel = this.$formGroup.data('heading-level') || '2'; - this.$heading = $(`${legendText}`); + this.$heading = $(`${this.legendText}`); this.$fieldset.before(this.$heading); this.$fieldset.find('legend').addClass('visuallyhidden'); @@ -54,17 +57,11 @@ }[field] || `No ${field}s`) }, addContent: function(legendText, fieldLabel) { - const $content = $(`

    - - -

    `); - - this.$text = $content.find('.selection-summary__text'); - this.$changeButton = $content.find('button'); + this.$text = $(`

    `); if (fieldLabel === 'folder') { this.$text.addClass('selection-summary__text--folders'); } - this.$el.append($content); + this.$el.append(this.$text); }, update: function(selection, total, field) { let template; @@ -80,34 +77,38 @@ this.$text.html(this.templates[template](selection, total, field)); } }; - this.addFooterAndDoneButton = function (legendText) { - this.$footer = $(`

    `); + this.footer = { + buttonContent: { + change: (fieldLabel) => `Choose ${fieldLabel}s`, + done: (fieldLabel) => `Done choosing ${fieldLabel}s` + }, + getEl: function (module) { + const buttonState = module.expanded ? 'done' : 'change'; + const buttonContent = this.buttonContent[buttonState](module.fieldLabel); - this.$doneButton = this.$footer.find('.button'); - this.$fieldset.append(this.$footer); - }; - this.addARIAToButtons = function () { - const aria = { - 'aria-expanded': this.expanded, - 'aria-controls': this.$fieldset.attr('id') - }; + return $(``); + }, + update: function (module) { + this.$el.remove(); + this.$el = this.getEl(module); - this.summary.$changeButton.attr(aria); - this.$doneButton.attr(aria); + module.$formGroup.append(this.$el); + } }; this.expand = function(e) { if (e !== undefined) { e.preventDefault(); } if (!this.expanded) { this.$fieldset.show(); - this.summary.$changeButton.attr('aria-expanded', true); - this.$doneButton.attr('aria-expanded', true); this.expanded = true; + this.footer.update(this); } // shift focus whether expanded or not @@ -118,22 +119,27 @@ if (this.expanded) { this.$fieldset.hide(); - this.summary.$changeButton.attr('aria-expanded', false); - this.$doneButton.attr('aria-expanded', false); this.expanded = false; + this.footer.update(this); } // shift focus whether expanded or not _focusTextElement(this.summary.$text); }; + this.handleClick = function(e) { + if (this.expanded) { + this.collapse(e); + } else { + this.expand(e); + } + }; this.handleSelection = function(e) { this.summary.update(this.getSelection(), this.total, this.fieldLabel); }; this.bindEvents = function() { const self = this; - this.summary.$changeButton.on('click', this.expand.bind(this)); - this.$doneButton.on('click', this.collapse.bind(this)); + this.$formGroup.on('click', '.button', this.handleClick.bind(this)); this.$checkboxes.on('click', this.handleSelection.bind(this)); // take summary out of tab order when focus moves diff --git a/app/assets/stylesheets/components/checkboxes.scss b/app/assets/stylesheets/components/checkboxes.scss index f2ea72826..9ec3fa9d0 100644 --- a/app/assets/stylesheets/components/checkboxes.scss +++ b/app/assets/stylesheets/components/checkboxes.scss @@ -1,8 +1,8 @@ .selection-summary { .selection-summary__text { - display: inline-block; padding: .526315em .789473em .263157em 0px; + margin-bottom: $gutter / 2; &:focus { outline: none; @@ -76,13 +76,16 @@ } +.selection-content { + margin-bottom: ($gutter / 3) * 2; + + .checkboxes-nested { + margin-bottom: 0; + } +} + .selection-footer { clear: both; - padding-top: $gutter / 6; - - @include media(tablet) { - padding-top: $gutter / 3; - } .button-secondary { // revert full-width button for smaller screens @@ -92,8 +95,7 @@ } // styles specific to the collapsible checkboxes module -[data-module=collapsible-checkboxes] { - +.selection-wrapper { fieldset:focus { outline: none; } diff --git a/tests/javascripts/collapsibleCheckboxes.test.js b/tests/javascripts/collapsibleCheckboxes.test.js index e52b4d8f8..db0636b84 100644 --- a/tests/javascripts/collapsibleCheckboxes.test.js +++ b/tests/javascripts/collapsibleCheckboxes.test.js @@ -89,6 +89,13 @@ describe('Collapsible fieldset', () => { }); + test("adds the right classes to the group and fieldset", () => { + + expect(formGroup.classList.contains('selection-wrapper')).toBe(true); + expect(fieldset.classList.contains('selection-content')).toBe(true); + + }); + test("adds a heading before the selected fieldset", () => { const heading = helpers.element(fieldset).getPreviousSibling( @@ -101,12 +108,10 @@ describe('Collapsible fieldset', () => { test("adds the right content and classes to the summary", () => { - const summary = formGroup.querySelector('.selection-summary'); + const summary = formGroup.querySelector('.selection-summary__text'); - expect(summary.querySelector('p')).not.toBeNull(); - expect(summary.querySelector('p .selection-summary__text')).not.toBeNull(); - debugger; - expect(summary.querySelector('p .selection-summary__text').classList.contains('selection-summary__text--folders')).toBe(true); + expect(summary).not.toBeNull(); + expect(summary.classList.contains('selection-summary__text--folders')).toBe(true); }); @@ -118,36 +123,18 @@ describe('Collapsible fieldset', () => { }); - test("has a change button", () => { + test("has a button to expand the fieldset", () => { - const changeButton = document.querySelector('.selection-summary .button'); + const button = formGroup.querySelector('.button'); - expect(changeButton).not.toBeNull(); - expect(changeButton.textContent).toEqual('Change Folders this team member can see'); + expect(button).not.toBeNull(); + expect(button.textContent.trim()).toEqual('Choose folders'); }); - test("has a 'Done' button", () => { + test("has the correct aria attributes on the button", () => { - const nextEl = fieldset.querySelector('.button'); - - expect(helpers.element(nextEl).nodeName).toEqual('button'); - - }); - - test("has the correct aria attributes on both buttons", () => { - - const changeButton = document.querySelector('.selection-summary .button'); - const doneButton = fieldset.querySelector('.button'); - - // check change button - expect(helpers.element(changeButton).hasAttributesSetTo({ - 'aria-controls': fieldset.getAttribute('id'), - 'aria-expanded': 'false' - })).toBe(true); - - // check done button - expect(helpers.element(doneButton).hasAttributesSetTo({ + expect(helpers.element(formGroup.querySelector('.button')).hasAttributesSetTo({ 'aria-controls': fieldset.getAttribute('id'), 'aria-expanded': 'false' })).toBe(true); @@ -170,7 +157,7 @@ describe('Collapsible fieldset', () => { const summaryText = document.querySelector('.selection-summary__text'); // default state is for none to be selected - expect(summaryText.textContent).toEqual("No folders (only templates outside a folder)"); + expect(summaryText.textContent.trim()).toEqual("No folders (only templates outside a folder)"); }); @@ -186,7 +173,7 @@ describe('Collapsible fieldset', () => { const summaryText = document.querySelector('.selection-summary__text'); - expect(summaryText.textContent).toEqual("3 of 10 folders"); + expect(summaryText.textContent.trim()).toEqual("3 of 10 folders"); }); @@ -200,7 +187,7 @@ describe('Collapsible fieldset', () => { const summaryText = document.querySelector('.selection-summary__text'); - expect(summaryText.textContent).toEqual("All folders"); + expect(summaryText.textContent.trim()).toEqual("All folders"); }); @@ -217,20 +204,14 @@ describe('Collapsible fieldset', () => { }); - describe("when 'change' is clicked", () => { - - let changeButton; - let doneButton; + describe("when button is clicked while the fieldset is collapsed", () => { beforeEach(() => { // start module window.GOVUK.modules.start(); - doneButton = fieldset.querySelector('.button'); - changeButton = document.querySelector('.selection-summary .button'); - - helpers.triggerEvent(changeButton, 'click'); + helpers.triggerEvent(formGroup.querySelector('.button'), 'click'); }); @@ -239,37 +220,39 @@ describe('Collapsible fieldset', () => { expect(helpers.element(fieldset).is('hidden')).toBe(false); }); + test("it focuses the fieldset", () => { expect(document.activeElement).toBe(fieldset); }); + test("it uses ARIA to mark the checkboxes as expanded", () => { - expect(changeButton.getAttribute('aria-expanded')).toEqual('true'); - expect(doneButton.getAttribute('aria-expanded')).toEqual('true'); + expect(formGroup.querySelector('.button').getAttribute('aria-expanded')).toEqual('true'); }); + + test("it changes it's text to indicate it's new action", () => { + + expect(formGroup.querySelector('.button').textContent.trim()).toEqual("Done choosing folders"); + + }); + }); - describe("when 'done' is clicked", () => { - - let changeButton; - let doneButton; + describe("when button is clicked when the fieldset is expanded", () => { beforeEach(() => { // start module window.GOVUK.modules.start(); - doneButton = fieldset.querySelector('.button'); - changeButton = document.querySelector('.selection-summary .button'); - // show the checkboxes - helpers.triggerEvent(changeButton, 'click'); + helpers.triggerEvent(formGroup.querySelector('.button'), 'click'); - // click the done button - helpers.triggerEvent(doneButton, 'click'); + // click the button + helpers.triggerEvent(formGroup.querySelector('.button'), 'click'); }); @@ -287,8 +270,13 @@ describe('Collapsible fieldset', () => { test("it uses ARIA to mark the checkboxes as collapsed", () => { - expect(changeButton.getAttribute('aria-expanded')).toEqual('false'); - expect(doneButton.getAttribute('aria-expanded')).toEqual('false'); + expect(formGroup.querySelector('.button').getAttribute('aria-expanded')).toEqual('false'); + + }); + + test("it changes it's text to indicate it's new action", () => { + + expect(formGroup.querySelector('.button').textContent.trim()).toEqual("Choose folders"); }); }); @@ -296,8 +284,7 @@ describe('Collapsible fieldset', () => { describe("when the selection changes", () => { const showCheckboxes = () => { - changeButton = document.querySelector('.selection-summary .button'); - helpers.triggerEvent(changeButton, 'click'); + helpers.triggerEvent(formGroup.querySelector('.button'), 'click'); }; const checkFirstCheckbox = () => { @@ -321,8 +308,6 @@ describe('Collapsible fieldset', () => { }); }; - let changeButton; - describe("from some to none the summary updates to reflect that", () => { test("if fields are called 'folders'", () => { @@ -341,7 +326,7 @@ describe('Collapsible fieldset', () => { // click the first checkbox helpers.triggerEvent(checkboxes[0], 'click'); - expect(summaryText.textContent).toEqual("No folders (only templates outside a folder)"); + expect(summaryText.textContent.trim()).toEqual("No folders (only templates outside a folder)"); }); @@ -361,7 +346,7 @@ describe('Collapsible fieldset', () => { // click the first checkbox helpers.triggerEvent(checkboxes[0], 'click'); - expect(summaryText.textContent).toEqual("No team members (only you)"); + expect(summaryText.textContent.trim()).toEqual("No team members (only you)"); }); @@ -381,7 +366,7 @@ describe('Collapsible fieldset', () => { // click the first checkbox helpers.triggerEvent(checkboxes[0], 'click'); - expect(summaryText.textContent).toEqual("No arbitrary things"); + expect(summaryText.textContent.trim()).toEqual("No arbitrary things"); }); @@ -405,7 +390,7 @@ describe('Collapsible fieldset', () => { // click the first checkbox helpers.triggerEvent(checkboxes[1], 'click'); - expect(summaryText.textContent).toEqual("9 of 10 folders"); + expect(summaryText.textContent.trim()).toEqual("9 of 10 folders"); }); @@ -425,7 +410,7 @@ describe('Collapsible fieldset', () => { // click the first checkbox helpers.triggerEvent(checkboxes[1], 'click'); - expect(summaryText.textContent).toEqual("9 of 10 team members"); + expect(summaryText.textContent.trim()).toEqual("9 of 10 team members"); }); @@ -448,7 +433,7 @@ describe('Collapsible fieldset', () => { helpers.triggerEvent(checkboxes[0], 'click'); - expect(summaryText.textContent).toEqual("All folders"); + expect(summaryText.textContent.trim()).toEqual("All folders"); }); @@ -467,7 +452,7 @@ describe('Collapsible fieldset', () => { helpers.triggerEvent(checkboxes[0], 'click'); - expect(summaryText.textContent).toEqual("All team members"); + expect(summaryText.textContent.trim()).toEqual("All team members"); }); From bb16626209bf2a11cf2a9c8b671df36b0d486b16 Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Fri, 10 May 2019 11:29:11 +0100 Subject: [PATCH 13/19] Split module out into objects for each component Is clearer than just having one large hierarchical object and makes the relationships between the module and its components clearer. --- .../javascripts/collapsibleCheckboxes.js | 294 +++++++++--------- 1 file changed, 154 insertions(+), 140 deletions(-) diff --git a/app/assets/javascripts/collapsibleCheckboxes.js b/app/assets/javascripts/collapsibleCheckboxes.js index f6bb41fab..6f378c074 100644 --- a/app/assets/javascripts/collapsibleCheckboxes.js +++ b/app/assets/javascripts/collapsibleCheckboxes.js @@ -1,150 +1,164 @@ (function (Modules) { "use strict"; - Modules.CollapsibleCheckboxes = function() { - const _focusTextElement = ($el) => { - $el - .attr('tabindex', '-1') - .focus(); - }; + function Summary (module) { + this.module = module; + this.$el = module.$formGroup.find('.selection-summary'); + this.fieldLabel = module.fieldLabel; + this.total = module.total; + this.addContent(); + this.update(module.getSelection()); + } + Summary.prototype.templates = { + all: (selection, total, field) => `All ${field}s`, + some: (selection, total, field) => `${selection} of ${total} ${field}s`, + none: (selection, total, field) => ({ + "folder": "No folders (only templates outside a folder)", + "team member": "No team members (only you)" + }[field] || `No ${field}s`) + }; + Summary.prototype.addContent = function() { + this.$text = $(`

    `); - this.start = function(component) { - this.$formGroup = $(component); - this.$fieldset = this.$formGroup.find('fieldset'); - this.$checkboxes = this.$fieldset.find('input[type=checkbox]'); - this.fieldLabel = this.$formGroup.data('fieldLabel'); - this.total = this.$checkboxes.length; - this.legendText = this.$fieldset.find('legend').text().trim(); - this.expanded = false; + if (this.fieldLabel === 'folder') { this.$text.addClass('selection-summary__text--folders'); } - this.addHeadingHideLegend(); + this.$el.append(this.$text); + }; + Summary.prototype.update = function(selection) { + let template; - // generate summary and footer - this.summary.$el = this.$formGroup.find('.selection-summary'); - this.footer.$el = this.footer.getEl(this); - this.footer.update(this); + if (selection === this.total) { + template = 'all'; + } else if (selection > 0) { + template = 'some'; + } else { + template = 'none'; + } - // create summary from component pieces and match text to current selection - this.summary.addContent(this.legendText, this.fieldLabel); - this.summary.update(this.getSelection(), this.total, this.fieldLabel); - this.$fieldset.before(this.summary.$el); - - // add custom classes - this.$formGroup.addClass('selection-wrapper'); - this.$fieldset.addClass('selection-content'); - - // hide checkboxes - this.$fieldset.hide(); - - this.bindEvents(); - }; - this.getSelection = function() { return this.$checkboxes.filter(':checked').length; }; - this.addHeadingHideLegend = function() { - const headingLevel = this.$formGroup.data('heading-level') || '2'; - - this.$heading = $(`${this.legendText}`); - this.$fieldset.before(this.$heading); - - this.$fieldset.find('legend').addClass('visuallyhidden'); - }; - this.summary = { - templates: { - all: (selection, total, field) => `All ${field}s`, - some: (selection, total, field) => `${selection} of ${total} ${field}s`, - none: (selection, total, field) => ({ - "folder": "No folders (only templates outside a folder)", - "team member": "No team members (only you)" - }[field] || `No ${field}s`) - }, - addContent: function(legendText, fieldLabel) { - this.$text = $(`

    `); - - if (fieldLabel === 'folder') { this.$text.addClass('selection-summary__text--folders'); } - - this.$el.append(this.$text); - }, - update: function(selection, total, field) { - let template; - - if (selection === total) { - template = 'all'; - } else if (selection > 0) { - template = 'some'; - } else { - template = 'none'; - } - - this.$text.html(this.templates[template](selection, total, field)); - } - }; - this.footer = { - buttonContent: { - change: (fieldLabel) => `Choose ${fieldLabel}s`, - done: (fieldLabel) => `Done choosing ${fieldLabel}s` - }, - getEl: function (module) { - const buttonState = module.expanded ? 'done' : 'change'; - const buttonContent = this.buttonContent[buttonState](module.fieldLabel); - - return $(`

    `); - }, - update: function (module) { - this.$el.remove(); - this.$el = this.getEl(module); - - module.$formGroup.append(this.$el); - } - }; - this.expand = function(e) { - if (e !== undefined) { e.preventDefault(); } - - if (!this.expanded) { - this.$fieldset.show(); - this.expanded = true; - this.footer.update(this); - } - - // shift focus whether expanded or not - _focusTextElement(this.$fieldset); - }; - this.collapse = function(e) { - if (e !== undefined) { e.preventDefault(); } - - if (this.expanded) { - this.$fieldset.hide(); - this.expanded = false; - this.footer.update(this); - } - - // shift focus whether expanded or not - _focusTextElement(this.summary.$text); - }; - this.handleClick = function(e) { - if (this.expanded) { - this.collapse(e); - } else { - this.expand(e); - } - }; - this.handleSelection = function(e) { - this.summary.update(this.getSelection(), this.total, this.fieldLabel); - }; - this.bindEvents = function() { - const self = this; - - this.$formGroup.on('click', '.button', this.handleClick.bind(this)); - this.$checkboxes.on('click', this.handleSelection.bind(this)); - - // take summary out of tab order when focus moves - this.summary.$el.on('blur', (e) => $(this).attr('tabindex', '-1')); - }; + this.$text.html(this.templates[template](selection, this.total, this.fieldLabel)); + }; + Summary.prototype.bindEvents = function () { + // take summary out of tab order when focus moves + this.$el.on('blur', (e) => $(this).attr('tabindex', '-1')); }; + function Footer (module) { + this.module = module; + this.fieldLabel = module.fieldLabel; + this.fieldsetId = module.$fieldset.attr('id'); + this.$el = this.getEl(this.module.expanded); + this.module.$formGroup.append(this.$el); + } + Footer.prototype.buttonContent = { + change: (fieldLabel) => `Choose ${fieldLabel}s`, + done: (fieldLabel) => `Done choosing ${fieldLabel}s` + }; + Footer.prototype.getEl = function (expanded) { + const buttonState = expanded ? 'done' : 'change'; + const buttonContent = this.buttonContent[buttonState](this.fieldLabel); + + return $(``); + }; + Footer.prototype.update = function (expanded) { + this.$el.remove(); + this.$el = this.getEl(expanded); + + this.module.$formGroup.append(this.$el); + }; + + function CollapsibleCheckboxes () {} + CollapsibleCheckboxes.prototype._focusTextElement = ($el) => { + $el + .attr('tabindex', '-1') + .focus(); + }; + CollapsibleCheckboxes.prototype.start = function(component) { + this.$formGroup = $(component); + this.$fieldset = this.$formGroup.find('fieldset'); + this.$checkboxes = this.$fieldset.find('input[type=checkbox]'); + this.fieldLabel = this.$formGroup.data('fieldLabel'); + this.total = this.$checkboxes.length; + this.legendText = this.$fieldset.find('legend').text().trim(); + this.expanded = false; + + this.addHeadingHideLegend(); + + // generate summary and footer + this.footer = new Footer(this); + this.summary = new Summary(this); + + this.$fieldset.before(this.summary.$el); + + // add custom classes + this.$formGroup.addClass('selection-wrapper'); + this.$fieldset.addClass('selection-content'); + + // hide checkboxes + this.$fieldset.hide(); + + this.bindEvents(); + }; + CollapsibleCheckboxes.prototype.getSelection = function() { return this.$checkboxes.filter(':checked').length; }; + CollapsibleCheckboxes.prototype.addHeadingHideLegend = function() { + const headingLevel = this.$formGroup.data('heading-level') || '2'; + + this.$heading = $(`${this.legendText}`); + this.$fieldset.before(this.$heading); + + this.$fieldset.find('legend').addClass('visuallyhidden'); + }; + CollapsibleCheckboxes.prototype.expand = function(e) { + if (e !== undefined) { e.preventDefault(); } + + if (!this.expanded) { + this.$fieldset.show(); + this.expanded = true; + this.summary.update(this.getSelection()); + this.footer.update(this.expanded); + } + + // shift focus whether expanded or not + this._focusTextElement(this.$fieldset); + }; + CollapsibleCheckboxes.prototype.collapse = function(e) { + if (e !== undefined) { e.preventDefault(); } + + if (this.expanded) { + this.$fieldset.hide(); + this.expanded = false; + this.summary.update(this.getSelection()); + this.footer.update(this.expanded); + } + + // shift focus whether expanded or not + this._focusTextElement(this.summary.$text); + }; + CollapsibleCheckboxes.prototype.handleClick = function(e) { + if (this.expanded) { + this.collapse(e); + } else { + this.expand(e); + } + }; + CollapsibleCheckboxes.prototype.handleSelection = function(e) { + this.summary.update(this.getSelection(), this.total, this.fieldLabel); + }; + CollapsibleCheckboxes.prototype.bindEvents = function() { + const self = this; + + this.$formGroup.on('click', '.button', this.handleClick.bind(this)); + this.$checkboxes.on('click', this.handleSelection.bind(this)); + + this.summary.bindEvents(this); + }; + + Modules.CollapsibleCheckboxes = CollapsibleCheckboxes; + }(window.GOVUK.Modules)); From 19b68048d0d6f3fcd1b41cd3768ebae24d5bacdb Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Fri, 10 May 2019 13:58:21 +0100 Subject: [PATCH 14/19] Fix undefined local variables in sticky JS Running the sticky JS in Jest raised errors due to these variables not being assigned properly. It's JavaScript so any variable not defined by the `var` prefix will automatically become a property of the global object. That is not what is intended by the code so requires changing. --- app/assets/javascripts/stick-to-window-when-scrolling.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/stick-to-window-when-scrolling.js b/app/assets/javascripts/stick-to-window-when-scrolling.js index 7f2066aa5..0a3176beb 100644 --- a/app/assets/javascripts/stick-to-window-when-scrolling.js +++ b/app/assets/javascripts/stick-to-window-when-scrolling.js @@ -11,12 +11,11 @@ var $scrollArea = $el.closest('.sticky-scroll-area'); $scrollArea = $scrollArea.length ? $scrollArea : $el.parent(); - scrollArea = $scrollArea.get(0); this._els = [el]; this.edge = edge; this.selector = selector; - this.node = scrollArea; + this.node = $scrollArea.get(0); this.setEvents(); }; ScrollArea.prototype.addEl = function (el) { @@ -38,7 +37,7 @@ }; ScrollArea.prototype.getFocusedDetails = { forElement: function ($focusedElement) { - focused = { + var focused = { 'top': $focusedElement.offset().top, 'height': $focusedElement.outerHeight(), 'type': 'element' From d75c26eb83b7661481ea84399b521df7320eb7bb Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Fri, 10 May 2019 14:01:12 +0100 Subject: [PATCH 15/19] Make done button, and its surround, sticky --- .../javascripts/collapsibleCheckboxes.js | 14 +++++-- .../javascripts/collapsibleCheckboxes.test.js | 37 +++++++++++++++++++ 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/collapsibleCheckboxes.js b/app/assets/javascripts/collapsibleCheckboxes.js index 6f378c074..3eea8693d 100644 --- a/app/assets/javascripts/collapsibleCheckboxes.js +++ b/app/assets/javascripts/collapsibleCheckboxes.js @@ -1,6 +1,8 @@ -(function (Modules) { +(function (global) { "use strict"; + const GOVUK = global.GOVUK; + function Summary (module) { this.module = module; this.$el = module.$formGroup.find('.selection-summary'); @@ -56,8 +58,9 @@ Footer.prototype.getEl = function (expanded) { const buttonState = expanded ? 'done' : 'change'; const buttonContent = this.buttonContent[buttonState](this.fieldLabel); + const stickyClass = expanded ? ' js-stick-at-bottom-when-scrolling' : ''; - return $(`