mirror of
https://github.com/GSA/notifications-admin.git
synced 2026-08-24 00:07:02 -04:00
Add govukCollapsibleNestedCheckboxesField
Includes:
1. changes to make NestedFieldMixin work
with new fields and CSS for nested checkboxes
2. adds custom version of GOVUK checkboxes
component to allow us to:
- add classes to elements currently inaccessible
- wrap the checkboxes in a list
- add child checkboxes to each checkbox (making
tree structures possible through recursion
Change 2. should be pushed upstream to the GOVUK
Design System as a proposal for changes to the
GOVUK Checkboxes component.
This commit is contained in:
@@ -1,3 +1,7 @@
|
|||||||
|
// Taken from https://github.com/alphagov/govuk-frontend/blob/v2.13.0/src/components/checkboxes/_checkboxes.scss
|
||||||
|
$govuk-touch-target-size: 44px;
|
||||||
|
$govuk-checkboxes-size: 40px;
|
||||||
|
|
||||||
.selection-summary {
|
.selection-summary {
|
||||||
|
|
||||||
.selection-summary__text {
|
.selection-summary__text {
|
||||||
@@ -65,6 +69,30 @@
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.govuk-form-group--nested {
|
||||||
|
|
||||||
|
$border-thickness: $govuk-touch-target-size - $govuk-checkboxes-size;
|
||||||
|
$border-indent: $govuk-touch-target-size / 2;
|
||||||
|
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
// To equalise the spacing between the line and the top/bottom of
|
||||||
|
// the radio
|
||||||
|
margin-top: govuk-spacing(1) + ($border-thickness / 2);
|
||||||
|
margin-bottom: govuk-spacing(1) * -1;
|
||||||
|
padding-left: govuk-spacing(2) + 2;
|
||||||
|
|
||||||
|
&:before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: $border-indent * -1;
|
||||||
|
width: $border-thickness;
|
||||||
|
height: 100%;
|
||||||
|
background: $govuk-border-colour;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.selection-content {
|
.selection-content {
|
||||||
margin-bottom: govuk-spacing(4);
|
margin-bottom: govuk-spacing(4);
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from notifications_utils.recipients import (
|
|||||||
normalise_phone_number,
|
normalise_phone_number,
|
||||||
validate_phone_number,
|
validate_phone_number,
|
||||||
)
|
)
|
||||||
|
from werkzeug.utils import cached_property
|
||||||
from wtforms import (
|
from wtforms import (
|
||||||
BooleanField,
|
BooleanField,
|
||||||
DateField,
|
DateField,
|
||||||
@@ -317,13 +318,16 @@ class RadioFieldWithNoneOption(FieldWithNoneOption, RadioField):
|
|||||||
|
|
||||||
|
|
||||||
class NestedFieldMixin:
|
class NestedFieldMixin:
|
||||||
|
|
||||||
def children(self):
|
def children(self):
|
||||||
|
|
||||||
# start map with root option as a single child entry
|
# start map with root option as a single child entry
|
||||||
child_map = {None: [option for option in self
|
child_map = {None: [option for option in self
|
||||||
if option.data == self.NONE_OPTION_VALUE]}
|
if option.data == self.NONE_OPTION_VALUE]}
|
||||||
|
|
||||||
# add entries for all other children
|
# add entries for all other children
|
||||||
for option in self:
|
for option in self:
|
||||||
|
# assign all options with a NONE_OPTION_VALUE (not always None) to the None key
|
||||||
if option.data == self.NONE_OPTION_VALUE:
|
if option.data == self.NONE_OPTION_VALUE:
|
||||||
child_ids = [
|
child_ids = [
|
||||||
folder['id'] for folder in self.all_template_folders
|
folder['id'] for folder in self.all_template_folders
|
||||||
@@ -339,6 +343,47 @@ class NestedFieldMixin:
|
|||||||
|
|
||||||
return child_map
|
return child_map
|
||||||
|
|
||||||
|
# to be used as the only version of .children once radios are converted
|
||||||
|
@cached_property
|
||||||
|
def _children(self):
|
||||||
|
return self.children()
|
||||||
|
|
||||||
|
def get_items_from_options(self, field):
|
||||||
|
items = []
|
||||||
|
|
||||||
|
for option in self._children[None]:
|
||||||
|
item = self.get_item_from_option(option)
|
||||||
|
if option.data in self._children:
|
||||||
|
item['children'] = self.render_children(field.name, option.label.text, self._children[option.data])
|
||||||
|
items.append(item)
|
||||||
|
|
||||||
|
return items
|
||||||
|
|
||||||
|
def render_children(self, name, label, options):
|
||||||
|
params = {
|
||||||
|
"name": name,
|
||||||
|
"fieldset": {
|
||||||
|
"legend": {
|
||||||
|
"text": label,
|
||||||
|
"classes": "govuk-visually-hidden"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"formGroup": {
|
||||||
|
"classes": "govuk-form-group--nested"
|
||||||
|
},
|
||||||
|
"asList": True,
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
for option in options:
|
||||||
|
item = self.get_item_from_option(option)
|
||||||
|
|
||||||
|
if len(self._children[option.data]):
|
||||||
|
item['children'] = self.render_children(name, option.label.text, self._children[option.data])
|
||||||
|
|
||||||
|
params['items'].append(item)
|
||||||
|
|
||||||
|
return render_template('forms/fields/checkboxes/template.njk', params=params)
|
||||||
|
|
||||||
|
|
||||||
class NestedRadioField(RadioFieldWithNoneOption, NestedFieldMixin):
|
class NestedRadioField(RadioFieldWithNoneOption, NestedFieldMixin):
|
||||||
pass
|
pass
|
||||||
@@ -490,6 +535,8 @@ class RegisterUserFromOrgInviteForm(StripWhitespaceForm):
|
|||||||
# based on work done by @richardjpope: https://github.com/richardjpope/recourse/blob/master/recourse/forms.py#L6
|
# based on work done by @richardjpope: https://github.com/richardjpope/recourse/blob/master/recourse/forms.py#L6
|
||||||
class govukCheckboxesField(SelectMultipleField):
|
class govukCheckboxesField(SelectMultipleField):
|
||||||
|
|
||||||
|
render_as_list = False
|
||||||
|
|
||||||
def __init__(self, label='', validators=None, param_extensions=None, **kwargs):
|
def __init__(self, label='', validators=None, param_extensions=None, **kwargs):
|
||||||
super(govukCheckboxesField, self).__init__(label, validators, **kwargs)
|
super(govukCheckboxesField, self).__init__(label, validators, **kwargs)
|
||||||
# default choices to a single True Boolean
|
# default choices to a single True Boolean
|
||||||
@@ -497,30 +544,34 @@ class govukCheckboxesField(SelectMultipleField):
|
|||||||
self.choices = [('y', label)]
|
self.choices = [('y', label)]
|
||||||
self.param_extensions = param_extensions
|
self.param_extensions = param_extensions
|
||||||
|
|
||||||
|
def get_item_from_option(self, option):
|
||||||
|
return {
|
||||||
|
"name": option.name,
|
||||||
|
"id": option.id,
|
||||||
|
"text": option.label.text,
|
||||||
|
"value": str(option.data), # to protect against non-string types like uuids
|
||||||
|
"checked": option.checked
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_items_from_options(self, field):
|
||||||
|
return [self.get_item_from_option(option) for option in field]
|
||||||
|
|
||||||
# self.__call__ renders the HTML for the field by:
|
# self.__call__ renders the HTML for the field by:
|
||||||
# 1. delegating to self.meta.render_field which
|
# 1. delegating to self.meta.render_field which
|
||||||
# 2. calls field.widget
|
# 2. calls field.widget
|
||||||
# this bypasses that by making self.widget a method with the same interface as widget.__call__
|
# this bypasses that by making self.widget a method with the same interface as widget.__call__
|
||||||
def widget(self, field, **kwargs):
|
def widget(self, field, **kwargs):
|
||||||
items = []
|
|
||||||
|
|
||||||
# error messages
|
# error messages
|
||||||
error_message = None
|
error_message = None
|
||||||
if field.errors:
|
if field.errors:
|
||||||
error_message = {"text": " ".join(field.errors).strip()}
|
error_message = {"text": " ".join(field.errors).strip()}
|
||||||
|
|
||||||
# convert options to ones govuk understands
|
# returns either a list or a hierarchy of lists
|
||||||
for option in field:
|
# depending on how get_items_from_options is implemented
|
||||||
items.append({
|
items = self.get_items_from_options(field)
|
||||||
"name": option.name,
|
|
||||||
"id": option.id,
|
|
||||||
"text": option.label.text,
|
|
||||||
"value": option.data,
|
|
||||||
"checked": option.checked
|
|
||||||
})
|
|
||||||
|
|
||||||
params = {
|
params = {
|
||||||
'idPrefix': field.id,
|
|
||||||
'name': field.name,
|
'name': field.name,
|
||||||
'errorMessage': error_message,
|
'errorMessage': error_message,
|
||||||
'items': items
|
'items': items
|
||||||
@@ -535,21 +586,23 @@ class govukCheckboxesField(SelectMultipleField):
|
|||||||
"text": field.label.text,
|
"text": field.label.text,
|
||||||
"classes": "govuk-fieldset__legend--s"
|
"classes": "govuk-fieldset__legend--s"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"asList": self.render_as_list
|
||||||
})
|
})
|
||||||
|
|
||||||
# extend default params with any sent in
|
# extend default params with any sent in
|
||||||
if self.param_extensions:
|
if self.param_extensions.keys():
|
||||||
params.update(self.param_extensions)
|
params.update(self.param_extensions)
|
||||||
|
|
||||||
return Markup(
|
return Markup(
|
||||||
render_template('vendor/govuk-frontend/components/checkboxes/template.njk', params=params))
|
render_template('forms/fields/checkboxes/macro.njk', params=params))
|
||||||
|
|
||||||
|
|
||||||
# Extends fields using the govukCheckboxesField interface to wrap their render in HTML needed by the collapsible JS
|
# Extends fields using the govukCheckboxesField interface to wrap their render in HTML needed by the collapsible JS
|
||||||
class govukCollapsibleCheckboxesMixin:
|
class govukCollapsibleCheckboxesMixin:
|
||||||
def __init__(self, label='', validators=None, field_label='', param_extensions=None, **kwargs):
|
def __init__(self, label='', validators=None, field_label='', param_extensions=None, **kwargs):
|
||||||
|
|
||||||
|
super(govukCollapsibleCheckboxesMixin, self).__init__(label, validators, param_extensions, **kwargs)
|
||||||
self.field_label = field_label
|
self.field_label = field_label
|
||||||
|
|
||||||
def widget(self, field, **kwargs):
|
def widget(self, field, **kwargs):
|
||||||
@@ -576,6 +629,14 @@ class govukCollapsibleCheckboxesField(govukCollapsibleCheckboxesMixin, govukChec
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# govukCollapsibleCheckboxesMixin adds an ARIA live-region to the hint and wraps the render in HTML needed by the
|
||||||
|
# collapsible JS
|
||||||
|
# NestedFieldMixin puts the items into a tree hierarchy, pre-rendering the sub-trees of the top-level items
|
||||||
|
class govukCollapsibleNestedCheckboxesField(govukCollapsibleCheckboxesMixin, NestedFieldMixin, govukCheckboxesField):
|
||||||
|
NONE_OPTION_VALUE = None
|
||||||
|
render_as_list = True
|
||||||
|
|
||||||
|
|
||||||
class PermissionsForm(StripWhitespaceForm):
|
class PermissionsForm(StripWhitespaceForm):
|
||||||
def __init__(self, all_template_folders=None, *args, **kwargs):
|
def __init__(self, all_template_folders=None, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|||||||
4
app/templates/forms/fields/checkboxes/macro.njk
Normal file
4
app/templates/forms/fields/checkboxes/macro.njk
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{%- macro govukCheckboxes(params) %}
|
||||||
|
{%- include "./template.njk" -%}
|
||||||
|
{%- endmacro %}
|
||||||
|
{{ govukCheckboxes(params) }}
|
||||||
134
app/templates/forms/fields/checkboxes/template.njk
Normal file
134
app/templates/forms/fields/checkboxes/template.njk
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
{% from "components/error-message/macro.njk" import govukErrorMessage -%}
|
||||||
|
{% from "components/fieldset/macro.njk" import govukFieldset %}
|
||||||
|
{% from "components/hint/macro.njk" import govukHint %}
|
||||||
|
{% from "components/label/macro.njk" import govukLabel %}
|
||||||
|
|
||||||
|
|
||||||
|
{#- Copied from https://github.com/alphagov/govuk-frontend/blob/v2.13.0/src/components/checkboxes/template.njk
|
||||||
|
Changes:
|
||||||
|
- `classes` option added to `item` allow custom classes on the `.govuk-checkboxes__item` element
|
||||||
|
- `classes` option added to `item.hint` allow custom classes on the `.govuk-hint` element (added to GOVUK Frontend in v3.5.0 - remove when we update)
|
||||||
|
- `asList` option added the root `params` object to allow setting of the `.govuk-checkboxes` and `.govuk-checkboxes__item` element types
|
||||||
|
- `children` option added to `item` allowing the sending in of prerendered child checkboxes (allowing the creation of tree structures through recursion) -#}
|
||||||
|
{#- If an id 'prefix' is not passed, fall back to using the name attribute
|
||||||
|
instead. We need this for error messages and hints as well -#}
|
||||||
|
{% set idPrefix = params.idPrefix if params.idPrefix else params.name %}
|
||||||
|
|
||||||
|
{#- a record of other elements that we need to associate with the input using
|
||||||
|
aria-describedby – for example hints or error messages -#}
|
||||||
|
{% set describedBy = params.describedBy if params.describedBy else "" %}
|
||||||
|
{% if params.fieldset.describedBy %}
|
||||||
|
{% set describedBy = params.fieldset.describedBy %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{#- set the types of element used for the checkboxes and their group based on
|
||||||
|
whether asList is set -#}
|
||||||
|
{% if params.asList %}
|
||||||
|
{% set groupElement = 'ul' %}
|
||||||
|
{% set groupItemElement = 'li' %}
|
||||||
|
{% else %}
|
||||||
|
{% set groupElement = 'div' %}
|
||||||
|
{% set groupItemElement = 'div' %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% set isConditional = false %}
|
||||||
|
{% for item in params.items %}
|
||||||
|
{% if item.conditional %}
|
||||||
|
{% set isConditional = true %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{#- fieldset is false by default -#}
|
||||||
|
{% set hasFieldset = true if params.fieldset else false %}
|
||||||
|
|
||||||
|
{#- Capture the HTML so we can optionally nest it in a fieldset -#}
|
||||||
|
{% set innerHtml %}
|
||||||
|
{% if params.hint %}
|
||||||
|
{% set hintId = idPrefix + '-hint' %}
|
||||||
|
{% set describedBy = describedBy + ' ' + hintId if describedBy else hintId %}
|
||||||
|
{{ govukHint({
|
||||||
|
id: hintId,
|
||||||
|
classes: params.hint.classes,
|
||||||
|
attributes: params.hint.attributes,
|
||||||
|
html: params.hint.html,
|
||||||
|
text: params.hint.text
|
||||||
|
}) | indent(2) | trim }}
|
||||||
|
{% endif %}
|
||||||
|
{% if params.errorMessage %}
|
||||||
|
{% set errorId = idPrefix + '-error' %}
|
||||||
|
{% set describedBy = describedBy + ' ' + errorId if describedBy else errorId %}
|
||||||
|
{{ govukErrorMessage({
|
||||||
|
id: errorId,
|
||||||
|
classes: params.errorMessage.classes,
|
||||||
|
attributes: params.errorMessage.attributes,
|
||||||
|
html: params.errorMessage.html,
|
||||||
|
text: params.errorMessage.text,
|
||||||
|
visuallyHiddenText: params.errorMessage.visuallyHiddenText
|
||||||
|
}) | indent(2) | trim }}
|
||||||
|
{% endif %}
|
||||||
|
<{{ groupElement }} class="govuk-checkboxes {%- if params.classes %} {{ params.classes }}{% endif %}"
|
||||||
|
{%- for attribute, value in params.attributes %} {{ attribute }}="{{ value }}"{% endfor %}
|
||||||
|
{%- if isConditional %} data-module="checkboxes"{% endif -%}>
|
||||||
|
{% for item in params.items %}
|
||||||
|
{% set id = item.id if item.id else idPrefix + "-" + loop.index %}
|
||||||
|
{% 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 %}
|
||||||
|
<{{ groupItemElement }} class="govuk-checkboxes__item {%- if item.classes %} {{ item.classes }}{% endif %}">
|
||||||
|
<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 %} 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 %}
|
||||||
|
{%- if item.children %}
|
||||||
|
{{ item.children | safe }}
|
||||||
|
{%- endif %}
|
||||||
|
{% if params.asList and item.conditional %}
|
||||||
|
<div class="govuk-checkboxes__conditional{% if not item.checked %} govuk-checkboxes__conditional--hidden{% endif %}" id="{{ conditionalId }}">
|
||||||
|
{{ item.conditional.html | safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</{{ groupItemElement }}>
|
||||||
|
{% if not params.asList and item.conditional %}
|
||||||
|
<div class="govuk-checkboxes__conditional{% if not item.checked %} govuk-checkboxes__conditional--hidden{% endif %}" id="{{ conditionalId }}">
|
||||||
|
{{ item.conditional.html | safe }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</{{ groupElement }}>
|
||||||
|
{% endset -%}
|
||||||
|
|
||||||
|
<div class="govuk-form-group {%- if params.errorMessage %} govuk-form-group--error{% endif %} {%- if params.formGroup.classes %} {{ params.formGroup.classes }}{% endif %}">
|
||||||
|
{% if params.fieldset %}
|
||||||
|
{% call govukFieldset({
|
||||||
|
describedBy: describedBy,
|
||||||
|
classes: params.fieldset.classes,
|
||||||
|
attributes: params.fieldset.attributes,
|
||||||
|
legend: params.fieldset.legend
|
||||||
|
}) %}
|
||||||
|
{{ innerHtml | trim | safe }}
|
||||||
|
{% endcall %}
|
||||||
|
{% else %}
|
||||||
|
{{ innerHtml | trim | safe }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user