mirror of
https://github.com/cisagov/manage.get.gov.git
synced 2025-05-31 09:43:54 +02:00
Show and Hide Questions Dynamically (#212)
* Show and hide form questions dynamically * Respond to PR feedback * Remove debugger tags
This commit is contained in:
parent
d218033538
commit
d8ae0d9753
9 changed files with 362 additions and 10 deletions
186
src/registrar/assets/js/get-gov.js
Normal file
186
src/registrar/assets/js/get-gov.js
Normal 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);
|
||||||
|
}
|
||||||
|
})();
|
|
@ -22,7 +22,7 @@ i.e.
|
||||||
|
|
||||||
@use "uswds-core" as *;
|
@use "uswds-core" as *;
|
||||||
|
|
||||||
// Test custom style (except this has not enough contrast)
|
/* Styles for making visible to screen reader / AT users only. */
|
||||||
//p {
|
.sr-only {
|
||||||
// color: color('blue-10v');
|
@include sr-only;
|
||||||
//}
|
}
|
||||||
|
|
|
@ -42,6 +42,23 @@ class OrganizationForm(forms.Form):
|
||||||
],
|
],
|
||||||
widget=forms.RadioSelect,
|
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):
|
class ContactForm(forms.Form):
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<!-- Test page -->
|
<!-- Test page -->
|
||||||
{% extends 'application_form.html' %}
|
{% extends 'application_form.html' %}
|
||||||
{% load widget_tweaks %}
|
{% load widget_tweaks %}
|
||||||
|
{% load dynamic_question_tags %}
|
||||||
|
|
||||||
{% block title %}Apply for a .gov domain - About your organization{% endblock %}
|
{% block title %}Apply for a .gov domain - About your organization{% endblock %}
|
||||||
|
|
||||||
|
@ -11,14 +12,42 @@
|
||||||
{{ wizard.management_form }}
|
{{ wizard.management_form }}
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
|
|
||||||
<fieldset class="usa-fieldset">
|
{% radio_buttons_by_value wizard.form.organization_type as choices %}
|
||||||
<legend>
|
|
||||||
<h2> What kind of government organization do you represent?</h2>
|
|
||||||
</legend>
|
|
||||||
|
|
||||||
{{ wizard.form.organization_type|add_class:"usa-radio" }}
|
|
||||||
|
|
||||||
|
<fieldset id="organization_type__fieldset" class="usa-fieldset">
|
||||||
|
<legend>
|
||||||
|
<h2> What kind of government organization do you represent?</h2>
|
||||||
|
</legend>
|
||||||
|
{{ 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>
|
||||||
|
|
||||||
|
<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>
|
<button type="submit" class="usa-button">Next</button>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -23,6 +23,7 @@
|
||||||
{% block css %}
|
{% block css %}
|
||||||
<link rel="stylesheet" href="{% static 'css/styles.css' %}">
|
<link rel="stylesheet" href="{% static 'css/styles.css' %}">
|
||||||
<script src="{% static 'js/uswds-init.min.js' %}" defer></script>
|
<script src="{% static 'js/uswds-init.min.js' %}" defer></script>
|
||||||
|
<script src="{% static 'js/get-gov.js' %}" defer></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block canonical %}
|
{% block canonical %}
|
||||||
|
|
14
src/registrar/templates/includes/radio_button.html
Normal file
14
src/registrar/templates/includes/radio_button.html
Normal 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>
|
55
src/registrar/templatetags/debugger_tags.py
Normal file
55
src/registrar/templatetags/debugger_tags.py
Normal 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
|
48
src/registrar/templatetags/dynamic_question_tags.py
Normal file
48
src/registrar/templatetags/dynamic_question_tags.py
Normal 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
|
|
@ -27,6 +27,8 @@
|
||||||
10027 OUTOFSCOPE http://app:8080/public/debug_toolbar/js/toolbar.js
|
10027 OUTOFSCOPE http://app:8080/public/debug_toolbar/js/toolbar.js
|
||||||
# USWDS.min.js contains suspicious words "query", "select", "from" in ordinary usage
|
# USWDS.min.js contains suspicious words "query", "select", "from" in ordinary usage
|
||||||
10027 OUTOFSCOPE http://app:8080/public/js/uswds.min.js
|
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)
|
10028 FAIL (Open Redirect - Passive/beta)
|
||||||
10029 FAIL (Cookie Poisoning - Passive/beta)
|
10029 FAIL (Cookie Poisoning - Passive/beta)
|
||||||
10030 FAIL (User Controllable Charset - Passive/beta)
|
10030 FAIL (User Controllable Charset - Passive/beta)
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue