Show and Hide Questions Dynamically (#212)

* Show and hide form questions dynamically

* Respond to PR feedback

* Remove debugger tags
This commit is contained in:
Seamus Johnston 2022-11-02 20:35:53 +00:00 committed by GitHub
parent d218033538
commit d8ae0d9753
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 362 additions and 10 deletions

View file

@ -0,0 +1,186 @@
/** Strings announced to assistive technology users. */
var ARIA = {
QUESTION_REMOVED: "Previous follow-up question removed",
QUESTION_ADDED: "New follow-up question required"
}
var REQUIRED = "required";
var INPUT = "input";
/** Helper function. Makes an element invisible. */
function makeHidden(el) {
el.style.position = "absolute";
el.style.left = "-100vw";
// The choice of `visiblity: hidden`
// over `display: none` is due to
// UX: the former will allow CSS
// transitions when the elements appear.
el.style.visibility = "hidden";
}
/** Helper function. Makes visible a perviously hidden element. */
function makeVisible(el) {
el.style.position = "relative";
el.style.left = "unset";
el.style.visibility = "visible";
}
/** Executes `func` once per selected child of a given element. */
function forEachChild(el, selector, func) {
const children = el.querySelectorAll(selector);
for (const child of children) {
func(child)
}
}
/** Helper function. Removes `required` attribute from input. */
const removeRequired = input => input.removeAttribute(REQUIRED);
/** Helper function. Adds `required` attribute to input. */
const setRequired = input => input.setAttribute(REQUIRED, "");
/** Helper function. Removes `checked` attribute from input. */
const removeChecked = input => input.checked = false;
/** Helper function. Adds `checked` attribute to input. */
const setChecked = input => input.checked = true;
/** Helper function. Creates and returns a live region element. */
function createLiveRegion(id) {
const liveRegion = document.createElement("div");
liveRegion.setAttribute("role", "region");
liveRegion.setAttribute("aria-live", "polite");
liveRegion.setAttribute("id", id + "-live-region");
liveRegion.classList.add("sr-only");
document.body.appendChild(liveRegion);
return liveRegion;
}
/** Currently selected radio buttons. */
var selected = {};
/** Mapping of radio buttons to the toggleables they control. */
var radioToggles = {};
/**
* Helper function. Tracks state of selected radio button.
*
* This is required due to JavaScript not having a native
* event trigger for "deselect" on radio buttons. Tracking
* which button has been deselected (and hiding the associated
* toggleable) is a manual task.
* */
function rememberSelected(radioButton) {
selected[radioButton.name] = radioButton;
}
/** Helper function. Announces changes to assistive technology users. */
function announce(id, text) {
const liveRegion = document.getElementById(id + "-live-region");
liveRegion.innerHTML = text;
}
/**
* Used by an event handler to hide HTML.
*
* Hides any previously visible HTML associated with
* previously selected radio buttons.
*/
function hideToggleable(e) {
// has any button in this radio button group been selected?
const selectionExists = e.target.name in selected;
if (selectionExists) {
// does the selection have any hidden content associated with it?
const hasToggleable = selected[e.target.name].id in radioToggles;
if (hasToggleable) {
const prevRadio = selected[e.target.name];
const prevToggleable = radioToggles[prevRadio.id];
// is this event handler for a different button?
const selectionHasChanged = (e.target != prevRadio);
// is the previous button's content still visible?
const prevSelectionVisible = (prevToggleable.style.visibility !== "hidden");
if (selectionHasChanged && prevSelectionVisible) {
makeHidden(prevToggleable);
forEachChild(prevToggleable, INPUT, removeChecked);
forEachChild(prevToggleable, INPUT, removeRequired);
announce(prevToggleable.id, ARIA.QUESTION_REMOVED);
}
}
}
}
function revealToggleable(e) {
// if the currently selected radio button has a toggle
// make it visible
if (e.target.id in radioToggles) {
const toggleable = radioToggles[e.target.id];
rememberSelected(e.target);
if (e.target.required) forEachChild(toggleable, INPUT, setRequired);
makeVisible(toggleable);
announce(toggleable.id, ARIA.QUESTION_ADDED);
}
}
/** On radio button selection change, handles associated toggleables. */
function handleToggle(e) {
// hide any previously visible HTML associated with previously selected radio buttons
hideToggleable(e);
// display any HTML associated with the newly selected radio button
revealToggleable(e);
}
/**
* An IIFE that will hide any elements with `hide-on-load` attribute.
*
* Why not start with `hidden`? Because this is for use with form questions:
* if Javascript fails, users will still need access to those questions.
*/
(function hiddenInit() {
"use strict";
const hiddens = document.querySelectorAll('[hide-on-load]');
for(const hidden of hiddens) {
makeHidden(hidden);
forEachChild(hidden, INPUT, removeRequired);
}
})();
/**
* An IIFE that adds onChange listeners to radio buttons.
*
* An element with `toggle-by="<id>,<id>"` will be hidden/shown
* by a radio button with `id="<id>"`.
*
* It also inserts the ARIA live region to be used when
* announcing show/hide to screen reader users.
*/
(function toggleInit() {
"use strict";
// get elements to show/hide
const toggleables = document.querySelectorAll('[toggle-by]');
for(const toggleable of toggleables) {
// get the (comma-seperated) list of radio button ids
// which trigger this toggleable to become visible
const attribute = toggleable.getAttribute("toggle-by") || "";
if (!attribute.length) continue;
const radioIDs = attribute.split(",");
createLiveRegion(toggleable.id)
for (const id of radioIDs) {
radioToggles[id] = toggleable;
// if it is already selected, track that
const radioButton = document.getElementById(id);
if (radioButton.checked) rememberSelected(radioButton);
}
}
// all radio buttons must react to selection changes
const radioButtons = document.querySelectorAll('input[type="radio"]');
for (const radioButton of Array.from(radioButtons)) {
radioButton.addEventListener('change', handleToggle);
}
})();

View file

@ -22,7 +22,7 @@ i.e.
@use "uswds-core" as *;
// Test custom style (except this has not enough contrast)
//p {
// color: color('blue-10v');
//}
/* Styles for making visible to screen reader / AT users only. */
.sr-only {
@include sr-only;
}

View file

@ -42,6 +42,23 @@ class OrganizationForm(forms.Form):
],
widget=forms.RadioSelect,
)
federal_type = forms.ChoiceField(
required=False,
choices=[
("Executive", "Executive"),
("Judicial", "Judicial"),
("Legislative", "Legislative"),
],
widget=forms.RadioSelect,
)
is_election_board = forms.ChoiceField(
required=False,
choices=[
("Yes", "Yes"),
("No", "No"),
],
widget=forms.RadioSelect,
)
class ContactForm(forms.Form):

