Skip to content

Commit

Permalink
Merge pull request #2151 from frankieroberto/add-divider-to-checkboxes
Browse files Browse the repository at this point in the history
Add "None of these" and "or" divider to checkboxes
  • Loading branch information
36degrees authored Jun 18, 2021
2 parents f359870 + 1042a8e commit 93ab1d4
Show file tree
Hide file tree
Showing 6 changed files with 249 additions and 42 deletions.
13 changes: 13 additions & 0 deletions src/govuk/components/checkboxes/_index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,19 @@
opacity: .5;
}

// =========================================================
// Dividers ('or')
// =========================================================

.govuk-checkboxes__divider {
$govuk-divider-size: $govuk-checkboxes-size !default;
@include govuk-font($size: 19);
@include govuk-text-colour;
width: $govuk-divider-size;
margin-bottom: govuk-spacing(2);
text-align: center;
}

// =========================================================
// Conditional reveals
// =========================================================
Expand Down
64 changes: 61 additions & 3 deletions src/govuk/components/checkboxes/checkboxes.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,47 @@ Checkboxes.prototype.syncConditionalRevealWithInputState = function ($input) {
}
}

/**
* Uncheck other checkboxes
*
* Find any other checkbox inputs with the same name value, and uncheck them.
* This is useful for when a “None of these" checkbox is checked.
*/
Checkboxes.prototype.unCheckAllInputsExcept = function ($input) {
var allInputsWithSameName = document.querySelectorAll('input[type="checkbox"][name="' + $input.name + '"]')

nodeListForEach(allInputsWithSameName, function ($inputWithSameName) {
var hasSameFormOwner = ($input.form === $inputWithSameName.form)
if (hasSameFormOwner && $inputWithSameName !== $input) {
$inputWithSameName.checked = false
}
})

this.syncAllConditionalReveals()
}

/**
* Uncheck exclusive inputs
*
* Find any checkbox inputs with the same name value and the 'exclusive' behaviour,
* and uncheck them. This helps prevent someone checking both a regular checkbox and a
* "None of these" checkbox in the same fieldset.
*/
Checkboxes.prototype.unCheckExclusiveInputs = function ($input) {
var allInputsWithSameNameAndExclusiveBehaviour = document.querySelectorAll(
'input[data-behaviour="exclusive"][type="checkbox"][name="' + $input.name + '"]'
)

nodeListForEach(allInputsWithSameNameAndExclusiveBehaviour, function ($exclusiveInput) {
var hasSameFormOwner = ($input.form === $exclusiveInput.form)
if (hasSameFormOwner) {
$exclusiveInput.checked = false
}
})

this.syncAllConditionalReveals()
}

