diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js new file mode 100644 index 000000000..b3f0af84b --- /dev/null +++ b/src/registrar/assets/js/get-gov.js @@ -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=","` will be hidden/shown + * by a radio button with `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); + } +})(); diff --git a/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss b/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss index c176ed937..98f5a0cf0 100644 --- a/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss +++ b/src/registrar/assets/sass/_theme/_uswds-theme-custom-styles.scss @@ -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; + } diff --git a/src/registrar/forms/application_wizard.py b/src/registrar/forms/application_wizard.py index 85320baef..7c492d56b 100644 --- a/src/registrar/forms/application_wizard.py +++ b/src/registrar/forms/application_wizard.py @@ -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): diff --git a/src/registrar/templates/application_organization.html b/src/registrar/templates/application_organization.html index e03bf5a17..f994a7d1e 100644 --- a/src/registrar/templates/application_organization.html +++ b/src/registrar/templates/application_organization.html @@ -1,6 +1,7 @@ {% 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 %} -
- -

What kind of government organization do you represent?

-
- - {{ wizard.form.organization_type|add_class:"usa-radio" }} + {% radio_buttons_by_value wizard.form.organization_type as choices %} +
+ +

What kind of government organization do you represent?

+
+ {{ 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 %}
+ +
+ +

Which federal branch does your organization belong to?

+
+ + {{ wizard.form.federal_type|add_class:"usa-radio" }} +
+ +
+ +

Is your organization an election office?

+
+ + {{ wizard.form.is_election_board|add_class:"usa-radio" }} +
+ diff --git a/src/registrar/templates/base.html b/src/registrar/templates/base.html index 36d465140..c0ebf9dca 100644 --- a/src/registrar/templates/base.html +++ b/src/registrar/templates/base.html @@ -23,6 +23,7 @@ {% block css %} + {% endblock %} {% block canonical %} diff --git a/src/registrar/templates/includes/radio_button.html b/src/registrar/templates/includes/radio_button.html new file mode 100644 index 000000000..8f43547dd --- /dev/null +++ b/src/registrar/templates/includes/radio_button.html @@ -0,0 +1,14 @@ +
+ +
\ No newline at end of file diff --git a/src/registrar/templatetags/debugger_tags.py b/src/registrar/templatetags/debugger_tags.py new file mode 100644 index 000000000..c4e56829a --- /dev/null +++ b/src/registrar/templatetags/debugger_tags.py @@ -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 diff --git a/src/registrar/templatetags/dynamic_question_tags.py b/src/registrar/templatetags/dynamic_question_tags.py new file mode 100644 index 000000000..452f28b21 --- /dev/null +++ b/src/registrar/templatetags/dynamic_question_tags.py @@ -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 diff --git a/src/zap.conf b/src/zap.conf index 47a4fc030..e384e3518 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -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)