diff --git a/docs/javascript.md b/docs/javascript.md index e651c399..5eff3a5c 100644 --- a/docs/javascript.md +++ b/docs/javascript.md @@ -398,3 +398,41 @@ GOVUK.selectionButtons = function (elms, opts) { ``` This method will mean the `destroy` method is not available to call. + +## 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..2d2e2d68 --- /dev/null +++ b/javascripts/govuk/show-hide-content.js @@ -0,0 +1,157 @@ +(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 input[type="radio"]', + checkbox: '.block-label 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.parent('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 + var selector = selectors.radio + '[name=' + escapeElementName($control.attr('name')) + ']'; + var $radios = $control.closest('form').find(selector); + + // Hide radios in group + $radios.each(function() { + hideToggledContent($(this)); + }); + + // Select radio button content + showToggledContent($control, $content); + } + + // Handle checkbox show/hide + function handleCheckboxContent($control, $content) { + + // Show checkbox content + if ($control.is(':checked')) { + showToggledContent($control, $content); + } + + // Hide checkbox content + else { + hideToggledContent($control, $content); + } + } + + // Set up event handlers etc + function init($container, selector, handler) { + $container = $container || $(document.body); + + // Handle control clicks + function deferred() { + var $control = $(this); + handler($control, getToggledContent($control)); + } + + // Prepare ARIA attributes + var $controls = $(selector); + $controls.each(initToggledContent); + + // Handle events + $container.on('click.' + selectors.namespace, selector, deferred); + + // Any already :checked on init? + if ($controls.is(':checked')) { + $controls.filter(':checked').each(deferred); + } + } + + // Set up radio show/hide content for container + self.showHideRadioToggledContent = function($container) { + init($container, selectors.radio, handleRadioContent); + }; + + // Set up checkbox show/hide content for container + self.showHideCheckboxToggledContent = function($container) { + init($container, 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 0b7e7822..41c05c59 100644 --- a/spec/manifest.js +++ b/spec/manifest.js @@ -6,6 +6,7 @@ var manifest = { '../../javascripts/govuk/modules/auto-track-event.js', '../../javascripts/govuk/multivariate-test.js', '../../javascripts/govuk/primary-links.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', @@ -21,6 +22,7 @@ var manifest = { '../unit/Modules/auto-track-event.spec.js', '../unit/multivariate-test.spec.js', '../unit/primary-links.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', diff --git a/spec/unit/show-hide-content.spec.js b/spec/unit/show-hide-content.spec.js new file mode 100644 index 00000000..815a1e21 --- /dev/null +++ b/spec/unit/show-hide-content.spec.js @@ -0,0 +1,246 @@ +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 visible', 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: '.block-label input[type="radio"]' + })); + expect(events && events.click).toContain(jasmine.objectContaining({ + namespace: 'ShowHideContent', + selector: '.block-label 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: '.block-label input[type="radio"]' + })); + expect(events && events.click).not.toContain(jasmine.objectContaining({ + namespace: 'ShowHideContent', + selector: '.block-label input[type="checkbox"]' + })); + }); + }); +});