View file

@ -1,6 +1,7 @@
<!-- Test page -->
{% extends 'application_form.html' %}
{% load widget_tweaks %}
{% load dynamic_question_tags %}
{% block title %}Apply for a .gov domain - About your organization{% endblock %}
@ -11,14 +12,42 @@
{{ wizard.management_form }}
{% csrf_token %}
<fieldset class="usa-fieldset">
{% radio_buttons_by_value wizard.form.organization_type as choices %}
<fieldset id="organization_type__fieldset" class="usa-fieldset">
<legend>
<h2> What kind of government organization do you represent?</h2>
</legend>
{{ wizard.form.organization_type|add_class:"usa-radio" }}
{{ wizard.form.organization_type.errors }}
{% include "includes/radio_button.html" with choice=choices.Federal %}
{% include "includes/radio_button.html" with choice=choices.Interstate %}
{% include "includes/radio_button.html" with choice=choices.State_or_Territory %}
{% include "includes/radio_button.html" with choice=choices.Tribal %}
{% include "includes/radio_button.html" with choice=choices.County %}
{% include "includes/radio_button.html" with choice=choices.City %}
{% include "includes/radio_button.html" with choice=choices.Special_District %}
</fieldset>
<fieldset id="federal_type__fieldset" {% trigger wizard.form.federal_type choices.Federal %} class="usa-fieldset">
<legend>
<h2> Which federal branch does your organization belong to?</h2>
</legend>
<noscript>
Only required if you selected "Federal".
</noscript>
{{ wizard.form.federal_type|add_class:"usa-radio" }}
</fieldset>
<fieldset id="is_election_board__fieldset" {% trigger wizard.form.is_election_board choices.Tribal choices.County choices.City choices.Special_District%} class="usa-fieldset">
<legend>
<h2> Is your organization an election office?</h2>
</legend>
<noscript>
Only required if you selected "Tribal", "County", "City" or "Special District".
</noscript>
{{ wizard.form.is_election_board|add_class:"usa-radio" }}
</fieldset>
<button type="submit" class="usa-button">Next</button>
</form>

