зеркало из https://github.com/mozilla/gecko-dev.git
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:
Родитель
11fa357965
Коммит
4b5ce7176f
|
@ -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");
|
||||
|
|
Загрузка…
Ссылка в новой задаче