Login-page a11y improvements

Closes #27190

Signed-off-by: Peter Keuter <github@peterkeuter.nl>
This commit is contained in:
Peter Keuter 2024-02-28 15:25:12 +01:00 committed by GitHub
parent 788d146bf2
commit 9cced05049
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 347 additions and 15 deletions

View file

@ -11,7 +11,7 @@
<div class="${properties.kcFormGroupClass!}">
<label for="username" class="${properties.kcLabelClass!}"><#if !realm.loginWithEmailAllowed>${msg("username")}<#elseif !realm.registrationEmailAsUsername>${msg("usernameOrEmail")}<#else>${msg("email")}</#if></label>
<input tabindex="1" id="username" class="${properties.kcInputClass!}" name="username" value="${(login.username!'')}" type="text" autofocus autocomplete="off"
<input tabindex="2" id="username" class="${properties.kcInputClass!}" name="username" value="${(login.username!'')}" type="text" autofocus autocomplete="username"
aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>"
/>
@ -28,11 +28,11 @@
<label for="password" class="${properties.kcLabelClass!}">${msg("password")}</label>
<div class="${properties.kcInputGroup!}">
<input tabindex="2" id="password" class="${properties.kcInputClass!}" name="password" type="password" autocomplete="off"
<input tabindex="3" id="password" class="${properties.kcInputClass!}" name="password" type="password" autocomplete="current-password"
aria-invalid="<#if messagesPerField.existsError('username','password')>true</#if>"
/>
<button class="${properties.kcFormPasswordVisibilityButtonClass!}" type="button" aria-label="${msg("showPassword")}"
aria-controls="password" data-password-toggle
aria-controls="password" data-password-toggle tabindex="4"
data-icon-show="${properties.kcFormPasswordVisibilityIconShow!}" data-icon-hide="${properties.kcFormPasswordVisibilityIconHide!}"
data-label-show="${msg('showPassword')}" data-label-hide="${msg('hidePassword')}">
<i class="${properties.kcFormPasswordVisibilityIconShow!}" aria-hidden="true"></i>
@ -53,9 +53,9 @@
<div class="checkbox">
<label>
<#if login.rememberMe??>
<input tabindex="3" id="rememberMe" name="rememberMe" type="checkbox" checked> ${msg("rememberMe")}
<input tabindex="5" id="rememberMe" name="rememberMe" type="checkbox" checked> ${msg("rememberMe")}
<#else>
<input tabindex="3" id="rememberMe" name="rememberMe" type="checkbox"> ${msg("rememberMe")}
<input tabindex="5" id="rememberMe" name="rememberMe" type="checkbox"> ${msg("rememberMe")}
</#if>
</label>
</div>
@ -63,7 +63,7 @@
</div>
<div class="${properties.kcFormOptionsWrapperClass!}">
<#if realm.resetPasswordAllowed>
<span><a tabindex="5" href="${url.loginResetCredentialsUrl}">${msg("doForgotPassword")}</a></span>
<span><a tabindex="6" href="${url.loginResetCredentialsUrl}">${msg("doForgotPassword")}</a></span>
</#if>
</div>
@ -71,7 +71,7 @@
<div id="kc-form-buttons" class="${properties.kcFormGroupClass!}">
<input type="hidden" id="id-hidden-input" name="credentialId" <#if auth.selectedCredential?has_content>value="${auth.selectedCredential}"</#if>/>
<input tabindex="4" class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" name="login" id="kc-login" type="submit" value="${msg("doLogIn")}"/>
<input tabindex="7" class="${properties.kcButtonClass!} ${properties.kcButtonPrimaryClass!} ${properties.kcButtonBlockClass!} ${properties.kcButtonLargeClass!}" name="login" id="kc-login" type="submit" value="${msg("doLogIn")}"/>
</div>
</form>
</#if>
@ -82,7 +82,7 @@
<#if realm.password && realm.registrationAllowed && !registrationDisabled??>
<div id="kc-registration-container">
<div id="kc-registration">
<span>${msg("noAccount")} <a tabindex="6"
<span>${msg("noAccount")} <a tabindex="8"
href="${url.registrationUrl}">${msg("doRegister")}</a></span>
</div>
</div>
@ -91,7 +91,7 @@
<#if realm.password && social.providers??>
<div id="kc-social-providers" class="${properties.kcFormSocialAccountSectionClass!}">
<hr/>
<h4>${msg("identity-provider-login-label")}</h4>
<h2>${msg("identity-provider-login-label")}</h2>
<ul class="${properties.kcFormSocialAccountListClass!} <#if social.providers?size gt 3>${properties.kcFormSocialAccountListGridClass!}</#if>">
<#list social.providers as p>

View file

@ -0,0 +1,315 @@
// @ts-check
/*
* This content is licensed according to the W3C Software License at
* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document
*
* File: menu-button-links.js
*
* Desc: Creates a menu button that opens a menu of links
*
* Modified by Peter Keuter to adhere to the coding standards of Keycloak
* Original file: https://www.w3.org/WAI/content-assets/wai-aria-practices/patterns/menu-button/examples/js/menu-button-links.js
* Source: https://www.w3.org/TR/wai-aria-practices/examples/menu-button/menu-button-links.html
*/
class MenuButtonLinks {
constructor(domNode) {
this.domNode = domNode;
this.buttonNode = domNode.querySelector("button");
this.menuNode = domNode.querySelector('[role="menu"]');
this.menuitemNodes = [];
this.firstMenuitem = false;
this.lastMenuitem = false;
this.firstChars = [];
this.buttonNode.addEventListener("keydown", (e) => this.onButtonKeydown(e));
this.buttonNode.addEventListener("click", (e) => this.onButtonClick(e));
const nodes = domNode.querySelectorAll('[role="menuitem"]');
for (const menuitem of nodes) {
this.menuitemNodes.push(menuitem);
menuitem.tabIndex = -1;
this.firstChars.push(menuitem.textContent.trim()[0].toLowerCase());
menuitem.addEventListener("keydown", (e) => this.onMenuitemKeydown(e));
menuitem.addEventListener("mouseover", (e) =>
this.onMenuitemMouseover(e)
);
if (!this.firstMenuitem) {
this.firstMenuitem = menuitem;
}
this.lastMenuitem = menuitem;
}
domNode.addEventListener("focusin", () => this.onFocusin());
domNode.addEventListener("focusout", () => this.onFocusout());
window.addEventListener(
"mousedown",
(e) => this.onBackgroundMousedown(e),
true
);
}
setFocusToMenuitem = (newMenuitem) =>
this.menuitemNodes.forEach((item) => {
if (item === newMenuitem) {
item.tabIndex = 0;
newMenuitem.focus();
} else {
item.tabIndex = -1;
}
});
setFocusToFirstMenuitem = () => this.setFocusToMenuitem(this.firstMenuitem);
setFocusToLastMenuitem = () => this.setFocusToMenuitem(this.lastMenuitem);
setFocusToPreviousMenuitem = (currentMenuitem) => {
let newMenuitem, index;
if (currentMenuitem === this.firstMenuitem) {
newMenuitem = this.lastMenuitem;
} else {
index = this.menuitemNodes.indexOf(currentMenuitem);
newMenuitem = this.menuitemNodes[index - 1];
}
this.setFocusToMenuitem(newMenuitem);
return newMenuitem;
};
setFocusToNextMenuitem = (currentMenuitem) => {
let newMenuitem, index;
if (currentMenuitem === this.lastMenuitem) {
newMenuitem = this.firstMenuitem;
} else {
index = this.menuitemNodes.indexOf(currentMenuitem);
newMenuitem = this.menuitemNodes[index + 1];
}
this.setFocusToMenuitem(newMenuitem);
return newMenuitem;
};
setFocusByFirstCharacter = (currentMenuitem, char) => {
let start, index;
if (char.length > 1) {
return;
}
char = char.toLowerCase();
// Get start index for search based on position of currentItem
start = this.menuitemNodes.indexOf(currentMenuitem) + 1;
if (start >= this.menuitemNodes.length) {
start = 0;
}
// Check remaining slots in the menu
index = this.firstChars.indexOf(char, start);
// If not found in remaining slots, check from beginning
if (index === -1) {
index = this.firstChars.indexOf(char, 0);
}
// If match was found...
if (index > -1) {
this.setFocusToMenuitem(this.menuitemNodes[index]);
}
};
// Utilities
getIndexFirstChars = (startIndex, char) => {
for (let i = startIndex; i < this.firstChars.length; i++) {
if (char === this.firstChars[i]) {
return i;
}
}
return -1;
};
// Popup menu methods
openPopup = () => {
this.menuNode.style.display = "block";
this.buttonNode.setAttribute("aria-expanded", "true");
};
closePopup = () => {
if (this.isOpen()) {
this.buttonNode.setAttribute("aria-expanded", "false");
this.menuNode.style.removeProperty("display");
}
};
isOpen = () => {
return this.buttonNode.getAttribute("aria-expanded") === "true";
};
// Menu event handlers
onFocusin = () => {
this.domNode.classList.add("focus");
};
onFocusout = () => {
this.domNode.classList.remove("focus");
};
onButtonKeydown = (event) => {
const key = event.key;
let flag = false;
switch (key) {
case " ":
case "Enter":
case "ArrowDown":
case "Down":
this.openPopup();
this.setFocusToFirstMenuitem();
flag = true;
break;
case "Esc":
case "Escape":
this.closePopup();
this.buttonNode.focus();
flag = true;
break;
case "Up":
case "ArrowUp":
this.openPopup();
this.setFocusToLastMenuitem();
flag = true;
break;
default:
break;
}
if (flag) {
event.stopPropagation();
event.preventDefault();
}
};
onButtonClick(event) {
if (this.isOpen()) {
this.closePopup();
this.buttonNode.focus();
} else {
this.openPopup();
this.setFocusToFirstMenuitem();
}
event.stopPropagation();
event.preventDefault();
}
onMenuitemKeydown(event) {
const tgt = event.currentTarget;
const key = event.key;
let flag = false;
const isPrintableCharacter = (str) => str.length === 1 && str.match(/\S/);
if (event.ctrlKey || event.altKey || event.metaKey) {
return;
}
if (event.shiftKey) {
if (isPrintableCharacter(key)) {
this.setFocusByFirstCharacter(tgt, key);
flag = true;
}
if (event.key === "Tab") {
this.buttonNode.focus();
this.closePopup();
flag = true;
}
} else {
switch (key) {
case " ":
window.location.href = tgt.href;
break;
case "Esc":
case "Escape":
this.closePopup();
this.buttonNode.focus();
flag = true;
break;
case "Up":
case "ArrowUp":
this.setFocusToPreviousMenuitem(tgt);
flag = true;
break;
case "ArrowDown":
case "Down":
this.setFocusToNextMenuitem(tgt);
flag = true;
break;
case "Home":
case "PageUp":
this.setFocusToFirstMenuitem();
flag = true;
break;
case "End":
case "PageDown":
this.setFocusToLastMenuitem();
flag = true;
break;
case "Tab":
this.closePopup();
break;
default:
if (isPrintableCharacter(key)) {
this.setFocusByFirstCharacter(tgt, key);
flag = true;
}
break;
}
}
if (flag) {
event.stopPropagation();
event.preventDefault();
}
}
onMenuitemMouseover(event) {
const tgt = event.currentTarget;
tgt.focus();
}
onBackgroundMousedown(event) {
if (!this.domNode.contains(event.target)) {
if (this.isOpen()) {
this.closePopup();
this.buttonNode.focus();
}
}
}
}
const menuButtons = document.querySelectorAll(".menu-button-links");
for (const button of menuButtons) {
new MenuButtonLinks(button);
}

View file

@ -29,6 +29,7 @@
<script src="${url.resourcesPath}/${script}" type="text/javascript"></script>
</#list>
</#if>
<script src="${url.resourcesPath}/js/menu-button-links.js" type="module"></script>
<#if scripts??>
<#list scripts as script>
<script src="${script}" type="text/javascript"></script>
@ -58,13 +59,15 @@
<#if realm.internationalizationEnabled && locale.supported?size gt 1>
<div class="${properties.kcLocaleMainClass!}" id="kc-locale">
<div id="kc-locale-wrapper" class="${properties.kcLocaleWrapperClass!}">
<div id="kc-locale-dropdown" class="${properties.kcLocaleDropDownClass!}">
<a href="#" id="kc-current-locale-link">${locale.current}</a>
<ul class="${properties.kcLocaleListClass!}">
<div id="kc-locale-dropdown" class="menu-button-links ${properties.kcLocaleDropDownClass!}">
<button tabindex="1" id="kc-current-locale-link" aria-label="${msg("languages")}" aria-haspopup="true" aria-expanded="false" aria-controls="language-switch1">${locale.current}</button>
<ul role="menu" tabindex="-1" aria-labelledby="kc-current-locale-link" aria-activedescendant="" id="language-switch1" class="${properties.kcLocaleListClass!}">
<#assign i = 1>
<#list locale.supported as l>
<li class="${properties.kcLocaleListItemClass!}">
<a class="${properties.kcLocaleItemClass!}" href="${l.url}">${l.label}</a>
<li class="${properties.kcLocaleListItemClass!}" role="none">
<a role="menuitem" id="language-${i}" class="${properties.kcLocaleItemClass!}" href="${l.url}">${l.label}</a>
</li>
<#assign i++>
</#list>
</ul>
</div>

View file

@ -53,7 +53,17 @@ h1#kc-page-title {
font-size: var(--pf-global--FontSize--sm);
}
a#kc-current-locale-link::after {
#kc-locale-dropdown button {
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--pf-global--Color--200);
text-align: right;
font-size: var(--pf-global--FontSize--sm);
}
button#kc-current-locale-link::after {
content: "\2c5";
margin-left: var(--pf-global--spacer--xs)
}
@ -274,6 +284,10 @@ ul#kc-totp-supported-apps {
color: var(--pf-global--Color--200);
}
.kc-social-gray h2 {
font-size: 1em;
}
.kc-social-item {
margin-bottom: var(--pf-global--spacer--sm);
font-size: 15px;