/**
* Click event handler
*
Expand All @@ -97,12 +138,29 @@ Checkboxes.prototype.syncConditionalRevealWithInputState = function ($input) {
Checkboxes.prototype.handleClick = function (event) {
var $target = event.target

// If a checkbox with aria-controls, handle click
var isCheckbox = $target.getAttribute('type') === 'checkbox'
// Ignore clicks on things that aren't checkbox inputs
if ($target.type !== 'checkbox') {
return
}

// If the checkbox conditionally-reveals some content, sync the state
var hasAriaControls = $target.getAttribute('aria-controls')
if (isCheckbox && hasAriaControls) {
if (hasAriaControls) {
this.syncConditionalRevealWithInputState($target)
}

// No further behaviour needed for unchecking
if (!$target.checked) {
return
}

// Handle 'exclusive' checkbox behaviour (ie "None of these")
var hasBehaviourExclusive = ($target.getAttribute('data-behaviour') === 'exclusive')
if (hasBehaviourExclusive) {
this.unCheckAllInputsExcept($target)
} else {
this.unCheckExclusiveInputs($target)
}
}

export default Checkboxes
69 changes: 69 additions & 0 deletions src/govuk/components/checkboxes/checkboxes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,72 @@ describe('Checkboxes with conditional reveals', () => {
})
})
})

describe('Checkboxes with a None checkbox', () => {
describe('when JavaScript is available', () => {
it('unchecks other checkboxes when the None checkbox is checked', async () => {
await goToAndGetComponent('checkboxes', 'with-divider-and-None')

// Check the first 3 checkboxes
await page.click('#with-divider-and-none')
await page.click('#with-divider-and-none-2')
await page.click('#with-divider-and-none-3')

// Check the None checkbox
await page.click('#with-divider-and-none-5')

// Expect first 3 checkboxes to have been unchecked
const firstCheckboxIsUnchecked = await waitForVisibleSelector('[id="with-divider-and-none"]:not(:checked)')
expect(firstCheckboxIsUnchecked).toBeTruthy()

const secondCheckboxIsUnchecked = await waitForVisibleSelector('[id="with-divider-and-none-2"]:not(:checked)')
expect(secondCheckboxIsUnchecked).toBeTruthy()

const thirdCheckboxIsUnchecked = await waitForVisibleSelector('[id="with-divider-and-none-3"]:not(:checked)')
expect(thirdCheckboxIsUnchecked).toBeTruthy()
})

it('unchecks the None checkbox when any other checkbox is checked', async () => {
await goToAndGetComponent('checkboxes', 'with-divider-and-None')

// Check the None checkbox
await page.click('#with-divider-and-none-5')

// Check the first checkbox
await page.click('#with-divider-and-none')

// Expect the None checkbox to have been unchecked
const noneCheckboxIsUnchecked = await waitForVisibleSelector('[id="with-divider-and-none-5"]:not(:checked)')
expect(noneCheckboxIsUnchecked).toBeTruthy()
})
})
})

describe('Checkboxes with a None checkbox and conditional reveals', () => {
describe('when JavaScript is available', () => {
it('unchecks other checkboxes and hides conditional reveals when the None checkbox is checked', async () => {
const $ = await goToAndGetComponent('checkboxes', 'with-divider,-None-and-conditional-items')

// Check the 4th checkbox, which reveals an additional field
await page.click('#with-divider-and-none-and-conditional-items-4')

const $checkedInput = $('#with-divider-and-none-and-conditional-items-4')
const conditionalContentId = $checkedInput.attr('aria-controls')

// Expect conditional content to have been revealed
const isConditionalContentVisible = await waitForVisibleSelector(`[id="${conditionalContentId}"]`)
expect(isConditionalContentVisible).toBeTruthy()

// Check the None checkbox
await page.click('#with-divider-and-none-and-conditional-items-6')

// Expect the 4th checkbox to have been unchecked
const forthCheckboxIsUnchecked = await waitForVisibleSelector('[id="with-divider-and-none-and-conditional-items-4"]:not(:checked)')
expect(forthCheckboxIsUnchecked).toBeTruthy()

// Expect conditional content to have been hidden
const isConditionalContentHidden = await waitForHiddenSelector(`[id="${conditionalContentId}"]`)
expect(isConditionalContentHidden).toBeTruthy()
})
})
})
54 changes: 54 additions & 0 deletions src/govuk/components/checkboxes/checkboxes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ params:
required: false
description: Provide hint to each checkbox item.
isComponent: true
- name: divider
type: string
required: false
description: Divider text to separate checkbox items, for example the text "or".
- name: checked
type: boolean
required: false
Expand All @@ -82,6 +86,10 @@ params:
type: string
required: false
description: Provide content for the conditional reveal.
- name: behaviour
type: string
required: false
description: If set to `exclusive`, implements a "None of these" type behaviour via javascript when checkboxes are clicked
- name: disabled
type: boolean
required: false
Expand Down Expand Up @@ -121,6 +129,52 @@ examples:
- value: other
text: Citizen of another country

- name: with divider and None
data:
name: with-divider-and-none
fieldset:
legend:
text: Which types of waste do you transport regularly?
classes: govuk-fieldset__legend--l
isPageHeading: true
items:
- value: animal
text: Waste from animal carcasses
- value: mines
text: Waste from mines or quarries
- value: farm
text: Farm or agricultural waste
- divider: or
- value: none
text: None of these
behaviour: exclusive

- name: with divider, None and conditional items
data:
name: with-divider-and-none-and-conditional-items
fieldset:
legend:
text: Do you have any access needs?
classes: govuk-fieldset__legend--l
isPageHeading: true
items:
- value: accessible-toilets
text: Accessible toilets available
- value: braille
text: Braille translation service available
- value: disabled-car-parking
text: Disabled car parking available
- value: another-access-need
text: Another access need
conditional:
html: |
<label class="govuk-label" for="other-access-needs">Other access needs</label>
<textarea class="govuk-textarea govuk-!-width-one-third" name="other-access-needs" id="other-access-needs"></textarea>
- divider: or
- value: none
text: None of these
behaviour: exclusive

- name: with id and name
data:
name: with-id-and-name
Expand Down
76 changes: 37 additions & 39 deletions src/govuk/components/checkboxes/template.njk
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,6 @@
{% set describedBy = params.fieldset.describedBy %}
{% endif %}

{% set isConditional = false %}
{% for item in params.items %}
{% if item.conditional.html %}
{% set isConditional = true %}
{% endif %}
{% endfor %}

{#- fieldset is false by default -#}
{% set hasFieldset = true if params.fieldset else false %}

Expand Down Expand Up @@ -51,7 +44,7 @@
{% endif %}
<div class="govuk-checkboxes {%- if params.classes %} {{ params.classes }}{% endif %}"
{%- for attribute, value in params.attributes %} {{ attribute }}="{{ value }}"{% endfor %}
{%- if isConditional %} data-module="govuk-checkboxes"{% endif -%}>
data-module="govuk-checkboxes">
{% for item in params.items %}
{% if item %}
{#- If the user explicitly sets an id, use this instead of the regular idPrefix -#}
Expand All @@ -67,38 +60,43 @@
{%- endif -%}
{% set name = item.name if item.name else params.name %}
{% set conditionalId = "conditional-" + id %}
{% set hasHint = true if item.hint.text or item.hint.html %}
{% set itemHintId = id + "-item-hint" if hasHint else "" %}
{% set itemDescribedBy = describedBy if not hasFieldset else "" %}
{% set itemDescribedBy = (itemDescribedBy + " " + itemHintId) | trim %}
<div class="govuk-checkboxes__item">
<input class="govuk-checkboxes__input" id="{{ id }}" name="{{ name }}" type="checkbox" value="{{ item.value }}"
{{-" checked" if item.checked }}
{{-" disabled" if item.disabled }}
{%- if item.conditional.html %} data-aria-controls="{{ conditionalId }}"{% endif -%}
{%- if itemDescribedBy %} aria-describedby="{{ itemDescribedBy }}"{% endif -%}
{%- for attribute, value in item.attributes %} {{ attribute }}="{{ value }}"{% endfor -%}>
{{ govukLabel({
html: item.html,
text: item.text,
classes: 'govuk-checkboxes__label' + (' ' + item.label.classes if item.label.classes),
attributes: item.label.attributes,
for: id
}) | indent(6) | trim }}
{% if hasHint %}
{{ govukHint({
id: itemHintId,
classes: 'govuk-checkboxes__hint' + (' ' + item.hint.classes if item.hint.classes),
attributes: item.hint.attributes,
html: item.hint.html,
text: item.hint.text
}) | indent(6) | trim }}
{% endif %}
</div>
{% if item.conditional.html %}
<div class="govuk-checkboxes__conditional{% if not item.checked %} govuk-checkboxes__conditional--hidden{% endif %}" id="{{ conditionalId }}">
{{ item.conditional.html | safe }}
{%- if item.divider %}
<div class="govuk-checkboxes__divider">{{ item.divider }}</div>
{%- else %}
{% set hasHint = true if item.hint.text or item.hint.html %}
{% set itemHintId = id + "-item-hint" if hasHint else "" %}
{% set itemDescribedBy = describedBy if not hasFieldset else "" %}
{% set itemDescribedBy = (itemDescribedBy + " " + itemHintId) | trim %}
<div class="govuk-checkboxes__item">
<input class="govuk-checkboxes__input" id="{{ id }}" name="{{ name }}" type="checkbox" value="{{ item.value }}"
{{-" checked" if item.checked }}
{{-" disabled" if item.disabled }}
{%- if item.conditional.html %} data-aria-controls="{{ conditionalId }}"{% endif -%}
{%- if item.behaviour %} data-behaviour="{{ item.behaviour }}"{% endif -%}
{%- if itemDescribedBy %} aria-describedby="{{ itemDescribedBy }}"{% endif -%}
{%- for attribute, value in item.attributes %} {{ attribute }}="{{ value }}"{% endfor -%}>
{{ govukLabel({
html: item.html,
text: item.text,
classes: 'govuk-checkboxes__label' + (' ' + item.label.classes if item.label.classes),
attributes: item.label.attributes,
for: id
}) | indent(6) | trim }}
{% if hasHint %}
{{ govukHint({
id: itemHintId,
classes: 'govuk-checkboxes__hint' + (' ' + item.hint.classes if item.hint.classes),
attributes: item.hint.attributes,
html: item.hint.html,
text: item.hint.text
}) | indent(6) | trim }}
{% endif %}
</div>
{% if item.conditional.html %}
<div class="govuk-checkboxes__conditional{% if not item.checked %} govuk-checkboxes__conditional--hidden{% endif %}" id="{{ conditionalId }}">
{{ item.conditional.html | safe }}
</div>
{% endif %}
{% endif %}
{% endif %}
{% endfor %}
Expand Down
15 changes: 15 additions & 0 deletions src/govuk/components/checkboxes/template.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,21 @@ describe('Checkboxes', () => {
expect($items.length).toEqual(2)
})

it('render example with a divider and ‘None’ checkbox with exclusive behaviour', () => {
const $ = render('checkboxes', examples['with divider and None'])

const $component = $('.govuk-checkboxes')

const $divider = $component.find('.govuk-checkboxes__divider').first()
expect($divider.text().trim()).toEqual('or')

const $items = $component.find('.govuk-checkboxes__item')
expect($items.length).toEqual(4)

const $orItemInput = $items.last().find('input').first()
expect($orItemInput.attr('data-behaviour')).toEqual('exclusive')
})

it('render additional label classes', () => {
const $ = render('checkboxes', examples['with label classes'])

Expand Down

0 comments on commit 93ab1d4

Please sign in to comment.