Repository: microsoft/vscode (TypeScript)
Date: 2022-05-26 18:03:58+00:00
Task type: Bug Fix

Issue:
Page-down in notebook goes up
- Open https://github.dev/greazer/covid-19-data/blob/master/SampleNbsAndScripts/myanalysis_plotly.ipynb
- Select the top cell and page down a few times
- Focus hits a cell that is longer than the viewport, which is maybe important, then the next page down moves to a higher cell


Issue discussion:
https://github.com/microsoft/vscode/blob/3381e7b20b96d27ea8ac4ee6aa1c065d7b47fa29/src/vs/base/browser/ui/list/listWidget.ts#L1599-L1600

When a list item is larger than the viewport, and it's also the only element in the viewport, pressing page down will reveal to the previous element, this is because we did a `lastPageIndex - 1` here (the catch is, the last page element is also the first page element)

Solution patch:
diff --git a/src/vs/base/browser/ui/list/listWidget.ts b/src/vs/base/browser/ui/list/listWidget.ts
--- a/src/vs/base/browser/ui/list/listWidget.ts
+++ b/src/vs/base/browser/ui/list/listWidget.ts
@@ -1597,14 +1597,12 @@ export class List<T> implements ISpliceable<T>, IThemable, IDisposable {
 	async focusNextPage(browserEvent?: UIEvent, filter?: (element: T) => boolean): Promise<void> {
 		let lastPageIndex = this.view.indexAt(this.view.getScrollTop() + this.view.renderHeight);
 		lastPageIndex = lastPageIndex === 0 ? 0 : lastPageIndex - 1;
-		const lastPageElement = this.view.element(lastPageIndex);
 		const currentlyFocusedElementIndex = this.getFocus()[0];
-		const currentlyFocusedElement = this.view.element(currentlyFocusedElementIndex);
 
-		if (currentlyFocusedElement !== lastPageElement && lastPageIndex > currentlyFocusedElementIndex) {
+		if (currentlyFocusedElementIndex !== lastPageIndex && (currentlyFocusedElementIndex === undefined || lastPageIndex > currentlyFocusedElementIndex)) {
 			const lastGoodPageIndex = this.findPreviousIndex(lastPageIndex, false, filter);
 
-			if (lastGoodPageIndex > -1 && currentlyFocusedElement !== this.view.element(lastGoodPageIndex)) {
+			if (lastGoodPageIndex > -1 && currentlyFocusedElementIndex !== lastGoodPageIndex) {
 				this.setFocus([lastGoodPageIndex], browserEvent);
 			} else {
 				this.setFocus([lastPageIndex], browserEvent);
@@ -1639,14 +1637,12 @@ export class List<T> implements ISpliceable<T>, IThemable, IDisposable {
 			firstPageIndex = this.view.indexAfter(scrollTop - 1);
 		}
 
-		const firstPageElement = this.view.element(firstPageIndex);
 		const currentlyFocusedElementIndex = this.getFocus()[0];
-		const currentlyFocusedElement = this.view.element(currentlyFocusedElementIndex);
 
-		if (currentlyFocusedElement !== firstPageElement && currentlyFocusedElementIndex >= firstPageIndex) {
+		if (currentlyFocusedElementIndex !== firstPageIndex && (currentlyFocusedElementIndex === undefined || currentlyFocusedElementIndex >= firstPageIndex)) {
 			const firstGoodPageIndex = this.findNextIndex(firstPageIndex, false, filter);
 
-			if (firstGoodPageIndex > -1 && currentlyFocusedElement !== this.view.element(firstGoodPageIndex)) {
+			if (firstGoodPageIndex > -1 && currentlyFocusedElementIndex !== firstGoodPageIndex) {
 				this.setFocus([firstGoodPageIndex], browserEvent);
 			} else {
 				this.setFocus([firstPageIndex], browserEvent);


Test patch:
diff --git a/src/vs/base/test/browser/ui/list/listWidget.test.ts b/src/vs/base/test/browser/ui/list/listWidget.test.ts
new file mode 100644
--- /dev/null
+++ b/src/vs/base/test/browser/ui/list/listWidget.test.ts
@@ -0,0 +1,97 @@
+/*---------------------------------------------------------------------------------------------
+ *  Copyright (c) Microsoft Corporation. All rights reserved.
+ *  Licensed under the MIT License. See License.txt in the project root for license information.
+ *--------------------------------------------------------------------------------------------*/
+
+import * as assert from 'assert';
+import { IListRenderer, IListVirtualDelegate } from 'vs/base/browser/ui/list/list';
+import { List } from 'vs/base/browser/ui/list/listWidget';
+import { range } from 'vs/base/common/arrays';
+import { timeout } from 'vs/base/common/async';
+
+suite('ListWidget', function () {
+	test('Page up and down', async function () {
+		const element = document.createElement('div');
+		element.style.height = '200px';
+		element.style.width = '200px';
+
+		const delegate: IListVirtualDelegate<number> = {
+			getHeight() { return 20; },
+			getTemplateId() { return 'template'; }
+		};
+
+		let templatesCount = 0;
+
+		const renderer: IListRenderer<number, void> = {
+			templateId: 'template',
+			renderTemplate() { templatesCount++; },
+			renderElement() { },
+			disposeTemplate() { templatesCount--; }
+		};
+
+		const listWidget = new List<number>('test', element, delegate, [renderer]);
+
+		listWidget.layout(200);
+		assert.strictEqual(templatesCount, 0, 'no templates have been allocated');
+		listWidget.splice(0, 0, range(100));
+		listWidget.focusFirst();
+
+		listWidget.focusNextPage();
+		assert.strictEqual(listWidget.getFocus()[0], 9, 'first page down moves focus to element at bottom');
+
+		// scroll to next page is async
+		listWidget.focusNextPage();
+		await timeout(0);
+		assert.strictEqual(listWidget.getFocus()[0], 19, 'page down to next page');
+
+		listWidget.focusPreviousPage();
+		assert.strictEqual(listWidget.getFocus()[0], 10, 'first page up moves focus to element at top');
+
+		// scroll to previous page is async
+		listWidget.focusPreviousPage();
+		await timeout(0);
+		assert.strictEqual(listWidget.getFocus()[0], 0, 'page down to previous page');
+
+		listWidget.dispose();
+	});
+
+	test('Page up and down with item taller than viewport #149502', async function () {
+		const element = document.createElement('div');
+		element.style.height = '200px';
+		element.style.width = '200px';
+
+		const delegate: IListVirtualDelegate<number> = {
+			getHeight() { return 200; },
+			getTemplateId() { return 'template'; }
+		};
+
+		let templatesCount = 0;
+
+		const renderer: IListRenderer<number, void> = {
+			templateId: 'template',
+			renderTemplate() { templatesCount++; },
+			renderElement() { },
+			disposeTemplate() { templatesCount--; }
+		};
+
+		const listWidget = new List<number>('test', element, delegate, [renderer]);
+
+		listWidget.layout(200);
+		assert.strictEqual(templatesCount, 0, 'no templates have been allocated');
+		listWidget.splice(0, 0, range(100));
+		listWidget.focusFirst();
+		assert.strictEqual(listWidget.getFocus()[0], 0, 'initial focus is first element');
+
+		// scroll to next page is async
+		listWidget.focusNextPage();
+		await timeout(0);
+		assert.strictEqual(listWidget.getFocus()[0], 1, 'page down to next page');
+
+		// scroll to previous page is async
+		listWidget.focusPreviousPage();
+		await timeout(0);
+		assert.strictEqual(listWidget.getFocus()[0], 0, 'page up to next page');
+
+		listWidget.dispose();
+	});
+});