Web Inspector: Table should support deleting rows
authormattbaker@apple.com <mattbaker@apple.com@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Sat, 27 Oct 2018 23:30:03 +0000 (23:30 +0000)
committermattbaker@apple.com <mattbaker@apple.com@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Sat, 27 Oct 2018 23:30:03 +0000 (23:30 +0000)
https://bugs.webkit.org/show_bug.cgi?id=189803
<rdar://problem/44655709>

Reviewed by Devin Rousso.

Source/WebInspectorUI:

Add methods for removing rows from a Table without reloading the data
source. This patch adds Table.prototype.removeRow for removing a single
row, and Table.prototype.removeSelectedRows for removing the entire selection.
The latter also attempts to select a new row, if possible, before removing
the selection.

* UserInterface/Base/IndexSet.js:
(WI.IndexSet.prototype.copy):

* UserInterface/Views/Table.js:
(WI.Table):
(WI.Table.prototype.get numberOfRows):
Cache the number of rows in the table data source. Invalidate cached
value whenever table data is reloaded. Removing rows immediately updates
cached value, without incurring a potentially expensive reload.

(WI.Table.prototype.reloadData):
(WI.Table.prototype.selectRow):
(WI.Table.prototype.deselectRow):
(WI.Table.prototype.removeRow):
(WI.Table.prototype.removeSelectedRows):
Remove the selected rows and select a new row, if possible.
(WI.Table.prototype._updateVisibleRows):
(WI.Table.prototype._handleKeyDown):
(WI.Table.prototype._deselectAllAndSelect):
Drive-by fix: should work when `rowToSelect` isn't already selected.
(WI.Table.prototype._removeRows):
Remove rows and adjust the indexes of rows that are shifted up as a result
of preceding rows being removed.

LayoutTests:

* inspector/table/resources/table-utilities.js:
(TestPage.registerInitializer.InspectorTest.TableDelegate.prototype.tableDidRemoveRows):
(TestPage.registerInitializer.createDataSource):
(TestPage.registerInitializer.InspectorTest.createTable):
(TestPage.registerInitializer.InspectorTest.createTableWithDelegate):
(TestPage.registerInitializer):
Update table test utilities to keep TableDataSource backing array in
sync when rows are removed. Also allow the number of table rows to be
specified when creating a table for testing.

* inspector/table/table-remove-rows-expected.txt: Added.
* inspector/table/table-remove-rows.html: Added.
Add tests for new Table methods `removeRow(rowIndex)` and `removeSelectedRows()`.

* inspector/unit-tests/index-set-expected.txt:
* inspector/unit-tests/index-set.html:
Add tests for new IndexSet method `copy()`.

git-svn-id: https://svn.webkit.org/repository/webkit/trunk@237495 268f45cc-cd09-0410-ab3c-d52691b4dbfc

LayoutTests/ChangeLog
LayoutTests/inspector/table/resources/table-utilities.js
LayoutTests/inspector/table/table-remove-rows-expected.txt [new file with mode: 0644]
LayoutTests/inspector/table/table-remove-rows.html [new file with mode: 0644]
LayoutTests/inspector/unit-tests/index-set-expected.txt
LayoutTests/inspector/unit-tests/index-set.html
Source/WebInspectorUI/ChangeLog
Source/WebInspectorUI/UserInterface/Base/IndexSet.js
Source/WebInspectorUI/UserInterface/Views/Table.js

