diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-07 17:32:43 +0000 |
commit | 6bf0a5cb5034a7e684dcc3500e841785237ce2dd (patch) | |
tree | a68f146d7fa01f0134297619fbe7e33db084e0aa /comm/mail/base/test/browser/browser_selectionWidgetController.js | |
parent | Initial commit. (diff) | |
download | thunderbird-upstream.tar.xz thunderbird-upstream.zip |
Adding upstream version 1:115.7.0.upstream/1%115.7.0upstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to '')
-rw-r--r-- | comm/mail/base/test/browser/browser_selectionWidgetController.js | 6196 |
1 files changed, 6196 insertions, 0 deletions
diff --git a/comm/mail/base/test/browser/browser_selectionWidgetController.js b/comm/mail/base/test/browser/browser_selectionWidgetController.js new file mode 100644 index 0000000000..8e57c64bdc --- /dev/null +++ b/comm/mail/base/test/browser/browser_selectionWidgetController.js @@ -0,0 +1,6196 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at http://mozilla.org/MPL/2.0/. */ + +var tabInfo; +var win; + +add_setup(async () => { + tabInfo = window.openContentTab( + "chrome://mochitests/content/browser/comm/mail/base/test/browser/files/selectionWidget.xhtml" + ); + await BrowserTestUtils.browserLoaded(tabInfo.browser); + + tabInfo.browser.focus(); + win = tabInfo.browser.contentWindow; +}); + +registerCleanupFunction(() => { + window.tabmail.closeTab(tabInfo); +}); + +var selectionModels = ["focus", "browse", "browse-multi"]; + +/** + * The selection widget. + * + * @type {HTMLElement} + */ +var widget; +/** + * A focusable item before the widget. + * + * @type {HTMLElement} + */ +var before; +/** + * A focusable item after the widget. + * + * @type {HTMLElement} + */ +var after; + +/** + * Reset the page and create a new widget. + * + * The "widget", "before" and "after" variables will be reset to the new + * elements. + * + * @param {object} options - Options to set. + * @param {string} options.model - The selection model to use. + * @param {string} [options.direction="right-to-left"] - The direction of the + * widget. Choosing "top-to-bottom" will layout items from top to bottom. + * Choosing "right-to-left" or "left-to-right" will set the page's direction + * to "rtl" or "ltr", respectively, and will layout items in the writing + * direction. + * @param {boolean} [options.draggable=false] - Whether to make the items + * draggable. + */ +function reset(options) { + function createTabStop(text) { + let el = win.document.createElement("span"); + el.tabIndex = 0; + el.id = text; + el.textContent = text; + return el; + } + before = createTabStop("before"); + after = createTabStop("after"); + + let { model, direction } = options; + if (!direction) { + // Default to a less-common format. + direction = "right-to-left"; + } + info(`Creating ${direction} widget with "${model}" model`); + + widget = win.document.createElement("test-selection-widget"); + widget.id = "widget"; + widget.setAttribute("selection-model", model); + widget.setAttribute( + "layout-direction", + direction == "top-to-bottom" ? "vertical" : "horizontal" + ); + widget.toggleAttribute("items-draggable", options.draggable); + + win.document.body.replaceChildren(before, widget, after); + + win.document.dir = direction == "left-to-right" ? "ltr" : "rtl"; + + before.focus(); +} + +/** + * Create an array of sequential integers. + * + * @param {number} start - The starting integer. + * @param {number} num - The number of integers. + * + * @returns {number[]} - Array of integers between start and (start + num - 1). + */ +function range(start, num) { + return Array.from({ length: num }, (_, i) => start + i); +} + +/** + * Assert that the specified items are selected in the widget, and nothing else. + * + * @param {number[]} indices - The indices of the selected items. + * @param {string} msg - A message to use for the assertion. + */ +function assertSelection(indices, msg) { + let selected = widget.selectedIndices(); + Assert.deepEqual(selected, indices, `Selected indices should match: ${msg}`); + // Test that the return of getSelectionRanges is as expected. + let expectRanges = []; + let lastIndex = -2; + let rangeIndex = -1; + for (let index of indices) { + if (index == lastIndex + 1) { + expectRanges[rangeIndex].end++; + } else { + rangeIndex++; + expectRanges.push({ start: index, end: index + 1 }); + } + lastIndex = index; + } + Assert.deepEqual( + widget.getSelectionRanges(), + expectRanges, + `Selection ranges should match expected: ${msg}` + ); +} + +/** + * Assert that the given element is focused. + * + * @param {object} expect - The expected focused element. + * @param {HTMLElement} [expect.element] - The expected element that will + * have focus. + * @param {number} [expect.index] - If the `element` property is not given, this + * specifies the index of the item widget we expect to have focus. + * @param {string} [expect.text] - Optionally test that the element also has the + * given text content. + * @param {string} msg - A message to use for the assertion. + */ +function assertFocus(expect, msg) { + let expectElement; + let name; + if (expect.element != undefined) { + expectElement = expect.element; + name = `Element #${expectElement.id}`; + } else { + expectElement = widget.items[expect.index].element; + name = `Item ${expect.index}`; + } + let active = win.document.activeElement; + let activeIndex = widget.items.findIndex(i => i.element == active); + if (activeIndex >= 0) { + active = `"${active.textContent}", index: ${activeIndex}`; + } else if (active.id) { + active = `#${active.id}`; + } else { + active = `<${active.localName}>`; + } + Assert.ok( + expectElement.matches(":focus"), + `${name} should have focus (active: ${active}): ${msg}` + ); +} + +/** + * Shift the focus by one step by pressing Tab and assert the new focused + * element. + * + * @param {boolean} forward - Whether to move the focus forward. + * @param {object} expect - The expected focused element after pressing tab. + * Same as passed to {@link assertFocus}. + * @param {string} msg - A message to use for the assertion. + */ +function stepFocus(forward, expect, msg) { + EventUtils.synthesizeKey("KEY_Tab", { shiftKey: !forward }, win); + assertFocus( + expect, + `After moving ${forward ? "forward" : "backward"}: ${msg}` + ); +} + +/** + * @typedef {object} ItemState + * @property {string} text - The text content of the item. + * @property {boolean} [selected=false] - Whether the item is selected. + * @property {boolean} [focused=false] - Whether the item is focused. + */ + +/** + * Assert the text order, selection state and focus of the widget items. + * + * @param {ItemState[]} expected - The expected state of the widget items, in + * the expected order of the items. + * @param {string} msg - A message to use for the assertion. + */ +function assertState(expected, msg) { + let textOrder = []; + let focusIndex; + let selectedIndices = []; + for (let [index, state] of expected.entries()) { + textOrder.push(state.text); + if (state.selected) { + selectedIndices.push(index); + } + if (state.focused) { + if (focusIndex != undefined) { + throw new Error("More than one item specified as having focus"); + } + focusIndex = index; + } + } + Assert.deepEqual( + Array.from(widget.items, i => i.element.textContent), + textOrder, + `Text order should match: ${msg}` + ); + assertSelection(selectedIndices, msg); + if (focusIndex != undefined) { + assertFocus({ index: focusIndex }, msg); + } else { + Assert.ok( + !widget.querySelector(":focus"), + `Widget should not contain any focus: ${msg}` + ); + } +} + +/** + * Click the empty space of the widget. + * + * @param {object} mouseEvent - Properties for the click event. + */ +function clickWidgetEmptySpace(mouseEvent) { + let widgetRect = widget.getBoundingClientRect(); + if (widget.getAttribute("layout-direction") == "vertical") { + // Try click end, which we assume is empty. + EventUtils.synthesizeMouse( + widget, + widgetRect.width / 2, + widgetRect.height - 5, + mouseEvent, + win + ); + } else if (widget.matches(":dir(rtl)")) { + // Try click the left, which we assume is empty. + EventUtils.synthesizeMouse( + widget, + 5, + widgetRect.height / 2, + mouseEvent, + win + ); + } else { + // Try click the right, which we assume is empty. + EventUtils.synthesizeMouse( + widget, + widgetRect.width - 5, + widgetRect.height / 2, + mouseEvent, + win + ); + } +} + +/** + * Click the specified widget item. + * + * @param {number} index - The index of the item to click. + * @param {object} mouseEvent - Properties for the click event. + */ +function clickWidgetItem(index, mouseEvent) { + EventUtils.synthesizeMouseAtCenter( + widget.items[index].element, + mouseEvent, + win + ); +} + +/** + * Trigger the select-all shortcut. + */ +function selectAllShortcut() { + EventUtils.synthesizeKey( + "a", + AppConstants.platform == "macosx" ? { metaKey: true } : { ctrlKey: true }, + win + ); +} + +// If the widget is empty, it receives focus on itself. +add_task(function test_empty_widget_focus() { + for (let model of selectionModels) { + reset({ model }); + + assertFocus({ element: before }, "Initial"); + + // Move focus forward. + stepFocus(true, { element: widget }, "Move into widget"); + stepFocus(true, { element: after }, "Move out of widget"); + + // Move focus backward. + stepFocus(false, { element: widget }, "Move back to widget"); + stepFocus(false, { element: before }, "Move back out of widget"); + + // Clicking also gives focus. + for (let shiftKey of [false, true]) { + for (let ctrlKey of [false, true]) { + info( + `Clicking empty widget: ctrlKey: ${ctrlKey}, shiftKey: ${shiftKey}` + ); + clickWidgetEmptySpace({ shiftKey, ctrlKey }); + assertFocus({ element: widget }, "Widget receives focus after click"); + // Move focus for the next loop. + stepFocus(true, { element: after }, "Move back out"); + } + } + } +}); + +/** + * Test that the initial focus is as expected. + * + * @param {string} model - The selection model to use. + * @param {Function} setup - A callback to set up the widget. + * @param {number} clickIndex - The index of an item to click. + * @param {object} expect - The expected states. + * @param {number} expect.focusIndex - The expected focus index. + * @param {number[]} expect.selection - The expected initial selection. + * @param {boolean} expect.selectFocus - Whether we expect the focused item to + * become selected. + */ +function subtest_initial_focus(model, setup, expect) { + let { focusIndex: index, selection, selectFocus } = expect; + + reset({ model }); + setup(); + + assertFocus({ element: before }, "Forward start"); + assertSelection(selection, "Initial selection"); + + stepFocus(true, { index }, "Move onto selected item"); + if (selectFocus) { + assertSelection([index], "Focus becomes selected"); + } else { + assertSelection(selection, "Selection remains when focussing"); + } + stepFocus(true, { element: after }, "Move out of widget"); + + // Reverse. + reset({ model }); + after.focus(); + setup(); + + assertFocus({ element: after }, "Reverse start"); + assertSelection(selection, "Reverse start"); + + stepFocus(false, { index }, "Move backward to selected item"); + if (selectFocus) { + assertSelection([index], "Focus becomes selected"); + } else { + assertSelection(selection, "Selection remains when focussing"); + } + stepFocus(false, { element: before }, "Move out of widget"); + + // With mouse click. + for (let shiftKey of [false, true]) { + for (let ctrlKey of [false, true]) { + info(`Clicking widget: ctrlKey: ${ctrlKey}, shiftKey: ${shiftKey}`); + + reset({ model }); + setup(); + + assertFocus({ element: before }, "Click empty start"); + assertSelection(selection, "Click empty start"); + clickWidgetEmptySpace({ ctrlKey, shiftKey }); + assertFocus( + { index }, + "Selected item becomes focused with click on empty" + ); + if (selectFocus) { + assertSelection([index], "Focus becomes selected on click on empty"); + } else { + assertSelection(selection, "Selection remains when click on empty"); + } + + // With mouse click on item focus moves to the clicked item instead. + for (let clickIndex of [ + (index || widget.items.length) - 1, + index, + index + 1, + ]) { + reset({ model }); + setup(); + + assertFocus({ element: before }, "Click first item start"); + assertSelection(selection, "Click first item start"); + + clickWidgetItem(clickIndex, { shiftKey, ctrlKey }); + + if ( + (shiftKey && ctrlKey) || + ((shiftKey || ctrlKey) && (model == "focus" || model == "browse")) + ) { + // Both modifiers, or multi-selection not supported, so acts the + // same as clicking empty. + assertFocus( + { index }, + "Selected item becomes focused with click on item" + ); + if (selectFocus) { + assertSelection([index], "Focus becomes selected on click on item"); + } else { + assertSelection(selection, "Selection remains when click on item"); + } + } else { + assertFocus( + { index: clickIndex }, + "Clicked item becomes focused with click on item" + ); + let clickSelection; + if (ctrlKey) { + if (selection.includes(clickIndex)) { + // Toggle off clicked item. + clickSelection = selection.filter(index => index != clickIndex); + } else { + clickSelection = selection.concat([clickIndex]).sort(); + } + } else if (shiftKey) { + // Range selection is always from 0, regardless of the selection + // before the click. + clickSelection = range(0, clickIndex + 1); + } else { + clickSelection = [clickIndex]; + } + assertSelection(clickSelection, "Selection after click on item"); + } + } + } + } +} + +// If the widget has a selection when we move into it, the selected item is +// focused. +add_task(function test_initial_focus() { + for (let model of selectionModels) { + // With no initial selection. + subtest_initial_focus( + model, + () => { + widget.addItems(0, ["First", "Second", "Third", "Fourth"]); + }, + { focusIndex: 0, selection: [], selectFocus: true } + ); + // With call to selectSingleItem + subtest_initial_focus( + model, + () => { + widget.addItems(0, ["First", "Second", "Third", "Fourth"]); + widget.selectSingleItem(2); + }, + { focusIndex: 2, selection: [2], selectFocus: false } + ); + + // Using the setItemSelected API + if (model == "focus" || model == "browse") { + continue; + } + + subtest_initial_focus( + model, + () => { + widget.addItems(0, ["First", "Second", "Third", "Fourth"]); + widget.setItemSelected(2, true); + }, + { focusIndex: 2, selection: [2], selectFocus: false } + ); + + // With multiple selected, we move focus to the first selected. + subtest_initial_focus( + model, + () => { + widget.addItems(0, ["First", "Second", "Third", "Fourth"]); + widget.setItemSelected(2, true); + widget.setItemSelected(1, true); + }, + { focusIndex: 1, selection: [1, 2], selectFocus: false } + ); + + // If we use both methods. + subtest_initial_focus( + model, + () => { + widget.addItems(0, ["First", "Second", "Third", "Fourth"]); + widget.selectSingleItem(2, true); + widget.setItemSelected(1, true); + }, + { focusIndex: 1, selection: [1, 2], selectFocus: false } + ); + + // If we call selectSingleItem and then unselect it, we act same as the + // default case. + subtest_initial_focus( + model, + () => { + widget.addItems(0, ["First", "Second", "Third", "Fourth"]); + widget.selectSingleItem(2, true); + widget.setItemSelected(2, false); + }, + { focusIndex: 0, selection: [], selectFocus: true } + ); + } +}); + +// If selectSingleItem API method is called, we select an item and make it the +// focus. +add_task(function test_select_single_item_method() { + function subTestSelectSingleItem(outside, index) { + if (outside) { + stepFocus(true, { element: after }, "Moving focus to outside widget"); + } + + widget.selectSingleItem(index); + assertSelection([index], "Item becomes selected after call"); + + if (outside) { + assertFocus({ element: after }, "Focus remains outside the widget"); + // Return. + stepFocus(false, { index }, "Focus moves to selected item on return"); + assertSelection([index], "Item remains selected on return"); + } else { + assertFocus({ index }, "Focus force moved to selected item"); + } + } + + for (let model of selectionModels) { + reset({ model }); + widget.addItems(0, ["First", "Second", "Third", "Fourth"]); + + stepFocus(true, { index: 0 }, "Move onto first item"); + + for (let outside of [false, true]) { + info(`Testing selecting item${outside ? " with focus outside" : ""}`); + + EventUtils.synthesizeKey("KEY_Home", {}, win); + assertFocus({ index: 0 }, "Focus initially on first item"); + assertSelection([0], "Initial selection on first item"); + + subTestSelectSingleItem(outside, 1); + // Selecting again. + subTestSelectSingleItem(outside, 1); + + if (model == "focus") { + continue; + } + + // Split focus from selection + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Third item has focus"); + assertSelection([1], "Second item remains selected"); + + // Select focused item. + subTestSelectSingleItem(outside, 2); + + // Split again. + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Second item has focus"); + assertSelection([2], "Third item remains selected"); + + // Selecting selected item will still move focus. + subTestSelectSingleItem(outside, 2); + + // Split again. + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertFocus({ index: 0 }, "First item has focus"); + assertSelection([2], "Third item remains selected"); + + // Select neither focused nor selected. + subTestSelectSingleItem(outside, 1); + } + + // With mouse click to focus. + for (let shiftKey of [false, true]) { + for (let ctrlKey of [false, true]) { + info(`Clicking widget: ctrlKey: ${ctrlKey}, shiftKey: ${shiftKey}`); + + reset({ model }); + widget.addItems(0, ["First", "Second", "Third"]); + stepFocus(true, { index: 0 }, "Move onto first item"); + assertSelection([0], "First item becomes selected"); + + // Move focus outside widget. + stepFocus(true, { element: after }, "Move focus outside"); + + // Select an item. + widget.selectSingleItem(1); + + // Click empty space will focus the selected item. + clickWidgetEmptySpace({}); + assertFocus( + { index: 1 }, + "Selected item becomes focused with click on empty" + ); + assertSelection( + [1], + "Second item remains selected with click on empty" + ); + + // With mouse click on selected item. + stepFocus(false, { element: before }, "Move focus outside"); + widget.selectSingleItem(2); + + clickWidgetItem(2, { shiftKey, ctrlKey }); + assertFocus( + { index: 2 }, + "Selected item becomes focused with click on selected" + ); + if (ctrlKey && !shiftKey && model == "browse-multi") { + assertSelection( + [], + "Item becomes unselected with Ctrl+click on selected" + ); + } else { + // NOTE: Shift+Click will select from the item to itself. + assertSelection( + [2], + "Selected item remains selected with click on selected" + ); + } + + // With mouse click on non-selected item. + stepFocus(false, { element: before }, "Move focus outside"); + widget.selectSingleItem(1); + + clickWidgetItem(2, { shiftKey, ctrlKey }); + if ( + (shiftKey && ctrlKey) || + ((shiftKey || ctrlKey) && (model == "focus" || model == "browse")) + ) { + // Both modifiers, or multi-selection not supported, so acts the + // same as clicking empty. + assertFocus( + { index: 1 }, + "Selected item becomes focused with click on item" + ); + assertSelection( + [1], + "Selected item remains selected with click on item" + ); + } else { + assertFocus( + { index: 2 }, + "Third item becomes focused with click on item" + ); + if (ctrlKey) { + assertSelection( + [1, 2], + "Third item becomes selected with Ctrl+click" + ); + } else if (shiftKey) { + assertSelection( + [1, 2], + "Second to third item become selected with Shift+click" + ); + } else { + assertSelection( + [2], + "Third item becomes selected with click on item" + ); + } + } + } + } + } +}); + +// If setItemSelected API method is called, we set the selection state of an +// item but do not change anything else. +add_task(function test_set_item_selected_method() { + for (let model of selectionModels) { + reset({ model }); + widget.addItems(0, ["First", "Second", "Third", "Fourth", "Fifth"]); + stepFocus(true, { index: 0 }, "Initial focus on first item"); + assertSelection([0], "Initial selection on first item"); + + if (model == "focus" || model == "browse") { + // This method always throws. + Assert.throws( + () => widget.setItemSelected(2, true), + /Widget does not support multi-selection/ + ); + // Even if it would not change the single selection state. + Assert.throws( + () => widget.setItemSelected(2, false), + /Widget does not support multi-selection/ + ); + Assert.throws( + () => widget.setItemSelected(0, true), + /Widget does not support multi-selection/ + ); + continue; + } + + // Can select. + widget.setItemSelected(2, true); + assertFocus({ index: 0 }, "Same focus"); + assertSelection([0, 2], "Item 2 becomes selected"); + + // And unselect. + widget.setItemSelected(0, false); + assertFocus({ index: 0 }, "Same focus"); + assertSelection([2], "Item 0 is unselected"); + + // Does nothing extra if already selected/unselected. + widget.setItemSelected(2, true); + assertFocus({ index: 0 }, "Same focus"); + assertSelection([2], "Same selected"); + + widget.setItemSelected(0, false); + assertFocus({ index: 0 }, "Same focus"); + assertSelection([2], "Same selected"); + + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + + // Select the focused item. + assertFocus({ index: 3 }, "Focus on item 3"); + assertSelection([2], "Same selected"); + + widget.setItemSelected(3, true); + assertFocus({ index: 3 }, "Same focus"); + assertSelection([2, 3], "Item 3 selected"); + + widget.setItemSelected(2, false); + assertFocus({ index: 3 }, "Same focus"); + assertSelection([3], "Item 2 unselected"); + + // Can select none this way. + widget.setItemSelected(3, false); + assertFocus({ index: 3 }, "Same focus"); + assertSelection([], "None selected"); + } +}); + +/** + * Test navigation for the given direction. + * + * @param {string} model - The selection model to use. + * @param {string} direction - The layout direction of the widget. + * @param {object} keys - Navigation keys. + * @param {string} keys.forward - The key to move forward. + * @param {string} keys.backward - The key to move backward. + */ +function subtest_keyboard_navigation(model, direction, keys) { + let { forward: forwardKey, backward: backwardKey } = keys; + reset({ model, direction }); + widget.addItems(0, ["First", "Second", "Third"]); + + stepFocus(true, { index: 0 }, "Initially on first item"); + + // Without Ctrl, selection follows focus. + + // Forward. + EventUtils.synthesizeKey(forwardKey, {}, win); + assertFocus({ index: 1 }, "Forward to second item"); + assertSelection([1], "Second item becomes selected on focus"); + EventUtils.synthesizeKey(forwardKey, {}, win); + assertFocus({ index: 2 }, "Forward to third item"); + assertSelection([2], "Third item becomes selected on focus"); + EventUtils.synthesizeKey(forwardKey, {}, win); + assertFocus({ index: 2 }, "Forward at end remains on third item"); + assertSelection([2], "Third item remains selected"); + + // Backward. + EventUtils.synthesizeKey(backwardKey, {}, win); + assertFocus({ index: 1 }, "Backward to second item"); + assertSelection([1], "Second item becomes selected on focus"); + EventUtils.synthesizeKey(backwardKey, {}, win); + assertFocus({ index: 0 }, "Backward to first item"); + assertSelection([0], "First item becomes selected on focus"); + EventUtils.synthesizeKey(backwardKey, {}, win); + assertFocus({ index: 0 }, "Backward at end remains on first item"); + assertSelection([0], "First item remains selected"); + + // End. + EventUtils.synthesizeKey("KEY_End", {}, win); + assertFocus({ index: 2 }, "Third becomes focused on End"); + assertSelection([2], "Third becomes selected on End"); + // Move to middle. + EventUtils.synthesizeKey(backwardKey, {}, win); + EventUtils.synthesizeKey("KEY_End", {}, win); + assertFocus({ index: 2 }, "Third becomes focused on End from second"); + assertSelection([2], "Third becomes selected on End from second"); + EventUtils.synthesizeKey("KEY_End", {}, win); + assertFocus({ index: 2 }, "Third remains focused on End from third"); + assertSelection([2], "Third becomes selected on End from third"); + + // Home. + EventUtils.synthesizeKey("KEY_Home", {}, win); + assertFocus({ index: 0 }, "First becomes focused on Home"); + assertSelection([0], "First becomes selected on Home"); + // Move to middle. + EventUtils.synthesizeKey(forwardKey, {}, win); + EventUtils.synthesizeKey("KEY_Home", {}, win); + assertFocus({ index: 0 }, "First becomes focused on Home from second"); + assertSelection([0], "First becomes selected on Home from second"); + EventUtils.synthesizeKey("KEY_Home", {}, win); + assertFocus({ index: 0 }, "First remains focused on Home from first"); + assertSelection([0], "First becomes selected on Home from first"); + + // With Ctrl key, selection does not follow focus. + if (model == "focus") { + // Disabled in "focus" model. + // Move to middle item. + EventUtils.synthesizeKey(forwardKey, {}, win); + assertFocus({ index: 1 }, "Second item is focused"); + assertFocus({ index: 1 }, "Second item is selected"); + + for (let key of [backwardKey, forwardKey, "KEY_Home", "KEY_End"]) { + for (let shiftKey of [false, true]) { + info( + `Pressing Ctrl+${ + shiftKey ? "Shift+" : "" + }${key} on "focus" model widget` + ); + EventUtils.synthesizeKey(key, { ctrlKey: true, shiftKey }, win); + assertFocus({ index: 1 }, "Second item is still focused"); + assertSelection([1], "Second item is still selected"); + } + } + } else { + EventUtils.synthesizeKey(forwardKey, { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Ctrl+Forward to second item"); + assertSelection([0], "First item remains selected on Ctrl+Forward"); + + EventUtils.synthesizeKey(forwardKey, { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Ctrl+Forward to third item"); + assertSelection([0], "First item remains selected on Ctrl+Forward"); + + EventUtils.synthesizeKey(backwardKey, { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Ctrl+Backward to second item"); + assertSelection([0], "First item remains selected on Ctrl+Backward"); + + EventUtils.synthesizeKey(backwardKey, { ctrlKey: true }, win); + assertFocus({ index: 0 }, "Ctrl+Backward to first item"); + assertSelection([0], "First item remains selected on Ctrl+Backward"); + + EventUtils.synthesizeKey("KEY_End", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Ctrl+End to third item"); + assertSelection([0], "First item remains selected on Ctrl+End"); + + EventUtils.synthesizeKey(backwardKey, {}, win); + assertFocus({ index: 1 }, "Backward to second item"); + assertSelection([1], "Selection moves with focus when not pressing Ctrl"); + + EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }, win); + assertFocus({ index: 0 }, "Ctrl+Home to first item"); + assertSelection([1], "Second item remains selected on Ctrl+Home"); + + // Does nothing if combined with Shift. + for (let key of [backwardKey, forwardKey, "KEY_Home", "KEY_End"]) { + info(`Pressing Ctrl+Shift+${key} on "${model}" model widget`); + EventUtils.synthesizeKey(key, { ctrlKey: true, shiftKey: true }, win); + assertFocus({ index: 0 }, "First item is still focused"); + assertSelection([1], "Second item is still selected"); + } + + // Even if focus remains the same, the selection is still updated if we + // don't press Ctrl. + EventUtils.synthesizeKey(backwardKey, {}, win); + assertFocus({ index: 0 }, "Focus remains on first item"); + assertSelection( + [0], + "Selection moves to the first item since Ctrl was not pressed" + ); + } +} + +// Navigating with keyboard will move focus, and possibly selection. +add_task(function test_keyboard_navigation() { + for (let model of selectionModels) { + subtest_keyboard_navigation(model, "top-to-bottom", { + forward: "KEY_ArrowDown", + backward: "KEY_ArrowUp", + }); + subtest_keyboard_navigation(model, "right-to-left", { + forward: "KEY_ArrowLeft", + backward: "KEY_ArrowRight", + }); + subtest_keyboard_navigation(model, "left-to-right", { + forward: "KEY_ArrowRight", + backward: "KEY_ArrowLeft", + }); + } +}); + +/** + * A method to scroll the widget. + * + * @callback ScrollMethod + * @param {number} pos - The position/offset to scroll to. + */ +/** + * The position of an element, relative to the layout of the widget. + * + * @typedef {object} StartEndPositions + * @property {number} start - The starting position of the element in the + * direction of the widget's layout. The value should be a pixel offset + * from some fixed point, such that a higher value indicates an element + * further from the start of the widget. + * @property {number} end - The ending position of the element in the + * direction of the widget's layout. This should use the same fixed point + * as the start. + * @property {number} xStart - An X position in the client coordinates that + * points to the inside of the element, close to the starting corner. I.e. + * the block-start and inline-start. + * @property {number} yStart - A Y position in the client coordinates that + * points to the inside of the element, close to the starting corner. + * @property {number} xEnd - An X position in the client coordinates that + * points to the inside of the element, close to the ending corner. I.e. + * the block-end and inline-end. + * @property {number} yEnd - A Y position in the client coordinates that + * points to the inside of the element, close to the ending corner. + */ +/** + * A method to return the starting and ending positions of the bounding + * client rectangle of an element. + * + * @callback GetStartEndMethod + * @param {DOMRect} rect - The rectangle to get the positions of. + * @returns {object} positions +/** + * Test page navigation for the given direction. + * + * @param {string} model - The selection model to use on the widget. + * @param {string} direction - The direction of the widget layout. + * @param {object} details - Details about the direction. + * @param {string} details.sizeName - The CSS style name that controls the + * widget size in the direction of widget layout. + * @param {string} details.forwardKey - The key to press to move forward one + * item. + * @param {string} details.backwardKey - The key to press to move backward + * one item. + * @param {ScrollMethod} details.scrollTo - A method to call to scroll the + * widget. + * @param {GetStartEndMethod} details.getStartEnd - A method to get the + * positioning of an element. + */ +function subtest_page_navigation(model, direction, details) { + let { sizeName, forwardKey, backwardKey, scrollTo, getStartEnd } = details; + function getStartEndBoundary(element) { + return getStartEnd(element.getBoundingClientRect()); + } + function assertInView(expect, msg) { + let { first, firstClipped, last, lastClipped } = expect; + if (!firstClipped) { + firstClipped = 0; + } + if (!lastClipped) { + lastClipped = 0; + } + let { start: viewStart, end: viewEnd } = getStartEndBoundary(widget); + // The widget has a 1px border that should not contribute to the view + // size. + viewStart += 1; + viewEnd -= 1; + let firstStart = getStartEndBoundary( + widget.items[expect.first].element + ).start; + Assert.equal( + firstStart, + viewStart - firstClipped, + `Item ${first} should be at the start of the view (${viewStart}) clipped by ${firstClipped}: ${msg}` + ); + if (expect.first > 0) { + Assert.lessOrEqual( + getStartEndBoundary(widget.items[expect.first - 1].element).end, + viewStart, + `Item ${expect.first - 1} should be out of view: ${msg}` + ); + } + let lastEnd = getStartEndBoundary(widget.items[expect.last].element).end; + Assert.equal( + lastEnd, + viewEnd + lastClipped, + `Item ${last} should be at the end of the view (${viewEnd}) clipped by ${lastClipped}: ${msg}` + ); + if (expect.last < widget.items.length - 1) { + Assert.greaterOrEqual( + getStartEndBoundary(widget.items[expect.last + 1].element).start, + viewEnd, + `Item ${expect.last + 1} should be out of view: ${msg}` + ); + } + } + reset({ model, direction }); + widget.addItems( + 0, + range(0, 70).map(i => `add-${i}`) + ); + let { start: itemStart, end: itemEnd } = getStartEndBoundary( + widget.items[0].element + ); + Assert.equal(itemEnd - itemStart, 30, "Expected item size"); + + assertInView({ first: 0, last: 19 }, "First 20 items in view"); + stepFocus(true, { index: 0 }, "Move into widget"); + assertSelection([0], "Fist item selected"); + assertInView({ first: 0, last: 19 }, "First 20 items still in view"); + + // PageDown goes to the end of the current page. + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + assertInView({ first: 0, last: 19 }, "First 20 item still in view"); + assertFocus({ index: 19 }, "Focus moves to end of the page"); + assertSelection([19], "Selection at end of the page"); + + // Pressing forward key will scroll the next item into view. + EventUtils.synthesizeKey(forwardKey, {}, win); + assertInView({ first: 1, last: 20 }, "Items 1 to 20 in view"); + assertFocus({ index: 20 }, "Focus at end of the page"); + assertSelection([20], "Selection at end of the page"); + + // Pressing backward will not change the view. + EventUtils.synthesizeKey(backwardKey, {}, win); + assertInView({ first: 1, last: 20 }, "Items 1 to 20 still in view"); + assertFocus({ index: 19 }, "Focus moves up to 19"); + assertSelection([19], "Selection moves up to 19"); + + // PageDown goes to the end of the current page. + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + assertInView({ first: 1, last: 20 }, "Items 1 to 20 still in view"); + assertFocus({ index: 20 }, "Focus moves to end of page"); + assertSelection([20], "Selection moves to end of page"); + + // PageDown when already at the end of the page will move to the next + // page. + // The last index from the previous page (20) should still be visible at + // the top. + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + assertInView({ first: 20, last: 39 }, "Items 20 to 39 in view"); + assertFocus({ index: 39 }, "Focus moves to end of new page"); + assertSelection([39], "Selection moves to end of new page"); + + // Another PageDown will do the same. + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + assertInView({ first: 39, last: 58 }, "Items 39 to 58 in view"); + assertFocus({ index: 58 }, "Focus moves to end of new page"); + assertSelection([58], "Selection moves to end of new page"); + + // Last PageDown will take us to the end. + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + assertInView({ first: 50, last: 69 }, "Last 20 items in view"); + assertFocus({ index: 69 }, "Focus moves to end"); + assertSelection([69], "Selection moves to end"); + + // Same thing in reverse with PageUp. + // PageUp goes to the start of the current page. + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + assertInView({ first: 50, last: 69 }, "Last 20 item still in view"); + assertFocus({ index: 50 }, "Focus moves to start of the page"); + assertSelection([50], "Selection at end of the page"); + + // Pressing backward will scroll the previous item into view. + EventUtils.synthesizeKey(backwardKey, {}, win); + assertInView({ first: 49, last: 68 }, "Items 49 to 68 in view"); + assertFocus({ index: 49 }, "Focus at start of the page"); + assertSelection([49], "Selection at start of the page"); + + // Pressing forward will not change the view. + EventUtils.synthesizeKey(forwardKey, {}, win); + assertInView({ first: 49, last: 68 }, "Items 49 to 68 still in view"); + assertFocus({ index: 50 }, "Focus moves up to 50"); + assertSelection([50], "Selection moves up to 50"); + + // PageUp goes to the start of the current page. + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + assertInView({ first: 49, last: 68 }, "Items 49 to 68 still in view"); + assertFocus({ index: 49 }, "Focus moves to start of page"); + assertSelection([49], "Selection moves to start of page"); + + // PageUp when already at the start of the page will move one page up. + // The first index from the previously shown page (49) should still be + // visible at the bottom. + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + assertInView({ first: 30, last: 49 }, "Items 30 to 49 in view"); + assertFocus({ index: 30 }, "Focus moves to start of new page"); + assertSelection([30], "Selection moves to start of new page"); + + // Another PageUp will do the same. + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + assertInView({ first: 11, last: 30 }, "Items 11 to 30 in view"); + assertFocus({ index: 11 }, "Focus moves to start of new page"); + assertSelection([11], "Selection moves to start of new page"); + + // Last PageUp will take us to the start. + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + assertInView({ first: 0, last: 19 }, "Items 0 to 19 in view"); + assertFocus({ index: 0 }, "Focus moves to start"); + assertSelection([0], "Selection moves to start"); + + // PageDown with focus above the view. Focus should move to the end of the + // visible page. + scrollTo(120); + assertInView({ first: 4, last: 23 }, "Items 4 to 23 in view"); + assertFocus({ index: 0 }, "Focus remains above the view"); + assertSelection([0], "Selection remains above the view"); + + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + assertInView({ first: 4, last: 23 }, "Same items in view"); + assertFocus({ index: 23 }, "Focus moves to the end of the visible page"); + assertSelection([23], "Selection moves to the end of the visible page"); + + // PageDown with focus below the view. Focus should shift by one page, + // with the previous focus at the top of the page. + scrollTo(60); + assertInView({ first: 2, last: 21 }, "Items 2 to 21 in view"); + assertFocus({ index: 23 }, "Focus remains below the view"); + assertSelection([23], "Selection remains below the view"); + + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + assertInView( + { first: 23, last: 42 }, + "View shifts by a page relative to focus" + ); + assertFocus({ index: 42 }, "Focus moves to end of new page"); + assertSelection([42], "Selection moves to end of new page"); + + // PageUp with focus below the view. Focus should move to the start of the + // visible page. + scrollTo(630); + assertInView({ first: 21, last: 40 }, "Items 21 to 40 in view"); + assertFocus({ index: 42 }, "Focus remains below the view"); + assertSelection([42], "Selection remains below the view"); + + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + assertInView({ first: 21, last: 40 }, "Same items in view"); + assertFocus({ index: 21 }, "Focus moves to the start of the visible page"); + assertSelection([21], "Selection moves to the start of the visible page"); + + // PageUp with focus above the view. Focus should shift by one page, with + // the previous focus at the bottom of the page. + scrollTo(750); + assertInView({ first: 25, last: 44 }, "Items 25 to 44 in view"); + assertFocus({ index: 21 }, "Focus remains above the view"); + assertSelection([21], "Selection remains above the view"); + + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + assertInView( + { first: 2, last: 21 }, + "View shifts by a page relative to focus" + ); + assertFocus({ index: 2 }, "Focus moves to start of new page"); + assertSelection([2], "Selection moves to start of new page"); + + // Test when view does not exactly fit items. + for (let sizeDiff of [0, 10, 15, 20]) { + info(`Reducing widget size by ${sizeDiff}px`); + widget.style[sizeName] = `${600 - sizeDiff}px`; + + // When we reduce the size of the view by half an item or more, we + // reduce the page size from 20 to 19. + // NOTE: At each sizeDiff still fits strictly more than 19 items in its + // view. + let pageSize = sizeDiff < 15 ? 20 : 19; + + // Make sure that Home and End keys scroll the view and clip the items + // as expected. + EventUtils.synthesizeKey("KEY_Home", {}, win); + assertInView( + { first: 0, last: 19, lastClipped: sizeDiff }, + `Start of view with last item clipped by ${sizeDiff}px` + ); + assertFocus({ index: 0 }, "First item has focus"); + assertSelection([0], "First item is selected"); + + EventUtils.synthesizeKey("KEY_End", {}, win); + assertInView( + { first: 50, firstClipped: sizeDiff, last: 69 }, + `End of view with first item clipped by ${sizeDiff}px` + ); + assertFocus({ index: 69 }, "Last item has focus"); + assertSelection([69], "Last item is selected"); + + for (let lastClipped of [0, 10, 15, 20]) { + info(`Testing PageDown with last item clipped by ${lastClipped}px`); + // Across all sizeDiff and lastClipped values we still want the last + // item to be index 21 clipped by lastClipped. + // E.g. when sizeDiff is 10 and lastClipped is 10, then the scroll + // will be 60px and the first item will be index 2 with no clipping. + // But when the sizeDiff is 10 and the lastClipped is 20, then the + // scroll will be 50px and the first item will be index 1 with 20px + // clipping. + let scroll = 60 + sizeDiff - lastClipped; + scrollTo(scroll); + let first = Math.floor(scroll / 30); + let firstClipped = scroll % 30; + clickWidgetItem(3, {}); + assertInView( + { first, firstClipped, last: 21, lastClipped }, + `Last item 21 in view clipped by ${lastClipped}px` + ); + assertFocus({ index: 3 }, "Focus on item 3"); + assertSelection([3], "Selection on item 3"); + + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + let pageEnd; + if (lastClipped < 15) { + // The last item is more than half in view, so counts as part of the + // page. + // NOTE: Index of the first item is always "2", even if it was "1" + // before the scroll, because the view fits (19, 20] items. + assertInView( + { first: 2, firstClipped: sizeDiff, last: 21 }, + "Scrolls down to fully include the last item 21" + ); + pageEnd = 21; + } else { + // The last item is half or less in view, so only the one before it + // counts as being part of the page. + assertInView( + { first, firstClipped, last: 21, lastClipped }, + "Same view" + ); + pageEnd = 20; + } + assertFocus({ index: pageEnd }, "Focus moves to pageEnd"); + assertSelection([pageEnd], "Selection moves to pageEnd"); + + // Reset scroll to test scrolling when the focus is already at the + // pageEnd. + scrollTo(scroll); + assertInView( + { first, firstClipped, last: 21, lastClipped }, + `Last item 21 in view clipped by ${lastClipped}px` + ); + + // PageDown again will move by a page. The new end of the page will be + // scrolled just into view at the bottom. + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + let newPageEnd = pageEnd + pageSize - 1; + // NOTE: If the previous pageEnd would fit mostly in view, then we + // expect the first item in the view to be this item. Otherwise, we + // expect it to be the one before, which will ensure the previous + // pageEnd is fully visible. + firstClipped = sizeDiff; + first = sizeDiff < 15 ? pageEnd : pageEnd - 1; + assertInView( + { first, firstClipped, last: newPageEnd }, + "New page end scrolled into view, previous page end mostly visible" + ); + assertFocus({ index: newPageEnd }, "Focus moves to end of new page"); + assertSelection([newPageEnd], "Selection moves to end of new page"); + + // PageUp reverses the focus. + // We don't test the the view since that is handled lower down. + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + assertFocus({ index: pageEnd }, "Focus returns to pageEnd"); + assertSelection([pageEnd], "Selection returns to pageEnd"); + } + + for (let firstClipped of [0, 10, 15, 20]) { + // Across all sizeDiff and firstClipped values we still want the first + // item to be index 24 clipped by firstClipped. + // E.g. when sizeDiff is 10 and firstClipped is 10, then the scroll + // will be 730px and the last item will be index 44 with no clipping. + // But when the sizeDiff is 10 and the firstClipped is 0, then the + // scroll will be 720px and the last item will be index 43 with 10px + // clipping. + info(`Testing PageUp with first item clipped by ${firstClipped}px`); + scrollTo(720 + firstClipped); + let viewEnd = 720 + firstClipped + 600 - sizeDiff; + let last = Math.floor(viewEnd / 30); + let lastClipped = 30 - (viewEnd % 30); + clickWidgetItem(42, {}); + assertInView( + { first: 24, firstClipped, last, lastClipped }, + `First item 24 in view clipped by ${firstClipped}px` + ); + assertFocus({ index: 42 }, "Focus on item 42"); + assertSelection([42], "Selection on item 42"); + + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + let pageStart; + if (firstClipped < 15) { + // The first item is more than half in view, so counts as part of + // the page. + // NOTE: Index of the last item is always "43", even if it was "44" + // before the scroll, because the view fits (19, 20] items. + assertInView( + { first: 24, last: 43, lastClipped: sizeDiff }, + "Scrolls up to fully include the first item 24" + ); + pageStart = 24; + } else { + // The first item is half or less in view, so only the one after it + // counts as being part of the page. + assertInView( + { first: 24, firstClipped, last, lastClipped }, + "Same view" + ); + pageStart = 25; + } + assertFocus({ index: pageStart }, "Focus moves to pageStart"); + assertSelection([pageStart], "Selection moves to pageStart"); + + // Reset scroll. + scrollTo(720 + firstClipped); + assertInView( + { first: 24, firstClipped, last, lastClipped }, + `First item 24 in view clipped by ${firstClipped}px` + ); + + // PageUp again will move by a page. The new start of the page will be + // scrolled just into view at the top. + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + let newPageStart = pageStart - pageSize + 1; + // NOTE: If the previous pageStart would fit mostly in view, then we + // expect the last item in the view to be this item. Otherwise, we + // expect it to be the one after, which will ensure the previous + // pageStart is fully visible. + lastClipped = sizeDiff; + last = sizeDiff < 15 ? pageStart : pageStart + 1; + assertInView( + { first: newPageStart, last, lastClipped }, + "New page end scrolled into view, previous page end mostly visible" + ); + assertFocus({ index: newPageStart }, "Focus moves to start of new page"); + assertSelection([newPageStart], "Selection moves to start of new page"); + + // PageDown reverses the focus. + // We don't test the the view since that is handled further up. + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + assertFocus({ index: pageStart }, "Focus returns to pageStart"); + assertSelection([pageStart], "Selection returns to pageStart"); + } + } + + // When widget only fits 1 visible item or less. + for (let size of [10, 20, 30, 45, 50]) { + info(`Resizing widget to ${size}px`); + widget.style[sizeName] = `${size}px`; + + scrollTo(600); + // When the view size is less than the size of an item, we cannot always + // click the center of the item, so we need to click the start instead. + let { xStart, yStart } = getStartEndBoundary(widget.items[20].element); + EventUtils.synthesizeMouseAtPoint(xStart, yStart, {}, win); + let last = size > 30 ? 21 : 20; + let lastClipped = size > 30 ? 60 - size : 30 - size; + assertInView({ first: 20, last, lastClipped }, "Small number of items"); + assertFocus({ index: 20 }, "Focus on item 20"); + assertSelection([20], "Item 20 selected"); + + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + if (size <= 45) { + // Only 1 or 0 items fit on the page, so does nothing. + assertInView({ first: 20, last, lastClipped }, "Same view"); + assertFocus({ index: 20 }, "Same focus"); + assertSelection([20], "Same selected"); + } else { + // 2 items fit visibly on the page, so acts as normal. + assertInView( + { first: 20, firstClipped: lastClipped, last: 21 }, + "Last item scrolled into view" + ); + assertFocus({ index: 21 }, "Focus increases by one"); + assertSelection([21], "Selected moves to focus"); + } + + scrollTo(660 - size); + let { xEnd, yEnd } = getStartEndBoundary(widget.items[21].element); + EventUtils.synthesizeMouseAtPoint(xEnd, yEnd, {}, win); + let first = size > 30 ? 20 : 21; + let firstClipped = size > 30 ? 60 - size : 30 - size; + assertInView({ first, firstClipped, last: 21 }, "Small number of items"); + assertFocus({ index: 21 }, "Focus on item 21"); + assertSelection([21], "Item 21 selected"); + + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + if (size <= 45) { + // Only 1 or 0 items fit on the page, so does nothing. + assertInView({ first, firstClipped, last: 21 }, "Same view"); + assertFocus({ index: 21 }, "Same focus"); + assertSelection([21], "Same selected"); + } else { + // 2 items fit visibly on the page, so acts as normal. + assertInView( + { first: 20, last: 21, lastClipped: firstClipped }, + "First item scrolled into view" + ); + assertFocus({ index: 20 }, "Focus decreases by one"); + assertSelection([20], "Selected moves to focus"); + } + } + widget.style[sizeName] = null; + + // Disable page navigation. + // This would be used when the item sizes or the page layout do not allow + // for page navigation, or if PageUp and PageDown should be used for something + // else. + widget.toggleAttribute("no-pages", true); + + let gotKeys = []; + let keydownListener = event => { + gotKeys.push(event.key); + }; + win.document.body.addEventListener("keydown", keydownListener); + scrollTo(600); + clickWidgetItem(20, {}); + assertInView({ first: 20, last: 39 }, "Items 20 to 39 in view"); + assertFocus({ index: 20 }, "First item focused"); + assertSelection([20], "First item selected"); + + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + assertInView({ first: 20, last: 39 }, "Same view"); + assertFocus({ index: 20 }, "Same focus"); + assertSelection([20], "Same selected"); + Assert.deepEqual(gotKeys, ["PageUp"], "PageUp reaches document body"); + gotKeys = []; + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + assertInView({ first: 20, last: 39 }, "Same view"); + assertFocus({ index: 20 }, "Same focus"); + assertSelection([20], "Same selected"); + Assert.deepEqual(gotKeys, ["PageDown"], "PageDown reaches document body"); + gotKeys = []; + + clickWidgetItem(39, {}); + assertInView({ first: 20, last: 39 }, "Items 20 to 39 in view"); + assertFocus({ index: 39 }, "Last item focused"); + assertSelection([39], "Last item selected"); + + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + assertInView({ first: 20, last: 39 }, "Same view"); + assertFocus({ index: 39 }, "Same focus"); + assertSelection([39], "Same selected"); + Assert.deepEqual(gotKeys, ["PageUp"], "PageUp reaches document body"); + gotKeys = []; + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + assertInView({ first: 20, last: 39 }, "Same view"); + assertFocus({ index: 39 }, "Same focus"); + assertSelection([39], "Same selected"); + Assert.deepEqual(gotKeys, ["PageDown"], "PageDown reaches document body"); + gotKeys = []; + + widget.removeAttribute("no-pages"); + + // With page navigation enabled key-presses do not reach the document body. + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + Assert.deepEqual(gotKeys, [], "No key reaches document body"); + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + Assert.deepEqual(gotKeys, [], "No key reaches document body"); + + win.document.body.removeEventListener("keydown", keydownListener); + + // Test with modifiers. + for (let { shiftKey, ctrlKey } of [ + { shiftKey: true, ctrlKey: true }, + { shiftKey: false, ctrlKey: true }, + { shiftKey: true, ctrlKey: false }, + ]) { + info( + `Pressing ${ctrlKey ? "Ctrl+" : ""}${shiftKey ? "Shift+" : ""}PageUp/Down` + ); + EventUtils.synthesizeKey("KEY_Home", {}, win); + EventUtils.synthesizeKey(forwardKey, {}, win); + assertInView({ first: 0, last: 19 }, "First 20 items in view"); + assertFocus({ index: 1 }, "Item 1 has focus"); + assertSelection([1], "Item 1 is selected"); + + EventUtils.synthesizeKey("KEY_PageDown", { ctrlKey, shiftKey }, win); + assertInView({ first: 0, last: 19 }, "Same view"); + if ( + (ctrlKey && shiftKey) || + model == "focus" || + (model == "browse" && shiftKey) + ) { + // Does nothing. + assertFocus({ index: 1 }, "Same focus"); + assertSelection([1], "Same selected"); + // Move focus to the end of the view. + clickWidgetItem(19, {}); + assertFocus({ index: 19 }, "Focus at end of page"); + assertSelection([19], "Selected at end of page"); + } else { + assertFocus({ index: 19 }, "Focus moves to end of page"); + if (ctrlKey) { + // Splits focus from selected. + assertSelection([1], "Same selected"); + } else { + assertSelection(range(1, 19), "Range selection from 1 to 19"); + } + } + // And again, with focus at the end of the page. + EventUtils.synthesizeKey("KEY_PageDown", { ctrlKey, shiftKey }, win); + if ( + (ctrlKey && shiftKey) || + model == "focus" || + (model == "browse" && shiftKey) + ) { + // Does nothing. + assertInView({ first: 0, last: 19 }, "Same view"); + assertFocus({ index: 19 }, "Same focus"); + assertSelection([19], "Same selected"); + } else { + assertInView({ first: 19, last: 38 }, "View scrolls to focus"); + assertFocus({ index: 38 }, "Focus moves to end of new page"); + if (ctrlKey) { + // Splits focus from selected. + assertSelection([1], "Same selected"); + } else { + assertSelection(range(1, 38), "Range selection from 1 to 38"); + } + } + + EventUtils.synthesizeKey("KEY_End", {}, win); + EventUtils.synthesizeKey(backwardKey, {}, win); + assertInView({ first: 50, last: 69 }, "Last 20 items in view"); + assertFocus({ index: 68 }, "Item 68 has focus"); + assertSelection([68], "Item 68 is selected"); + + EventUtils.synthesizeKey("KEY_PageUp", { ctrlKey, shiftKey }, win); + assertInView({ first: 50, last: 69 }, "Same view"); + if ( + (ctrlKey && shiftKey) || + model == "focus" || + (model == "browse" && shiftKey) + ) { + // Does nothing. + assertFocus({ index: 68 }, "Same focus"); + assertSelection([68], "Same selected"); + // Move focus to the end of the view. + clickWidgetItem(50, {}); + assertFocus({ index: 50 }, "Focus at start of page"); + assertSelection([50], "Selected at start of page"); + } else { + assertFocus({ index: 50 }, "Focus moves to start of page"); + if (ctrlKey) { + // Splits focus from selected. + assertSelection([68], "Same selected"); + } else { + assertSelection(range(50, 19), "Range selection from 50 to 68"); + } + } + // And again, with focus at the start of the page. + EventUtils.synthesizeKey("KEY_PageUp", { ctrlKey, shiftKey }, win); + if ( + (ctrlKey && shiftKey) || + model == "focus" || + (model == "browse" && shiftKey) + ) { + // Does nothing. + assertInView({ first: 50, last: 69 }, "Same view"); + assertFocus({ index: 50 }, "Same focus"); + assertSelection([50], "Same selected"); + } else { + assertInView({ first: 31, last: 50 }, "View scrolls to focus"); + assertFocus({ index: 31 }, "Focus moves to start of new page"); + if (ctrlKey) { + // Splits focus from selected. + assertSelection([68], "Same selected"); + } else { + assertSelection(range(31, 38), "Range selection from 31 to 68"); + } + } + } + + // Does nothing with an empty widget. + reset({ model, direction }); + stepFocus(true, { element: widget }, "Focus on empty widget"); + assertState([], "Empty"); + EventUtils.synthesizeKey("KEY_PageDown", {}, win); + assertFocus({ element: widget }, "No change in focus"); + assertState([], "Empty"); + EventUtils.synthesizeKey("KEY_PageUp", {}, win); + assertFocus({ element: widget }, "No change in focus"); + assertState([], "Empty"); +} + +// Test that pressing PageUp or PageDown shifts the view according to the +// visible items. +add_task(function test_page_navigation() { + for (let model of selectionModels) { + subtest_page_navigation(model, "top-to-bottom", { + sizeName: "height", + forwardKey: "KEY_ArrowDown", + backwardKey: "KEY_ArrowUp", + scrollTo: pos => { + widget.scrollTop = pos; + }, + getStartEnd: rect => { + return { + start: rect.top, + end: rect.bottom, + xStart: rect.right - 1, + xEnd: rect.left + 1, + yStart: rect.top + 1, + yEnd: rect.bottom - 1, + }; + }, + }); + subtest_page_navigation(model, "right-to-left", { + sizeName: "width", + forwardKey: "KEY_ArrowLeft", + backwardKey: "KEY_ArrowRight", + scrollTo: pos => { + widget.scrollLeft = -pos; + }, + getStartEnd: rect => { + return { + start: -rect.right, + end: -rect.left, + xStart: rect.right - 1, + xEnd: rect.left + 1, + yStart: rect.top + 1, + yEnd: rect.bottom - 1, + }; + }, + }); + subtest_page_navigation(model, "left-to-right", { + sizeName: "width", + forwardKey: "KEY_ArrowRight", + backwardKey: "KEY_ArrowLeft", + scrollTo: pos => { + widget.scrollLeft = pos; + }, + getStartEnd: rect => { + return { + start: rect.left, + end: rect.right, + xStart: rect.left + 1, + xEnd: rect.right - 1, + yStart: rect.top + 1, + yEnd: rect.bottom - 1, + }; + }, + }); + } +}); + +// Using Space to select items. +add_task(function test_space_selection() { + for (let model of selectionModels) { + reset({ model, direction: "right-to-left" }); + widget.addItems(0, ["First", "Second", "Third", "Fourth"]); + + stepFocus(true, { index: 0 }, "Move focus to first item"); + assertSelection([0], "First item is selected"); + + // Selecting an already selected item does nothing. + EventUtils.synthesizeKey(" ", {}, win); + assertFocus({ index: 0 }, "First item still has focus"); + assertSelection([0], "First item is still selected"); + + if (model == "focus") { + // Just move to second item as set up for the loop. + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + } else { + // Selecting a non-selected item will move selection to it. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Second item has focus"); + assertSelection([0], "First item is still selected"); + EventUtils.synthesizeKey(" ", {}, win); + assertFocus({ index: 1 }, "Second item still has focus"); + assertSelection([1], "Second item becomes selected"); + } + + // Ctrl + Space will toggle the selection if multi-selection is supported. + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + if (model == "focus") { + // Did nothing. + assertFocus({ index: 1 }, "Second item still has focus"); + assertSelection([1], "Second item is still selected"); + } else if (model == "browse") { + // Did nothing. + assertFocus({ index: 1 }, "Second item still has focus"); + assertSelection([1], "Second item is still selected"); + // Make sure nothing happens when on a non-selected item as well. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Third item has focus"); + assertSelection([1], "Second item is still selected"); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Third item still has focus"); + assertSelection([1], "Second item is still selected"); + // Restore the previous state. + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + } else { + // Unselected the item. + assertFocus({ index: 1 }, "Second item still has focus"); + assertSelection([], "Second item was un-selected"); + // Toggle again. + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Second item still has focus"); + assertSelection([1], "Second item was re-selected"); + + // Do on another index. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertFocus({ index: 3 }, "Fourth item has focus"); + assertSelection([1], "Second item is still selected"); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 3 }, "Fourth item still has focus"); + assertSelection([1, 3], "Fourth item becomes selected as well"); + + // Move to third without clearing. + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Third item has focus"); + assertSelection([1, 3], "Fourth and second item remain selected"); + + // Merge the two ranges together. + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Third item still has focus"); + assertSelection([1, 2, 3], "Third item becomes selected"); + + // Shrink the range at the end. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertFocus({ index: 3 }, "Fourth item has focus"); + assertSelection([1, 2, 3], "Same selection"); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 3 }, "Fourth item still has focus"); + assertSelection([1, 2], "Fourth item unselected"); + + // Shrink the range at the start. + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Second item has focus"); + assertSelection([1, 2], "Same selection"); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Second item still has focus"); + assertSelection([2], "Second item unselected"); + + // No selection. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Third item has focus"); + assertSelection([2], "Same selection"); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Third item still has focus"); + assertSelection([], "Third item unselected"); + + // Using arrow keys without modifier will re-introduce a single selection. + EventUtils.synthesizeKey("KEY_ArrowRight"); + assertFocus({ index: 1 }, "Second item has focus"); + assertSelection([1], "Second item becomes selected"); + + // Grow range at the start. + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertFocus({ index: 0 }, "First item has focus"); + assertSelection([1], "Same selection"); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 0 }, "First item still has focus"); + assertSelection([0, 1], "First item becomes selected"); + + // Grow range at the end. + EventUtils.synthesizeKey("KEY_End", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Third item has focus"); + assertSelection([0, 1], "Same selection"); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Third item still has focus"); + assertSelection([0, 1, 2], "Third item becomes selected"); + + // Split the range in half. + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Second item has focus"); + assertSelection([0, 1, 2], "Same selection"); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Second item still has focus"); + assertSelection([0, 2], "Second item unselected"); + + // Pressing Space without a modifier clears the multi-selection. + EventUtils.synthesizeKey(" ", {}, win); + } + + // Make sure we are in the expected shared state between models. + assertFocus({ index: 1 }, "Second item has focus"); + assertSelection([1], "Second item is selected"); + + // Shift + Space will do nothing. + for (let ctrlKey of [false, true]) { + info(`Pressing ${ctrlKey ? "Ctrl+" : ""}Shift+space on item`); + // On selected item. + EventUtils.synthesizeKey(" ", { ctrlKey, shiftKey: true }, win); + assertFocus({ index: 1 }, "Second item still has focus"); + assertSelection([1], "Second item is still selected"); + + if (model == "focus") { + continue; + } + + // On non-selected item. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Third item has focus"); + assertSelection([1], "Second item is still selected"); + EventUtils.synthesizeKey(" ", { ctrlKey, shiftKey: true }, win); + assertFocus({ index: 2 }, "Third item still has focus"); + assertSelection([1], "Second item is still selected"); + + // Restore for next loop. + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + assertFocus({ index: 1 }, "Second item has focus"); + assertSelection([1], "Second item is selected"); + } + } +}); + +// Clicking an item will focus and select it. +add_task(function test_clicking_items() { + for (let model of selectionModels) { + reset({ model, direction: "right-to-left" }); + widget.addItems(0, [ + "First", + "Second", + "Third", + "Fourth", + "Fifth", + "Sixth", + "Seventh", + "Eighth", + ]); + + assertFocus({ element: before }, "Focus initially outside widget"); + assertSelection([], "No initial selection"); + + // Focus moves into widget, onto the clicked item. + clickWidgetItem(1, {}); + assertFocus({ index: 1 }, "Focus clicked second item"); + assertSelection([1], "Selected clicked second item"); + + // Focus moves to different item. + clickWidgetItem(2, {}); + assertFocus({ index: 2 }, "Focus clicked third item"); + assertSelection([2], "Selected clicked third item"); + + // Click same item. + clickWidgetItem(2, {}); + assertFocus({ index: 2 }, "Focus remains on third item"); + assertSelection([2], "Selected remains on third item"); + + // Focus outside widget, focus moves but selection remains. + before.focus(); + assertFocus({ element: before }, "Focus outside widget"); + assertSelection([2], "Selected remains on third item"); + + // Clicking same item will return focus to it. + clickWidgetItem(2, {}); + assertFocus({ index: 2 }, "Focus returns to third item"); + assertSelection([2], "Selected remains on third item"); + + // Do the same, but return to a different item. + before.focus(); + assertFocus({ element: before }, "Focus outside widget"); + assertSelection([2], "Selected remains on third item"); + + // Clicking same item will return focus to it. + clickWidgetItem(1, {}); + assertFocus({ index: 1 }, "Focus moves to second item"); + assertSelection([1], "Selected moves to second item"); + + // Switching to keyboard works. + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + assertFocus({ index: 0 }, "Focus moves to first item"); + assertSelection([0], "Selected moves to first item"); + + // Returning to mouse works. + clickWidgetItem(1, {}); + assertFocus({ index: 1 }, "Focus moves to second item"); + assertSelection([1], "Selected moves to second item"); + + // Toggle selection with Ctrl+Click. + clickWidgetItem(3, { ctrlKey: true }); + if (model == "browse-multi") { + assertFocus({ index: 3 }, "Focus moves to fourth item"); + assertSelection([1, 3], "Fourth item is selected"); + + clickWidgetItem(7, { ctrlKey: true }); + assertFocus({ index: 7 }, "Focus moves to eighth item"); + assertSelection([1, 3, 7], "Eighth item selected"); + + // Extend selection range by one after. + clickWidgetItem(4, { ctrlKey: true }); + assertFocus({ index: 4 }, "Focus moves to fifth item"); + assertSelection([1, 3, 4, 7], "Fifth item is selected"); + + // Extend selection range by one before. + clickWidgetItem(6, { ctrlKey: true }); + assertFocus({ index: 6 }, "Focus moves to seventh item"); + assertSelection([1, 3, 4, 6, 7], "Seventh item is selected"); + + // Merge the two ranges together. + clickWidgetItem(5, { ctrlKey: true }); + assertFocus({ index: 5 }, "Focus moves to sixth item"); + assertSelection([1, 3, 4, 5, 6, 7], "Sixth item is selected"); + + // Reverse by unselecting. + clickWidgetItem(7, { ctrlKey: true }); + assertFocus({ index: 7 }, "Focus moves to eight item"); + assertSelection([1, 3, 4, 5, 6], "Eight item is unselected"); + + clickWidgetItem(3, { ctrlKey: true }); + assertFocus({ index: 3 }, "Focus moves to fourth item"); + assertSelection([1, 4, 5, 6], "Fourth item is unselected"); + + // Split a range. + clickWidgetItem(5, { ctrlKey: true }); + assertFocus({ index: 5 }, "Focus moves to sixth item"); + assertSelection([1, 4, 6], "Sixth item is unselected"); + + clickWidgetItem(1, { ctrlKey: true }); + assertFocus({ index: 1 }, "Focus moves to second item"); + assertSelection([4, 6], "Second item is unselected"); + + clickWidgetItem(6, { ctrlKey: true }); + assertFocus({ index: 6 }, "Focus moves to seventh item"); + assertSelection([4], "Seventh item is unselected"); + + // Can get zero-selection. + clickWidgetItem(4, { ctrlKey: true }); + assertFocus({ index: 4 }, "Focus moves to fifth item"); + assertSelection([], "None selected"); + + // Get into the same state as the other case. + clickWidgetItem(1, { ctrlKey: true }); + assertFocus({ index: 1 }, "Focus moves to second item"); + assertSelection([1], "Second item is selected"); + } else { + // No multi-selection, so does nothing. + assertFocus({ index: 1 }, "Focus remains on second item"); + assertSelection([1], "Second item remains selected"); + } + + // Ctrl+Shift+Click does nothing in all models. + clickWidgetItem(2, { ctrlKey: true, shiftKey: true }); + assertFocus({ index: 1 }, "Focus remains on second item"); + assertSelection([1], "Second item remains selected"); + } +}); + +add_task(function test_select_all() { + for (let model of selectionModels) { + reset({ model, direction: "right-to-left" }); + widget.addItems(0, ["First", "Second", "Third", "Fourth", "Fifth"]); + + stepFocus(true, { index: 0 }, "Move focus to first item"); + assertSelection([0], "First item is selected"); + + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + assertFocus({ index: 1 }, "Focus on second item"); + assertSelection([1], "Second item is selected"); + + selectAllShortcut(); + + assertFocus({ index: 1 }, "Focus remains on second item"); + if (model == "browse-multi") { + assertSelection([0, 1, 2, 3, 4], "All items are selected"); + // Can insert a hole. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Focus moves to third item"); + assertSelection([0, 1, 2, 3, 4], "All items are still selected"); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Focus remains on third item"); + assertSelection([0, 1, 3, 4], "Third item was unselected"); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Focus moves to the second item"); + assertSelection([0, 1, 3, 4], "Selection remains the same"); + EventUtils.synthesizeKey(" ", {}, win); + assertFocus({ index: 1 }, "Focus remains on the second item"); + assertSelection([1], "Only the second item is selected"); + } else { + // Did nothing. + assertSelection([1], "Second item is still selected"); + } + + // Wrong platform modifier does nothing. + EventUtils.synthesizeKey( + "a", + AppConstants.platform == "macosx" ? { ctrlKey: true } : { metaKey: true }, + win + ); + assertFocus({ index: 1 }, "Focus remains on second item"); + assertSelection([1], "Second item still selected"); + } +}); + +// Holding the shift key should perform a range selection if multi-selection is +// supported by the model. +add_task(function test_range_selection() { + for (let model of selectionModels) { + reset({ model, direction: "right-to-left" }); + widget.addItems(0, ["First", "Second", "Third", "Fourth", "Fifth"]); + + stepFocus(true, { index: 0 }, "Move focus to first item"); + assertSelection([0], "First item is selected"); + + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + + assertFocus({ index: 2 }, "Focus on third item"); + assertSelection([2], "Third item is selected"); + + // Nothing happens with Ctrl+Shift in any model. + EventUtils.synthesizeKey( + "KEY_ArrowLeft", + { shiftKey: true, ctrlKey: true }, + win + ); + assertFocus({ index: 2 }, "Focus remains on third item"); + assertSelection([2], "Only second item is selected"); + EventUtils.synthesizeKey( + "KEY_ArrowRight", + { shiftKey: true, ctrlKey: true }, + win + ); + assertFocus({ index: 2 }, "Focus remains on third item"); + assertSelection([2], "Only second item is selected"); + + // With just Shift modifier. + if (model == "focus" || model == "browse") { + // No range selection. + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertFocus({ index: 2 }, "Focus remains on third item"); + assertSelection([2], "Only second item is selected"); + + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertFocus({ index: 2 }, "Focus remains on third item"); + assertSelection([2], "Only second item is selected"); + + clickWidgetItem(3, { shiftKey: true }); + assertFocus({ index: 2 }, "Focus remains on third item"); + assertSelection([2], "Only second item is selected"); + + clickWidgetItem(1, { shiftKey: true }); + assertFocus({ index: 2 }, "Focus remains on third item"); + assertSelection([2], "Only second item is selected"); + continue; + } + + // Range selection with shift key. + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertFocus({ index: 3 }, "Focus on fourth item"); + assertSelection([2, 3], "Select from third to fourth item"); + + // Reverse + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertFocus({ index: 2 }, "Focus on third item"); + assertSelection([2], "Select from third to same item"); + + // Go back another step. + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertFocus({ index: 1 }, "Focus on second item"); + assertSelection([1, 2], "Third to second items are selected"); + + // Split focus from selection. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Focus on third item"); + assertSelection([1, 2], "Third to second items are still selected"); + + // Back to range selection. + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertFocus({ index: 3 }, "Focus on fourth item"); + assertSelection([2, 3], "Third to fourth items are selected"); + + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertFocus({ index: 4 }, "Focus on fifth item"); + assertSelection([2, 3, 4], "Third to fifth items are selected"); + + // Moving without a modifier breaks the range. + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + assertFocus({ index: 4 }, "Focus remains on final fifth item"); + assertSelection([4], "Fifth item is selected"); + + // Again at the middle. + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertFocus({ index: 3 }, "Focus moves to fourth item"); + assertSelection([3, 4], "Fifth to fourth items are selected"); + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + assertFocus({ index: 2 }, "Focus moves to third item"); + assertSelection([2], "Only third item is selected"); + + // Home and End also work. + EventUtils.synthesizeKey("KEY_Home", { shiftKey: true }, win); + assertFocus({ index: 0 }, "Focus moves to first item"); + assertSelection([0, 1, 2], "Up to third item is selected"); + + EventUtils.synthesizeKey("KEY_End", { shiftKey: true }, win); + assertFocus({ index: 4 }, "Focus moves to last item"); + assertSelection([2, 3, 4], "Third item and above is selected"); + + // Ctrl+A breaks range selection sequence, so we no longer select around the + // third item when we go back to using Shift+Arrow. + selectAllShortcut(); + assertFocus({ index: 4 }, "Focus remains on last item"); + assertSelection([0, 1, 2, 3, 4], "All items are selected"); + // The new shift+range will be from the focus index (the fifth item) rather + // than the third item used for the previous range. + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertFocus({ index: 3 }, "Focus moves to fourth item"); + assertSelection([3, 4], "Fifth to fourth item are selected"); + + // Ctrl+Space also breaks range selection sequence. + EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }, win); + assertFocus({ index: 0 }, "Focus moves to first item"); + assertSelection([3, 4], "Range selection remains"); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 0 }, "Focus still on first item"); + assertSelection([0, 3, 4], "First item added to selection"); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertFocus({ index: 1 }, "Focus moves to second item"); + assertSelection([0, 1], "First to second item are selected"); + + // Same when unselecting. + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Focus remains on second item"); + assertSelection([0], "Second item is no longer selected"); + + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertFocus({ index: 2 }, "Focus moves to third item"); + assertSelection([1, 2], "Second to third item are selected"); + + // Same when using setItemSelected API + widget.setItemSelected(4, true); + assertFocus({ index: 2 }, "Focus remains on third item"); + assertSelection([1, 2, 4], "Fifth item becomes selected"); + + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertFocus({ index: 3 }, "Focus moves to fourth item"); + assertSelection([2, 3], "Third to fourth item are selected"); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertFocus({ index: 4 }, "Focus moves to fifth item"); + assertSelection([2, 3, 4], "Third to fifth item are selected"); + + widget.setItemSelected(3, false); + assertFocus({ index: 4 }, "Focus remains on fifth item"); + assertSelection([2, 4], "Fourth item becomes unselected"); + + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertFocus({ index: 3 }, "Focus moves to fourth item"); + assertSelection([3, 4], "Fifth to fourth item are selected"); + + // Even when the selection state does not change. + widget.setItemSelected(3, true); + assertFocus({ index: 3 }, "Focus remains on fourth item"); + assertSelection([3, 4], "Same selection"); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertFocus({ index: 2 }, "Focus moves to third item"); + assertSelection([2, 3], "Fourth to third item are selected"); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertFocus({ index: 1 }, "Focus moves to second item"); + assertSelection([1, 2, 3], "Fourth to second item are selected"); + + widget.setItemSelected(4, false); + assertFocus({ index: 1 }, "Focus remains on second item"); + assertSelection([1, 2, 3], "Same selection"); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertFocus({ index: 2 }, "Focus moves to third item"); + assertSelection([1, 2], "Second to third item are selected"); + + // Same when selecting with space (no modifier). + EventUtils.synthesizeKey(" ", {}, win); + assertFocus({ index: 2 }, "Focus remains on third item"); + assertSelection([2], "Third item is selected"); + + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertFocus({ index: 3 }, "Focus moves to fourth item"); + assertSelection([2, 3], "Third to fourth item are selected"); + + // Same when using the selectSingleItem API. + widget.selectSingleItem(1); + assertFocus({ index: 1 }, "Focus moves to second item"); + assertSelection([1], "Second item is selected"); + + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertFocus({ index: 0 }, "Focus moves to first item"); + assertSelection([0, 1], "Second to first item are selected"); + + // If focus goes out and we return, the range origin is remembered. + stepFocus(true, { element: after }, "Move focus outside the widget"); + assertSelection([0, 1], "Second to first item are still selected"); + stepFocus(false, { index: 0 }, "Focus returns to the widget"); + assertSelection([0, 1], "Second to first item are still selected"); + + EventUtils.synthesizeKey("KEY_End", { shiftKey: true }, win); + assertFocus({ index: 4 }, "Focus moves to last item"); + assertSelection([1, 2, 3, 4], "Second to fifth item are selected"); + + // Clicking empty space does not clear it. + clickWidgetEmptySpace({}); + assertFocus({ index: 4 }, "Focus remains on last item"); + assertSelection([1, 2, 3, 4], "Second to fifth item are still selected"); + + // Shift+Click an item will use the same range origin established by the + // current selection. + clickWidgetItem(3, { shiftKey: true }); + assertFocus({ index: 3 }, "Focus moves to fourth item"); + assertSelection([1, 2, 3], "Second to fourth item are selected"); + + // Clicking without the modifier breaks the range selection sequence. + clickWidgetItem(2, {}); + assertFocus({ index: 2 }, "Focus moves to third item"); + assertSelection([2], "Only the third item is selected"); + + // Shift click will select between the third item and the the clicked item. + clickWidgetItem(4, { shiftKey: true }); + assertFocus({ index: 4 }, "Focus moves to fifth item"); + assertSelection([2, 3, 4], "Third to fifth item are selected"); + + // Reverse direction about the same point. + clickWidgetItem(0, { shiftKey: true }); + assertFocus({ index: 0 }, "Focus moves to first item"); + assertSelection([0, 1, 2], "Third to first item are selected"); + + // Ctrl+Click breaks the range selection sequence. + clickWidgetItem(1, { ctrlKey: true }); + assertFocus({ index: 1 }, "Focus moves to second item"); + assertSelection([0, 2], "Second item is unselected"); + clickWidgetItem(3, { shiftKey: true }); + assertFocus({ index: 3 }, "Focus moves to fourth item"); + assertSelection([1, 2, 3], "Second to fourth item are selected"); + + // Same when Ctrl+Click on non-selected. + clickWidgetItem(4, { ctrlKey: true }); + assertFocus({ index: 4 }, "Focus moves to fifth item"); + assertSelection([1, 2, 3, 4], "Fifth item is selected"); + clickWidgetItem(3, { shiftKey: true }); + assertFocus({ index: 3 }, "Focus moves to fourth item"); + assertSelection([3, 4], "Fifth to fourth item are selected"); + + // Selecting-all also breaks range selection sequence. + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Focus moves to third item"); + assertSelection([3, 4], "Same selection"); + + selectAllShortcut(); + assertFocus({ index: 2 }, "Focus remains on third item"); + assertSelection([0, 1, 2, 3, 4], "All items selected"); + + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertFocus({ index: 1 }, "Focus moves to second item"); + assertSelection([1, 2], "Third to second item selected"); + } +}); + +// Adding items to widget with existing items, should not change the selected +// item. +add_task(function test_add_items_to_nonempty() { + for (let model of selectionModels) { + reset({ model, direction: "right-to-left" }); + assertState([], "Empty"); + + widget.addItems(0, ["0-add"]); + stepFocus(true, { index: 0, text: "0-add" }, "Move focus to 0-add"); + assertState([{ text: "0-add", selected: true, focused: true }], "One item"); + + // Add item after. + widget.addItems(1, ["1-add"]); + assertState( + [{ text: "0-add", selected: true, focused: true }, { text: "1-add" }], + "0-add still focused and selected" + ); + + // Add item before. 0-add moves to index 1. + widget.addItems(0, ["2-add"]); + assertState( + [ + { text: "2-add" }, + { text: "0-add", selected: true, focused: true }, + { text: "1-add" }, + ], + "0-add still focused and selected" + ); + + // Add several before. + widget.addItems(1, ["3-add", "4-add", "5-add"]); + assertState( + [ + { text: "2-add" }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + { text: "0-add", selected: true, focused: true }, + { text: "1-add" }, + ], + "0-add still focused and selected" + ); + + // Key navigation works. + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + assertState( + [ + { text: "2-add" }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "0-add" }, + { text: "1-add" }, + ], + "5-add becomes focused and selected" + ); + + // With focus outside the widget. + reset({ model, direction: "right-to-left" }); + assertState([], "Empty"); + + widget.addItems(0, ["0-add"]); + stepFocus(true, { index: 0 }, "Move focus to 0-add"); + assertState([{ text: "0-add", selected: true, focused: true }], "One item"); + + stepFocus(true, { element: after }, "Move focus to after widget"); + // Add after. + widget.addItems(1, ["1-add", "2-add"]); + assertState( + [{ text: "0-add", selected: true }, { text: "1-add" }, { text: "2-add" }], + "0-add still selected but not focused" + ); + stepFocus(false, { index: 0 }, "Move focus back to 0-add"); + assertState( + [ + { text: "0-add", selected: true, focused: true }, + { text: "1-add" }, + { text: "2-add" }, + ], + "0-add selected and focused" + ); + + stepFocus(false, { element: before }, "Move focus to before widget"); + // Add before. + widget.addItems(0, ["3-add", "4-add"]); + assertState( + [ + { text: "3-add" }, + { text: "4-add" }, + { text: "0-add", selected: true }, + { text: "1-add" }, + { text: "2-add" }, + ], + "0-add selected but not focused" + ); + stepFocus(true, { index: 2 }, "Move focus back to 0-add"); + assertState( + [ + { text: "3-add" }, + { text: "4-add" }, + { text: "0-add", selected: true, focused: true }, + { text: "1-add" }, + { text: "2-add" }, + ], + "0-add selected and focused" + ); + + // With focus separate from selection. + if (model == "focus") { + continue; + } + + reset({ model, direction: "right-to-left" }); + assertState([], "Empty"); + + widget.addItems(0, ["0-add", "1-add", "2-add"]); + assertState( + [{ text: "0-add" }, { text: "1-add" }, { text: "2-add" }], + "None selected or focused" + ); + stepFocus(true, { index: 0 }, "Move focus to 0-add"); + + // With selection after focus. + EventUtils.synthesizeKey("KEY_End", {}, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertState( + [ + { text: "0-add" }, + { text: "1-add", focused: true }, + { text: "2-add", selected: true }, + ], + "Selection after focus" + ); + + // Add after both selection and focus. + widget.addItems(3, ["3-add"]); + assertState( + [ + { text: "0-add" }, + { text: "1-add", focused: true }, + { text: "2-add", selected: true }, + { text: "3-add" }, + ], + "Same items selected and focused" + ); + + // Add before both selection and focus. + widget.addItems(1, ["4-add"]); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "1-add", focused: true }, + { text: "2-add", selected: true }, + { text: "3-add" }, + ], + "Same items selected and focused" + ); + + // Before selection, after focus. + widget.addItems(3, ["5-add"]); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "1-add", focused: true }, + { text: "5-add" }, + { text: "2-add", selected: true }, + { text: "3-add" }, + ], + "Same items selected and focused" + ); + + // Swap selection to be before focus. + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertState( + [ + { text: "0-add" }, + { text: "4-add", selected: true }, + { text: "1-add" }, + { text: "5-add", focused: true }, + { text: "2-add" }, + { text: "3-add" }, + ], + "Selection before focus" + ); + + // After selection, before focus. + widget.addItems(2, ["6-add", "7-add", "8-add"]); + assertState( + [ + { text: "0-add" }, + { text: "4-add", selected: true }, + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "1-add" }, + { text: "5-add", focused: true }, + { text: "2-add" }, + { text: "3-add" }, + ], + "Same items selected and focused" + ); + + // With multi-selection. + if (model == "browse") { + continue; + } + + reset({ model, direction: "right-to-left" }); + assertState([], "Empty"); + + widget.addItems(0, ["0-add", "1-add", "2-add"]); + assertState( + [{ text: "0-add" }, { text: "1-add" }, { text: "2-add" }], + "None selected" + ); + stepFocus(true, { index: 0 }, "Move focus to 0-add"); + + // Select all. + EventUtils.synthesizeKey("KEY_End", { shiftKey: true }, win); + assertState( + [ + { text: "0-add", selected: true }, + { text: "1-add", selected: true }, + { text: "2-add", selected: true, focused: true }, + ], + "All selected" + ); + + // Add after all. + widget.addItems(3, ["3-add", "4-add", "5-add"]); + assertState( + [ + { text: "0-add", selected: true }, + { text: "1-add", selected: true }, + { text: "2-add", selected: true, focused: true }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Same range selected" + ); + + // Can continue shift selection to newly added item + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertState( + [ + { text: "0-add", selected: true }, + { text: "1-add", selected: true }, + { text: "2-add", selected: true }, + { text: "3-add", selected: true, focused: true }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Range extended to new item" + ); + + // Add before all. + widget.addItems(0, ["6-add", "7-add"]); + assertState( + [ + { text: "6-add" }, + { text: "7-add" }, + { text: "0-add", selected: true }, + { text: "1-add", selected: true }, + { text: "2-add", selected: true }, + { text: "3-add", selected: true, focused: true }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Same range selected" + ); + + // Can continue shift selection about the "0-add" item. + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertState( + [ + { text: "6-add" }, + { text: "7-add" }, + { text: "0-add", selected: true }, + { text: "1-add", selected: true }, + { text: "2-add", selected: true, focused: true }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Range extended backward" + ); + + // And change direction of shift selection range. + EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }, win); + assertState( + [ + { text: "6-add", focused: true }, + { text: "7-add" }, + { text: "0-add", selected: true }, + { text: "1-add", selected: true }, + { text: "2-add", selected: true }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Focus moves to first item" + ); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertState( + [ + { text: "6-add" }, + { text: "7-add", selected: true, focused: true }, + { text: "0-add", selected: true }, + { text: "1-add" }, + { text: "2-add" }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Selection pivoted about 0-add" + ); + + // Add items in the middle of the range. Selection in the range is not added + // initially. + widget.addItems(2, ["8-add", "9-add", "10-add"]); + assertState( + [ + { text: "6-add" }, + { text: "7-add", selected: true, focused: true }, + { text: "8-add" }, + { text: "9-add" }, + { text: "10-add" }, + { text: "0-add", selected: true }, + { text: "1-add" }, + { text: "2-add" }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Backward range selection with single gap" + ); + + // But continuing the shift selection will fill in the holes again. + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertState( + [ + { text: "6-add", selected: true, focused: true }, + { text: "7-add", selected: true }, + { text: "8-add", selected: true }, + { text: "9-add", selected: true }, + { text: "10-add", selected: true }, + { text: "0-add", selected: true }, + { text: "1-add" }, + { text: "2-add" }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Backward range selection with no gap" + ); + + // Do the same but with a selection range moving forward and two holes. + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertState( + [ + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "9-add", selected: true }, + { text: "10-add", selected: true, focused: true }, + { text: "0-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Forward range selection" + ); + + widget.addItems(3, ["11-add"]); + assertState( + [ + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "11-add" }, + { text: "9-add", selected: true }, + { text: "10-add", selected: true, focused: true }, + { text: "0-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Forward range selection with one gap" + ); + + widget.addItems(5, ["12-add", "13-add"]); + assertState( + [ + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "11-add" }, + { text: "9-add", selected: true }, + { text: "12-add" }, + { text: "13-add" }, + { text: "10-add", selected: true, focused: true }, + { text: "0-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Forward range selection with two gaps" + ); + + // Continuing the shift selection will fill in the holes. + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertState( + [ + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "11-add", selected: true }, + { text: "9-add", selected: true }, + { text: "12-add", selected: true }, + { text: "13-add", selected: true }, + { text: "10-add", selected: true }, + { text: "0-add", selected: true, focused: true }, + { text: "1-add" }, + { text: "2-add" }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Extended range forward with no gaps" + ); + + // With multi-selection via toggling. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertState( + [ + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "11-add", selected: true }, + { text: "9-add", selected: true }, + { text: "12-add", selected: true }, + { text: "13-add", selected: true }, + { text: "10-add", selected: true }, + { text: "0-add", selected: true }, + { text: "1-add" }, + { text: "2-add", selected: true, focused: true }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Selected 2-add" + ); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertState( + [ + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "11-add", selected: true }, + { text: "9-add", selected: true }, + { text: "12-add", selected: true }, + { text: "13-add", focused: true }, + { text: "10-add", selected: true }, + { text: "0-add", selected: true }, + { text: "1-add" }, + { text: "2-add", selected: true }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "De-selected 13-add" + ); + + widget.addItems(6, ["14-add", "15-add"]); + assertState( + [ + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "11-add", selected: true }, + { text: "9-add", selected: true }, + { text: "12-add", selected: true }, + { text: "14-add" }, + { text: "15-add" }, + { text: "13-add", focused: true }, + { text: "10-add", selected: true }, + { text: "0-add", selected: true }, + { text: "1-add" }, + { text: "2-add", selected: true }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Same selected items" + ); + + widget.addItems(3, ["16-add"]); + assertState( + [ + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "16-add" }, + { text: "11-add", selected: true }, + { text: "9-add", selected: true }, + { text: "12-add", selected: true }, + { text: "14-add" }, + { text: "15-add" }, + { text: "13-add", focused: true }, + { text: "10-add", selected: true }, + { text: "0-add", selected: true }, + { text: "1-add" }, + { text: "2-add", selected: true }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "Same selected items" + ); + + // With select-all + selectAllShortcut(); + assertState( + [ + { text: "6-add", selected: true }, + { text: "7-add", selected: true }, + { text: "8-add", selected: true }, + { text: "16-add", selected: true }, + { text: "11-add", selected: true }, + { text: "9-add", selected: true }, + { text: "12-add", selected: true }, + { text: "14-add", selected: true }, + { text: "15-add", selected: true }, + { text: "13-add", selected: true, focused: true }, + { text: "10-add", selected: true }, + { text: "0-add", selected: true }, + { text: "1-add", selected: true }, + { text: "2-add", selected: true }, + { text: "3-add", selected: true }, + { text: "4-add", selected: true }, + { text: "5-add", selected: true }, + ], + "All items selected" + ); + + // Added items do not become selected. + widget.addItems(4, ["17-add", "18-add"]); + assertState( + [ + { text: "6-add", selected: true }, + { text: "7-add", selected: true }, + { text: "8-add", selected: true }, + { text: "16-add", selected: true }, + { text: "17-add" }, + { text: "18-add" }, + { text: "11-add", selected: true }, + { text: "9-add", selected: true }, + { text: "12-add", selected: true }, + { text: "14-add", selected: true }, + { text: "15-add", selected: true }, + { text: "13-add", selected: true, focused: true }, + { text: "10-add", selected: true }, + { text: "0-add", selected: true }, + { text: "1-add", selected: true }, + { text: "2-add", selected: true }, + { text: "3-add", selected: true }, + { text: "4-add", selected: true }, + { text: "5-add", selected: true }, + ], + "Added items not selected" + ); + + // Added items will be selected if we select-all again. + selectAllShortcut(); + assertState( + [ + { text: "6-add", selected: true }, + { text: "7-add", selected: true }, + { text: "8-add", selected: true }, + { text: "16-add", selected: true }, + { text: "17-add", selected: true }, + { text: "18-add", selected: true }, + { text: "11-add", selected: true }, + { text: "9-add", selected: true }, + { text: "12-add", selected: true }, + { text: "14-add", selected: true }, + { text: "15-add", selected: true }, + { text: "13-add", selected: true, focused: true }, + { text: "10-add", selected: true }, + { text: "0-add", selected: true }, + { text: "1-add", selected: true }, + { text: "2-add", selected: true }, + { text: "3-add", selected: true }, + { text: "4-add", selected: true }, + { text: "5-add", selected: true }, + ], + "All items selected" + ); + } +}); + +/** + * Test that pressing a key on a non-empty widget that has focus on itself will + * move to the expected index. + * + * @param {object} initialState - The initial state of the widget to set up. + * @param {string} initialState.model - The selection model to use. + * @param {string} initialState.direction - The layout direction of the widget. + * @param {number} initialState.numItems - The number of items in the widget. + * @param {Function} [initialState.scroll] - A method to call to scroll the + * widget. + * @param {string} key - The key to press once the widget is set up. + * @param {number} index - The expected index for the item that will receive + * focus after the key press. + */ +function subtest_keypress_on_focused_widget(initialState, key, index) { + let { model, direction, numItems, scroll } = initialState; + for (let ctrlKey of [false, true]) { + for (let shiftKey of [false, true]) { + info( + `Adding items to empty ${direction} widget and then pressing ${ + ctrlKey ? "Ctrl+" : "" + }${shiftKey ? "Shift+" : ""}${key}` + ); + reset({ model, direction }); + + stepFocus(true, { element: widget }, "Move focus onto empty widget"); + widget.addItems( + 0, + range(0, numItems).map(i => `add-${i}`) + ); + scroll?.(); + + assertFocus( + { element: widget }, + "Focus remains on the widget after adding items" + ); + assertSelection([], "No items are selected yet"); + + EventUtils.synthesizeKey(key, { ctrlKey, shiftKey }, win); + if ( + (ctrlKey && shiftKey) || + (model == "browse" && shiftKey) || + (model == "focus" && (ctrlKey || shiftKey)) + ) { + // Does nothing. + assertFocus({ element: widget }, "Focus remains on widget"); + assertSelection([], "No change in selection"); + continue; + } + + assertFocus({ index }, `Focus moves to ${index} after ${key}`); + if (ctrlKey) { + assertSelection([], `No selection if pressing Ctrl+${key}`); + } else if (shiftKey) { + assertSelection( + range(0, index + 1), + `Range selection from 0 to ${index} if pressing Shift+${key}` + ); + } else { + assertSelection([index], `Item selected after ${key}`); + } + } + } +} + +// If items are added to an empty widget that has focus, nothing happens +// initially. Arrow keys will focus the first item. +add_task(function test_add_items_to_empty_with_focus() { + for (let model of selectionModels) { + // Step navigation always takes us to the first item. + subtest_keypress_on_focused_widget( + { model, direction: "top-to-bottom", numItems: 3 }, + "KEY_ArrowUp", + 0 + ); + subtest_keypress_on_focused_widget( + { model, direction: "top-to-bottom", numItems: 3 }, + "KEY_ArrowDown", + 0 + ); + subtest_keypress_on_focused_widget( + { model, direction: "right-to-left", numItems: 3 }, + "KEY_ArrowRight", + 0 + ); + subtest_keypress_on_focused_widget( + { model, direction: "right-to-left", numItems: 3 }, + "KEY_ArrowLeft", + 0 + ); + subtest_keypress_on_focused_widget( + { model, direction: "left-to-right", numItems: 3 }, + "KEY_ArrowLeft", + 0 + ); + subtest_keypress_on_focused_widget( + { model, direction: "left-to-right", numItems: 3 }, + "KEY_ArrowRight", + 0 + ); + // Home also takes us to the first item. + subtest_keypress_on_focused_widget( + { model, direction: "top-to-bottom", numItems: 3 }, + "KEY_Home", + 0 + ); + subtest_keypress_on_focused_widget( + { model, direction: "right-to-left", numItems: 3 }, + "KEY_Home", + 0 + ); + subtest_keypress_on_focused_widget( + { model, direction: "left-to-right", numItems: 3 }, + "KEY_Home", + 0 + ); + // End takes us to the last item. + subtest_keypress_on_focused_widget( + { model, direction: "top-to-bottom", numItems: 3 }, + "KEY_End", + 2 + ); + subtest_keypress_on_focused_widget( + { model, direction: "right-to-left", numItems: 3 }, + "KEY_End", + 2 + ); + subtest_keypress_on_focused_widget( + { model, direction: "left-to-right", numItems: 3 }, + "KEY_End", + 2 + ); + // PageUp and PageDown take us to the start or end of the visible page. + subtest_keypress_on_focused_widget( + { model, direction: "top-to-bottom", numItems: 3 }, + "KEY_PageUp", + 0 + ); + subtest_keypress_on_focused_widget( + { model, direction: "top-to-bottom", numItems: 3 }, + "KEY_PageDown", + 2 + ); + subtest_keypress_on_focused_widget( + { + model, + direction: "top-to-bottom", + numItems: 30, + scroll: () => { + widget.scrollTop = 270; + }, + }, + "KEY_PageUp", + 9 + ); + subtest_keypress_on_focused_widget( + { + model, + direction: "top-to-bottom", + numItems: 30, + scroll: () => { + widget.scrollTop = 60; + }, + }, + "KEY_PageDown", + 21 + ); + + // Arrow keys in other directions do nothing. + reset({ model, direction: "top-to-bottom" }); + stepFocus(true, { element: widget }, "Move focus onto empty widget"); + widget.addItems(0, ["First", "Second"]); + for (let key of ["KEY_ArrowRight", "KEY_ArrowLeft"]) { + EventUtils.synthesizeKey(key, {}, win); + assertFocus({ element: widget }, `Focus remains on widget after ${key}`); + assertSelection([], `No items become selected after ${key}`); + } + + reset({ model, direction: "right-to-left" }); + stepFocus(true, { element: widget }, "Move focus onto empty widget"); + widget.addItems(0, ["First", "Second"]); + for (let key of ["KEY_ArrowUp", "KEY_ArrowDown"]) { + EventUtils.synthesizeKey(key, {}, win); + assertFocus({ element: widget }, `Focus remains on widget after ${key}`); + assertSelection([], `No items become selected after ${key}`); + } + + // Pressing Space does nothing. + reset({ model }); + stepFocus(true, { element: widget }, "Move focus onto empty widget"); + widget.addItems(0, ["First", "Second"]); + for (let ctrlKey of [false, true]) { + for (let shiftKey of [false, true]) { + info( + `Pressing ${ctrlKey ? "Ctrl+" : ""}${shiftKey ? "Shift+" : ""}Space` + ); + EventUtils.synthesizeKey(" ", {}, win); + assertFocus({ element: widget }, "Focus remains on widget after Space"); + assertSelection([], "No items become selected after Space"); + } + } + + // Selecting all + reset({ model }); + stepFocus(true, { element: widget }, "Move focus onto empty widget"); + widget.addItems(0, ["First", "Second", "Third"]); + + selectAllShortcut(); + assertFocus({ element: widget }, "Focus remains on the widget"); + if (model == "browse-multi") { + assertSelection([0, 1, 2], "All items selected"); + } else { + assertSelection([], "still no selection"); + } + + // Adding and then removing items does not set focus. + reset({ model }); + stepFocus(true, { element: widget }, "Move focus onto empty widget"); + widget.addItems(0, ["First", "Second", "Third", "Fourth"]); + widget.removeItems(2, 2); + assertState( + [{ text: "First" }, { text: "Second" }], + "No item focused or selected" + ); + assertFocus({ element: widget }, "Focus remains on the widget"); + widget.removeItems(0, 1); + assertState([{ text: "Second" }], "No item focused or selected"); + assertFocus({ element: widget }, "Focus remains on the widget"); + + // Moving items does not set focus. + reset({ model }); + stepFocus(true, { element: widget }, "Move focus onto empty widget"); + widget.addItems(0, ["First", "Second", "Third", "Fourth"]); + widget.moveItems(1, 0, 2, false); + assertState( + [ + { text: "Second" }, + { text: "Third" }, + { text: "First" }, + { text: "Fourth" }, + ], + "No item focused or selected" + ); + assertFocus({ element: widget }, "Focus remains on the widget"); + widget.moveItems(0, 1, 3, true); + assertState( + [ + { text: "Fourth" }, + { text: "Second" }, + { text: "Third" }, + { text: "First" }, + ], + "No item focused or selected" + ); + assertFocus({ element: widget }, "Focus remains on the widget"); + + // This does not effect clicking. + // NOTE: case where widget does not initially have focus on clicking is + // handled by test_initial_no_select_focus + for (let ctrlKey of [false, true]) { + for (let shiftKey of [false, true]) { + info( + `Adding items to empty focused widget and then ${ + ctrlKey ? "Ctrl+" : "" + }${shiftKey ? "Shift+" : ""}Click` + ); + reset({ model }); + stepFocus(true, { element: widget }, "Move focus onto empty widget"); + widget.addItems(0, ["First", "Second", "Third"]); + + // Clicking empty space does nothing. + clickWidgetEmptySpace({ ctrlKey, shiftKey }); + assertFocus({ element: widget }, "Focus remains on widget"); + assertSelection([], "No item selected"); + + // Clicking an item can change focus and selection. + clickWidgetItem(1, { ctrlKey, shiftKey }); + if ( + (ctrlKey && shiftKey) || + ((model == "focus" || model == "browse") && (ctrlKey || shiftKey)) + ) { + assertFocus({ element: widget }, "Focus remains on widget"); + assertSelection([], "No selection"); + continue; + } + assertFocus({ index: 1 }, "Focus moves to second item"); + if (shiftKey) { + assertSelection([0, 1], "First and second item selected"); + } else { + assertSelection([1], "Second item selected"); + } + } + } + } +}); + +// Removing items from the widget with existing items, may change focus or +// selection if the corresponding item was removed. +add_task(function test_remove_items_nonempty() { + for (let model of selectionModels) { + reset({ model, direction: "right-to-left" }); + + widget.addItems(0, ["0-add", "1-add", "2-add", "3-add", "4-add", "5-add"]); + assertState( + [ + { text: "0-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "No initial focus or selection" + ); + + clickWidgetItem(2, {}); + assertState( + [ + { text: "0-add" }, + { text: "1-add" }, + { text: "2-add", selected: true, focused: true }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add" }, + ], + "2-add focused and selected" + ); + + // Remove one after. + widget.removeItems(3, 1); + assertState( + [ + { text: "0-add" }, + { text: "1-add" }, + { text: "2-add", selected: true, focused: true }, + { text: "4-add" }, + { text: "5-add" }, + ], + "2-add still focused and selected" + ); + + // Remove one before. + widget.removeItems(0, 1); + assertState( + [ + { text: "1-add" }, + { text: "2-add", selected: true, focused: true }, + { text: "4-add" }, + { text: "5-add" }, + ], + "2-add still focused and selected" + ); + + widget.addItems(0, ["6-add", "7-add"]); + assertState( + [ + { text: "6-add" }, + { text: "7-add" }, + { text: "1-add" }, + { text: "2-add", selected: true, focused: true }, + { text: "4-add" }, + { text: "5-add" }, + ], + "2-add still focused and selected" + ); + + // Remove several before. + widget.removeItems(1, 2); + assertState( + [ + { text: "6-add" }, + { text: "2-add", selected: true, focused: true }, + { text: "4-add" }, + { text: "5-add" }, + ], + "2-add still focused and selected" + ); + + // Remove selected and focused. Focus should move to the next item. + widget.removeItems(1, 1); + assertState( + [ + { text: "6-add" }, + { text: "4-add", selected: true, focused: true }, + { text: "5-add" }, + ], + "Selection and focus move to 4-add" + ); + + widget.addItems(0, ["8-add"]); + widget.addItems(3, ["9-add", "10-add"]); + assertState( + [ + { text: "8-add" }, + { text: "6-add" }, + { text: "4-add", selected: true, focused: true }, + { text: "9-add" }, + { text: "10-add" }, + { text: "5-add" }, + ], + "Selection and focus still on 4-add" + ); + + // Remove selected and focused, not at boundary. + widget.removeItems(1, 3); + assertState( + [ + { text: "8-add" }, + { text: "10-add", selected: true, focused: true }, + { text: "5-add" }, + ], + "Selection and focus move to 10-add" + ); + + // Remove last item whilst it has focus. Focus should move to the new last + // item. + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + assertState( + [ + { text: "8-add" }, + { text: "10-add" }, + { text: "5-add", selected: true, focused: true }, + ], + "Last item is focused and selected" + ); + + widget.removeItems(2, 1); + assertState( + [{ text: "8-add" }, { text: "10-add", selected: true, focused: true }], + "New last item is focused and selected" + ); + + // Delete focused whilst outside widget. + widget.addItems(2, ["11-add"]); + assertState( + [ + { text: "8-add" }, + { text: "10-add", selected: true, focused: true }, + { text: "11-add" }, + ], + "10-add is focused and selected" + ); + stepFocus(false, { element: before }); + + widget.removeItems(1, 1); + assertFocus({ element: before }, "Focus remains outside widget"); + assertState( + [{ text: "8-add" }, { text: "11-add", selected: true }], + "11-add becomes selected" + ); + + stepFocus(true, { index: 1 }, "11-add becomes focused"); + assertState( + [{ text: "8-add" }, { text: "11-add", selected: true, focused: true }], + "11-add is selected" + ); + + // With focus separate from selected. + if (model == "focus") { + continue; + } + + // Move selection to be before focus. + widget.addItems(2, ["12-add", "13-add", "14-add"]); + assertState( + [ + { text: "8-add" }, + { text: "11-add", selected: true, focused: true }, + { text: "12-add" }, + { text: "13-add" }, + { text: "14-add" }, + ], + "11-add is selected and focused" + ); + + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertState( + [ + { text: "8-add" }, + { text: "11-add", selected: true }, + { text: "12-add", focused: true }, + { text: "13-add" }, + { text: "14-add" }, + ], + "Selection before focus" + ); + + // Remove focused, but not selected. + widget.removeItems(2, 1); + assertState( + [ + { text: "8-add" }, + { text: "11-add", selected: true }, + { text: "13-add", focused: true }, + { text: "14-add" }, + ], + "Focus moves to 13-add, but selection is the same" + ); + + // Remove focused and selected. + widget.removeItems(1, 2); + assertState( + [{ text: "8-add" }, { text: "14-add", selected: true, focused: true }], + "Focus moves to 14-add and becomes selected" + ); + + // Restore selection before focus. + widget.addItems(0, ["15-add"]); + assertState( + [ + { text: "15-add" }, + { text: "8-add" }, + { text: "14-add", selected: true, focused: true }, + ], + "14-add has focus and selection" + ); + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + assertState( + [ + { text: "15-add" }, + { text: "8-add", selected: true, focused: true }, + { text: "14-add" }, + ], + "8-add is focused and selected" + ); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertState( + [ + { text: "15-add" }, + { text: "8-add", selected: true }, + { text: "14-add", focused: true }, + ], + "Selection before focus again" + ); + + // Remove selected, but not focused. + widget.removeItems(1, 1); + assertState( + [{ text: "15-add" }, { text: "14-add", focused: true }], + "14-add still has focus, but selection is lost" + ); + + // Move selection to be after focus. + widget.addItems(1, ["16-add", "17-add"]); + widget.addItems(4, ["18-add"]); + assertState( + [ + { text: "15-add" }, + { text: "16-add" }, + { text: "17-add" }, + { text: "14-add", focused: true }, + { text: "18-add" }, + ], + "Still no selection" + ); + // Select focused. + EventUtils.synthesizeKey(" ", {}, win); + assertFocus({ index: 3 }, "14-add has focus"); + assertSelection([3], "14-add is selected"); + assertState( + [ + { text: "15-add" }, + { text: "16-add" }, + { text: "17-add" }, + { text: "14-add", selected: true, focused: true }, + { text: "18-add" }, + ], + "14-add is selected and focused" + ); + // Move focus. + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertState( + [ + { text: "15-add" }, + { text: "16-add", focused: true }, + { text: "17-add" }, + { text: "14-add", selected: true }, + { text: "18-add" }, + ], + "Selection after focus" + ); + + // Remove focused, but not selected. + widget.removeItems(1, 1); + assertState( + [ + { text: "15-add" }, + { text: "17-add", focused: true }, + { text: "14-add", selected: true }, + { text: "18-add" }, + ], + "Focus moves to 17-add, selection stays on 14-add" + ); + + // Remove focused and selected. + widget.removeItems(1, 2); + assertState( + [{ text: "15-add" }, { text: "18-add", selected: true, focused: true }], + "Focus and selection moves to 18-add" + ); + + // Restore selection after focus. + widget.addItems(2, ["19-add", "20-add"]); + assertState( + [ + { text: "15-add" }, + { text: "18-add", selected: true, focused: true }, + { text: "19-add" }, + { text: "20-add" }, + ], + "Still no selection" + ); + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + assertState( + [ + { text: "15-add" }, + { text: "18-add" }, + { text: "19-add", selected: true, focused: true }, + { text: "20-add" }, + ], + "19-add focused and selected" + ); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertState( + [ + { text: "15-add" }, + { text: "18-add", focused: true }, + { text: "19-add", selected: true }, + { text: "20-add" }, + ], + "Selection after focus again" + ); + + // Remove selected, but not focused. + widget.removeItems(2, 2); + assertState( + [{ text: "15-add" }, { text: "18-add", focused: true }], + "18-add still has focus, but selection is lost" + ); + + // With multi-selection + if (model == "browse") { + continue; + } + + widget.addItems(0, ["21-add", "22-add", "23-add"]); + assertState( + [ + { text: "21-add" }, + { text: "22-add" }, + { text: "23-add" }, + { text: "15-add" }, + { text: "18-add", focused: true }, + ], + "18-add focused, no selection yet" + ); + widget.addItems(5, [ + "24-add", + "25-add", + "26-add", + "27-add", + "28-add", + "29-add", + "30-add", + "31-add", + ]); + assertState( + [ + { text: "21-add" }, + { text: "22-add" }, + { text: "23-add" }, + { text: "15-add" }, + { text: "18-add", focused: true }, + { text: "24-add" }, + { text: "25-add" }, + { text: "26-add" }, + { text: "27-add" }, + { text: "28-add" }, + { text: "29-add" }, + { text: "30-add" }, + { text: "31-add" }, + ], + "18-add focused, no selection yet" + ); + + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + + assertState( + [ + { text: "21-add" }, + { text: "22-add" }, + { text: "23-add" }, + { text: "15-add" }, + { text: "18-add", selected: true }, + { text: "24-add", selected: true }, + { text: "25-add", selected: true }, + { text: "26-add", selected: true, focused: true }, + { text: "27-add" }, + { text: "28-add" }, + { text: "29-add" }, + { text: "30-add" }, + { text: "31-add" }, + ], + "Forward range selection from 18-add to 26-add" + ); + + // Delete after the selection range + widget.removeItems(10, 1); + assertState( + [ + { text: "21-add" }, + { text: "22-add" }, + { text: "23-add" }, + { text: "15-add" }, + { text: "18-add", selected: true }, + { text: "24-add", selected: true }, + { text: "25-add", selected: true }, + { text: "26-add", selected: true, focused: true }, + { text: "27-add" }, + { text: "28-add" }, + { text: "30-add" }, + { text: "31-add" }, + ], + "Same range selection" + ); + + // Delete before the selection range. + widget.removeItems(1, 1); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "15-add" }, + { text: "18-add", selected: true }, + { text: "24-add", selected: true }, + { text: "25-add", selected: true }, + { text: "26-add", selected: true, focused: true }, + { text: "27-add" }, + { text: "28-add" }, + { text: "30-add" }, + { text: "31-add" }, + ], + "Same range selection" + ); + + // Delete the start of the selection range. + widget.removeItems(2, 3); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "26-add", selected: true, focused: true }, + { text: "27-add" }, + { text: "28-add" }, + { text: "30-add" }, + { text: "31-add" }, + ], + "Selection range from 25-add to 26-add" + ); + + // Selection pivot is now around 25-add. + EventUtils.synthesizeKey("KEY_Home", { shiftKey: true }, win); + assertState( + [ + { text: "21-add", selected: true, focused: true }, + { text: "23-add", selected: true }, + { text: "25-add", selected: true }, + { text: "26-add" }, + { text: "27-add" }, + { text: "28-add" }, + { text: "30-add" }, + { text: "31-add" }, + ], + "Selection range from 25-add to 21-add" + ); + EventUtils.synthesizeKey("KEY_End", { shiftKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "26-add", selected: true }, + { text: "27-add", selected: true }, + { text: "28-add", selected: true }, + { text: "30-add", selected: true }, + { text: "31-add", selected: true, focused: true }, + ], + "Selection range from 25-add to 31-add" + ); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "26-add", selected: true }, + { text: "27-add", selected: true }, + { text: "28-add", selected: true, focused: true }, + { text: "30-add" }, + { text: "31-add" }, + ], + "Selection range from 25-add to 28-add" + ); + + // Delete the end of the selection. + // As a special case, the focus moves to the end of the selection, rather + // than to the next item. + widget.removeItems(4, 2); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "26-add", selected: true, focused: true }, + { text: "30-add" }, + { text: "31-add" }, + ], + "Selection range from 25-add to 26-add" + ); + + // Do same with a gap. + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "26-add", selected: true }, + { text: "30-add", selected: true, focused: true }, + { text: "31-add" }, + ], + "Continue selection range from 25-add to 30-add" + ); + + widget.addItems(3, ["32-add"]); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "32-add" }, + { text: "26-add", selected: true }, + { text: "30-add", selected: true, focused: true }, + { text: "31-add" }, + ], + "Selection range from 25-add to 30-add with gap" + ); + + widget.removeItems(5, 1); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "32-add" }, + { text: "26-add", selected: true, focused: true }, + { text: "31-add" }, + ], + "Focus moves to the end of the range, after the gap" + ); + + // Do the same with a gap and all items after the gap are removed. + widget.addItems(6, ["33-add", "34-add"]); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "32-add" }, + { text: "26-add", selected: true, focused: true }, + { text: "31-add" }, + { text: "33-add" }, + { text: "34-add" }, + ], + "Added 33-add and 34-add" + ); + + clickWidgetItem(5, { shiftKey: true }); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "32-add", selected: true }, + { text: "26-add", selected: true }, + { text: "31-add", selected: true, focused: true }, + { text: "33-add" }, + { text: "34-add" }, + ], + "Selection extended to 31-add and gap filled" + ); + + widget.addItems(4, ["35-add", "36-add", "37-add"]); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "32-add", selected: true }, + { text: "35-add" }, + { text: "36-add" }, + { text: "37-add" }, + { text: "26-add", selected: true }, + { text: "31-add", selected: true, focused: true }, + { text: "33-add" }, + { text: "34-add" }, + ], + "Selection from 25-add to 31-add with gap" + ); + + widget.removeItems(6, 3); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "32-add", selected: true, focused: true }, + { text: "35-add" }, + { text: "36-add" }, + { text: "33-add" }, + { text: "34-add" }, + ], + "Focus jumps gap to what is left of the selection range" + ); + + // Same, with entire gap also removed. + clickWidgetItem(6, { shiftKey: true }); + widget.addItems(5, ["38-add"]); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "32-add", selected: true }, + { text: "35-add", selected: true }, + { text: "38-add" }, + { text: "36-add", selected: true }, + { text: "33-add", selected: true, focused: true }, + { text: "34-add" }, + ], + "Selection from 25-add to 33-add with gap" + ); + + widget.removeItems(4, 4); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "32-add", selected: true, focused: true }, + { text: "34-add" }, + ], + "Focus moves to end of what is left of the selection range" + ); + + // Test deleting the end of the selection with focus in a gap. + // We don't expect to follow the special treatment because the user has + // explicitly moved the focus "outside" of the selected range. + widget.addItems(3, ["39-add"]); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "39-add", focused: true }, + { text: "32-add", selected: true }, + { text: "34-add" }, + ], + "Focus in range gap" + ); + + widget.removeItems(3, 2); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "34-add", focused: true }, + ], + "Focus moves from gap to 34-add, outside the selection range" + ); + + // Same, but deleting the start of the range. + widget.addItems(4, ["40-add", "41-add", "42-add"]); + clickWidgetItem(5, { shiftKey: true }); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "34-add", selected: true }, + { text: "40-add", selected: true }, + { text: "41-add", selected: true, focused: true }, + { text: "42-add" }, + ], + "Selection from 25-add to 41-add" + ); + + widget.addItems(3, ["43-add", "44-add", "45-add"]); + EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "23-add" }, + { text: "25-add", selected: true }, + { text: "43-add", focused: true }, + { text: "44-add" }, + { text: "45-add" }, + { text: "34-add", selected: true }, + { text: "40-add", selected: true }, + { text: "41-add", selected: true }, + { text: "42-add" }, + ], + "Focus in gap" + ); + + widget.removeItems(1, 3); + assertState( + [ + { text: "21-add" }, + { text: "44-add", focused: true }, + { text: "45-add" }, + { text: "34-add", selected: true }, + { text: "40-add", selected: true }, + { text: "41-add", selected: true }, + { text: "42-add" }, + ], + "Focus moves to next item, rather than the selection start" + ); + + // Test deleting the end of the selection with the focus towards the end of + // the range. + widget.addItems(7, ["46-add", "47-add", "48-add", "49-add", "50-add"]); + clickWidgetItem(7, { shiftKey: true }); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "44-add" }, + { text: "45-add" }, + { text: "34-add", selected: true }, + { text: "40-add", selected: true }, + { text: "41-add", selected: true }, + { text: "42-add", selected: true, focused: true }, + { text: "46-add", selected: true }, + { text: "47-add" }, + { text: "48-add" }, + { text: "49-add" }, + { text: "50-add" }, + ], + "Range selection from 34-add to 46-add, with focus on 42-add" + ); + + widget.removeItems(6, 2); + assertState( + [ + { text: "21-add" }, + { text: "44-add" }, + { text: "45-add" }, + { text: "34-add", selected: true }, + { text: "40-add", selected: true }, + { text: "41-add", selected: true, focused: true }, + { text: "47-add" }, + { text: "48-add" }, + { text: "49-add" }, + { text: "50-add" }, + ], + "Focus still moves to the end of the selection" + ); + + // Test deleting with focus after the end of the range. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }); + assertState( + [ + { text: "21-add" }, + { text: "44-add" }, + { text: "45-add" }, + { text: "34-add", selected: true }, + { text: "40-add", selected: true }, + { text: "41-add", selected: true }, + { text: "47-add", focused: true }, + { text: "48-add" }, + { text: "49-add" }, + { text: "50-add" }, + ], + "Focus still moves to the end of the selection" + ); + + widget.removeItems(5, 2); + assertState( + [ + { text: "21-add" }, + { text: "44-add" }, + { text: "45-add" }, + { text: "34-add", selected: true }, + { text: "40-add", selected: true }, + { text: "48-add", focused: true }, + { text: "49-add" }, + { text: "50-add" }, + ], + "Focus remains outside the range" + ); + + // Test deleting with focus in the middle of the range, and end of the range + // is not deleted. + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }); + assertState( + [ + { text: "21-add" }, + { text: "44-add" }, + { text: "45-add" }, + { text: "34-add", selected: true }, + { text: "40-add", selected: true, focused: true }, + { text: "48-add", selected: true }, + { text: "49-add", selected: true }, + { text: "50-add" }, + ], + "Focus in the middle of the range" + ); + + widget.removeItems(4, 1); + assertState( + [ + { text: "21-add" }, + { text: "44-add" }, + { text: "45-add" }, + { text: "34-add", selected: true }, + { text: "48-add", selected: true, focused: true }, + { text: "49-add", selected: true }, + { text: "50-add" }, + ], + "Focus moves to next item, rather than the end of the range" + ); + + // With focus just before a gap. + widget.addItems(5, ["51-add", "52-add"]); + assertState( + [ + { text: "21-add" }, + { text: "44-add" }, + { text: "45-add" }, + { text: "34-add", selected: true }, + { text: "48-add", selected: true, focused: true }, + { text: "51-add" }, + { text: "52-add" }, + { text: "49-add", selected: true }, + { text: "50-add" }, + ], + "Focus just before a gap" + ); + + widget.removeItems(4, 1); + assertState( + [ + { text: "21-add" }, + { text: "44-add" }, + { text: "45-add" }, + { text: "34-add", selected: true }, + { text: "51-add", focused: true }, + { text: "52-add" }, + { text: "49-add", selected: true }, + { text: "50-add" }, + ], + "Focus moves forward into the gap" + ); + + // Selection pivot is about 34-add + clickWidgetItem(1, { shiftKey: true }); + assertState( + [ + { text: "21-add" }, + { text: "44-add", selected: true, focused: true }, + { text: "45-add", selected: true }, + { text: "34-add", selected: true }, + { text: "51-add" }, + { text: "52-add" }, + { text: "49-add" }, + { text: "50-add" }, + ], + "Selection from 34-add backward to 44-add" + ); + clickWidgetItem(5, { shiftKey: true }); + assertState( + [ + { text: "21-add" }, + { text: "44-add" }, + { text: "45-add" }, + { text: "34-add", selected: true }, + { text: "51-add", selected: true }, + { text: "52-add", selected: true, focused: true }, + { text: "49-add" }, + { text: "50-add" }, + ], + "Selection from 34-add forward to 52-add" + ); + + // Delete the whole range. + widget.removeItems(3, 3); + assertState( + [ + { text: "21-add" }, + { text: "44-add" }, + { text: "45-add" }, + { text: "49-add", selected: true, focused: true }, + { text: "50-add" }, + ], + "Focus and selection moves to after the selection range" + ); + + // Do the same with focus outside the range. + widget.addItems(5, ["53-add", "54-add", "55-add"]); + EventUtils.synthesizeKey("KEY_Home", {}, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + clickWidgetItem(2, { shiftKey: true }); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "44-add", selected: true }, + { text: "45-add", selected: true }, + { text: "49-add", focused: true }, + { text: "50-add" }, + { text: "53-add" }, + { text: "54-add" }, + { text: "55-add" }, + ], + "Focus outside selection" + ); + + widget.removeItems(1, 2); + assertState( + [ + { text: "21-add" }, + { text: "49-add", focused: true }, + { text: "50-add" }, + { text: "53-add" }, + { text: "54-add" }, + { text: "55-add" }, + ], + "Focus remains on same item and unselected" + ); + + // Do the same, but the focus is also removed. + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "49-add", selected: true }, + { text: "50-add", selected: true }, + { text: "53-add", focused: true }, + { text: "54-add" }, + { text: "55-add" }, + ], + "Focus outside selection" + ); + + widget.removeItems(1, 3); + assertState( + [ + { text: "21-add" }, + { text: "54-add", selected: true, focused: true }, + { text: "55-add" }, + ], + "Focus and selection moves to 49-add" + ); + + // * Do the same tests but with selection travelling backwards. * + widget.addItems(3, [ + "56-add", + "57-add", + "58-add", + "59-add", + "60-add", + "61-add", + "62-add", + "63-add", + "64-add", + "65-add", + "66-add", + ]); + assertState( + [ + { text: "21-add" }, + { text: "54-add", selected: true, focused: true }, + { text: "55-add" }, + { text: "56-add" }, + { text: "57-add" }, + { text: "58-add" }, + { text: "59-add" }, + { text: "60-add" }, + { text: "61-add" }, + { text: "62-add" }, + { text: "63-add" }, + { text: "64-add" }, + { text: "65-add" }, + { text: "66-add" }, + ], + "Same selection and focus" + ); + EventUtils.synthesizeKey("KEY_End", {}, win); + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + EventUtils.synthesizeKey("KEY_ArrowRight", {}, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "54-add" }, + { text: "55-add" }, + { text: "56-add" }, + { text: "57-add", selected: true, focused: true }, + { text: "58-add", selected: true }, + { text: "59-add", selected: true }, + { text: "60-add", selected: true }, + { text: "61-add", selected: true }, + { text: "62-add", selected: true }, + { text: "63-add" }, + { text: "64-add" }, + { text: "65-add" }, + { text: "66-add" }, + ], + "Backward range selection from 62-add to 57-add" + ); + + // Delete after the selection range + widget.removeItems(11, 2); + assertState( + [ + { text: "21-add" }, + { text: "54-add" }, + { text: "55-add" }, + { text: "56-add" }, + { text: "57-add", selected: true, focused: true }, + { text: "58-add", selected: true }, + { text: "59-add", selected: true }, + { text: "60-add", selected: true }, + { text: "61-add", selected: true }, + { text: "62-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Same range selection" + ); + + // Delete before the selection range. + widget.removeItems(2, 2); + assertState( + [ + { text: "21-add" }, + { text: "54-add" }, + { text: "57-add", selected: true, focused: true }, + { text: "58-add", selected: true }, + { text: "59-add", selected: true }, + { text: "60-add", selected: true }, + { text: "61-add", selected: true }, + { text: "62-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Same range selection" + ); + + // Delete the end of the selection range. + widget.removeItems(7, 1); + assertState( + [ + { text: "21-add" }, + { text: "54-add" }, + { text: "57-add", selected: true, focused: true }, + { text: "58-add", selected: true }, + { text: "59-add", selected: true }, + { text: "60-add", selected: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range backwards from 61-add to 57-add" + ); + + // Selection pivot is now around 61-add. + EventUtils.synthesizeKey("KEY_End", { shiftKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "54-add" }, + { text: "57-add" }, + { text: "58-add" }, + { text: "59-add" }, + { text: "60-add" }, + { text: "61-add", selected: true }, + { text: "63-add", selected: true }, + { text: "66-add", selected: true, focused: true }, + ], + "Selection range forwards from 61-add to 66-add" + ); + EventUtils.synthesizeKey("KEY_Home", { shiftKey: true }, win); + assertState( + [ + { text: "21-add", selected: true, focused: true }, + { text: "54-add", selected: true }, + { text: "57-add", selected: true }, + { text: "58-add", selected: true }, + { text: "59-add", selected: true }, + { text: "60-add", selected: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range backwards from 61-add to 21-add" + ); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "54-add" }, + { text: "57-add", selected: true, focused: true }, + { text: "58-add", selected: true }, + { text: "59-add", selected: true }, + { text: "60-add", selected: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range backwards from 61-add to 57-add" + ); + + // Delete the start of the selection. + widget.removeItems(1, 3); + assertState( + [ + { text: "21-add" }, + { text: "59-add", selected: true, focused: true }, + { text: "60-add", selected: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range shrinks to 59-add" + ); + + // Do the same with a gap after the focus and its next item. + widget.addItems(3, ["67-add", "68-add", "69-add"]); + assertState( + [ + { text: "21-add" }, + { text: "59-add", selected: true, focused: true }, + { text: "60-add", selected: true }, + { text: "67-add" }, + { text: "68-add" }, + { text: "69-add" }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range backwards from 61-add to 59-add with gap" + ); + + widget.removeItems(1, 1); + assertState( + [ + { text: "21-add" }, + { text: "60-add", selected: true, focused: true }, + { text: "67-add" }, + { text: "68-add" }, + { text: "69-add" }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Focus moves to the next item, before gap" + ); + + // Do the same with a gap and all items before the gap are removed. + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "60-add" }, + { text: "67-add", selected: true, focused: true }, + { text: "68-add", selected: true }, + { text: "69-add", selected: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range backward reduced to 67-add with gap filled" + ); + widget.addItems(4, ["70-add", "71-add"]); + assertState( + [ + { text: "21-add" }, + { text: "60-add" }, + { text: "67-add", selected: true, focused: true }, + { text: "68-add", selected: true }, + { text: "70-add" }, + { text: "71-add" }, + { text: "69-add", selected: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range backward from 61-add to 67-add with gap" + ); + + widget.removeItems(2, 2); + assertState( + [ + { text: "21-add" }, + { text: "60-add" }, + { text: "70-add" }, + { text: "71-add" }, + { text: "69-add", selected: true, focused: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Focus jumps gap to selection range" + ); + + // Same, with entire gap also removed. + clickWidgetItem(1, { shiftKey: true }); + widget.addItems(2, ["72-add"]); + assertState( + [ + { text: "21-add" }, + { text: "60-add", selected: true, focused: true }, + { text: "72-add" }, + { text: "70-add", selected: true }, + { text: "71-add", selected: true }, + { text: "69-add", selected: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range backwards from 60-add to 70-add" + ); + + widget.removeItems(1, 3); + assertState( + [ + { text: "21-add" }, + { text: "71-add", selected: true, focused: true }, + { text: "69-add", selected: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Focus moves to the start of what is left of the selection range" + ); + + // Test deleting the start of the selection with focus in a gap. + // We don't expect to follow the special treatment because the user has + // explicitly moved the focus "outside" of the selected range. + widget.addItems(2, ["73-add", "74-add", "75-add"]); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "71-add", selected: true }, + { text: "73-add", focused: true }, + { text: "74-add" }, + { text: "75-add" }, + { text: "69-add", selected: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Focus in range gap" + ); + + widget.removeItems(1, 2); + assertState( + [ + { text: "21-add" }, + { text: "74-add", focused: true }, + { text: "75-add" }, + { text: "69-add", selected: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Focus moves to the next item, rather than the selection range" + ); + + // Same, but deleting the end of the range. + clickWidgetItem(1, { shiftKey: true }); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "74-add", selected: true }, + { text: "75-add", selected: true }, + { text: "69-add", selected: true, focused: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range backward from 61-add to 74-add, with focus shifted" + ); + widget.addItems(3, ["76-add", "77-add"]); + assertState( + [ + { text: "21-add" }, + { text: "74-add", selected: true }, + { text: "75-add", selected: true }, + { text: "76-add" }, + { text: "77-add" }, + { text: "69-add", selected: true, focused: true }, + { text: "61-add", selected: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Focus in gap" + ); + + widget.removeItems(5, 2); + assertState( + [ + { text: "21-add" }, + { text: "74-add", selected: true }, + { text: "75-add", selected: true }, + { text: "76-add" }, + { text: "77-add" }, + { text: "63-add", focused: true }, + { text: "66-add" }, + ], + "Focus moves to next item, rather than selection range end" + ); + + // Selection pivot now about 75-add. + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "74-add" }, + { text: "75-add", selected: true }, + { text: "76-add", selected: true }, + { text: "77-add", selected: true, focused: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range forward from 75-add to 77-add" + ); + + widget.addItems(1, ["78-add", "79-add", "80-add"]); + clickWidgetItem(2, { shiftKey: true }); + assertState( + [ + { text: "21-add" }, + { text: "78-add" }, + { text: "79-add", selected: true, focused: true }, + { text: "80-add", selected: true }, + { text: "74-add", selected: true }, + { text: "75-add", selected: true }, + { text: "76-add" }, + { text: "77-add" }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range backward from 75-add to 79-add" + ); + + // Move focus to the end of the range and delete again, but with no gap this + // time. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertState( + [ + { text: "21-add" }, + { text: "78-add" }, + { text: "79-add", selected: true }, + { text: "80-add", selected: true }, + { text: "74-add", selected: true }, + { text: "75-add", selected: true, focused: true }, + { text: "76-add" }, + { text: "77-add" }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Focus moved to the end of the selection" + ); + + widget.removeItems(5, 2); + assertState( + [ + { text: "21-add" }, + { text: "78-add" }, + { text: "79-add", selected: true }, + { text: "80-add", selected: true }, + { text: "74-add", selected: true }, + { text: "77-add", focused: true }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Focus moved to the next item rather than the selection start" + ); + + // Deleting with focus before the selection start. + EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }); + assertState( + [ + { text: "21-add" }, + { text: "78-add", focused: true }, + { text: "79-add", selected: true }, + { text: "80-add", selected: true }, + { text: "74-add", selected: true }, + { text: "77-add" }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Focus before the selection start" + ); + + widget.removeItems(1, 1); + assertState( + [ + { text: "21-add" }, + { text: "79-add", selected: true, focused: true }, + { text: "80-add", selected: true }, + { text: "74-add", selected: true }, + { text: "77-add" }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Focus moves to the next item, which happens to be in the selection range" + ); + + // Test deleting with focus in the middle of the range. + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }); + assertState( + [ + { text: "21-add", selected: true }, + { text: "79-add", selected: true, focused: true }, + { text: "80-add", selected: true }, + { text: "74-add", selected: true }, + { text: "77-add" }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Selection range backwards from 74-add to 21-add, with focus in middle" + ); + + widget.removeItems(1, 1); + assertState( + [ + { text: "21-add", selected: true }, + { text: "80-add", selected: true, focused: true }, + { text: "74-add", selected: true }, + { text: "77-add" }, + { text: "63-add" }, + { text: "66-add" }, + ], + "Focus moves to the next item, rather than the selection start or end" + ); + + // Delete the whole range. + widget.removeItems(0, 4); + assertState( + [{ text: "63-add", selected: true, focused: true }, { text: "66-add" }], + "Focus and selection move to the next remaining item" + ); + + // Do the same with focus outside the range. + widget.addItems(0, ["81-add", "82-add", "83-add", "84-add", "85-add"]); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertState( + [ + { text: "81-add" }, + { text: "82-add" }, + { text: "83-add" }, + { text: "84-add", focused: true }, + { text: "85-add", selected: true }, + { text: "63-add", selected: true }, + { text: "66-add" }, + ], + "Focus outside backward selection range" + ); + + widget.removeItems(4, 2); + assertState( + [ + { text: "81-add" }, + { text: "82-add" }, + { text: "83-add" }, + { text: "84-add", focused: true }, + { text: "66-add" }, + ], + "Focus remains the same and is not selected" + ); + + // Same, but with focus also removed. + widget.addItems(5, ["86-add"]); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertState( + [ + { text: "81-add" }, + { text: "82-add", focused: true }, + { text: "83-add", selected: true }, + { text: "84-add", selected: true }, + { text: "66-add" }, + { text: "86-add" }, + ], + "Focus outside backwards selection range" + ); + + widget.removeItems(1, 3); + assertState( + [ + { text: "81-add" }, + { text: "66-add", selected: true, focused: true }, + { text: "86-add" }, + ], + "Focus moves to next item and selected" + ); + + // With multi-selection via toggling. + + widget.addItems(3, [ + "87-add", + "88-add", + "89-add", + "90-add", + "91-add", + "92-add", + "93-add", + "94-add", + ]); + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { shiftKey: true }, win); + assertState( + [ + { text: "81-add" }, + { text: "66-add" }, + { text: "86-add" }, + { text: "87-add", selected: true }, + { text: "88-add", selected: true }, + { text: "89-add", selected: true }, + { text: "90-add", selected: true, focused: true }, + { text: "91-add" }, + { text: "92-add" }, + { text: "93-add" }, + { text: "94-add" }, + ], + "Start with range selection forward from 87-add to 90-add" + ); + + clickWidgetItem(4, { ctrlKey: true }); + clickWidgetItem(5, { ctrlKey: true }); + assertState( + [ + { text: "81-add" }, + { text: "66-add" }, + { text: "86-add" }, + { text: "87-add", selected: true }, + { text: "88-add" }, + { text: "89-add", focused: true }, + { text: "90-add", selected: true }, + { text: "91-add" }, + { text: "92-add" }, + { text: "93-add" }, + { text: "94-add" }, + ], + "Range selection from 87-add to 90-add, with 88-add and 89-add unselected" + ); + + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }); + EventUtils.synthesizeKey(" ", { ctrlKey: true }); + assertState( + [ + { text: "81-add" }, + { text: "66-add" }, + { text: "86-add" }, + { text: "87-add", selected: true }, + { text: "88-add" }, + { text: "89-add" }, + { text: "90-add", selected: true }, + { text: "91-add", selected: true, focused: true }, + { text: "92-add" }, + { text: "93-add" }, + { text: "94-add" }, + ], + "Mixed selection" + ); + + // Remove before the selected items + widget.removeItems(0, 2); + assertState( + [ + { text: "86-add" }, + { text: "87-add", selected: true }, + { text: "88-add" }, + { text: "89-add" }, + { text: "90-add", selected: true }, + { text: "91-add", selected: true, focused: true }, + { text: "92-add" }, + { text: "93-add" }, + { text: "94-add" }, + ], + "Same selection and focus" + ); + + // Remove after + widget.removeItems(6, 1); + assertState( + [ + { text: "86-add" }, + { text: "87-add", selected: true }, + { text: "88-add" }, + { text: "89-add" }, + { text: "90-add", selected: true }, + { text: "91-add", selected: true, focused: true }, + { text: "93-add" }, + { text: "94-add" }, + ], + "Same selection and focus" + ); + + // Removed the focused item, unlike a simple range selection, the focused + // item is not bound to stay within the selected items. + widget.removeItems(5, 1); + assertState( + [ + { text: "86-add" }, + { text: "87-add", selected: true }, + { text: "88-add" }, + { text: "89-add" }, + { text: "90-add", selected: true }, + { text: "93-add", focused: true }, + { text: "94-add" }, + ], + "Focus moves to next item and not selected" + ); + + // Remove the unselected items, merging the two ranges together. + widget.removeItems(2, 2); + assertState( + [ + { text: "86-add" }, + { text: "87-add", selected: true }, + { text: "90-add", selected: true }, + { text: "93-add", focused: true }, + { text: "94-add" }, + ], + "Focus remains the same" + ); + + // Remove the selected items. + widget.removeItems(1, 2); + assertState( + [ + { text: "86-add" }, + { text: "93-add", focused: true }, + { text: "94-add" }, + ], + "Focus remains the same, and not selected" + ); + + // Remove all selected items, including the focused item. + widget.addItems(0, [ + "95-add", + "96-add", + "97-add", + "98-add", + "99-add", + "100-add", + "101-add", + ]); + EventUtils.synthesizeKey(" ", {}, win); + assertState( + [ + { text: "95-add" }, + { text: "96-add" }, + { text: "97-add" }, + { text: "98-add" }, + { text: "99-add" }, + { text: "100-add" }, + { text: "101-add" }, + { text: "86-add" }, + { text: "93-add", selected: true, focused: true }, + { text: "94-add" }, + ], + "Single selection" + ); + + clickWidgetItem(6, { ctrlKey: true }); + clickWidgetItem(5, { ctrlKey: true }); + assertState( + [ + { text: "95-add" }, + { text: "96-add" }, + { text: "97-add" }, + { text: "98-add" }, + { text: "99-add" }, + { text: "100-add", selected: true, focused: true }, + { text: "101-add", selected: true }, + { text: "86-add" }, + { text: "93-add", selected: true }, + { text: "94-add" }, + ], + "Mixed selection with focus selected" + ); + + widget.removeItems(5, 4); + assertState( + [ + { text: "95-add" }, + { text: "96-add" }, + { text: "97-add" }, + { text: "98-add" }, + { text: "99-add" }, + { text: "94-add", selected: true, focused: true }, + ], + "Focus moves to next item and selected" + ); + + // Remove all selected, with focus outside the selection + clickWidgetItem(1, {}); + clickWidgetItem(3, { ctrlKey: true }); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }); + assertState( + [ + { text: "95-add" }, + { text: "96-add", selected: true }, + { text: "97-add", focused: true }, + { text: "98-add", selected: true }, + { text: "99-add" }, + { text: "94-add" }, + ], + "Mixed selection with focus not selected" + ); + + widget.removeItems(1, 3); + assertState( + [ + { text: "95-add" }, + { text: "99-add", selected: true, focused: true }, + { text: "94-add" }, + ], + "Focus moves to next item and selected" + ); + + // With select all. + widget.addItems(0, ["102-add", "103-add", "104-add"]); + widget.addItems(6, ["105-add", "106-add"]); + selectAllShortcut(); + assertState( + [ + { text: "102-add", selected: true }, + { text: "103-add", selected: true }, + { text: "104-add", selected: true }, + { text: "95-add", selected: true }, + { text: "99-add", selected: true, focused: true }, + { text: "94-add", selected: true }, + { text: "105-add", selected: true }, + { text: "106-add", selected: true }, + ], + "All selected" + ); + + // Remove middle and focused. + widget.removeItems(4, 1); + assertState( + [ + { text: "102-add", selected: true }, + { text: "103-add", selected: true }, + { text: "104-add", selected: true }, + { text: "95-add", selected: true }, + { text: "94-add", selected: true, focused: true }, + { text: "105-add", selected: true }, + { text: "106-add", selected: true }, + ], + "Focus moves to the next item, selections remain" + ); + + // Remove before focused. + widget.removeItems(1, 1); + assertState( + [ + { text: "102-add", selected: true }, + { text: "104-add", selected: true }, + { text: "95-add", selected: true }, + { text: "94-add", selected: true, focused: true }, + { text: "105-add", selected: true }, + { text: "106-add", selected: true }, + ], + "Focus and selection remain" + ); + + // Remove after the focus. + widget.removeItems(4, 2); + assertState( + [ + { text: "102-add", selected: true }, + { text: "104-add", selected: true }, + { text: "95-add", selected: true }, + { text: "94-add", selected: true, focused: true }, + ], + "Focus and selection remain" + ); + + // Remove end and focused. + widget.removeItems(3, 1); + assertState( + [ + { text: "102-add", selected: true }, + { text: "104-add", selected: true }, + { text: "95-add", selected: true, focused: true }, + ], + "Focus moves to the last item, selection remains" + ); + + // Remove start and focused. + EventUtils.synthesizeKey("KEY_Home", { ctrlKey: true }, win); + assertState( + [ + { text: "102-add", selected: true, focused: true }, + { text: "104-add", selected: true }, + { text: "95-add", selected: true }, + ], + "Focus on first item" + ); + + widget.removeItems(0, 1); + assertState( + [ + { text: "104-add", selected: true, focused: true }, + { text: "95-add", selected: true }, + ], + "Focus moves to next item" + ); + + // Remove items to cause two distinct ranges to merge together, with + // in-between ranges removed. + widget.addItems(2, [ + "107-add", + "108-add", + "109-add", + "110-add", + "111-add", + "112-add", + "113-add", + ]); + clickWidgetItem(0, { ctrlKey: true }); + assertState( + [ + { text: "104-add", focused: true }, + { text: "95-add", selected: true }, + { text: "107-add" }, + { text: "108-add" }, + { text: "109-add" }, + { text: "110-add" }, + { text: "111-add" }, + { text: "112-add" }, + { text: "113-add" }, + ], + "Added items and de-selected 104-add" + ); + + clickWidgetItem(3, { ctrlKey: true }); + clickWidgetItem(4, { ctrlKey: true }); + clickWidgetItem(6, { ctrlKey: true }); + clickWidgetItem(8, { ctrlKey: true }); + assertState( + [ + { text: "104-add" }, + { text: "95-add", selected: true }, + { text: "107-add" }, + { text: "108-add", selected: true }, + { text: "109-add", selected: true }, + { text: "110-add" }, + { text: "111-add", selected: true }, + { text: "112-add" }, + { text: "113-add", selected: true, focused: true }, + ], + "Several selection ranges" + ); + + widget.removeItems(2, 6); + assertState( + [ + { text: "104-add" }, + { text: "95-add", selected: true }, + { text: "113-add", selected: true, focused: true }, + ], + "End ranges merged together" + ); + + // Do the same, but where parts of the end ranges are also removed. + widget.addItems(3, [ + "114-add", + "115-add", + "116-add", + "117-add", + "118-add", + "119-add", + "120-add", + "121-add", + "122-add", + "123-add", + "124-add", + "125-add", + "126-add", + "127-add", + ]); + clickWidgetItem(4, { ctrlKey: true }); + clickWidgetItem(5, { ctrlKey: true }); + clickWidgetItem(7, { ctrlKey: true }); + clickWidgetItem(10, { ctrlKey: true }); + clickWidgetItem(12, { ctrlKey: true }); + clickWidgetItem(13, { ctrlKey: true }); + clickWidgetItem(14, { ctrlKey: true }); + clickWidgetItem(16, { ctrlKey: true }); + + clickWidgetItem(9, { ctrlKey: true }); + assertState( + [ + { text: "104-add" }, + { text: "95-add", selected: true }, + { text: "113-add", selected: true }, + { text: "114-add" }, + { text: "115-add", selected: true }, + { text: "116-add", selected: true }, + { text: "117-add" }, + { text: "118-add", selected: true }, + { text: "119-add" }, + { text: "120-add", selected: true, focused: true }, + { text: "121-add", selected: true }, + { text: "122-add" }, + { text: "123-add", selected: true }, + { text: "124-add", selected: true }, + { text: "125-add", selected: true }, + { text: "126-add" }, + { text: "127-add", selected: true }, + ], + "Several ranges" + ); + + widget.removeItems(5, 8); + assertState( + [ + { text: "104-add" }, + { text: "95-add", selected: true }, + { text: "113-add", selected: true }, + { text: "114-add" }, + { text: "115-add", selected: true }, + { text: "124-add", selected: true, focused: true }, + { text: "125-add", selected: true }, + { text: "126-add" }, + { text: "127-add", selected: true }, + ], + "Two ranges merged and rest removed, focus moves to next item" + ); + } +}); + +// If widget is emptied whilst focused, focus moves to widget. +add_task(function test_emptying_widget() { + for (let model of selectionModels) { + // Empty with focused widget. + reset({ model }); + stepFocus(true, { element: widget }, "Initial"); + widget.addItems(0, ["First", "Second"]); + assertFocus({ element: widget }, "Focus still on widget after adding"); + widget.removeItems(0, 2); + assertFocus({ element: widget }, "Focus still on widget after removing"); + + // Empty with focused item. + widget.addItems(0, ["First", "Second"]); + EventUtils.synthesizeKey("KEY_Home", {}, win); + assertFocus({ index: 0 }, "Focus on first item"); + widget.removeItems(0, 2); + assertFocus({ element: widget }, "Focus moves to widget after removing"); + + // Empty with focus elsewhere. + widget.addItems(0, ["First", "Second"]); + stepFocus(false, { element: before }, "Focus elsewhere"); + widget.removeItems(0, 2); + assertFocus({ element: before }, "Focus still elsewhere after removing"); + stepFocus(true, { element: widget }, "Widget becomes focused"); + + // Empty with focus elsewhere, but active item. + widget.addItems(0, ["First", "Second"]); + // Move away from and back to widget to focus second item. + stepFocus(true, { element: after }, "Focus elsewhere"); + widget.selectSingleItem(1); + stepFocus(false, { index: 1 }, "Focus on second item"); + stepFocus(false, { element: before }, "Return focus to elsewhere"); + widget.removeItems(0, 2); + assertFocus({ element: before }, "Focus still elsewhere after removing"); + stepFocus(true, { element: widget }, "Widget becomes focused"); + } +}); + +/** + * Test moving items in the widget. + * + * @param {string} model - The selection model to use. + * @param {boolean} reCreate - Whether the widget should reCreate the items when + * moving them. + */ +function subtest_move_items(model, reCreate) { + reset({ model, direction: "right-to-left" }); + + widget.addItems(0, [ + "0-add", + "1-add", + "2-add", + "3-add", + "4-add", + "5-add", + "6-add", + "7-add", + "8-add", + "9-add", + "10-add", + "11-add", + "12-add", + "13-add", + ]); + clickWidgetItem(5, {}); + + assertState( + [ + { text: "0-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "3-add" }, + { text: "4-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "9-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Item 5 selected and focused" + ); + + // Move items before focus. + widget.moveItems(4, 3, 1, reCreate); + assertState( + [ + { text: "0-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "4-add" }, + { text: "3-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "9-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Same focus and selection" + ); + widget.moveItems(1, 3, 2, reCreate); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "3-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "6-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "9-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Same focus and selection" + ); + + // Move items after focus. + widget.moveItems(6, 8, 2, reCreate); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "3-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "8-add" }, + { text: "9-add" }, + { text: "6-add" }, + { text: "7-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Same focus and selection" + ); + widget.moveItems(9, 6, 1, reCreate); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "3-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "7-add" }, + { text: "8-add" }, + { text: "9-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Same focus and selection" + ); + + // Move from before focus to after focus. + widget.moveItems(2, 3, 3, reCreate); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "3-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "9-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Same focus and selection, but moved" + ); + + // Move from after focus to before focus. + widget.moveItems(3, 2, 5, reCreate); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "3-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "9-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Same focus and selection, but moved" + ); + + // Move selected and focused up. + widget.moveItems(7, 3, 1, reCreate); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "3-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "1-add" }, + { text: "2-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "9-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Focus and selection moved to index 3" + ); + + // Move down. + widget.moveItems(3, 5, 1, reCreate); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "3-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "7-add" }, + { text: "8-add" }, + { text: "9-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Focus and selection moved to index 5" + ); + + // Move in a group. + widget.moveItems(4, 5, 3, reCreate); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "3-add" }, + { text: "1-add" }, + { text: "8-add" }, + { text: "2-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "7-add" }, + { text: "9-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Focus and selection moved to index 6" + ); + widget.moveItems(5, 4, 3, reCreate); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "3-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "7-add" }, + { text: "8-add" }, + { text: "9-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Focus and selection moved back to index 5" + ); + + // With focus split from selection. + if (model == "focus") { + return; + } + + // Focus before selection. + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertState( + [ + { text: "0-add" }, + { text: "4-add" }, + { text: "3-add" }, + { text: "1-add", focused: true }, + { text: "2-add" }, + { text: "5-add", selected: true }, + { text: "7-add" }, + { text: "8-add" }, + { text: "9-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Focus before selection" + ); + + // Move before both. + widget.moveItems(0, 1, 1, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "0-add" }, + { text: "3-add" }, + { text: "1-add", focused: true }, + { text: "2-add" }, + { text: "5-add", selected: true }, + { text: "7-add" }, + { text: "8-add" }, + { text: "9-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Same focus and selection" + ); + + // Move after both. + widget.moveItems(8, 7, 1, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "0-add" }, + { text: "3-add" }, + { text: "1-add", focused: true }, + { text: "2-add" }, + { text: "5-add", selected: true }, + { text: "7-add" }, + { text: "9-add" }, + { text: "8-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Same focus and selection" + ); + + // Move focus to after selected. + widget.moveItems(3, 6, 2, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "0-add" }, + { text: "3-add" }, + { text: "5-add", selected: true }, + { text: "7-add" }, + { text: "9-add" }, + { text: "1-add", focused: true }, + { text: "2-add" }, + { text: "8-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Focus moved to after selection" + ); + + // Move focus before selected. + widget.moveItems(5, 2, 3, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "0-add" }, + { text: "9-add" }, + { text: "1-add", focused: true }, + { text: "2-add" }, + { text: "3-add" }, + { text: "5-add", selected: true }, + { text: "7-add" }, + { text: "8-add" }, + { text: "6-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Focus moved to before selection" + ); + + // Move selection before focus. + widget.moveItems(5, 1, 5, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "3-add" }, + { text: "5-add", selected: true }, + { text: "7-add" }, + { text: "8-add" }, + { text: "6-add" }, + { text: "0-add" }, + { text: "9-add" }, + { text: "1-add", focused: true }, + { text: "2-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Selected moved to before focus" + ); + + // Move selection after focus. + widget.moveItems(2, 8, 1, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "3-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "6-add" }, + { text: "0-add" }, + { text: "9-add" }, + { text: "1-add", focused: true }, + { text: "5-add", selected: true }, + { text: "2-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Selected moved to after focus" + ); + + // Navigation still works. + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertState( + [ + { text: "4-add" }, + { text: "3-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "6-add" }, + { text: "0-add" }, + { text: "9-add", focused: true }, + { text: "1-add" }, + { text: "5-add", selected: true }, + { text: "2-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Selected moved to after focus" + ); + + // Test with multi-selection. + if (model == "browse") { + return; + } + + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertState( + [ + { text: "4-add" }, + { text: "3-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "6-add", selected: true, focused: true }, + { text: "0-add", selected: true }, + { text: "9-add", selected: true }, + { text: "1-add" }, + { text: "5-add" }, + { text: "2-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Range selection from 9-add to 6-add" + ); + + // Move non-selected into the middle of the selected. + widget.moveItems(8, 5, 2, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "3-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "6-add", selected: true, focused: true }, + { text: "5-add" }, + { text: "2-add" }, + { text: "0-add", selected: true }, + { text: "9-add", selected: true }, + { text: "1-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Non-selected gap" + ); + + // Moving an item always ends a Shift range selection. + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertState( + [ + { text: "4-add" }, + { text: "3-add" }, + { text: "7-add" }, + { text: "8-add", selected: true, focused: true }, + { text: "6-add", selected: true }, + { text: "5-add" }, + { text: "2-add" }, + { text: "0-add" }, + { text: "9-add" }, + { text: "1-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Range selection from 6-add to 8-add" + ); + + clickWidgetItem(9, { shiftKey: true }, win); + assertState( + [ + { text: "4-add" }, + { text: "3-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "6-add", selected: true }, + { text: "5-add", selected: true }, + { text: "2-add", selected: true }, + { text: "0-add", selected: true }, + { text: "9-add", selected: true }, + { text: "1-add", selected: true, focused: true }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Range selection from 6-add to 1-add" + ); + + // Move selected to middle of selected. + widget.moveItems(8, 6, 2, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "3-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "6-add", selected: true }, + { text: "5-add", selected: true }, + { text: "9-add", selected: true }, + { text: "1-add", selected: true, focused: true }, + { text: "2-add", selected: true }, + { text: "0-add", selected: true }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Selection block" + ); + + // Also ends a Shift range selection. + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertState( + [ + { text: "4-add" }, + { text: "3-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "6-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "9-add", selected: true }, + { text: "1-add", selected: true }, + { text: "2-add" }, + { text: "0-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Range selection from 1-add to 5-add" + ); + + // Move from start of selection to end. + widget.moveItems(5, 7, 1, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "3-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "6-add" }, + { text: "9-add", selected: true }, + { text: "1-add", selected: true }, + { text: "5-add", selected: true, focused: true }, + { text: "2-add" }, + { text: "0-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Moved to end" + ); + + // And reverse. + widget.moveItems(7, 5, 1, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "3-add" }, + { text: "7-add" }, + { text: "8-add" }, + { text: "6-add" }, + { text: "5-add", selected: true, focused: true }, + { text: "9-add", selected: true }, + { text: "1-add", selected: true }, + { text: "2-add" }, + { text: "0-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Moved back to start" + ); + + // Also broke Shift range selection. + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { shiftKey: true }, win); + assertState( + [ + { text: "4-add" }, + { text: "3-add", selected: true, focused: true }, + { text: "7-add", selected: true }, + { text: "8-add", selected: true }, + { text: "6-add", selected: true }, + { text: "5-add", selected: true }, + { text: "9-add" }, + { text: "1-add" }, + { text: "2-add" }, + { text: "0-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "13-add" }, + ], + "Range selection from 5-add to 3-add" + ); + + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_End", { ctrlKey: true }, win); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + + assertState( + [ + { text: "4-add" }, + { text: "3-add", selected: true }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "6-add", selected: true }, + { text: "5-add", selected: true }, + { text: "9-add" }, + { text: "1-add" }, + { text: "2-add", selected: true, focused: true }, + { text: "0-add", selected: true }, + { text: "10-add" }, + { text: "11-add", selected: true }, + { text: "12-add" }, + { text: "13-add", selected: true }, + ], + "Multi-selection" + ); + + // Move selected with gap into middle of a selection block. + widget.moveItems(8, 4, 6, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "3-add", selected: true }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "2-add", selected: true, focused: true }, + { text: "0-add", selected: true }, + { text: "10-add" }, + { text: "11-add", selected: true }, + { text: "12-add" }, + { text: "13-add", selected: true }, + { text: "6-add", selected: true }, + { text: "5-add", selected: true }, + { text: "9-add" }, + { text: "1-add" }, + ], + "Merged ranges together on both sides" + ); + + // Move selected with gap to start of a selection block. + widget.moveItems(5, 1, 5, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "0-add", selected: true }, + { text: "10-add" }, + { text: "11-add", selected: true }, + { text: "12-add" }, + { text: "13-add", selected: true }, + { text: "3-add", selected: true }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "2-add", selected: true, focused: true }, + { text: "6-add", selected: true }, + { text: "5-add", selected: true }, + { text: "9-add" }, + { text: "1-add" }, + ], + "Merged ranges together at start" + ); + + // Move selected with gap to end of a selection block. + widget.moveItems(1, 4, 8, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "2-add", selected: true, focused: true }, + { text: "6-add", selected: true }, + { text: "5-add", selected: true }, + { text: "0-add", selected: true }, + { text: "10-add" }, + { text: "11-add", selected: true }, + { text: "12-add" }, + { text: "13-add", selected: true }, + { text: "3-add", selected: true }, + { text: "7-add" }, + { text: "8-add", selected: true }, + { text: "9-add" }, + { text: "1-add" }, + ], + "Merged ranges together at end" + ); + + // Move block with non-selected boundaries into middle of selected. + widget.moveItems(5, 3, 6, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "2-add", selected: true, focused: true }, + { text: "6-add", selected: true }, + { text: "10-add" }, + { text: "11-add", selected: true }, + { text: "12-add" }, + { text: "13-add", selected: true }, + { text: "3-add", selected: true }, + { text: "7-add" }, + { text: "5-add", selected: true }, + { text: "0-add", selected: true }, + { text: "8-add", selected: true }, + { text: "9-add" }, + { text: "1-add" }, + ], + "Split range block" + ); + + // Move block with selected at start into middle of selected. + widget.moveItems(1, 6, 5, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "13-add", selected: true }, + { text: "3-add", selected: true }, + { text: "7-add" }, + { text: "5-add", selected: true }, + { text: "0-add", selected: true }, + { text: "2-add", selected: true, focused: true }, + { text: "6-add", selected: true }, + { text: "10-add" }, + { text: "11-add", selected: true }, + { text: "12-add" }, + { text: "8-add", selected: true }, + { text: "9-add" }, + { text: "1-add" }, + ], + "Merged ranges together at start" + ); + + // Move block with selected at end into middle of selected. + widget.moveItems(8, 6, 4, reCreate); + assertState( + [ + { text: "4-add" }, + { text: "13-add", selected: true }, + { text: "3-add", selected: true }, + { text: "7-add" }, + { text: "5-add", selected: true }, + { text: "0-add", selected: true }, + { text: "10-add" }, + { text: "11-add", selected: true }, + { text: "12-add" }, + { text: "8-add", selected: true }, + { text: "2-add", selected: true, focused: true }, + { text: "6-add", selected: true }, + { text: "9-add" }, + { text: "1-add" }, + ], + "Merged ranges together at end" + ); + + // Move selected into non-selected region and move to start. + widget.moveItems(4, 0, 6, reCreate); + assertState( + [ + { text: "5-add", selected: true }, + { text: "0-add", selected: true }, + { text: "10-add" }, + { text: "11-add", selected: true }, + { text: "12-add" }, + { text: "8-add", selected: true }, + { text: "4-add" }, + { text: "13-add", selected: true }, + { text: "3-add", selected: true }, + { text: "7-add" }, + { text: "2-add", selected: true, focused: true }, + { text: "6-add", selected: true }, + { text: "9-add" }, + { text: "1-add" }, + ], + "Merged ranges together at end" + ); + + // Remove gap between two selections and move to end. + widget.moveItems(2, 9, 5, reCreate); + assertState( + [ + { text: "5-add", selected: true }, + { text: "0-add", selected: true }, + { text: "13-add", selected: true }, + { text: "3-add", selected: true }, + { text: "7-add" }, + { text: "2-add", selected: true, focused: true }, + { text: "6-add", selected: true }, + { text: "9-add" }, + { text: "1-add" }, + { text: "10-add" }, + { text: "11-add", selected: true }, + { text: "12-add" }, + { text: "8-add", selected: true }, + { text: "4-add" }, + ], + "Merged ranges together" + ); + + // Navigation still works. + EventUtils.synthesizeKey("KEY_ArrowLeft", {}, win); + assertState( + [ + { text: "5-add" }, + { text: "0-add" }, + { text: "13-add" }, + { text: "3-add" }, + { text: "7-add" }, + { text: "2-add" }, + { text: "6-add", selected: true, focused: true }, + { text: "9-add" }, + { text: "1-add" }, + { text: "10-add" }, + { text: "11-add" }, + { text: "12-add" }, + { text: "8-add" }, + { text: "4-add" }, + ], + "Move by one index and single select" + ); +} + +// Moving items in the widget will move focus and selection with the moved +// items. +add_task(function test_move_items() { + for (let model of selectionModels) { + // We want to be sure the methods work with or without re-creating the + // item elements. + subtest_move_items(model, false); + subtest_move_items(model, true); + } +}); + +// Test that dragging is possible. +add_task(function test_can_drag_items() { + /** + * Assert that dragging can occur and takes place with the expected selection. + * + * @param {number} index - The index of the item to start dragging on. We also + * expect this item to have focus during and after dragging. + * @param {number[]} selection - The expected selection during and after + * dragging. + * @param {string} msg - A message to use in assertions. + */ + function assertDragstart(index, selection, msg) { + let element = widget.items[index].element; + let eventFired = false; + + let dragstartListener = event => { + eventFired = true; + Assert.ok( + element.contains(event.target), + `Item ${index} contains the dragstart target` + ); + assertFocus({ index }, `Item ${index} has focus in dragstart: ${msg}`); + assertSelection(selection, `Selection in dragstart: ${msg}`); + }; + widget.addEventListener("dragstart", dragstartListener, true); + + // Synthesize the start of a drag. + let rect = element.getBoundingClientRect(); + let x = rect.left + rect.width / 2; + let y = rect.top + rect.height / 2; + EventUtils.synthesizeMouseAtPoint(x, y, { type: "mousedown" }, win); + EventUtils.synthesizeMouseAtPoint(x, y, { type: "mousemove" }, win); + EventUtils.synthesizeMouseAtPoint(x, y + 60, { type: "mousemove" }, win); + // Don't care about ending the drag. + + Assert.ok(eventFired, `dragstart event fired: ${msg}`); + widget.removeEventListener("dragstart", dragstartListener, true); + assertSelection(selection, `Same selection after dragging: ${msg}`); + assertFocus( + { index }, + `Item ${index} still has focus after dragging: ${msg}` + ); + } + + for (let model of selectionModels) { + reset({ model, draggable: true }); + widget.addItems(0, ["First", "Second", "Third"]); + assertFocus({ element: before }, "Focus outside widget"); + assertSelection([], "No initial selection"); + assertDragstart(1, [1], "First drag with no focus or selection"); + + assertDragstart(1, [1], "Already selected item"); + assertDragstart(2, [2], "Non-selected item"); + + reset({ model, draggable: true }); + widget.addItems(0, ["First", "Second", "Third"]); + widget.selectSingleItem(1); + assertFocus({ element: before }, "Focus outside widget"); + assertSelection([1], "Initial selection on item 1"); + assertDragstart(1, [1], "First drag on selected item"); + + reset({ model, draggable: true }); + widget.addItems(0, ["First", "Second", "Third", "Fourth", "Fifth"]); + widget.selectSingleItem(3); + assertFocus({ element: before }, "Focus outside widget"); + assertSelection([3], "Initial selection on item 3"); + assertDragstart(2, [2], "First drag on non-selected item"); + + // With focus split from selected. + if (model == "focus") { + continue; + } + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + assertFocus({ index: 3 }, "Focus on item 3"); + assertSelection([2], "Item 2 is selected"); + assertDragstart(3, [3], "Non-selected but focused item"); + + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + assertFocus({ index: 2 }, "Focus on item 2"); + assertSelection([3], "Item 3 is selected"); + assertDragstart(3, [3], "Selected but non-focused item"); + + // With mutli-selection. + if (model == "browse") { + continue; + } + + // Clicking a non-selected item will change to selection to the single item + // before dragging. + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowRight", { ctrlKey: true }, win); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 1 }, "Focus on item 1"); + assertSelection([1, 3], "Multi selection"); + assertDragstart(2, [2], "Selection moves to item 2 before drag"); + + // Clicking a selected item will keep the same selection for dragging. + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey("KEY_ArrowLeft", { ctrlKey: true }, win); + EventUtils.synthesizeKey(" ", { ctrlKey: true }, win); + assertFocus({ index: 4 }, "Focus on item 4"); + assertSelection([2, 4], "Multi selection"); + assertDragstart( + 4, + [2, 4], + "Selection same when dragging selected and focused" + ); + assertDragstart( + 2, + [2, 4], + "Selection same when dragging selected and non-focussed" + ); + } +}); |