Skip to content

Commit de9e07f

Browse files
Preferences: Scroll to focused item when search cleared (#16992)
1 parent 7751b60 commit de9e07f

File tree

4 files changed

+103
-24
lines changed

4 files changed

+103
-24
lines changed

packages/preferences/src/browser/preference-tree-model.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ export class PreferenceTreeModel extends TreeModelImpl {
7070
protected _isFiltered: boolean = false;
7171
protected _currentRows: Map<string, PreferenceTreeNodeRow> = new Map();
7272
protected _totalVisibleLeaves = 0;
73+
private _suppressSelection = false;
7374

7475
get currentRows(): Readonly<Map<string, PreferenceTreeNodeRow>> {
7576
return this._currentRows;
@@ -117,7 +118,9 @@ export class PreferenceTreeModel extends TreeModelImpl {
117118
if (this.isFiltered) {
118119
this.expandAll();
119120
} else if (CompositeTreeNode.is(this.root)) {
120-
this.collapseAll(this.root);
121+
const root = this.root;
122+
// Avoid intermediate selection events while collapsing.
123+
this.withSuppressedSelection(() => this.collapseAll(root));
121124
}
122125
this.updateFilteredRows(PreferenceFilterChangeSource.Search);
123126
}),
@@ -238,6 +241,21 @@ export class PreferenceTreeModel extends TreeModelImpl {
238241
}
239242
}
240243

244+
override selectNode(node: Readonly<SelectableTreeNode>): void {
245+
if (!this._suppressSelection) {
246+
super.selectNode(node);
247+
}
248+
}
249+
250+
protected withSuppressedSelection(fn: () => void): void {
251+
this._suppressSelection = true;
252+
try {
253+
fn();
254+
} finally {
255+
this._suppressSelection = false;
256+
}
257+
}
258+
241259
getNodeFromPreferenceId(id: string): Preference.TreeNode | undefined {
242260
const node = this.getNode(this.treeGenerator.getNodeId(id));
243261
return node && Preference.TreeNode.is(node) ? node : undefined;

packages/preferences/src/browser/style/search-input.css

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
.theia-settings-container .settings-search-container {
1818
display: flex;
1919
align-items: center;
20+
padding: 1px; /* Enough for the search bar's outline to show up. */
2021
}
2122

2223
.theia-settings-container .settings-search-container .settings-search-input {
@@ -36,10 +37,6 @@
3637
display: flex;
3738
}
3839

39-
.theia-settings-container .settings-search-container .clear-all {
40-
background: var(--theia-icon-clear);
41-
}
42-
4340
.theia-settings-container .settings-search-container .results-found {
4441
background-color: var(--theia-badge-background);
4542
border-radius: 2px;
@@ -59,6 +56,17 @@
5956
border: var(--theia-border-width) solid transparent;
6057
opacity: 0.7;
6158
cursor: pointer;
59+
/* Reset default button styles when using <button> */
60+
appearance: none;
61+
background-color: transparent;
62+
padding: 0;
63+
font: inherit;
64+
color: inherit;
65+
}
66+
67+
.theia-settings-container .settings-search-container .option:disabled {
68+
cursor: default;
69+
opacity: var(--theia-mod-disabled-opacity);
6270
}
6371

6472
.theia-settings-container .settings-search-container .enabled {

packages/preferences/src/browser/views/preference-editor-widget.ts

Lines changed: 61 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ import {
2525
TopDownTreeIterator,
2626
ExpandableTreeNode,
2727
} from '@theia/core/lib/browser';
28-
import { Disposable, DisposableCollection, PreferenceProviderDataChanges, PreferenceProviderProvider, PreferenceSchemaService, PreferenceService, unreachable } from '@theia/core';
28+
import {
29+
Disposable, DisposableCollection, nls, PreferenceProviderDataChanges,
30+
PreferenceProviderProvider, PreferenceSchemaService, PreferenceService, unreachable
31+
} from '@theia/core';
2932
import { BaseWidget, DEFAULT_SCROLL_OPTIONS } from '@theia/core/lib/browser/widgets/widget';
3033
import { PreferenceTreeModel, PreferenceFilterChangeEvent, PreferenceFilterChangeSource } from '../preference-tree-model';
3134
import { PreferenceNodeRendererFactory, GeneralPreferenceNodeRenderer } from './components/preference-node-renderer';
@@ -41,7 +44,7 @@ export interface PreferencesEditorState {
4144
@injectable()
4245
export class PreferencesEditorWidget extends BaseWidget implements StatefulWidget {
4346
static readonly ID = 'settings.editor';
44-
static readonly LABEL = 'Settings Editor';
47+
static readonly LABEL = nls.localizeByDefault('Settings Editor');
4548

4649
override scrollOptions = DEFAULT_SCROLL_OPTIONS;
4750

@@ -56,6 +59,11 @@ export class PreferencesEditorWidget extends BaseWidget implements StatefulWidge
5659
protected lastUserSelection = '';
5760
protected isAtScrollTop = true;
5861
protected firstVisibleChildID = '';
62+
/**
63+
* Tracks the node ID of the preference row the user last focused.
64+
* Used to scroll back to that preference when the search filter is cleared.
65+
*/
66+
protected lastFocusedRendererNodeId = '';
5967
protected renderers = new Map<string, GeneralPreferenceNodeRenderer>();
6068
protected preferenceDataKeys = new Map<string, string>();
6169
// The commonly used section will duplicate preference ID's, so we'll keep a separate list of them.
@@ -95,10 +103,20 @@ export class PreferencesEditorWidget extends BaseWidget implements StatefulWidge
95103
innerWrapper.classList.add('settings-main-scroll-container');
96104
this.scrollContainer = innerWrapper;
97105
innerWrapper.addEventListener('scroll', this.onScroll, { passive: true });
106+
innerWrapper.addEventListener('focusin', e => {
107+
const target = e.target;
108+
if (target instanceof HTMLElement) {
109+
const prefRow = target.closest('.single-pref');
110+
const nodeId = prefRow?.getAttribute('data-node-id');
111+
if (nodeId) {
112+
this.lastFocusedRendererNodeId = nodeId;
113+
}
114+
}
115+
});
98116
this.node.appendChild(innerWrapper);
99117
const noLeavesMessage = document.createElement('div');
100118
noLeavesMessage.classList.add('settings-no-results-announcement');
101-
noLeavesMessage.textContent = 'That search query has returned no results.';
119+
noLeavesMessage.textContent = nls.localizeByDefault('No Settings Found');
102120
this.node.appendChild(noLeavesMessage);
103121
}
104122

@@ -114,20 +132,50 @@ export class PreferencesEditorWidget extends BaseWidget implements StatefulWidge
114132

115133
protected handleDisplayChange(e: PreferenceFilterChangeEvent): void {
116134
const { isFiltered } = this.model;
117-
const currentFirstVisible = this.firstVisibleChildID;
118135
const leavesAreVisible = this.areLeavesVisible();
136+
// Capture scroll target before handlers modify visibility / DOM layout.
137+
const scrollTarget = this.getScrollTarget(e.source);
138+
119139
if (e.source === PreferenceFilterChangeSource.Search) {
120140
this.handleSearchChange(isFiltered, leavesAreVisible);
121141
} else if (e.source === PreferenceFilterChangeSource.Scope) {
122142
this.handleScopeChange(isFiltered);
123-
this.showInTree(currentFirstVisible);
124143
} else if (e.source === PreferenceFilterChangeSource.Schema) {
125144
this.handleSchemaChange(isFiltered);
126-
this.showInTree(currentFirstVisible);
127145
} else {
128146
unreachable(e.source, 'Not all PreferenceFilterChangeSource enum variants handled.');
129147
}
130-
this.resetScroll(currentFirstVisible, e.source === PreferenceFilterChangeSource.Search);
148+
149+
if (scrollTarget) {
150+
this.showInTree(scrollTarget);
151+
}
152+
this.resetScroll(scrollTarget);
153+
154+
if (e.source === PreferenceFilterChangeSource.Search) {
155+
// Reset focus if search context changes.
156+
this.lastFocusedRendererNodeId = '';
157+
}
158+
}
159+
160+
protected getScrollTarget(source: PreferenceFilterChangeSource): string | undefined {
161+
if (source !== PreferenceFilterChangeSource.Search) {
162+
return this.firstVisibleChildID;
163+
}
164+
if (!this.model.isFiltered && this.lastFocusedRendererNodeId && this.isRendererInViewport(this.lastFocusedRendererNodeId)) {
165+
return this.lastFocusedRendererNodeId;
166+
}
167+
return undefined;
168+
}
169+
170+
protected isRendererInViewport(nodeId: string): boolean {
171+
const { id, collection } = this.analyzeIDAndGetRendererGroup(nodeId);
172+
const renderer = collection.get(id);
173+
if (!renderer?.visible) { return false; }
174+
const scrollTop = this.scrollContainer.scrollTop;
175+
const viewportBottom = scrollTop + this.scrollContainer.clientHeight;
176+
const elementTop = renderer.node.offsetTop;
177+
const elementBottom = elementTop + renderer.node.offsetHeight;
178+
return elementTop < viewportBottom && elementBottom > scrollTop;
131179
}
132180

133181
protected handleRegistryChange(): void {
@@ -232,33 +280,31 @@ export class PreferencesEditorWidget extends BaseWidget implements StatefulWidge
232280
}
233281
}
234282

235-
protected resetScroll(nodeIDToScrollTo?: string, filterWasCleared: boolean = false): void {
283+
protected resetScroll(nodeIDToScrollTo?: string): void {
236284
if (this.scrollBar) { // Absent on widget creation
237-
this.doResetScroll(nodeIDToScrollTo, filterWasCleared);
285+
this.doResetScroll(nodeIDToScrollTo);
238286
} else {
239287
const interval = setInterval(() => {
240288
if (this.scrollBar) {
241289
clearInterval(interval);
242-
this.doResetScroll(nodeIDToScrollTo, filterWasCleared);
290+
this.doResetScroll(nodeIDToScrollTo);
243291
}
244292
}, 500);
245293
}
246294
}
247295

248-
protected doResetScroll(nodeIDToScrollTo?: string, filterWasModified: boolean = false): void {
296+
protected doResetScroll(nodeIDToScrollTo?: string): void {
249297
requestAnimationFrame(() => {
250298
this.scrollBar?.update();
251-
if (filterWasModified) {
252-
this.scrollContainer.scrollTop = 0;
253-
} else if (nodeIDToScrollTo) {
299+
if (nodeIDToScrollTo) {
254300
const { id, collection } = this.analyzeIDAndGetRendererGroup(nodeIDToScrollTo);
255301
const renderer = collection.get(id);
256302
if (renderer?.visible) {
257303
this.scrollContainer.scrollTo(0, renderer.node.offsetTop);
258304
return;
259305
}
260306
}
261-
307+
this.scrollContainer.scrollTop = 0;
262308
});
263309
};
264310

packages/preferences/src/browser/views/preference-searchbar-widget.tsx

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export class PreferencesSearchbarWidget extends ReactWidget implements StatefulW
6666
* Clears the search input and all search results.
6767
* @param e on-click mouse event.
6868
*/
69-
protected clearSearchResults = async (e: React.MouseEvent): Promise<void> => {
69+
protected clearSearchResults = async (e: React.SyntheticEvent): Promise<void> => {
7070
const search = document.getElementById(PreferencesSearchbarWidget.SEARCHBAR_ID) as HTMLInputElement;
7171
if (search) {
7272
search.value = '';
@@ -99,6 +99,8 @@ export class PreferencesSearchbarWidget extends ReactWidget implements StatefulW
9999
return this.searchTermExists() ?
100100
(<span
101101
className="results-found"
102+
role="status"
103+
aria-live="polite"
102104
title={resultsFound}>
103105
{resultsFound}
104106
</span>)
@@ -109,11 +111,16 @@ export class PreferencesSearchbarWidget extends ReactWidget implements StatefulW
109111
* Renders a clear all button.
110112
*/
111113
protected renderClearAllOption(): React.ReactNode {
112-
return <span
113-
className={`${codicon('clear-all')} option ${(this.searchTermExists() ? 'enabled' : '')}`}
114+
const enabled = this.searchTermExists();
115+
return <button
116+
className={`option ${enabled ? 'enabled' : ''}`}
117+
aria-label={nls.localizeByDefault('Clear Search Results')}
114118
title={nls.localizeByDefault('Clear Search Results')}
119+
disabled={!enabled}
115120
onClick={this.clearSearchResults}
116-
/>;
121+
>
122+
<i className={codicon('clear-all')} />
123+
</button>;
117124
}
118125

119126
/**

0 commit comments

Comments
 (0)