index 1b580d9..ba62b80 100644 (file)
@@ -1,3 +1,29 @@
+2018-10-27  Matt Baker  <mattbaker@apple.com>
+
+        Web Inspector: Table should support deleting rows
+        https://bugs.webkit.org/show_bug.cgi?id=189803
+        <rdar://problem/44655709>
+
+        Reviewed by Devin Rousso.
+
+        * inspector/table/resources/table-utilities.js:
+        (TestPage.registerInitializer.InspectorTest.TableDelegate.prototype.tableDidRemoveRows):
+        (TestPage.registerInitializer.createDataSource):
+        (TestPage.registerInitializer.InspectorTest.createTable):
+        (TestPage.registerInitializer.InspectorTest.createTableWithDelegate):
+        (TestPage.registerInitializer):
+        Update table test utilities to keep TableDataSource backing array in
+        sync when rows are removed. Also allow the number of table rows to be
+        specified when creating a table for testing.
+
+        * inspector/table/table-remove-rows-expected.txt: Added.
+        * inspector/table/table-remove-rows.html: Added.
+        Add tests for new Table methods `removeRow(rowIndex)` and `removeSelectedRows()`.
+
+        * inspector/unit-tests/index-set-expected.txt:
+        * inspector/unit-tests/index-set.html:
+        Add tests for new IndexSet method `copy()`.
+
 2018-10-26  Commit Queue  <commit-queue@webkit.org>
 
         Unreviewed, rolling out r237479 and r237484.
