Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add "None of these" and "or" divider to checkboxes #2151

Merged
merged 5 commits into from
Jun 18, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/govuk/components/checkboxes/_index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,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