diff --git a/browser/components/aboutlogins/content/aboutLogins.css b/browser/components/aboutlogins/content/aboutLogins.css index fbc2a50d909c..830df54a1165 100644 --- a/browser/components/aboutlogins/content/aboutLogins.css +++ b/browser/components/aboutlogins/content/aboutLogins.css @@ -27,7 +27,6 @@ login-filter { login-list { grid-area: logins; - overflow: hidden auto; } login-item { diff --git a/browser/components/aboutlogins/content/aboutLogins.ftl b/browser/components/aboutlogins/content/aboutLogins.ftl index 30bbb96d2f04..843334c3790b 100644 --- a/browser/components/aboutlogins/content/aboutLogins.ftl +++ b/browser/components/aboutlogins/content/aboutLogins.ftl @@ -18,6 +18,7 @@ login-filter = .placeholder = Search Logins login-list = + .aria-label = Logins matching search query .count = { $count -> [one] { $count } login diff --git a/browser/components/aboutlogins/content/aboutLogins.html b/browser/components/aboutlogins/content/aboutLogins.html index 42a2af0bd620..b8a9f601e7d3 100644 --- a/browser/components/aboutlogins/content/aboutLogins.html +++ b/browser/components/aboutlogins/content/aboutLogins.html @@ -34,7 +34,8 @@ -
    +
    diff --git a/browser/components/aboutlogins/content/components/login-item.css b/browser/components/aboutlogins/content/components/login-item.css index 7198041bcd9b..1042becbc6e3 100644 --- a/browser/components/aboutlogins/content/components/login-item.css +++ b/browser/components/aboutlogins/content/components/login-item.css @@ -130,6 +130,16 @@ background-image: url("chrome://browser/content/aboutlogins/icons/hide-password.svg") !important; } +.reveal-password-checkbox:-moz-focusring { + outline: 2px solid var(--in-content-border-active); + /* offset outline to align with 1px border-width set for buttons/menulists above. */ + outline-offset: -1px; + /* Make outline-radius slightly bigger than the border-radius set above, + * to make the thicker outline corners look smooth */ + -moz-outline-radius: 3px; + box-shadow: 0 0 0 4px var(--in-content-border-active-shadow); +} + @supports -moz-bool-pref("browser.in-content.dark-mode") { @media (prefers-color-scheme: dark) { :host { diff --git a/browser/components/aboutlogins/content/components/login-item.js b/browser/components/aboutlogins/content/components/login-item.js index bad7694887fc..c89768644435 100644 --- a/browser/components/aboutlogins/content/components/login-item.js +++ b/browser/components/aboutlogins/content/components/login-item.js @@ -253,6 +253,7 @@ export default class LoginItem extends ReflectedFluentElement { this._revealCheckbox.checked = false; + this._editButton.focus(); this.render(); } @@ -364,11 +365,16 @@ export default class LoginItem extends ReflectedFluentElement { this._deleteButton.disabled = this.dataset.isNewLogin; this._editButton.disabled = shouldEdit; - this._originInput.readOnly = !this.dataset.isNewLogin; + let inputTabIndex = !shouldEdit ? -1 : 0; + this._originInput.readOnly = !shouldEdit; + this._originInput.tabIndex = inputTabIndex; this._usernameInput.readOnly = !shouldEdit; + this._usernameInput.tabIndex = inputTabIndex; this._passwordInput.readOnly = !shouldEdit; + this._passwordInput.tabIndex = inputTabIndex; if (shouldEdit) { this.dataset.editing = true; + this._originInput.focus(); } else { delete this.dataset.editing; } diff --git a/browser/components/aboutlogins/content/components/login-list-item.css b/browser/components/aboutlogins/content/components/login-list-item.css index fcb3234bdf30..0989f26b98f8 100644 --- a/browser/components/aboutlogins/content/components/login-list-item.css +++ b/browser/components/aboutlogins/content/components/login-list-item.css @@ -24,6 +24,11 @@ background-color: var(--in-content-box-background-active); } +:host(.keyboard-selected) { + border-inline-start-color: var(--in-content-border-active-shadow); + background-color: var(--in-content-box-background-odd); +} + :host(.selected) { border-inline-start-color: var(--in-content-border-highlight); background-color: var(--in-content-box-background-hover); diff --git a/browser/components/aboutlogins/content/components/login-list-item.js b/browser/components/aboutlogins/content/components/login-list-item.js index 5ff56191f3c2..57fe92e0d479 100644 --- a/browser/components/aboutlogins/content/components/login-list-item.js +++ b/browser/components/aboutlogins/content/components/login-list-item.js @@ -8,6 +8,10 @@ export default class LoginListItem extends HTMLElement { constructor(login) { super(); this._login = login; + this.id = login.guid ? + // Prepend the ID with a string since IDs must not begin with a number. + "lli-" + this._login.guid : + "new-login-list-item"; } connectedCallback() { @@ -22,6 +26,7 @@ export default class LoginListItem extends HTMLElement { this._title = this.shadowRoot.querySelector(".title"); this._username = this.shadowRoot.querySelector(".username"); + this.setAttribute("role", "option"); this.render(); diff --git a/browser/components/aboutlogins/content/components/login-list.css b/browser/components/aboutlogins/content/components/login-list.css index f634976713dd..96a32176cc7a 100644 --- a/browser/components/aboutlogins/content/components/login-list.css +++ b/browser/components/aboutlogins/content/components/login-list.css @@ -16,8 +16,6 @@ padding: 10px 18px; border-bottom: 1px solid var(--in-content-box-border-color); background-color: var(--in-content-box-info-background); - position: sticky; - top: 0; } .count { diff --git a/browser/components/aboutlogins/content/components/login-list.js b/browser/components/aboutlogins/content/components/login-list.js index 1dee4746360b..87a9979618a9 100644 --- a/browser/components/aboutlogins/content/components/login-list.js +++ b/browser/components/aboutlogins/content/components/login-list.js @@ -37,6 +37,7 @@ export default class LoginList extends ReflectedFluentElement { .addEventListener("change", this); window.addEventListener("AboutLoginsLoginSelected", this); window.addEventListener("AboutLoginsFilterLogins", this); + this.addEventListener("keydown", this); super.connectedCallback(); } @@ -51,6 +52,8 @@ export default class LoginList extends ReflectedFluentElement { if (!this._selectedGuid) { this._blankLoginListItem.classList.add("selected"); + this._blankLoginListItem.setAttribute("aria-selected", "true"); + this._list.setAttribute("aria-activedescendant", this._blankLoginListItem.id); this._list.append(this._blankLoginListItem); } @@ -59,6 +62,8 @@ export default class LoginList extends ReflectedFluentElement { listItem.setAttribute("missing-username", this.getAttribute("missing-username")); if (login.guid == this._selectedGuid) { listItem.classList.add("selected"); + listItem.setAttribute("aria-selected", "true"); + this._list.setAttribute("aria-activedescendant", listItem.id); } this._list.append(listItem); } @@ -89,11 +94,16 @@ export default class LoginList extends ReflectedFluentElement { this.render(); break; } + case "keydown": { + this._handleKeyboardNav(event); + break; + } } } static get reflectedFluentIDs() { - return ["count", + return ["aria-label", + "count", "last-used-option", "last-changed-option", "missing-username", @@ -109,6 +119,10 @@ export default class LoginList extends ReflectedFluentElement { handleSpecialCaseFluentString(attrName) { switch (attrName) { + case "aria-label": { + this._list.setAttribute("aria-label", this.getAttribute(attrName)); + break; + } case "missing-username": { break; } @@ -194,5 +208,84 @@ export default class LoginList extends ReflectedFluentElement { return matchingLoginGuids.length; } + + _handleKeyboardNav(event) { + if (this._list != this.shadowRoot.activeElement) { + return; + } + + let isLTR = document.dir == "ltr"; + let activeDescendantId = this._list.getAttribute("aria-activedescendant"); + let activeDescendant = activeDescendantId ? + this.shadowRoot.getElementById(activeDescendantId) : + this._list.firstElementChild; + let newlyFocusedItem = null; + switch (event.key) { + case "ArrowDown": { + let nextItem = activeDescendant.nextElementSibling; + if (!nextItem) { + return; + } + newlyFocusedItem = nextItem; + break; + } + case "ArrowLeft": { + let item = isLTR ? + activeDescendant.previousElementSibling : + activeDescendant.nextElementSibling; + if (!item) { + return; + } + newlyFocusedItem = item; + break; + } + case "ArrowRight": { + let item = isLTR ? + activeDescendant.nextElementSibling : + activeDescendant.previousElementSibling; + if (!item) { + return; + } + newlyFocusedItem = item; + break; + } + case "ArrowUp": { + let previousItem = activeDescendant.previousElementSibling; + if (!previousItem) { + return; + } + newlyFocusedItem = previousItem; + break; + } + case "Tab": { + // Bug 1562716: Pressing Tab from the login-list cycles back to the + // login-sort dropdown due to the login-list having `overflow` + // CSS property set. Explicitly forward focus here until + // this keyboard trap is fixed. + if (event.shiftKey) { + return; + } + let loginItem = document.querySelector("login-item"); + if (loginItem) { + event.preventDefault(); + loginItem.shadowRoot.querySelector(".edit-button").focus(); + } + return; + } + case " ": + case "Enter": { + event.preventDefault(); + activeDescendant.click(); + return; + } + default: + return; + } + event.preventDefault(); + this._list.setAttribute("aria-activedescendant", newlyFocusedItem.id); + activeDescendant.classList.remove("keyboard-selected"); + newlyFocusedItem.classList.add("keyboard-selected"); + newlyFocusedItem.scrollIntoView(false); + } } customElements.define("login-list", LoginList); diff --git a/browser/components/aboutlogins/tests/chrome/test_login_list.html b/browser/components/aboutlogins/tests/chrome/test_login_list.html index 6eb81cbac524..466b86aa292a 100644 --- a/browser/components/aboutlogins/tests/chrome/test_login_list.html +++ b/browser/components/aboutlogins/tests/chrome/test_login_list.html @@ -7,6 +7,7 @@ Test the login-list component Test the login-list component + @@ -74,7 +75,59 @@ add_task(async function test_empty_list() { is(gLoginList.textContent, "", "Initially empty"); }); +add_task(async function test_keyboard_navigation() { + gLoginList.setLogins([TEST_LOGIN_1, TEST_LOGIN_2, TEST_LOGIN_3]); + + while (document.activeElement != gLoginList) { + sendKey("TAB"); + await new Promise(resolve => requestAnimationFrame(resolve)); + } + + sendKey("TAB"); + sendKey("TAB"); + let loginSort = gLoginList.shadowRoot.querySelector("#login-sort"); + await SimpleTest.promiseWaitForCondition(() => loginSort == gLoginList.shadowRoot.activeElement, + "waiting for login-sort to get focus"); + ok(loginSort == gLoginList.shadowRoot.activeElement, "#login-sort should be focused after tabbing to it"); + + sendKey("TAB"); + let ol = gLoginList.shadowRoot.querySelector("ol"); + await SimpleTest.promiseWaitForCondition(() => ol.matches(":focus"), + "waiting for 'ol' to get focus"); + ok(ol.matches(":focus"), "'ol' should be focused after tabbing to it"); + + for (let [keyFwd, keyRev] of [["LEFT", "RIGHT"], ["DOWN", "UP"]]) { + sendKey(keyFwd); + await SimpleTest.promiseWaitForCondition(() => ol.getAttribute("aria-activedescendant") == ol.children[1].id, + `waiting for second item in list to get focused (${keyFwd})`); + ok(ol.children[1].classList.contains("keyboard-selected"), `second item should be marked as keyboard-selected (${keyFwd})`); + + sendKey(keyRev); + await SimpleTest.promiseWaitForCondition(() => ol.getAttribute("aria-activedescendant") == ol.children[0].id, + `waiting for first item in list to get focused (${keyRev})`); + ok(ol.children[0].classList.contains("keyboard-selected"), `first item should be marked as keyboard-selected (${keyRev})`); + } + + sendKey("DOWN"); + await SimpleTest.promiseWaitForCondition(() => ol.getAttribute("aria-activedescendant") == ol.children[1].id, + `waiting for second item in list to get focused (DOWN)`); + ok(ol.children[1].classList.contains("keyboard-selected"), `second item should be marked as keyboard-selected (DOWN)`); + let selectedGuid = ol.children[1].dataset.guid; + + let loginSelectedEvent = null; + gLoginList.addEventListener("AboutLoginsLoginSelected", event => loginSelectedEvent = event, {once: true}); + sendKey("RETURN"); + is(ol.querySelector(".selected").dataset.guid, selectedGuid, "item should be marked as selected"); + ok(loginSelectedEvent, "AboutLoginsLoginSelected event should be dispatched on pressing Enter"); + is(loginSelectedEvent.detail.guid, selectedGuid, "event should have expected login attached"); +}); + add_task(async function test_empty_login_username_in_list() { + // Clear the selection so the 'new' login will be in the list too. + window.dispatchEvent(new CustomEvent("AboutLoginsLoginSelected", { + detail: {}, + })); + gLoginList.setLogins([TEST_LOGIN_3]); let loginListItems = gLoginList.shadowRoot.querySelectorAll("login-list-item"); is(loginListItems.length, 2, "A blank login and the one stored login should be displayed");