Bug 1560359 - Add keyboard support for login-list on about:logins. r=fluent-reviewers,sfoster,flod,yzen

Differential Revision: https://phabricator.services.mozilla.com/D35828

--HG--
extra : moz-landing-system : lando
This commit is contained in:
Jared Wein 2019-07-02 18:32:18 +00:00
Родитель 11fa357965
Коммит 4b5ce7176f
10 изменённых файлов: 178 добавлений и 7 удалений

Просмотреть файл

@ -27,7 +27,6 @@ login-filter {
login-list {
grid-area: logins;
overflow: hidden auto;
}
login-item {

Просмотреть файл

@ -18,6 +18,7 @@ login-filter =
.placeholder = Search Logins
login-list =
.aria-label = Logins matching search query
.count =
{ $count ->
[one] { $count } login

Просмотреть файл

@ -34,7 +34,8 @@
</header>
<login-list data-l10n-id="login-list"
data-l10n-args='{"count": 0}'
data-l10n-attrs="count,
data-l10n-attrs="aria-label,
count,
last-changed-option,
last-used-option,
missing-username,
@ -79,7 +80,7 @@
</label>
<span class="count"></span>
</div>
<ol>
<ol role="listbox" tabindex="0">
</ol>
</template>

Просмотреть файл

@ -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 {

Просмотреть файл

@ -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;
}

Просмотреть файл

@ -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);

Просмотреть файл

@ -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();

Просмотреть файл

@ -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 {

Просмотреть файл

@ -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);

Просмотреть файл

@ -7,6 +7,7 @@ Test the login-list component
<meta charset="utf-8">
<title>Test the login-list component</title>
<script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
<script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"></script>
<script type="module" src="chrome://browser/content/aboutlogins/components/login-list.js"></script>
<script src="aboutlogins_common.js"></script>
@ -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");