diff --git a/README.md b/README.md index 5d66cdbb..397d1ade 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ In production: * [Stick at top when scrolling](/docs/javascript.md#stick-at-top-when-scrolling) * [Selection buttons](/docs/javascript.md#selection-buttons) * [Shim links with button role](/docs/javascript.md#shim-links-with-button-role) + * [Show/Hide content](/docs/javascript.md#show-hide-content) * [Analytics](/docs/analytics.md) * [Create an analytics tracker](/docs/analytics.md#create-an-analytics-tracker) * [Virtual pageviews](/docs/analytics.md#virtual-pageviews) diff --git a/docs/javascript.md b/docs/javascript.md index 23181762..f0ef9911 100644 --- a/docs/javascript.md +++ b/docs/javascript.md @@ -430,4 +430,41 @@ It’s also possible to define more or different keycodes to activate against: GOVUK.shimLinksWithButtonRole.init({ keycodes: [32, 114] }); + +## Show/Hide content + +Script to support show/hide content, toggled by radio buttons and checkboxes. This allows for progressive disclosure of question and answer forms based on selected values: + + + + + +
+

Show/Hide content to be toggled

+
+ +When the input's `checked` attribute is set, the show/hide content's `.js-hidden` class is removed and ARIA attributes are added to enable it. Note the sample `show-me` id attribute used to link the label to show/hide content. + +### Usage + +#### GOVUK.ShowHideContent + +To apply this behaviour to elements with the above HTML pattern, call the `GOVUK.ShowHideContent` constructor: + +``` +var showHideContent = new GOVUK.ShowHideContent(); +showHideContent.init(); +``` + +This will bind two event handlers to $(document.body), one for radio inputs and one for checkboxes. By listening for events bubbling up to the `body` tag, additional show/hide content added to the page will still be picked up after `.init()` is called. + +Alternatively, pass in your own selector. In the example below, event handlers are bound to the form instead. + +``` +var showHideContent = new GOVUK.ShowHideContent(); +showHideContent.init($('form.example')); ``` diff --git a/javascripts/govuk/show-hide-content.js b/javascripts/govuk/show-hide-content.js new file mode 100644 index 00000000..5894f388 --- /dev/null +++ b/javascripts/govuk/show-hide-content.js @@ -0,0 +1,171 @@ +;(function (global) { + 'use strict' + + var $ = global.jQuery + var GOVUK = global.GOVUK || {} + + function ShowHideContent () { + var self = this + + // Radio and Checkbox selectors + var selectors = { + namespace: 'ShowHideContent', + radio: '.block-label[data-target] input[type="radio"]', + checkbox: '.block-label[data-target] input[type="checkbox"]' + } + + // Escape name attribute for use in DOM selector + function escapeElementName (str) { + var result = str.replace('[', '\\[').replace(']', '\\]') + return result + } + + // Adds ARIA attributes to control + associated content + function initToggledContent () { + var $control = $(this) + var $content = getToggledContent($control) + + // Set aria-controls and defaults + if ($content.length) { + $control.attr('aria-controls', $content.attr('id')) + $control.attr('aria-expanded', 'false') + $content.attr('aria-hidden', 'true') + } + } + + // Return toggled content for control + function getToggledContent ($control) { + var id = $control.attr('aria-controls') + + // ARIA attributes aren't set before init + if (!id) { + id = $control.closest('label').data('target') + } + + // Find show/hide content by id + return $('#' + id) + } + + // Show toggled content for control + function showToggledContent ($control, $content) { + // Show content + if ($content.hasClass('js-hidden')) { + $content.removeClass('js-hidden') + $content.attr('aria-hidden', 'false') + + // If the controlling input, update aria-expanded + if ($control.attr('aria-controls')) { + $control.attr('aria-expanded', 'true') + } + } + } + + // Hide toggled content for control + function hideToggledContent ($control, $content) { + $content = $content || getToggledContent($control) + + // Hide content + if (!$content.hasClass('js-hidden')) { + $content.addClass('js-hidden') + $content.attr('aria-hidden', 'true') + + // If the controlling input, update aria-expanded + if ($control.attr('aria-controls')) { + $control.attr('aria-expanded', 'false') + } + } + } + + // Handle radio show/hide + function handleRadioContent ($control, $content) { + // All radios in this group which control content + var selector = selectors.radio + '[name=' + escapeElementName($control.attr('name')) + '][aria-controls]' + var $radios = $control.closest('form').find(selector) + + // Hide content for radios in group + $radios.each(function () { + hideToggledContent($(this)) + }) + + // Select content for this control + if ($control.is('[aria-controls]')) { + showToggledContent($control, $content) + } + } + + // Handle checkbox show/hide + function handleCheckboxContent ($control, $content) { + // Show checkbox content + if ($control.is(':checked')) { + showToggledContent($control, $content) + } else { // Hide checkbox content + hideToggledContent($control, $content) + } + } + + // Set up event handlers etc + function init ($container, elementSelector, eventSelectors, handler) { + $container = $container || $(document.body) + + // Handle control clicks + function deferred () { + var $control = $(this) + handler($control, getToggledContent($control)) + } + + // Prepare ARIA attributes + var $controls = $(elementSelector) + $controls.each(initToggledContent) + + // Handle events + $.each(eventSelectors, function (idx, eventSelector) { + $container.on('click.' + selectors.namespace, eventSelector, deferred) + }) + + // Any already :checked on init? + if ($controls.is(':checked')) { + $controls.filter(':checked').each(deferred) + } + } + + // Get event selectors for all radio groups + function getEventSelectorsForRadioGroups () { + var radioGroups = [] + + // Build an array of radio group selectors + return $(selectors.radio).map(function () { + var groupName = $(this).attr('name') + + if ($.inArray(groupName, radioGroups) === -1) { + radioGroups.push(groupName) + return 'input[type="radio"][name="' + $(this).attr('name') + '"]' + } + return null + }) + } + + // Set up radio show/hide content for container + self.showHideRadioToggledContent = function ($container) { + init($container, selectors.radio, getEventSelectorsForRadioGroups(), handleRadioContent) + } + + // Set up checkbox show/hide content for container + self.showHideCheckboxToggledContent = function ($container) { + init($container, selectors.checkbox, [selectors.checkbox], handleCheckboxContent) + } + + // Remove event handlers + self.destroy = function ($container) { + $container = $container || $(document.body) + $container.off('.' + selectors.namespace) + } + } + + ShowHideContent.prototype.init = function ($container) { + this.showHideRadioToggledContent($container) + this.showHideCheckboxToggledContent($container) + } + + GOVUK.ShowHideContent = ShowHideContent + global.GOVUK = GOVUK +})(window) diff --git a/spec/manifest.js b/spec/manifest.js index e8349ebe..098fdb44 100644 --- a/spec/manifest.js +++ b/spec/manifest.js @@ -1,12 +1,13 @@ // Paths are relative to the /spec/support folder var manifest = { - support : [ + support: [ '../../node_modules/jquery/dist/jquery.js', '../../javascripts/govuk/modules.js', '../../javascripts/govuk/modules/auto-track-event.js', '../../javascripts/govuk/multivariate-test.js', '../../javascripts/govuk/primary-links.js', '../../javascripts/govuk/shim-links-with-button-role.js', + '../../javascripts/govuk/show-hide-content.js', '../../javascripts/govuk/stick-at-top-when-scrolling.js', '../../javascripts/govuk/stop-scrolling-at-footer.js', '../../javascripts/govuk/selection-buttons.js', @@ -17,12 +18,13 @@ var manifest = { '../../javascripts/govuk/analytics/download-link-tracker.js', '../../javascripts/govuk/analytics/mailto-link-tracker.js' ], - test : [ + test: [ '../unit/modules.spec.js', '../unit/Modules/auto-track-event.spec.js', '../unit/multivariate-test.spec.js', '../unit/primary-links.spec.js', '../unit/shim-links-with-button-role.spec.js', + '../unit/show-hide-content.spec.js', '../unit/stick-at-top-when-scrolling.spec.js', '../unit/selection-button.spec.js', '../unit/analytics/google-analytics-universal-tracker.spec.js', @@ -32,4 +34,4 @@ var manifest = { '../unit/analytics/download-link-tracker.spec.js', '../unit/analytics/mailto-link-tracker.spec.js' ] -}; +} diff --git a/spec/support/LocalTestRunner.html b/spec/support/LocalTestRunner.html index ed594bed..c933dedf 100644 --- a/spec/support/LocalTestRunner.html +++ b/spec/support/LocalTestRunner.html @@ -3,16 +3,16 @@ Jasmine Test Runner - + - - + + - + diff --git a/spec/unit/show-hide-content.spec.js b/spec/unit/show-hide-content.spec.js new file mode 100644 index 00000000..8e8d086a --- /dev/null +++ b/spec/unit/show-hide-content.spec.js @@ -0,0 +1,245 @@ +describe('show-hide-content', function () { + 'use strict' + + beforeEach(function () { + + // Sample markup + this.$content = $( + + // Radio buttons (yes/no) + '
' + + '' + + '' + + '
' + + '' + + + // Checkboxes (multiple values) + '
' + + '' + + '' + + '' + + '
' + + '' + ) + + // Find radios/checkboxes + var $radios = this.$content.find('input[type=radio]') + var $checkboxes = this.$content.find('input[type=checkbox]') + + // Two radios + this.$radio1 = $radios.eq(0) + this.$radio2 = $radios.eq(1) + + // Three checkboxes + this.$checkbox1 = $checkboxes.eq(0) + this.$checkbox2 = $checkboxes.eq(1) + this.$checkbox3 = $checkboxes.eq(2) + + // Add to page + $(document.body).append(this.$content) + + // Show/Hide content + this.$radioShowHide = $('#show-hide-radios') + this.$checkboxShowHide = $('#show-hide-checkboxes') + + // Add show/hide content support + this.showHideContent = new GOVUK.ShowHideContent() + this.showHideContent.init() + }) + + afterEach(function () { + if (this.showHideContent) { + this.showHideContent.destroy() + } + + this.$content.remove() + }) + + describe('when this.showHideContent = new GOVUK.ShowHideContent() is called', function () { + it('should add the aria attributes to inputs with show/hide content', function () { + expect(this.$radio1.attr('aria-expanded')).toBe('false') + expect(this.$radio1.attr('aria-controls')).toBe('show-hide-radios') + }) + + it('should add the aria attributes to show/hide content', function () { + expect(this.$radioShowHide.attr('aria-hidden')).toBe('true') + expect(this.$radioShowHide.hasClass('js-hidden')).toEqual(true) + }) + + it('should hide the show/hide content visually', function () { + expect(this.$radioShowHide.hasClass('js-hidden')).toEqual(true) + }) + + it('should do nothing if no radios are checked', function () { + expect(this.$radio1.attr('aria-expanded')).toBe('false') + expect(this.$radio2.attr('aria-expanded')).toBe(undefined) + }) + + it('should do nothing if no checkboxes are checked', function () { + expect(this.$radio1.attr('aria-expanded')).toBe('false') + expect(this.$radio2.attr('aria-expanded')).toBe(undefined) + }) + + describe('with non-default markup', function () { + beforeEach(function () { + this.showHideContent.destroy() + }) + + it('should do nothing if a radio without show/hide content is checked', function () { + this.$radio2.prop('checked', true) + + // Defaults changed, initialise again + this.showHideContent = new GOVUK.ShowHideContent().init() + expect(this.$radio1.attr('aria-expanded')).toBe('false') + expect(this.$radioShowHide.attr('aria-hidden')).toBe('true') + expect(this.$radioShowHide.hasClass('js-hidden')).toEqual(true) + }) + + it('should do nothing if a checkbox without show/hide content is checked', function () { + this.$checkbox2.prop('checked', true) + + // Defaults changed, initialise again + this.showHideContent = new GOVUK.ShowHideContent().init() + expect(this.$checkbox1.attr('aria-expanded')).toBe('false') + expect(this.$checkboxShowHide.attr('aria-hidden')).toBe('true') + expect(this.$checkboxShowHide.hasClass('js-hidden')).toEqual(true) + }) + + it('should do nothing if checkboxes without show/hide content is checked', function () { + this.$checkbox2.prop('checked', true) + this.$checkbox3.prop('checked', true) + + // Defaults changed, initialise again + this.showHideContent = new GOVUK.ShowHideContent().init() + expect(this.$checkbox1.attr('aria-expanded')).toBe('false') + expect(this.$checkboxShowHide.attr('aria-hidden')).toBe('true') + expect(this.$checkboxShowHide.hasClass('js-hidden')).toEqual(true) + }) + + it('should make the show/hide content visible if its radio is checked', function () { + this.$radio1.prop('checked', true) + + // Defaults changed, initialise again + this.showHideContent = new GOVUK.ShowHideContent().init() + expect(this.$radio1.attr('aria-expanded')).toBe('true') + expect(this.$radioShowHide.attr('aria-hidden')).toBe('false') + expect(this.$radioShowHide.hasClass('js-hidden')).toEqual(false) + }) + + it('should make the show/hide content visible if its checkbox is checked', function () { + this.$checkbox1.prop('checked', true) + + // Defaults changed, initialise again + this.showHideContent = new GOVUK.ShowHideContent().init() + expect(this.$checkbox1.attr('aria-expanded')).toBe('true') + expect(this.$checkboxShowHide.attr('aria-hidden')).toBe('false') + expect(this.$checkboxShowHide.hasClass('js-hidden')).toEqual(false) + }) + }) + + describe('and a show/hide radio receives a click', function () { + it('should make the show/hide content visible', function () { + this.$radio1.click() + expect(this.$radioShowHide.hasClass('js-hidden')).toEqual(false) + }) + + it('should add the aria attributes to show/hide content', function () { + this.$radio1.click() + expect(this.$radio1.attr('aria-expanded')).toBe('true') + expect(this.$radioShowHide.attr('aria-hidden')).toBe('false') + expect(this.$radioShowHide.hasClass('js-hidden')).toEqual(false) + }) + }) + + describe('and a show/hide checkbox receives a click', function () { + it('should make the show/hide content visible', function () { + this.$checkbox1.click() + expect(this.$checkboxShowHide.hasClass('js-hidden')).toEqual(false) + }) + + it('should add the aria attributes to show/hide content', function () { + this.$checkbox1.click() + expect(this.$checkbox1.attr('aria-expanded')).toBe('true') + expect(this.$checkboxShowHide.attr('aria-hidden')).toBe('false') + expect(this.$checkboxShowHide.hasClass('js-hidden')).toEqual(false) + }) + }) + + describe('and a show/hide radio receives a click, but another group radio is clicked afterwards', function () { + it('should make the show/hide content hidden', function () { + this.$radio1.click() + this.$radio2.click() + expect(this.$radioShowHide.hasClass('js-hidden')).toEqual(true) + }) + + it('should add the aria attributes to show/hide content', function () { + this.$radio1.click() + this.$radio2.click() + expect(this.$radio1.attr('aria-expanded')).toBe('false') + expect(this.$radioShowHide.attr('aria-hidden')).toBe('true') + }) + }) + + describe('and a show/hide checkbox receives a click, but another checkbox is clicked afterwards', function () { + it('should keep the show/hide content visible', function () { + this.$checkbox1.click() + this.$checkbox2.click() + expect(this.$checkboxShowHide.hasClass('js-hidden')).toEqual(false) + }) + + it('should keep the aria attributes to show/hide content', function () { + this.$checkbox1.click() + this.$checkbox2.click() + expect(this.$checkbox1.attr('aria-expanded')).toBe('true') + expect(this.$checkboxShowHide.attr('aria-hidden')).toBe('false') + }) + }) + }) + + describe('before this.showHideContent.destroy() is called', function () { + it('document.body should have show/hide event handlers', function () { + var events = $._data(document.body, 'events') + expect(events && events.click).toContain(jasmine.objectContaining({ + namespace: 'ShowHideContent', + selector: 'input[type="radio"][name="single"]' + })) + expect(events && events.click).toContain(jasmine.objectContaining({ + namespace: 'ShowHideContent', + selector: '.block-label[data-target] input[type="checkbox"]' + })) + }) + }) + + describe('when this.showHideContent.destroy() is called', function () { + beforeEach(function () { + this.showHideContent.destroy() + }) + + it('should have no show/hide event handlers', function () { + var events = $._data(document.body, 'events') + expect(events && events.click).not.toContain(jasmine.objectContaining({ + namespace: 'ShowHideContent', + selector: 'input[type="radio"][name="single"]' + })) + expect(events && events.click).not.toContain(jasmine.objectContaining({ + namespace: 'ShowHideContent', + selector: '.block-label[data-target] input[type="checkbox"]' + })) + }) + }) +})