index c0d96d9..183533f 100644 (file)
@@ -21,6 +21,13 @@ TestPage.registerInitializer(() => {
             this.items = items || [];
         }
 
+        tableDidRemoveRows(table, rowIndexes)
+        {
+            // Prevent data source from getting out of sync.
+            for (let index = rowIndexes.length - 1; index >= 0; --index)
+                this.items.splice(index, 1);
+        }
+
         tableSelectionDidChange(table)
         {
             InspectorTest.pass("Table selection changed.");
@@ -36,9 +43,9 @@ TestPage.registerInitializer(() => {
         }
     };
 
-    function createDataSource() {
+    function createDataSource(numberOfRows = 10) {
         let items = [];
-        for (let i = 0; i < 10; ++i)
+        for (let i = 0; i < numberOfRows; ++i)
             items.push({index: i, name: `Row ${i}`});
 
         return new InspectorTest.TableDataSource(items);
@@ -58,16 +65,16 @@ TestPage.registerInitializer(() => {
         return table;
     }
 
-    InspectorTest.createTable = function() {
-        let dataSource = createDataSource();
+    InspectorTest.createTable = function(numberOfRows) {
+        let dataSource = createDataSource(numberOfRows);
         let delegate = new InspectorTest.TableDelegate(dataSource.items);
         return createTableInternal(dataSource, delegate);
     };
 
-    InspectorTest.createTableWithDelegate = function(delegate) {
+    InspectorTest.createTableWithDelegate = function(delegate, numberOfRows) {
         InspectorTest.assert(delegate instanceof InspectorTest.TableDelegate);
 
-        let dataSource = createDataSource();
+        let dataSource = createDataSource(numberOfRows);
         delegate.items = dataSource.items;
         return createTableInternal(dataSource, delegate);
     };
diff --git a/LayoutTests/inspector/table/table-remove-rows-expected.txt b/LayoutTests/inspector/table/table-remove-rows-expected.txt
new file mode 100644 (file)
index 0000000..92e34ab
--- /dev/null
@@ -0,0 +1,84 @@
+Tests that rows can be removed from Table, and that the selection is updated when removing selected rows.
+
+An asterix (*) indicates a selected row; a hyphen (-) indicates a removed row.
+
+
+== Running test suite: Table.RemoveRows
+-- Running test case: Table.RemoveRow.NoneSelected
+Given a Table with selected rows [], remove row 0.
+PASS: Should remove row 0.
+
+-- Running test case: Table.RemoveRow.Selected
+Given a Table with selected rows [0], remove row 0.
+Selection changed to [] before removing row 0.
+PASS: Should remove row 0.
+
+-- Running test case: Table.RemoveSelectedRows.Single.SelectFollowing
+Given a Table with selected rows [0]:
+ * Row 0
+   Row 1
+   Row 2
+   Row 3
+Selection changed before removing rows:
+ - Row 0
+ * Row 1
+   Row 2
+   Row 3
+PASS: Should remove rows [0].
+
+-- Running test case: Table.RemoveSelectedRows.Single.SelectPreceding
+Given a Table with selected rows [3]:
+   Row 0
+   Row 1
+   Row 2
+ * Row 3
+Selection changed before removing rows:
+   Row 0
+   Row 1
+ * Row 2
+ - Row 3
+PASS: Should remove rows [3].
+
+-- Running test case: Table.RemoveSelectedRows.Multiple.SelectFollowing
+Given a Table with selected rows [0,2]:
+ * Row 0
+   Row 1
+ * Row 2
+   Row 3
+Selection changed before removing rows:
+ - Row 0
+   Row 1
+ - Row 2
+ * Row 3
+PASS: Should remove rows [0,2].
+
+-- Running test case: Table.RemoveSelectedRows.Multiple.SelectHole
+Given a Table with selected rows [0,3]:
+ * Row 0
+   Row 1
+   Row 2
+ * Row 3
+Selection changed before removing rows:
+ - Row 0
+ * Row 1
+   Row 2
+ - Row 3
+PASS: Should remove rows [0,3].
+
+-- Running test case: Table.RemoveSelectedRows.Multiple.SelectPreceding
+Given a Table with selected rows [2,3]:
+   Row 0
+   Row 1
+ * Row 2
+ * Row 3
+Selection changed before removing rows:
+   Row 0
+ * Row 1
+ - Row 2
+ - Row 3
+PASS: Should remove rows [2,3].
+
+-- Running test case: Table.RemoveRow.NotCached
+Given a Table with selected rows [], remove row 999.
+PASS: Should remove row 999.
+
diff --git a/LayoutTests/inspector/table/table-remove-rows.html b/LayoutTests/inspector/table/table-remove-rows.html
new file mode 100644 (file)
index 0000000..c461503
--- /dev/null
@@ -0,0 +1,206 @@
+<!doctype html>
+<html>
+<head>
+<script src="../../http/tests/inspector/resources/inspector-test.js"></script>
+<script src="resources/table-utilities.js"></script>
+<script>
+function test()
+{
+    InspectorTest.redirectRequestAnimationFrame();
+
+    let suite = InspectorTest.createSyncSuite("Table.RemoveRows");
+
+    function logTable(table, rowsToRemove = []) {
+        for (let rowIndex = 0; rowIndex < table.numberOfRows; ++rowIndex) {
+            let type = " ";
+            if (rowsToRemove.includes(rowIndex))
+                type = "-";
+            else if (table.selectedRows.includes(rowIndex))
+                type = "*";
+            InspectorTest.log(` ${type} Row ${rowIndex}`);
+        }
+    }
+
+    class RemoveRowTestDelegate extends InspectorTest.TableDelegate
+    {
+        constructor()
+        {
+            super();
+
+            this._rowIndex = NaN;
+        }
+
+        // Public
+
+        triggerRemoveRow(table, rowIndex)
+        {
+            InspectorTest.assert(isNaN(this._rowIndex));
+
+            this._rowIndex = rowIndex;
+
+            InspectorTest.log(`Given a Table with selected rows [${table.selectedRows}], remove row ${rowIndex}.`);
+            table.removeRow(rowIndex);
+        }
+
+        // Table delegate
+
+        tableDidRemoveRows(table, rowIndexes)
+        {
+            super.tableDidRemoveRows(table, rowIndexes);
+
+            InspectorTest.expectShallowEqual(rowIndexes, [this._rowIndex], `Should remove row ${this._rowIndex}.`);
+            this._rowIndex = NaN;
+        }
+
+        tableSelectionDidChange(table)
+        {
+            if (isNaN(this._rowIndex))
+                return;
+
+            InspectorTest.log(`Selection changed to [${table.selectedRows}] before removing row ${this._rowIndex}.`);
+        }
+    }
+
+    class RemoveSelectedRowsTestDelegate extends InspectorTest.TableDelegate
+    {
+        constructor()
+        {
+            super();
+
+            this._rowIndexes = null;
+        }
+
+        // Public
+
+        triggerRemoveSelectedRows(table)
+        {
+            InspectorTest.assert(!this._rowIndexes);
+
+            this._rowIndexes = table.selectedRows;
+
+            InspectorTest.log(`Given a Table with selected rows [${this._rowIndexes}]:`);
+            logTable(table);
+
+            table.removeSelectedRows();
+        }
+
+        // Table delegate
+
+        tableDidRemoveRows(table, rowIndexes)
+        {
+            super.tableDidRemoveRows(table, rowIndexes);
+
+            InspectorTest.expectShallowEqual(rowIndexes, this._rowIndexes, `Should remove rows [${this._rowIndexes}].`);
+            this._rowIndexes = null;
+        }
+
+        tableSelectionDidChange(table)
+        {
+            if (!this._rowIndexes)
+                return;
+
+            InspectorTest.log(`Selection changed before removing rows:`);
+            logTable(table, this._rowIndexes);
+        }
+    }
+
+    const numberOfRows = 4;
+    const lastRowIndex = numberOfRows - 1;
+
+    suite.addTestCase({
+        name: "Table.RemoveRow.NoneSelected",
+        description: "Remove a row from a table with no selected rows.",
+        test() {
+            let testDelegate = new RemoveRowTestDelegate;
+            let table = InspectorTest.createTableWithDelegate(testDelegate, numberOfRows);
+
+            testDelegate.triggerRemoveRow(table, 0);
+
+            return true;
+        }
+    });
+
+    suite.addTestCase({
+        name: "Table.RemoveRow.Selected",
+        description: "Remove the only selected table row.",
+        test() {
+            let testDelegate = new RemoveRowTestDelegate;
+            let table = InspectorTest.createTableWithDelegate(testDelegate, numberOfRows);
+
+            table.selectRow(0);
+            testDelegate.triggerRemoveRow(table, 0);
+
+            return true;
+        }
+    });
+
+    function addTestCase({name, description, rowIndexes}) {
+        suite.addTestCase({
+            name, description,
+            test() {
+                let testDelegate = new RemoveSelectedRowsTestDelegate;
+                let table = InspectorTest.createTableWithDelegate(testDelegate, numberOfRows);
+                table.allowsMultipleSelection = true;
+
+                for (let rowIndex of rowIndexes)
+                    table.selectRow(rowIndex, true);
+
+                testDelegate.triggerRemoveSelectedRows(table);
+
+                return true;
+            }
+        });
+    }
+
+    addTestCase({
+        name: "Table.RemoveSelectedRows.Single.SelectFollowing",
+        description: "Remove the selected row, causing the following row to be selected.",
+        rowIndexes: [0],
+    });
+
+    addTestCase({
+        name: "Table.RemoveSelectedRows.Single.SelectPreceding",
+        description: "Remove the selected row, causing the preceding row to be selected.",
+        rowIndexes: [lastRowIndex],
+    });
+
+    addTestCase({
+        name: "Table.RemoveSelectedRows.Multiple.SelectFollowing",
+        description: "Remove selected rows, causing the row following the selection to be selected.",
+        rowIndexes: [0, lastRowIndex - 1],
+    });
+
+    addTestCase({
+        name: "Table.RemoveSelectedRows.Multiple.SelectHole",
+        description: "Remove selected rows, causing the first deselected row inside the selection to be selected.",
+        rowIndexes: [0, lastRowIndex],
+    });
+
+    addTestCase({
+        name: "Table.RemoveSelectedRows.Multiple.SelectPreceding",
+        description: "Remove selected rows, causing the row preceding the selection to be selected.",
+        rowIndexes: [lastRowIndex - 1, lastRowIndex],
+    });
+
+    suite.addTestCase({
+        name: "Table.RemoveRow.NotCached",
+        description: "Remove a row that is not in the table cache.",
+        test() {
+            let testDelegate = new RemoveRowTestDelegate;
+            let table = InspectorTest.createTableWithDelegate(testDelegate, 1000);
+
+            testDelegate.triggerRemoveRow(table, 999);
+
+            return true;
+        }
+    });
+
+    suite.runTestCasesAndFinish();
+}
+</script>
+</head>
+<body onLoad="runTest()">
+    <p>Tests that rows can be removed from Table, and that the selection is updated when removing selected rows.</p>
+    <p>An asterix (*) indicates a selected row; a hyphen (-) indicates a removed row.</p>
+</body>
+</html>
index c3363cf..81f9a5e 100644 (file)
@@ -68,3 +68,7 @@ PASS: Index less than 1 should be NaN.
 PASS: Index less than 2 should be 1.
 PASS: Index less than 3 should be 2.
 
+-- Running test case: IndexSet.prototype.copy
+PASS: Copy and original should be different objects.
+PASS: Copy and original should have the same values.
+
index 05a3db9..6c64ea8 100644 (file)
@@ -194,6 +194,18 @@ function test()
         }
     });
 
+    suite.addTestCase({
+        name: "IndexSet.prototype.copy",
+        test() {
+            let original = new WI.IndexSet([1, 2, 3]);
+            let copied = original.copy();
+            InspectorTest.expectNotEqual(copied, original, "Copy and original should be different objects.");
+            InspectorTest.expectShallowEqual(Array.from(copied), Array.from(original), "Copy and original should have the same values.");
+
+            return true;
+        }
+    });
+
     suite.runTestCasesAndFinish();
 }
 </script>
index ae27cb4..3d52fcd 100644 (file)
@@ -1,3 +1,41 @@
+2018-10-27  Matt Baker  <mattbaker@apple.com>
+
+        Web Inspector: Table should support deleting rows
+        https://bugs.webkit.org/show_bug.cgi?id=189803
+        <rdar://problem/44655709>
+
+        Reviewed by Devin Rousso.
+
+        Add methods for removing rows from a Table without reloading the data
+        source. This patch adds Table.prototype.removeRow for removing a single
+        row, and Table.prototype.removeSelectedRows for removing the entire selection.
+        The latter also attempts to select a new row, if possible, before removing
+        the selection.
+
+        * UserInterface/Base/IndexSet.js:
+        (WI.IndexSet.prototype.copy):
+
+        * UserInterface/Views/Table.js:
+        (WI.Table):
+        (WI.Table.prototype.get numberOfRows):
+        Cache the number of rows in the table data source. Invalidate cached
+        value whenever table data is reloaded. Removing rows immediately updates
+        cached value, without incurring a potentially expensive reload.
+
+        (WI.Table.prototype.reloadData):
+        (WI.Table.prototype.selectRow):
+        (WI.Table.prototype.deselectRow):
+        (WI.Table.prototype.removeRow):
+        (WI.Table.prototype.removeSelectedRows):
+        Remove the selected rows and select a new row, if possible.
+        (WI.Table.prototype._updateVisibleRows):
+        (WI.Table.prototype._handleKeyDown):
+        (WI.Table.prototype._deselectAllAndSelect):
+        Drive-by fix: should work when `rowToSelect` isn't already selected.
+        (WI.Table.prototype._removeRows):
+        Remove rows and adjust the indexes of rows that are shifted up as a result
+        of preceding rows being removed.
+
 2018-10-26  Devin Rousso  <drousso@apple.com>
 
         Web Inspector: simplify some editing code checks
index f48cd71..c8f8461 100644 (file)
@@ -96,6 +96,13 @@ WI.IndexSet = class IndexSet
         this._indexes = [];
     }
 
+    copy()
+    {
+        let indexSet = new WI.IndexSet;
+        indexSet._indexes = this._indexes.slice();
+        return indexSet;
+    }
+
     indexGreaterThan(value)
     {
         const following = true;
index 82b3c31..51208d8 100644 (file)
@@ -76,6 +76,7 @@ WI.Table = class Table extends WI.View
         this._resizersElement.className = "resizers";
 
         this._cachedRows = new Map;
+        this._cachedNumberOfRows = NaN;
 
         this._columnSpecs = new Map;
         this._columnOrder = [];
@@ -132,6 +133,14 @@ WI.Table = class Table extends WI.View
 
     get scrollContainer() { return this._scrollContainerElement; }
 
+    get numberOfRows()
+    {
+        if (isNaN(this._cachedNumberOfRows))
+            this._cachedNumberOfRows = this._dataSource.tableNumberOfRows(this);
+
+        return this._cachedNumberOfRows;
+    }
+
     get sortOrder()
     {
         return this._sortOrder;
@@ -241,6 +250,7 @@ WI.Table = class Table extends WI.View
         this._selectedRowIndex = NaN;
         this._selectedRows.clear();
 
+        this._cachedNumberOfRows = NaN;
         this._previousRevealedRowCount = NaN;
         this.needsLayout();
     }
@@ -309,6 +319,7 @@ WI.Table = class Table extends WI.View
     selectRow(rowIndex, extendSelection = false)
     {
         console.assert(!extendSelection || this._allowsMultipleSelection, "Cannot extend selection with multiple selection disabled.");
+        console.assert(rowIndex >= 0 && rowIndex < this.numberOfRows);
 
         if (this._isRowSelected(rowIndex)) {
             if (!extendSelection)
@@ -333,6 +344,8 @@ WI.Table = class Table extends WI.View
 
     deselectRow(rowIndex)
     {
+        console.assert(rowIndex >= 0 && rowIndex < this.numberOfRows);
+
         if (!this._isRowSelected(rowIndex))
             return;
 
@@ -373,6 +386,47 @@ WI.Table = class Table extends WI.View
         this._deselectAllAndSelect(rowIndex);
     }
 
+    removeRow(rowIndex)
+    {
+        console.assert(rowIndex >= 0 && rowIndex < this.numberOfRows);
+
+        if (this._isRowSelected(rowIndex))
+            this.deselectRow(rowIndex);
+
+        this._removeRows(new WI.IndexSet([rowIndex]));
+    }
+
+    removeSelectedRows()
+    {
+        let numberOfSelectedRows = this._selectedRows.size;
+        if (!numberOfSelectedRows)
+            return;
+
+        // Try selecting the row following the selection.
+        let lastSelectedRow = this._selectedRows.lastIndex;
+        let rowToSelect = lastSelectedRow + 1;
+        if (rowToSelect === this.numberOfRows) {
+            // If no row exists after the last selected row, try selecting a
+            // deselected row (hole) within the selection.
+            let firstSelectedRow = this._selectedRows.firstIndex;
+            if (lastSelectedRow - firstSelectedRow > numberOfSelectedRows) {
+                rowToSelect = this._selectedRows.firstIndex + 1;
+                while (this._selectedRows.has(rowToSelect))
+                    rowToSelect++;
+            } else {
+                // If the selection contains no holes, try selecting the row
+                // preceding the selection.
+                rowToSelect = firstSelectedRow > 0 ? firstSelectedRow - 1 : NaN;
+            }
+        }
+
+        // Change the selection before removing rows. This matches the behavior
+        // of macOS Finder (in list and column modes) when removing selected items.
+        let oldSelectedRows = this._selectedRows.copy();
+        this._deselectAllAndSelect(rowToSelect);
+        this._removeRows(oldSelectedRows);
+    }
+
     columnWithIdentifier(identifier)
     {
         return this._columnSpecs.get(identifier);
@@ -790,7 +844,7 @@ WI.Table = class Table extends WI.View
         let availableWidth = this._cachedWidth;
         let availableHeight = this._cachedHeight;
 
-        let numberOfRows = this._dataSource.tableNumberOfRows(this);
+        let numberOfRows = this.numberOfRows;
         this._cachedNumberOfRows = numberOfRows;
 
         let contentHeight = numberOfRows * this._rowHeight;
@@ -1011,7 +1065,7 @@ WI.Table = class Table extends WI.View
         if (belowTopThreshold && aboveBottomThreshold && !isNaN(this._previousRevealedRowCount))
             return;
 
-        let numberOfRows = this._dataSource.tableNumberOfRows(this);
+        let numberOfRows = this.numberOfRows;
         this._previousRevealedRowCount = numberOfRows;
 
         // Scroll back up if the number of rows was reduced such that the existing
@@ -1222,6 +1276,7 @@ WI.Table = class Table extends WI.View
         if (event.shiftKey || event.metaKey || event.ctrlKey)
             return;
 
+        let numberOfRows = this.numberOfRows;
         let rowToSelect = NaN;
 
         if (event.keyIdentifier === "Up") {
@@ -1366,8 +1421,6 @@ WI.Table = class Table extends WI.View
             return;
 
         for (let selectedRowIndex of this._selectedRows) {
-            if (selectedRowIndex === rowIndex)
-                continue;
             let oldSelectedRow = this._cachedRows.get(selectedRowIndex);
             if (oldSelectedRow)
                 oldSelectedRow.classList.remove("selected");
@@ -1376,12 +1429,70 @@ WI.Table = class Table extends WI.View
         this._selectedRowIndex = rowIndex;
         this._selectedRows.clear();
 
-        if (!isNaN(rowIndex))
+        if (!isNaN(rowIndex)) {
             this._selectedRows.add(rowIndex);
+            let newSelectedRow = this._cachedRows.get(rowIndex);
+            if (newSelectedRow)
+                newSelectedRow.classList.add("selected");
+        }
 
         this._notifySelectionDidChange();
     }
 
+    _removeRows(rowIndexes)
+    {
+        let removed = 0;
+
+        let adjustRowAtIndex = (index) => {
+            let newIndex = index - removed;
+            let row = this._cachedRows.get(index);
+            if (row) {
+                this._cachedRows.delete(row.__index);
+                row.__index = newIndex;
+                this._cachedRows.set(newIndex, row);
+            }
+
+            if (this._isRowSelected(index)) {
+                this._selectedRows.delete(index);
+                this._selectedRows.add(newIndex);
+                if (this._selectedRowIndex === index)
+                    this._selectedRowIndex = newIndex;
+            }
+        };
+
+        if (rowIndexes.has(this._selectedRowIndex))
+            this._selectedRowIndex = NaN;
+
+        for (let index = rowIndexes.firstIndex; index <= rowIndexes.lastIndex; ++index) {
+            if (rowIndexes.has(index)) {
+                let row = this._cachedRows.get(index);
+                if (row) {
+                    this._cachedRows.delete(index);
+                    row.remove();
+                }
+                removed++;
+                continue;
+            }
+
+            if (removed)
+                adjustRowAtIndex(index);
+        }
+
+        if (!removed)
+            return;
+
+        for (let index = rowIndexes.lastIndex + 1; index < this._cachedNumberOfRows; ++index)
+            adjustRowAtIndex(index);
+
+        this._cachedNumberOfRows -= removed;
+        console.assert(this._cachedNumberOfRows >= 0);
+
+        if (this._delegate.tableDidRemoveRows) {
+            this._delegate.tableDidRemoveRows(this, Array.from(rowIndexes));
+            console.assert(this._cachedNumberOfRows === this._dataSource.tableNumberOfRows(this), "Table data source should update after removing rows.");
+        }
+    }
+
     _isRowSelected(rowIndex)
     {
         return this._selectedRows.has(rowIndex);