Bug 1751978 - Add multiselection keyboard controls to treeview-listbox. r=darktrojan

We also fix various related accessibility problems with the treeview-listbox.

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

--HG--
extra : amend_source : fbd3db0931320065845c193a85f1a74b0a0e3491
This commit is contained in:
Henry Wilkes 2022-06-21 13:23:14 +03:00
Родитель b1f9783a25
Коммит dde93a8df6
5 изменённых файлов: 279 добавлений и 88 удалений

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

@ -917,6 +917,50 @@
this.shadowRoot.appendChild(this.filler);
this.shadowRoot.appendChild(document.createElement("slot"));
this.setAttribute("aria-multiselectable", "true");
this.addEventListener("focus", event => {
if (this._preventFocusHandler) {
this._preventFocusHandler = false;
return;
}
if (this.currentIndex == -1 && this._view.rowCount) {
let selectionChanged = false;
if (this.selectedIndex == -1) {
this._selection.select(0);
selectionChanged = true;
}
this.currentIndex = this.selectedIndex;
if (selectionChanged) {
this.dispatchEvent(new CustomEvent("select"));
}
}
});
this.addEventListener("mousedown", event => {
if (
this == document.activeElement ||
!event.target.closest(this._rowElementName)
) {
return;
}
// We prevent the focus handler because it can change the selection
// state, which currently rebuilds the view. If this happens the mouseup
// event will be on a different element, which means it will not receive
// the "click" event.
// Instead, we let the click handler change the selection state instead
// of the focus handler.
// Ideally, instead of this hack, we would not rebuild the view when
// just the selection changes since it should be a light operation.
this._preventFocusHandler = true;
// We expect the property to be cleared in the focus handler, because
// the default mousedown will invoke it, but we clear the property at
// the next loop just in case.
setTimeout(() => {
this._preventFocusHandler = false;
});
});
this.addEventListener("click", event => {
if (event.button !== 0) {
return;
@ -945,64 +989,73 @@
return;
}
if (event.ctrlKey && event.shiftKey) {
return;
}
if (event.ctrlKey) {
this.currentIndex = index;
this.toggleSelectionAtIndex(index);
this._toggleSelected(index);
} else if (event.shiftKey) {
this._selection.rangedSelect(-1, index, false);
this.scrollToIndex(index);
this.dispatchEvent(new CustomEvent("select"));
this._selectRange(index);
} else {
this.selectedIndex = index;
this._selectSingle(index);
}
});
this.addEventListener("keydown", event => {
if (event.altKey || event.ctrlKey || event.metaKey) {
if (event.altKey || event.metaKey) {
return;
}
let newIndex = this.currentIndex;
let currentIndex = this.currentIndex == -1 ? 0 : this.currentIndex;
let newIndex;
switch (event.key) {
case "ArrowUp":
newIndex = this.currentIndex - 1;
newIndex = currentIndex - 1;
break;
case "ArrowDown":
newIndex = this.currentIndex + 1;
newIndex = currentIndex + 1;
break;
case "ArrowLeft":
case "ArrowRight": {
event.preventDefault();
if (this.currentIndex == -1) {
return;
}
let isArrowRight = event.key == "ArrowRight";
let isRTL = this.matches(":dir(rtl)");
if (isArrowRight == isRTL) {
// Collapse action.
let currentLevel = this._view.getLevel(newIndex);
if (this._view.isContainerOpen(newIndex)) {
this.collapseRowAtIndex(newIndex);
let currentLevel = this._view.getLevel(this.currentIndex);
if (this._view.isContainerOpen(this.currentIndex)) {
this.collapseRowAtIndex(this.currentIndex);
return;
} else if (currentLevel == 0) {
return;
}
let parentIndex = this._view.getParentIndex(newIndex);
let parentIndex = this._view.getParentIndex(this.currentIndex);
if (parentIndex != -1) {
this.selectedIndex = parentIndex;
newIndex = parentIndex;
}
} else if (this._view.isContainer(newIndex)) {
} else if (this._view.isContainer(this.currentIndex)) {
// Expand action.
if (!this._view.isContainerOpen(newIndex)) {
let addedRows = this.expandRowAtIndex(newIndex);
if (!this._view.isContainerOpen(this.currentIndex)) {
let addedRows = this.expandRowAtIndex(this.currentIndex);
this.scrollToIndex(
newIndex +
this.currentIndex +
Math.min(
addedRows,
this.clientHeight / this._rowElementClass.ROW_HEIGHT - 1
)
);
} else {
this.selectedIndex++;
newIndex = this.currentIndex + 1;
}
}
if (newIndex != undefined) {
this._selectSingle(newIndex);
}
return;
}
case "Home":
@ -1014,35 +1067,46 @@
case "PageUp":
newIndex = Math.max(
0,
this.currentIndex -
currentIndex -
Math.floor(this.clientHeight / this._rowElementClass.ROW_HEIGHT)
);
break;
case "PageDown":
newIndex = Math.min(
this._view.rowCount - 1,
this.currentIndex +
currentIndex +
Math.floor(this.clientHeight / this._rowElementClass.ROW_HEIGHT)
);
break;
case " ":
if (event.originalTarget.closest("button")) {
return;
}
break;
default:
return;
}
newIndex = this._clampIndex(newIndex);
if (event.shiftKey) {
this._selection.rangedSelect(-1, newIndex, false);
this.scrollToIndex(newIndex);
this.dispatchEvent(new CustomEvent("select"));
} else {
this.selectedIndex = newIndex;
if (newIndex != undefined) {
newIndex = this._clampIndex(newIndex);
if (newIndex != null && (!event.ctrlKey || !event.shiftKey)) {
// Else, if both modifiers pressed, do nothing.
if (event.shiftKey) {
this._selectRange(newIndex);
} else if (event.ctrlKey) {
// Change focus, but not selection.
this.currentIndex = newIndex;
} else {
this._selectSingle(newIndex);
}
}
event.preventDefault();
return;
}
if (event.key == " ") {
if (this.currentIndex != -1 && !event.shiftKey) {
if (event.ctrlKey) {
this._toggleSelected(this.currentIndex);
} else {
this._selectSingle(this.currentIndex);
}
}
event.preventDefault();
}
event.preventDefault();
});
let lastTime = 0;
@ -1139,10 +1203,6 @@
throw ex;
}
}
if (view.rowCount && this.currentIndex == -1) {
this.currentIndex = 0;
}
}
this.invalidate();
@ -1296,6 +1356,9 @@
* @return {integer}
*/
_clampIndex(index) {
if (!this._view.rowCount) {
return null;
}
if (index < 0) {
return 0;
}
@ -1445,6 +1508,7 @@
this._selection.currentIndex = index;
if (index < 0 || index > this._view.rowCount - 1) {
this.removeAttribute("aria-activedescendant");
return;
}
@ -1453,6 +1517,48 @@
this.setAttribute("aria-activedescendant", `${this.id}-row${index}`);
}
/**
* Select and focus the given index.
*
* @param {number} index - The index to select.
*/
_selectSingle(index) {
let changeSelection =
this._selection.count != 1 || !this._selection.isSelected(index);
if (changeSelection) {
this._selection.select(index);
}
this.currentIndex = index;
if (changeSelection) {
this.dispatchEvent(new CustomEvent("select"));
}
}
/**
* Start or extend a range selection to the given index and focus it.
*
* @param {number} index - The index to select.
*/
_selectRange(index) {
this._selection.rangedSelect(-1, index, false);
this.currentIndex = index;
this.dispatchEvent(new CustomEvent("select"));
}
/**
* Toggle the selection state at the given index and focus it.
*
* @param {number} index - The index to toggle.
*/
_toggleSelected(index) {
this._selection.toggleSelect(index);
// We hack the internals of the JSTreeSelection to clear the
// shiftSelectPivot.
this._selection._shiftSelectPivot = null;
this.currentIndex = index;
this.dispatchEvent(new CustomEvent("select"));
}
/**
* In a selection, index of the most-recently-selected row.
*
@ -1469,13 +1575,7 @@
}
set selectedIndex(index) {
if (this._selection.count == 1 && this._selection.isSelected(index)) {
return;
}
this._selection.select(index);
this.currentIndex = index;
this.dispatchEvent(new CustomEvent("select"));
this._selectSingle(index);
}
/**
@ -1565,6 +1665,7 @@
this.list = this.parentNode;
this.view = this.list.view;
this.setAttribute("aria-selected", !!this.selected);
}
/**
@ -1591,13 +1692,8 @@
}
set selected(selected) {
this.setAttribute("aria-selected", selected);
this.setAttribute("aria-selected", !!selected);
this.classList.toggle("selected", !!selected);
// Throw focus back to the list if something in this row had it.
if (!selected && document.activeElement == this) {
this.list.focus();
}
}
}
customElements.define("tree-view-listrow", TreeViewListrow);

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

@ -135,7 +135,7 @@ async function subtestKeyboardAndMouse() {
}
checkCurrent(0);
checkSelected();
checkSelected(0);
// Click on some individual rows.
@ -143,7 +143,7 @@ async function subtestKeyboardAndMouse() {
"resource://testing-common/TestUtils.jsm"
);
async function clickOnRow(index, modifiers = {}) {
async function clickOnRow(index, modifiers = {}, expectEvent = true) {
if (modifiers.shiftKey) {
info(`clicking on row ${index} with shift key`);
} else if (modifiers.ctrlKey) {
@ -160,12 +160,12 @@ async function subtestKeyboardAndMouse() {
list.addEventListener("select", selectHandler, { once: true });
EventUtils.synthesizeMouse(list, x, y, modifiers, content);
await TestUtils.waitForCondition(
() => selectHandler.seenEvent,
"'select' event did not get fired"
() => !!selectHandler.seenEvent == expectEvent,
`'select' event should ${expectEvent ? "" : "not "}get fired`
);
}
await clickOnRow(0);
await clickOnRow(0, {}, false);
checkCurrent(0);
checkSelected(0);
@ -206,15 +206,15 @@ async function subtestKeyboardAndMouse() {
checkSelected(1, 2, 5);
await clickOnRow(5, { ctrlKey: true });
checkCurrent(5); // Is this right?
checkCurrent(5);
checkSelected(1, 2);
await clickOnRow(1, { ctrlKey: true });
checkCurrent(1); // Is this right?
checkCurrent(1);
checkSelected(2);
await clickOnRow(2, { ctrlKey: true });
checkCurrent(2); // Is this right?
checkCurrent(2);
checkSelected();
// Move around by pressing keys.
@ -231,7 +231,7 @@ async function subtestKeyboardAndMouse() {
EventUtils.synthesizeKey(key, modifiers, content);
await TestUtils.waitForCondition(
() => !!selectHandler.seenEvent == expectEvent,
`'select' event ${expectEvent ? "fired" : "did not fire"} as expected`
`'select' event should ${expectEvent ? "" : "not "}get fired`
);
}
@ -243,32 +243,112 @@ async function subtestKeyboardAndMouse() {
checkCurrent(1);
checkSelected(1);
pressKey("VK_UP");
await pressKey("VK_UP", { ctrlKey: true }, false);
checkCurrent(0);
checkSelected(1);
// Without Ctrl selection moves with focus again.
await pressKey("VK_UP");
checkCurrent(0);
checkSelected(0);
// Does nothing.
pressKey("VK_UP", undefined, false);
await pressKey("VK_UP", {}, false);
checkCurrent(0);
checkSelected(0);
await pressKey("VK_DOWN", { ctrlKey: true }, false);
checkCurrent(1);
checkSelected(0);
await pressKey("VK_DOWN", { ctrlKey: true }, false);
checkCurrent(2);
checkSelected(0);
// Multi select with Ctrl+Space.
await pressKey(" ", { ctrlKey: true });
checkCurrent(2);
checkSelected(0, 2);
await pressKey("VK_DOWN", { ctrlKey: true }, false);
checkCurrent(3);
checkSelected(0, 2);
await pressKey("VK_DOWN", { ctrlKey: true }, false);
checkCurrent(4);
checkSelected(0, 2);
await pressKey(" ", { ctrlKey: true });
checkCurrent(4);
checkSelected(0, 2, 4);
// Single selection restored with normal navigation.
await pressKey("VK_UP");
checkCurrent(3);
checkSelected(3);
// Can select none using Ctrl+Space.
await pressKey(" ", { ctrlKey: true });
checkCurrent(3);
checkSelected();
await pressKey("VK_DOWN");
checkCurrent(4);
checkSelected(4);
await pressKey("VK_HOME", { ctrlKey: true }, false);
checkCurrent(0);
checkSelected(4);
// Select only the current item with Space (no modifier).
await pressKey(" ");
checkCurrent(0);
checkSelected(0);
// The list is 630px high, so rows 0-11 are fully visible.
pressKey("VK_PAGE_DOWN");
await pressKey("VK_PAGE_DOWN");
await scrollingDelay();
checkCurrent(12);
checkSelected(12);
Assert.equal(list.getFirstVisibleIndex(), 1, "scrolled to the correct place");
pressKey("VK_PAGE_UP", { shiftKey: true });
await pressKey("VK_PAGE_UP", { shiftKey: true });
await scrollingDelay();
checkCurrent(0);
checkSelected(0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12);
Assert.equal(list.getFirstVisibleIndex(), 0, "scrolled to the correct place");
// Shrink shift selection.
await pressKey("VK_DOWN", { shiftKey: true });
checkCurrent(1);
checkSelected(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12);
await pressKey("VK_DOWN", { ctrlKey: true }, false);
checkCurrent(2);
checkSelected(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12);
await pressKey("VK_DOWN", { ctrlKey: true }, false);
checkCurrent(3);
checkSelected(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12);
// Break the shift sequence by Ctrl+Space.
await pressKey(" ", { ctrlKey: true });
checkCurrent(3);
checkSelected(1, 2, 4, 5, 6, 7, 8, 9, 10, 11, 12);
await pressKey("VK_DOWN", { shiftKey: true });
checkCurrent(4);
checkSelected(3, 4);
// Reverse selection direction.
await pressKey("VK_HOME", { shiftKey: true });
checkCurrent(0);
checkSelected(0, 1, 2, 3);
// Now rows 38-49 are fully visible.
pressKey("VK_END");
await pressKey("VK_END");
await scrollingDelay();
checkCurrent(49);
checkSelected(49);
@ -279,7 +359,7 @@ async function subtestKeyboardAndMouse() {
);
// Does nothing.
pressKey("VK_DOWN", undefined, false);
await pressKey("VK_DOWN", {}, false);
checkCurrent(49);
checkSelected(49);
Assert.equal(
@ -288,7 +368,7 @@ async function subtestKeyboardAndMouse() {
"scrolled to the correct place"
);
pressKey("VK_PAGE_UP");
await pressKey("VK_PAGE_UP");
await scrollingDelay();
checkCurrent(37);
checkSelected(37);
@ -298,7 +378,7 @@ async function subtestKeyboardAndMouse() {
"scrolled to the correct place"
);
pressKey("VK_PAGE_DOWN", { shiftKey: true });
await pressKey("VK_PAGE_DOWN", { shiftKey: true });
await scrollingDelay();
checkCurrent(49);
checkSelected(37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49);
@ -308,7 +388,7 @@ async function subtestKeyboardAndMouse() {
"scrolled to the correct place"
);
pressKey("VK_HOME");
await pressKey("VK_HOME");
await scrollingDelay();
checkCurrent(0);
checkSelected(0);
@ -350,7 +430,7 @@ async function subtestKeyboardAndMouse() {
"'select' event did not fire as expected"
);
pressKey("VK_DOWN");
await pressKey("VK_DOWN");
await scrollingDelay();
checkCurrent(1);
checkSelected(1);
@ -371,7 +451,7 @@ async function subtestKeyboardAndMouse() {
"'select' event did not fire as expected"
);
pressKey("VK_UP");
await pressKey("VK_UP");
checkCurrent(0);
checkSelected(0);
Assert.equal(list.getFirstVisibleIndex(), 0, "scrolled to the correct place");

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

@ -1799,9 +1799,6 @@ var cardsPane = {
_showContextMenu(event) {
let row;
if (event.target == this.cardsList) {
if (this.cardsList.selectedIndex == -1) {
return;
}
row = this.cardsList.getRowAtIndex(this.cardsList.currentIndex);
} else {
row = event.target.closest("ab-card-listrow, ab-table-card-listrow");
@ -1811,6 +1808,8 @@ var cardsPane = {
}
if (!this.cardsList.selectedIndices.includes(row.index)) {
this.cardsList.selectedIndex = row.index;
// Re-fetch the row in case it was replaced.
row = this.cardsList.getRowAtIndex(this.cardsList.currentIndex);
}
this.cardsList.focus();
@ -1979,7 +1978,6 @@ var cardsPane = {
_onSelect(event) {
detailsPane.displayCards(this.selectedCards);
this.cardsList.scrollToIndex(this.cardsList.selectedIndex);
},
_onKeyPress(event) {

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

@ -31,6 +31,7 @@ add_task(async function test_additions_and_removals() {
EventUtils.synthesizeKey("VK_DELETE", {}, abWindow);
await promptPromise;
await new Promise(r => abWindow.setTimeout(r));
await new Promise(r => abWindow.setTimeout(r));
}
let bookA = createAddressBook("book A");

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

@ -30,22 +30,38 @@ add_task(async function test_f6_cycle() {
let editButton = abDocument.getElementById("editButton");
// Check what happens with a contact selected.
openDirectory(book);
Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
// NOTE: When the "cards" element first receives focus it will select the
// first item, which causes the panel to be displayed.
cycle(
"books",
"searchInput",
"cards",
"editButton",
"books",
"searchInput",
"cards"
);
Assert.ok(BrowserTestUtils.is_visible(detailsPane));
// Check with no selection.
EventUtils.synthesizeMouseAtCenter(
cardsList.getRowAtIndex(0),
{ ctrlKey: true },
abWindow
);
Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
cycle("cards", "books", "searchInput", "cards");
// Still hidden.
Assert.ok(BrowserTestUtils.is_hidden(detailsPane));
// Check what happens with no contact selected.
cycle("searchInput", "cards", "books", "searchInput");
// Check what happens with a contact selected.
// Check what happens while editing. It should be nothing.
openDirectory(book);
EventUtils.synthesizeMouseAtCenter(cardsList.getRowAtIndex(0), {}, abWindow);
Assert.ok(BrowserTestUtils.is_visible(detailsPane));
cycle("cards", "editButton", "books", "searchInput", "cards");
// Check what happens while editing. It should be nothing.
EventUtils.synthesizeMouseAtCenter(editButton, {}, abWindow);
Assert.equal(abDocument.activeElement.id, "vcard-n-firstname");
EventUtils.synthesizeKey("KEY_F6", {}, abWindow);