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 *;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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">
|
||||
<legend>
|
||||
<h2> What kind of government organization do you represent?</h2>
|
||||
</legend>
|
||||
|
||||
{{ wizard.form.organization_type|add_class:"usa-radio" }}
|
||||
{% 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.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>
|
||||
|
|
|
@ -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 %}
|
||||
|
|
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
|
||||
# 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)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue