From 9cced050491b8db80be19ed6c419d3cb6eb4ba08 Mon Sep 17 00:00:00 2001 From: Peter Keuter Date: Wed, 28 Feb 2024 15:25:12 +0100 Subject: [PATCH] Login-page a11y improvements Closes #27190 Signed-off-by: Peter Keuter --- .../main/resources/theme/base/login/login.ftl | 18 +- .../login/resources/js/menu-button-links.js | 315 ++++++++++++++++++ .../resources/theme/base/login/template.ftl | 13 +- .../keycloak/login/resources/css/login.css | 16 +- 4 files changed, 347 insertions(+), 15 deletions(-) create mode 100644 themes/src/main/resources/theme/base/login/resources/js/menu-button-links.js diff --git a/themes/src/main/resources/theme/base/login/login.ftl b/themes/src/main/resources/theme/base/login/login.ftl index 81c9dbf144..d6008e0e4b 100755 --- a/themes/src/main/resources/theme/base/login/login.ftl +++ b/themes/src/main/resources/theme/base/login/login.ftl @@ -11,7 +11,7 @@
- @@ -28,11 +28,11 @@
-
<#if realm.resetPasswordAllowed> - ${msg("doForgotPassword")} + ${msg("doForgotPassword")}
@@ -71,7 +71,7 @@
value="${auth.selectedCredential}"/> - +
@@ -82,7 +82,7 @@ <#if realm.password && realm.registrationAllowed && !registrationDisabled??> @@ -91,7 +91,7 @@ <#if realm.password && social.providers??>

-

${msg("identity-provider-login-label")}

+

${msg("identity-provider-login-label")}

    <#list social.providers as p> diff --git a/themes/src/main/resources/theme/base/login/resources/js/menu-button-links.js b/themes/src/main/resources/theme/base/login/resources/js/menu-button-links.js new file mode 100644 index 0000000000..0fd79f0f68 --- /dev/null +++ b/themes/src/main/resources/theme/base/login/resources/js/menu-button-links.js @@ -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); +} diff --git a/themes/src/main/resources/theme/base/login/template.ftl b/themes/src/main/resources/theme/base/login/template.ftl index d28cf40eb4..a70b230b8f 100644 --- a/themes/src/main/resources/theme/base/login/template.ftl +++ b/themes/src/main/resources/theme/base/login/template.ftl @@ -29,6 +29,7 @@ + <#if scripts??> <#list scripts as script> @@ -58,13 +59,15 @@ <#if realm.internationalizationEnabled && locale.supported?size gt 1>
    -
    - ${locale.current} -
      + diff --git a/themes/src/main/resources/theme/keycloak/login/resources/css/login.css b/themes/src/main/resources/theme/keycloak/login/resources/css/login.css index 90b4d8dfc3..b9ce1e573c 100644 --- a/themes/src/main/resources/theme/keycloak/login/resources/css/login.css +++ b/themes/src/main/resources/theme/keycloak/login/resources/css/login.css @@ -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;