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"]'
+ }))
+ })
+ })
+})