Skip to content

Commit

Permalink
WAYF a11y improvements (#1099)
Browse files Browse the repository at this point in the history
* Change descriptive text for SR on search

* Add idpBanner as description for list

* Adjust focusability search/reset button

* Ensure no results are announced to SR

* Ensure search results are announced to SR

* Handle accessibility of previous selection section

- add notification of which element was deleted
- ensure focus remains ok

* Ensure SR users hear no access title text

* Handle focus of request access form steps

* Ensure form validation & success is handled correctly

* Ensure SR users hear no access title text

* Fix bug where noacces isn't always hidden

* Ensure delete idp button has descriptive text

* Change title text for remaining section

* Change title text for remaining section + remove form

* Hide noaccess disabled button from SR

* Ensure request form announcement is emptied on success

* Remove debug toolbar before a11y tests

* Fix weird missing variable

* Revert "Remove debug toolbar before a11y tests"

This reverts commit adf8df2
  • Loading branch information
Koen Cornelis committed Apr 1, 2021
1 parent 5ed55c0 commit e7b253e
Show file tree
Hide file tree
Showing 31 changed files with 257 additions and 60 deletions.
6 changes: 5 additions & 1 deletion theme/base/javascripts/handlers.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {switchConsentSection} from './consent/switchConsentSection';
import {addClickHandlerOnce} from './utility/addClickHandlerOnce';
import {
backButtonSelector,
firstRequestFieldId,
nokButtonSelector,
tooltipsAndModalLabels,
} from './selectors';
Expand Down Expand Up @@ -68,4 +69,7 @@ export const idpSubmitHandler = (e) => {
submitForm(e);
};
export const cancelButtonClickHandler = (parentSection, noAccess) => cancelButtonClickHandlerCreator(parentSection, noAccess);
export const requestButtonHandler = () => { toggleFormFieldsAndButton(); };
export const requestButtonHandler = () => {
toggleFormFieldsAndButton();
document.getElementById(firstRequestFieldId).focus();
};
13 changes: 11 additions & 2 deletions theme/base/javascripts/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,19 @@ export const showMoreCheckboxId = 'showMoreCheckbox';
/***
* WAYF SELECTORS
* ***/
export const idpClass = 'wayf__idp';
export const idpSelector = `.${idpClass}`;
export const deletedAnnouncementId = 'deletedAnnouncement';
export const wayfPageSelector = 'main.wayf';
export const configurationId = 'wayf-configuration';
export const selectedIdpsSectionSelector = '.wayf__previousSelection';
export const selectedIdpsListSelector = '.wayf__previousSelection .wayf__idpList';
export const selectedIdpsLiSelector = `${selectedIdpsListSelector} > li`;
export const selectedIdpsFirstIdp = `${selectedIdpsSectionSelector} ${idpSelector}:first-of-type`;
export const previousSelectionFirstIdp = '.wayf__previousSelection li:first-of-type .wayf__idp';
export const deleteButtonTemplateId = 'deleteButton';
export const addAccountButtonClass = 'previousSelection__addAccount';
export const addAccountButtonSelector = `.${addAccountButtonClass}`;
export const idpClass = 'wayf__idp';
export const idpSelector = `.${idpClass}`;
export const idpTemplateSelector = 'idpTemplate';
export const selectedIdpsSelector = `${selectedIdpsSectionSelector} ${idpSelector}`;
export const unconnectedLiClass = 'idpItem--noAccess';
Expand All @@ -76,14 +78,17 @@ export const searchResetClass = 'search__reset';
export const searchResetSelector = `.${searchResetClass}`;
export const searchSubmitClass = 'search__submit';
export const searchSubmitSelector = `.${searchSubmitClass}`;
export const searchAnnouncementId = 'searchResultAnnouncement';
export const noAccessFieldsToValidy = ['name', 'email'];
export const noAccessSectionSelector = '.wayf__noAccess';
export const noAccessLi = '.wayf__idpList > li';
export const noAccessFormSelector = '.noAccess__requestForm';
export const requestFormAnnouncementId = 'requestFormAnnouncement';
export const noAccessFieldsetsSelector = '.noAccess__requestForm fieldset';
export const noAccessTitle = '.noAccess__title';
export const noAccessUnconnectableClass = 'wayf__noAccess--unconnectable';
export const noAccessConnectableClass = 'wayf__noAccess--connectable';
export const noAccessIdpTitleId = 'temp_clone';
export const formErrorClass = 'form__error';
export const succesMessageSelector = '.notification__success';
export const entityIdInputSelector = 'input[name="idpEntityId"]';
Expand All @@ -110,6 +115,7 @@ export const lastRemainingIdpAfterSearchSelector = '.wayf__remainingIdps .wayf__
export const noMatchSelector = '.wayf__remainingIdps .wayf__idp[data-weight="0"]';
export const matchSelector = '.wayf__remainingIdps .wayf__idp:not([data-weight="0"])';
export const toggleButtonClass = 'previousSelection__toggleLabel';
export const toggleButtonSelector = `.${toggleButtonClass}`;
export const editButtonClass = 'previousSelection__edit';
export const doneButtonClass = 'previousSelection__done';
export const previousSelectionTitleSelector = '.previousSelection__title';
Expand All @@ -118,3 +124,6 @@ export const noResultSectionSelector = '.wayf__noResults';
export const idpListSelector = '.wayf__idpList';
export const ariaPressedCheckboxSelector = 'input[aria-pressed]';
export const topId = 'top';
export const firstRequestFieldId = 'name';
export const requestAccessButtonClass = 'noAccess__connectable';
export const requestAccessButtonSelector = `.${requestAccessButtonClass}`;
12 changes: 12 additions & 0 deletions theme/base/javascripts/utility/hideElementNoTab.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {hideElement} from './hideElement';

/**
* Hide an element both visually and from keyboard users (taborder).
*
* @param element HTML Element the element to hide.
* @param visuallyOnly boolean whether to hide visually only or not
*/
export const hideElementNoTab = (element, visuallyOnly = false) => {
element.setAttribute('tabindex', '-1');
hideElement(element, visuallyOnly);
};
12 changes: 12 additions & 0 deletions theme/base/javascripts/utility/showElementAlsoTab.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import {showElement} from './showElement';

/**
* Show an element by removing the hidden class.
*
* @param element HTML Element the element to show.
* @param visuallyOnly boolean whether to show visually only or not
*/
export const showElementAlsoTab = (element, visuallyOnly = false) => {
element.removeAttribute('tabindex');
showElement(element, visuallyOnly);
};
35 changes: 33 additions & 2 deletions theme/base/javascripts/wayf/deleteDisable/deleteIdp.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import {savePreviousSelection} from './savePreviousSelection';
import {configurationId, idpSelector} from '../../selectors';
import {
configurationId,
deletedAnnouncementId,
idpDeleteDisabledSelector,
idpSelector,
selectedIdpsLiSelector,
toggleButtonSelector,
} from '../../selectors';
import * as Cookies from 'js-cookie';
import {getData} from '../../utility/getData';

Expand All @@ -12,6 +19,9 @@ export const deleteIdp = (element) => {
const cookieName = JSON.parse(document.getElementById(configurationId).innerHTML).previousSelectionCookieName;
const idp = element.closest(idpSelector);
const id = getData(idp, 'entityid');
const parent = idp.parentElement;
const title = getData(parent, 'title');
const parentIndex = parseInt(getData(parent, 'index'));
const cookie = JSON.parse(Cookies.get(cookieName));

cookie.forEach((idp, index) => {
Expand All @@ -23,5 +33,26 @@ export const deleteIdp = (element) => {
savePreviousSelection(cookie, cookieName);

// Remove deleted item from html
idp.closest('li').remove();
parent.remove();

// Announce delete to screenreaders
announceDeletedIdp(title);
moveFocus(parentIndex);
};

function announceDeletedIdp(title) {
const deletedAnnouncement = document.getElementById(deletedAnnouncementId);
const announcement = getData(deletedAnnouncement, 'announcement');
deletedAnnouncement.innerHTML = `${title}${announcement}`;
}

function moveFocus(index) {
const nextAccount = document.querySelector(`${selectedIdpsLiSelector}[data-index="${(index + 1)}"] ${idpDeleteDisabledSelector}`);

if (!!nextAccount) {
nextAccount.focus();
return;
}

document.querySelector(toggleButtonSelector).focus();
}
13 changes: 12 additions & 1 deletion theme/base/javascripts/wayf/handleClickingDisabledIdp.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {hideSuccessMessage} from './noAccess/hideSuccessMessage';
import {attachClickHandlerToRequestButton} from './noAccess/attachClickHandlerToRequestButton';
import {setConnectability} from './deleteDisable/setConnectability';
import {getData} from '../utility/getData';
import {idpClass, idpSelector, noAccessFormSelector, noAccessLi, noAccessSectionSelector} from '../selectors';
import {idpClass, idpDeleteDisabledSelector, idpFormSelector, idpSelector, noAccessFormSelector, noAccessIdpTitleId, noAccessLi, noAccessSectionSelector, noAccessTitle} from '../selectors';

export const handleClickingDisabledIdp = (element) => {
let target = element;
Expand All @@ -18,12 +18,23 @@ export const handleClickingDisabledIdp = (element) => {
target = element.closest(idpSelector);
}
const cloneOfIdp = cloneIdp(target);
// ensure clone is not tabbable as there's no action to be taken there
cloneOfIdp.setAttribute('tabindex', '-1');
// change titleText of clone to represent state of clone
cloneOfIdp.querySelector(`#${noAccessIdpTitleId}`).firstElementChild.innerHTML = getData(li, 'titlestart');
// remove form so the login button is not there
cloneOfIdp.querySelector(idpFormSelector).remove();
// hide disabled idp button from screenreaders
cloneOfIdp.querySelector(idpDeleteDisabledSelector).setAttribute('aria-hidden', 'true');
const connectable = getData(target, 'connectable') === 'true';

setConnectability(noAccess, connectable);
toggleVisibility(parentSection);
toggleVisibility(noAccess);

// set focus so the new content is announced to screenreaders
document.querySelector(noAccessTitle).focus();

// empty list-item & insert the clone
li.innerHTML = '';
li.insertAdjacentElement('afterbegin', cloneOfIdp);
Expand Down
2 changes: 1 addition & 1 deletion theme/base/javascripts/wayf/handleDeleteDisable.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export const handleDeleteDisable = (e) => {
return;
}

// Remove item from previous selection & html
// Remove item from previous selection & html + announce
deleteIdp(element);

// Reindex & SortRemaining idps by title
Expand Down
19 changes: 19 additions & 0 deletions theme/base/javascripts/wayf/mouseBehaviour.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ import {handleIdpBanner} from './handleIdpBanner';
import {
ariaPressedCheckboxSelector,
defaultIdpSelector,
idpDeleteDisabledSelector,
idpListSelector,
remainingIdpSectionSelector,
selectedIdpsFirstIdp,
toggleButtonSelector,
} from '../selectors';
import {idpSubmitHandler} from '../handlers';
import {checkHover} from './idpFocus/checkHover';
import {isVisibleElement} from '../utility/isVisibleElement';

export const mouseBehaviour = () => {
// allow chosing an idp to login
Expand All @@ -30,6 +34,21 @@ export const mouseBehaviour = () => {
const checkBoxes = document.querySelectorAll(ariaPressedCheckboxSelector);
handleAriaPressed(checkBoxes);

// add a11y support for toggleButton
const toggleButton = document.querySelector(toggleButtonSelector);
toggleButton.addEventListener('click', () => {
const firstIdp = document.querySelector(selectedIdpsFirstIdp);
setTimeout(() => {
const deleteButton = firstIdp.querySelector(idpDeleteDisabledSelector);
if (isVisibleElement(deleteButton)) {
deleteButton.focus();
return;
}

toggleButton.focus();
}, 100);
});

// handle clicking defaultIdp banner
const defaultIdpLink = document.querySelector(defaultIdpSelector);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import {showSuccessMessage} from './showSuccessMessage';
import {toggleErrorMessage} from './toggleErrorMessage';
import {toggleFormFieldsAndButton} from './toggleFormFieldsAndButton';
import {valid} from "./validation";
import {scrollToTop} from './scrollToTop';
import {requestFormAnnouncementId} from '../../selectors';

/**
* Ensure submitting the form is possible.
* On success:
* - hide error message if present
* - hide no access section
* - show success message
* - show success message & focus on it
* - hide form fields
* - hide submit button
* - show request button
Expand All @@ -36,6 +36,8 @@ export const attachClickHandlerToForm = (form, parentSection, noAccess) => {
return false;
}

document.getElementById(requestFormAnnouncementId).innerHTML = '';

fetch('/authentication/idp/performRequestAccess', {
method: 'POST',
body: formData,
Expand All @@ -50,7 +52,6 @@ export const attachClickHandlerToForm = (form, parentSection, noAccess) => {
toggleFormFieldsAndButton();
showSuccessMessage(parentSection, noAccess);
form.reset();
scrollToTop();
}).catch(function (error) {
toggleErrorMessage();
console.log(error);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {hideNoAccess} from '../../wayf/noAccess/hideNoAccess';
import {isHiddenElement} from '../../utility/isHiddenElement';
import {showFormSelector} from '../../selectors';
import {searchFieldSelector, showFormSelector} from '../../selectors';
import {toggleFormFieldsAndButton} from '../../wayf/noAccess/toggleFormFieldsAndButton';

/**
Expand All @@ -16,5 +16,6 @@ export const cancelButtonClickHandlerCreator = (parentSection, noAccess) => {
if (isHiddenElement(noAccess.querySelector(showFormSelector))) {
toggleFormFieldsAndButton();
}
document.querySelector(searchFieldSelector).focus();
};
};
7 changes: 4 additions & 3 deletions theme/base/javascripts/wayf/noAccess/hideNoAccess.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {toggleVisibility} from '../../utility/toggleVisibility';
import {hideElement} from '../../utility/hideElement';
import {showElement} from '../../utility/showElement';

/**
* Hide the noAccess section & show the Idp list
Expand All @@ -7,6 +8,6 @@ import {toggleVisibility} from '../../utility/toggleVisibility';
* @param noAccessSection
*/
export const hideNoAccess = (parentSection, noAccessSection) => {
toggleVisibility(noAccessSection);
toggleVisibility(parentSection);
hideElement(noAccessSection);
showElement(parentSection);
};
2 changes: 2 additions & 0 deletions theme/base/javascripts/wayf/noAccess/showSuccessMessage.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,6 @@ export const showSuccessMessage = (parentSection, noAccess) => {
if (isHiddenElement(successMessage)) {
toggleVisibility(successMessage);
}

successMessage.focus();
};
43 changes: 38 additions & 5 deletions theme/base/javascripts/wayf/noAccess/validation.js
Original file line number Diff line number Diff line change
@@ -1,31 +1,64 @@
import {nodeListToArray} from "../../utility/nodeListToArray";
import {formErrorClass, noAccessFieldsToValidy} from '../../selectors';
import {
formErrorClass,
noAccessFieldsToValidy,
requestFormAnnouncementId,
} from '../../selectors';
import {hideElement} from '../../utility/hideElement';
import {showElement} from '../../utility/showElement';
import {getData} from '../../utility/getData';

/**
* Verify the name and email fields are not empty. Due to the way the validation was set up we can
* not utilize the 'required' attribute on the form input fields. So a JS validation is required
* to prevent empty form submits.
* Verify the name and email fields are not empty. Due to the way the validation was set up we can not utilize the 'required' attribute on the form input fields (it's also not good for accessibility). So a JS validation is required to prevent empty form submits.
*
* Starts by hiding previous validation messages, they will reappear if the error persists.
* Starts by hiding previous validation messages & disassociating them from the inputs, they will reappear if the error persists.
*
* If error messages are shown: associates them with their input fields & moves focus to the first error message.
*
* @param formData
* @returns {boolean} true if all is valid, false otherwise
*/
export const valid = (formData) => {
let isValid = true;
const fieldsWithErrors = [];

hideValidationMessages();
removeErrorAssociations();
noAccessFieldsToValidy.forEach(fieldName => {
if (isAnEmptyField(formData, fieldName)) {
addErrorAssociation(fieldName);
isValid = false;
fieldsWithErrors.push(fieldName);
}
});

if (!isValid) {
fieldsWithErrors.sort().reverse();
addAnnouncement(fieldsWithErrors);
document.querySelector(`[name="${fieldsWithErrors[0]}"]`).focus();
}

return isValid;
};

function addAnnouncement(fieldsWithErrors) {
const requestFormAnnouncement = document.getElementById(requestFormAnnouncementId);
requestFormAnnouncement.innerHTML = getData(requestFormAnnouncement, 'announcement');
}

function removeErrorAssociations() {
removeErrorAssociation('[id="nameerror"]');
removeErrorAssociation('[id="emailerror"]');
}

function removeErrorAssociation(selector) {
document.querySelector(selector).removeAttribute('aria-describedby');
}

function addErrorAssociation(fieldName) {
document.querySelector(`[name="${fieldName}"]`).setAttribute('aria-describedby', `${fieldName}error`);
}

function isAnEmptyField(formData, elementName) {
const value = document.getElementById(elementName).value.trim();

Expand Down
Loading

0 comments on commit e7b253e

Please sign in to comment.