View file

@ -23,6 +23,7 @@
{% block css %}
<link rel="stylesheet" href="{% static 'css/styles.css' %}">
<script src="{% static 'js/uswds-init.min.js' %}" defer></script>
<script src="{% static 'js/get-gov.js' %}" defer></script>
{% endblock %}
{% block canonical %}

View file

@ -0,0 +1,14 @@
<div>
<label for="{{ choice.id_for_label }}"
><input
type="{{ choice.data.type }}"
name="{{ choice.data.name }}"
value="{{ choice.data.value }}"
class="usa-radio"
id="{{ choice.id_for_label }}"
{% if choice.data.attrs.required %}required{% endif %}
{% if choice.data.selected %}checked{% endif %}
/>
{{ choice.data.label }}</label
>
</div>

View file

@ -0,0 +1,55 @@
# -*- coding: utf-8 -*-
"""
Make debugging Django templates easier.
Example:
{% load debugger_tags %}
{{ object|ipdb }}
Copyright (c) 2007 Michael Trier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
https://github.com/django-extensions/django-extensions/
"""
from django import template
register = template.Library()
@register.filter
def ipdb(obj): # pragma: no cover
"""Interactive Python debugger filter."""
__import__("ipdb").set_trace()
return obj
@register.filter
def pdb(obj):
"""Python debugger filter."""
__import__("pdb").set_trace()
return obj
@register.filter
def wdb(obj): # pragma: no cover
"""Web debugger filter."""
__import__("wdb").set_trace()
return obj

View file

@ -0,0 +1,48 @@
from django import template
from django.utils.html import format_html
register = template.Library()
@register.simple_tag
def radio_buttons_by_value(boundfield):
"""
Returns radio button subwidgets indexed by their value.
This makes it easier to visually identify the choices when
using them in templates.
"""
return {w.data["value"]: w for w in boundfield.subwidgets}
@register.simple_tag
def trigger(boundfield, *triggers):
"""
Inserts HTML to link a dynamic question to its trigger(s).
Currently only works for radio buttons.
Also checks whether the question should be hidden on page load.
If the question is already answered or if the question's
trigger is already selected, it should not be hidden.
Returns HTML attributes which will be read by a JavaScript
helper, if the user has JavaScript enabled.
"""
ids = []
hide_on_load = True
for choice in boundfield.subwidgets:
if choice.data["selected"]:
hide_on_load = False
for trigger in triggers:
ids.append(trigger.id_for_label)
if trigger.data["selected"]:
hide_on_load = False
html = format_html(
'toggle-by="{}"{}', ",".join(ids), " hide-on-load" if hide_on_load else ""
)
return html

View file

@ -27,6 +27,8 @@
10027 OUTOFSCOPE http://app:8080/public/debug_toolbar/js/toolbar.js
# USWDS.min.js contains suspicious words "query", "select", "from" in ordinary usage
10027 OUTOFSCOPE http://app:8080/public/js/uswds.min.js
# get-gov.js contains suspicious word "from" as in `Array.from()`
10027 OUTOFSCOPE http://app:8080/public/js/get-gov.js
10028 FAIL (Open Redirect - Passive/beta)
10029 FAIL (Cookie Poisoning - Passive/beta)
10030 FAIL (User Controllable Charset - Passive/beta)