From e0cd3c5efbd3be2359942a9dccd4a6a5644f1313 Mon Sep 17 00:00:00 2001 From: Tom Byers Date: Thu, 23 Apr 2020 12:15:24 +0100 Subject: [PATCH] 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. --- .../stylesheets/components/checkboxes.scss | 28 ++++ app/main/forms.py | 89 ++++++++++-- .../forms/fields/checkboxes/macro.njk | 4 + .../forms/fields/checkboxes/template.njk | 134 ++++++++++++++++++ 4 files changed, 241 insertions(+), 14 deletions(-) create mode 100644 app/templates/forms/fields/checkboxes/macro.njk create mode 100644 app/templates/forms/fields/checkboxes/template.njk diff --git a/app/assets/stylesheets/components/checkboxes.scss b/app/assets/stylesheets/components/checkboxes.scss index 3986c65bf..094c37d7a 100644 --- a/app/assets/stylesheets/components/checkboxes.scss +++ b/app/assets/stylesheets/components/checkboxes.scss @@ -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__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 { margin-bottom: govuk-spacing(4); diff --git a/app/main/forms.py b/app/main/forms.py index 7aeeb2603..6925293de 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -17,6 +17,7 @@ from notifications_utils.recipients import ( normalise_phone_number, validate_phone_number, ) +from werkzeug.utils import cached_property from wtforms import ( BooleanField, DateField, @@ -318,13 +319,16 @@ class RadioFieldWithNoneOption(FieldWithNoneOption, RadioField): class NestedFieldMixin: + def children(self): + # start map with root option as a single child entry child_map = {None: [option for option in self if option.data == self.NONE_OPTION_VALUE]} # add entries for all other children 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: child_ids = [ folder['id'] for folder in self.all_template_folders @@ -340,6 +344,47 @@ class NestedFieldMixin: 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): pass @@ -491,6 +536,8 @@ class RegisterUserFromOrgInviteForm(StripWhitespaceForm): # based on work done by @richardjpope: https://github.com/richardjpope/recourse/blob/master/recourse/forms.py#L6 class govukCheckboxesField(SelectMultipleField): + render_as_list = False + def __init__(self, label='', validators=None, param_extensions=None, **kwargs): super(govukCheckboxesField, self).__init__(label, validators, **kwargs) # default choices to a single True Boolean @@ -498,30 +545,34 @@ class govukCheckboxesField(SelectMultipleField): self.choices = [('y', label)] 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: # 1. delegating to self.meta.render_field which # 2. calls field.widget # this bypasses that by making self.widget a method with the same interface as widget.__call__ def widget(self, field, **kwargs): - items = [] # error messages error_message = None if field.errors: error_message = {"text": " ".join(field.errors).strip()} - # convert options to ones govuk understands - for option in field: - items.append({ - "name": option.name, - "id": option.id, - "text": option.label.text, - "value": option.data, - "checked": option.checked - }) + # returns either a list or a hierarchy of lists + # depending on how get_items_from_options is implemented + items = self.get_items_from_options(field) params = { - 'idPrefix': field.id, 'name': field.name, 'errorMessage': error_message, 'items': items @@ -536,21 +587,23 @@ class govukCheckboxesField(SelectMultipleField): "text": field.label.text, "classes": "govuk-fieldset__legend--s" } - } + }, + "asList": self.render_as_list }) # extend default params with any sent in - if self.param_extensions: + if self.param_extensions.keys(): params.update(self.param_extensions) 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 class govukCollapsibleCheckboxesMixin: 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 def widget(self, field, **kwargs): @@ -577,6 +630,14 @@ class govukCollapsibleCheckboxesField(govukCollapsibleCheckboxesMixin, govukChec 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): def __init__(self, all_template_folders=None, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/app/templates/forms/fields/checkboxes/macro.njk b/app/templates/forms/fields/checkboxes/macro.njk new file mode 100644 index 000000000..c91452053 --- /dev/null +++ b/app/templates/forms/fields/checkboxes/macro.njk @@ -0,0 +1,4 @@ +{%- macro govukCheckboxes(params) %} + {%- include "./template.njk" -%} +{%- endmacro %} +{{ govukCheckboxes(params) }} diff --git a/app/templates/forms/fields/checkboxes/template.njk b/app/templates/forms/fields/checkboxes/template.njk new file mode 100644 index 000000000..2ac9cf68b --- /dev/null +++ b/app/templates/forms/fields/checkboxes/template.njk @@ -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 %}"> + + {{ 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 %} +
+ {{ item.conditional.html | safe }} +
+ {% endif %} + + {% if not params.asList and item.conditional %} +
+ {{ item.conditional.html | safe }} +
+ {% endif %} + {% endfor %} + +{% endset -%} + +
+{% 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 %} +