Skip to content

Commit

Permalink
Improve a11y of address search form (#128)
Browse files Browse the repository at this point in the history
* applied aria-required to search input

* set aria-invalid on form input when empty

* set aria-describedby on form input to error msg when error occurs

* focus input on search error:

- this will announce the error as aria-describedby on the input to A.T.

* rm .hbs partial & inline markup with parent

* clear any previous error prior to showing no input error

* keep input value on form submit...

in case of a validation error so the user can see what they had entered

* rm deleted .hbs partial from jest setup

* fixed test: address search input should not be cleared on submit

* clean-up searchValidationErrors:

- add a common method for setting aria-describedby
- add a common method for setting aria-invalid & .invalid css class

* updated tests for searchValidationErrors

- test for aria attributes to be correctly set and unset

* updated tests for addressSearchForm:

- test that input has focus when handling an error

* rm unused method from SearchValidationErrors

* updated SearchValidationErrors html:

- no need to use a list since only error msg is ever shown at a time
- the <ul> element is never hidden so may confuse sr users

* removed asteriks from search validation error text

as it likely is annoying when announced to assistive tech
  • Loading branch information
clhenrick authored Dec 17, 2023
1 parent f74979c commit 522c806
Show file tree
Hide file tree
Showing 10 changed files with 131 additions and 47 deletions.
4 changes: 2 additions & 2 deletions app/public/locales/main-en.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
"label": "Street Address",
"placeholder": "Your NYC street address",
"val_errors": {
"not_found": "* Sorry, we don't recognize that NYC address, please try again!",
"address": "* Enter your building number, street name, and borough.",
"not_found": "Sorry, we don't recognize that NYC address, please try again!",
"address": "Enter your building number, street name, and borough.",
"generic": "Something went wrong, please try again."
}
},
Expand Down
4 changes: 2 additions & 2 deletions app/public/locales/main-es.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,8 @@
"label": "Dirección",
"placeholder": "Su dirección postal de NYC",
"val_errors": {
"not_found": "* Lo sentimos, no reconocemos ésta dirección, favor de intentar de nuevo.",
"address": "* Ingrese el número de su edificio, el nombre de la calle y el distrito.",
"not_found": "Lo sentimos, no reconocemos ésta dirección, favor de intentar de nuevo.",
"address": "Ingrese el número de su edificio, el nombre de la calle y el distrito.",
"generic": "Algo salió mal. Por favor, vuelva a intentarlo."
}
},
Expand Down
1 change: 0 additions & 1 deletion app/setupJest.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@ function registerHbsPartials() {
"language_toggle",
"progress_indicator",
"address_search_form",
"search_validation_errors",
"search_result_map",
"add_to_calendar",
"tenants_rights_modal",
Expand Down
7 changes: 5 additions & 2 deletions app/src/components/addressSearchForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export class AddressSearchForm extends Component {
this.cached.searchResult = undefined;

this.validationErrors = new SearchValidationErrors({
element: this.element.querySelector("ul"),
element: this.element.querySelector(".search-validation-errors"),
searchForm: this,
});

Expand Down Expand Up @@ -87,9 +87,10 @@ export class AddressSearchForm extends Component {
if (this.inputAddress.value.length) {
this.searchRentStabilized(this.inputAddress.value);
logAddressSearch(this.inputAddress.value);
this.inputAddress.value = "";
} else {
this.validationErrors.hideAll();
this.validationErrors.showNoInput();
this.inputAddress.focus();
}
}

Expand Down Expand Up @@ -164,6 +165,7 @@ export class AddressSearchForm extends Component {
this.store.dispatch(goToSlideIdx(2));
} else {
this.validationErrors.showNotFound();
this.inputAddress.focus();
logAddressNF(this.inputAddress.value);
}
}
Expand All @@ -178,6 +180,7 @@ export class AddressSearchForm extends Component {

handleFetchError(error) {
this.validationErrors.showGeneric();
this.inputAddress.focus();
// TODO: if exceptions are being logged from Redux middleware correctly,
// then the following logException call may be redundant
logException(handleErrorObj("AddressSearchForm.handleFetchError", error));
Expand Down
8 changes: 7 additions & 1 deletion app/src/components/addressSearchForm.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -332,6 +332,8 @@ describe("AddressSearchForm", () => {
expect(logAddressNF).toHaveBeenCalledWith(
"444 Unknown Street, Staten Island"
);
// NOTE: although the searchValidationErrors handles updating the DOM when an error occurs, the addressSearchForm focuses the input so that the error help text is announced to screen readers.
expect(document.activeElement).toEqual(addressSearchForm.inputAddress);
});

test("handleSubmit", () => {
Expand All @@ -352,7 +354,7 @@ describe("AddressSearchForm", () => {
expect(logAddressSearch).toHaveBeenCalledWith("999 Main Street");
expect(event.preventDefault).toHaveBeenCalled();
expect(spy2).toHaveBeenCalledWith("999 Main Street");
expect(addressSearchForm.inputAddress.value).toEqual("");
expect(addressSearchForm.inputAddress.value).toEqual("999 Main Street");
});

test("handleSubmit no user input", () => {
Expand All @@ -371,6 +373,8 @@ describe("AddressSearchForm", () => {
addressSearchForm.element.dispatchEvent(event);
expect(spy).toHaveBeenCalled();
expect(spy2).not.toHaveBeenCalled();
// NOTE: although the searchValidationErrors handles updating the DOM when an error occurs, the addressSearchForm focuses the input so that the error help text is announced to screen readers.
expect(document.activeElement).toEqual(addressSearchForm.inputAddress);
});

test("searchRentStabilized", () => {
Expand Down Expand Up @@ -436,6 +440,8 @@ describe("AddressSearchForm", () => {
addressSearchForm.handleFetchError(new Error("Something went wrong"));
expect(spy).toHaveBeenCalled();
expect(logException).toHaveBeenCalled();
// NOTE: although the searchValidationErrors handles updating the DOM when an error occurs, the addressSearchForm focuses the input so that the error help text is announced to screen readers.
expect(document.activeElement).toEqual(addressSearchForm.inputAddress);
});

test("cleanUp", () => {
Expand Down
54 changes: 43 additions & 11 deletions app/src/components/searchValidationErrors.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
import { Component } from "./_componentBase";

/** HTML id attribute values for validation text */
export const VALIDATION_TEXT_ID = {
NOT_FOUND: "error-not-found",
NO_INPUT: "error-address",
GENERIC: "error-generic",
};

export class SearchValidationErrors extends Component {
init({ searchForm }) {
this._searchForm = searchForm;
this.errorNotFound = this.element.querySelector("#error-not-found");
this.errorNoInput = this.element.querySelector("#error-address");
this.errorGeneric = this.element.querySelector("#error-generic");
}

showAll() {
this.showNotFound();
this.showNoInput();
this.showGeneric();
this.errorNotFound = this.element.querySelector(
`#${VALIDATION_TEXT_ID.NOT_FOUND}`
);
this.errorNoInput = this.element.querySelector(
`#${VALIDATION_TEXT_ID.NO_INPUT}`
);
this.errorGeneric = this.element.querySelector(
`#${VALIDATION_TEXT_ID.GENERIC}`
);
}

hideAll() {
Expand All @@ -22,28 +29,53 @@ export class SearchValidationErrors extends Component {

showNotFound() {
this.errorNotFound.classList.remove("hidden");
this.setInputAriaDescribedby(VALIDATION_TEXT_ID.NOT_FOUND);
this.setInputInvalid(true);
}

hideNotFound() {
this.errorNotFound.classList.add("hidden");
this.setInputAriaDescribedby();
this.setInputInvalid(false);
}

showNoInput() {
this.errorNoInput.classList.remove("hidden");
this._searchForm.inputAddress.classList.add("invalid");
this.setInputAriaDescribedby(VALIDATION_TEXT_ID.NO_INPUT);
this.setInputInvalid(true);
}

hideNoInput() {
this.errorNoInput.classList.add("hidden");
this._searchForm.inputAddress.classList.remove("invalid");
this.setInputAriaDescribedby();
this.setInputInvalid(false);
}

showGeneric() {
this.errorGeneric.classList.remove("hidden");
this.setInputAriaDescribedby(VALIDATION_TEXT_ID.GENERIC);
}

hideGeneric() {
this.errorGeneric.classList.add("hidden");
this.setInputAriaDescribedby();
}

setInputAriaDescribedby(value) {
if (typeof value === "string") {
this._searchForm.inputAddress.setAttribute("aria-describedby", value);
} else {
this._searchForm.inputAddress.removeAttribute("aria-describedby");
}
}

setInputInvalid(value) {
if (value) {
this._searchForm.inputAddress.classList.add("invalid");
} else {
this._searchForm.inputAddress.classList.remove("invalid");
}
this._searchForm.inputAddress.setAttribute("aria-invalid", value);
}

get noInputIsHidden() {
Expand Down
50 changes: 41 additions & 9 deletions app/src/components/searchValidationErrors.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { SearchValidationErrors } from "./searchValidationErrors";
import {
SearchValidationErrors,
VALIDATION_TEXT_ID,
} from "./searchValidationErrors";
import { AddressSearchForm } from "./addressSearchForm";
import { store } from "../store";

Expand Down Expand Up @@ -44,7 +47,7 @@ describe("SearchValidationErrors", () => {

setDocumentHtml(getMainHtml()); // eslint-disable-line no-undef

element = document.querySelector("#address-form ul");
element = document.querySelector(".search-validation-errors");
});

beforeEach(() => {
Expand Down Expand Up @@ -79,54 +82,83 @@ describe("SearchValidationErrors", () => {
expect(
searchValidationErrors.errorNotFound.classList.contains("hidden")
).toBe(false);
expect(addressSearchForm.inputAddress.getAttribute("aria-invalid")).toBe(
"true"
);
expect(
addressSearchForm.inputAddress.getAttribute("aria-describedby")
).toBe(VALIDATION_TEXT_ID.NOT_FOUND);
});

test("hideNotFound", () => {
searchValidationErrors.hideNotFound();
expect(
searchValidationErrors.errorNotFound.classList.contains("hidden")
).toBe(true);
expect(addressSearchForm.inputAddress.getAttribute("aria-invalid")).toBe(
"false"
);
expect(
addressSearchForm.inputAddress.getAttribute("aria-describedby")
).toBeNull();
});

test("showNoInput", () => {
searchValidationErrors.showNoInput();
expect(
searchValidationErrors.errorNoInput.classList.contains("hidden")
).toBe(false);
expect(addressSearchForm.inputAddress.getAttribute("aria-invalid")).toBe(
"true"
);
expect(
addressSearchForm.inputAddress.getAttribute("aria-describedby")
).toBe(VALIDATION_TEXT_ID.NO_INPUT);
});

test("hideNoInput", () => {
searchValidationErrors.hideNoInput();
expect(
searchValidationErrors.errorNoInput.classList.contains("hidden")
).toBe(true);
expect(addressSearchForm.inputAddress.getAttribute("aria-invalid")).toBe(
"false"
);
expect(
addressSearchForm.inputAddress.getAttribute("aria-describedby")
).toBeNull();
});

test("showGeneric", () => {
searchValidationErrors.showGeneric();
expect(
searchValidationErrors.errorGeneric.classList.contains("hidden")
).toBe(false);
expect(
addressSearchForm.inputAddress.getAttribute("aria-describedby")
).toBe(VALIDATION_TEXT_ID.GENERIC);
});

test("hideGeneric", () => {
searchValidationErrors.hideGeneric();
expect(
searchValidationErrors.errorGeneric.classList.contains("hidden")
).toBe(true);
});

test("showAll", () => {
searchValidationErrors.showAll();
expect(spyShowNoInput).toHaveBeenCalledTimes(1);
expect(spyShowNotFound).toHaveBeenCalledTimes(1);
expect(spyShowGeneric).toHaveBeenCalledTimes(1);
expect(
addressSearchForm.inputAddress.getAttribute("aria-describedby")
).toBeNull();
});

test("hideAll", () => {
searchValidationErrors.hideAll();
expect(spyHideNoInput).toHaveBeenCalledTimes(1);
expect(spyHideNotFound).toHaveBeenCalledTimes(1);
expect(spyHideGeneric).toHaveBeenCalledTimes(1);
expect(
addressSearchForm.inputAddress.getAttribute("aria-describedby")
).toBeNull();
expect(addressSearchForm.inputAddress.getAttribute("aria-invalid")).toBe(
"false"
);
});
});
30 changes: 25 additions & 5 deletions app/src/hbs_partials/address_search_form.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,30 @@
<form id="address-form">
<div class="user-data street-address">
<label for="address-search-input">{{slide02.form.label}}:</label>
<input id="address-search-input" class="address-input" name="address-input" type="search" list="autosuggest-results"
placeholder="{{slide02.form.placeholder}}" autocomplete="off">
<input
id="address-search-input"
class="address-input"
name="address-input"
type="search"
list="autosuggest-results"
aria-required="true"
placeholder="{{slide02.form.placeholder}}"
autocomplete="off"
>
<datalist id="autosuggest-results"></datalist>
</div>
{{>search_validation_errors}}
<button class="button submit" type="submit">{{ slide02.search }}</button>
</form>
<div class="search-validation-errors">
<p id="error-not-found" class="val-err hidden">
{{slide02.form.val_errors.not_found}}
</p>
<p id="error-address" class="val-err hidden">
{{slide02.form.val_errors.address}}
</p>
<p id="error-generic" class="val-err hidden">
{{slide02.form.val_errors.generic}}
</p>
</div>
<button class="button submit" type="submit">
{{ slide02.search }}
</button>
</form>
5 changes: 0 additions & 5 deletions app/src/hbs_partials/search_validation_errors.hbs

This file was deleted.

15 changes: 6 additions & 9 deletions app/src/scss/_search-validation-errors.scss
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
ul.search-validation-errors {
padding: 0;
.search-validation-errors {
margin-top: 5px;
height: 125px;

li {
p {
width: 70%;
display: block;
margin: 12px auto;
text-align: center;
padding: 10px;
Expand All @@ -14,7 +12,6 @@ ul.search-validation-errors {
font-weight: 300;
font-style: italic;
letter-spacing: 1px;
list-style: none;
background-color: $valError;

&.hidden {
Expand All @@ -23,14 +20,14 @@ ul.search-validation-errors {
}

@include responsive_height(medium-screens) {
li.es {
p.es {
font-size: 0.6em;
}
}

@include responsive(small-screens) {
max-width: 95%;
margin: 0 auto;
padding: 2px;
p {
width: auto;
}
}
}

0 comments on commit 522c806

Please sign in to comment.