Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/base/browser/ui/icons/iconSelectBox.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import './iconSelectBox.css';
7
import * as dom from '../../dom.js';
8
import { alert } from '../aria/aria.js';
9
import { IInputBoxStyles, InputBox } from '../inputbox/inputBox.js';
10
import { DomScrollableElement } from '../scrollbar/scrollableElement.js';
11
import { Emitter } from '../../../common/event.js';
12
import { IDisposable, DisposableStore, Disposable, MutableDisposable } from '../../../common/lifecycle.js';
13
import { ThemeIcon } from '../../../common/themables.js';
14
import { localize } from '../../../../nls.js';
15
import { IMatch } from '../../../common/filters.js';
16
import { ScrollbarVisibility } from '../../../common/scrollable.js';
17
import { HighlightedLabel } from '../highlightedlabel/highlightedLabel.js';
18
19
export interface IIconSelectBoxOptions {
20
readonly icons: ThemeIcon[];
21
readonly inputBoxStyles: IInputBoxStyles;
22
readonly showIconInfo?: boolean;
23
}
24
25
interface IRenderedIconItem {
26
readonly icon: ThemeIcon;
27
readonly element: HTMLElement;
28
readonly highlightMatches?: IMatch[];
29
}
30
31
export class IconSelectBox extends Disposable {
32
33
private static InstanceCount = 0;
34
readonly domId = `icon_select_box_id_${++IconSelectBox.InstanceCount}`;
35
36
readonly domNode: HTMLElement;
37
38
private _onDidSelect = this._register(new Emitter<ThemeIcon>());
39
readonly onDidSelect = this._onDidSelect.event;
40
41
private renderedIcons: IRenderedIconItem[] = [];
42
43
private focusedItemIndex: number = 0;
44
private numberOfElementsPerRow: number = 1;
45
46
protected inputBox: InputBox | undefined;
47
private scrollableElement: DomScrollableElement | undefined;
48
private iconsContainer: HTMLElement | undefined;
49
private iconIdElement: HighlightedLabel | undefined;
50
private readonly iconContainerWidth = 36;
51
private readonly iconContainerHeight = 36;
52
53
constructor(
54
private readonly options: IIconSelectBoxOptions,
55
) {
56
super();
57
this.domNode = dom.$('.icon-select-box');
58
this._register(this.create());
59
}
60
61
private create(): IDisposable {
62
const disposables = new DisposableStore();
63
64
const iconSelectBoxContainer = dom.append(this.domNode, dom.$('.icon-select-box-container'));
65
iconSelectBoxContainer.style.margin = '10px 15px';
66
67
const iconSelectInputContainer = dom.append(iconSelectBoxContainer, dom.$('.icon-select-input-container'));
68
iconSelectInputContainer.style.paddingBottom = '10px';
69
this.inputBox = disposables.add(new InputBox(iconSelectInputContainer, undefined, {
70
placeholder: localize('iconSelect.placeholder', "Search icons"),
71
inputBoxStyles: this.options.inputBoxStyles,
72
}));
73
74
const iconsContainer = this.iconsContainer = dom.$('.icon-select-icons-container', { id: `${this.domId}_icons` });
75
iconsContainer.role = 'listbox';
76
iconsContainer.tabIndex = 0;
77
this.scrollableElement = disposables.add(new DomScrollableElement(iconsContainer, {
78
useShadows: false,
79
horizontal: ScrollbarVisibility.Hidden,
80
}));
81
dom.append(iconSelectBoxContainer, this.scrollableElement.getDomNode());
82
83
if (this.options.showIconInfo) {
84
this.iconIdElement = this._register(new HighlightedLabel(dom.append(dom.append(iconSelectBoxContainer, dom.$('.icon-select-id-container')), dom.$('.icon-select-id-label'))));
85
}
86
87
const iconsDisposables = disposables.add(new MutableDisposable());
88
iconsDisposables.value = this.renderIcons(this.options.icons, [], iconsContainer);
89
this.scrollableElement.scanDomNode();
90
91
disposables.add(this.inputBox.onDidChange(value => {
92
const icons = [], matches = [];
93
for (const icon of this.options.icons) {
94
const match = this.matchesContiguous(value, icon.id);
95
if (match) {
96
icons.push(icon);
97
matches.push(match);
98
}
99
}
100
if (icons.length) {
101
iconsDisposables.value = this.renderIcons(icons, matches, iconsContainer);
102
this.scrollableElement?.scanDomNode();
103
}
104
}));
105
106
this.inputBox.inputElement.role = 'combobox';
107
this.inputBox.inputElement.ariaHasPopup = 'menu';
108
this.inputBox.inputElement.ariaAutoComplete = 'list';
109
this.inputBox.inputElement.ariaExpanded = 'true';
110
this.inputBox.inputElement.setAttribute('aria-controls', iconsContainer.id);
111
112
return disposables;
113
}
114
115
private renderIcons(icons: ThemeIcon[], matches: IMatch[][], container: HTMLElement): IDisposable {
116
const disposables = new DisposableStore();
117
dom.clearNode(container);
118
const focusedIcon = this.renderedIcons[this.focusedItemIndex]?.icon;
119
let focusedIconIndex = 0;
120
const renderedIcons: IRenderedIconItem[] = [];
121
if (icons.length) {
122
for (let index = 0; index < icons.length; index++) {
123
const icon = icons[index];
124
const iconContainer = dom.append(container, dom.$('.icon-container', { id: `${this.domId}_icons_${index}` }));
125
iconContainer.style.width = `${this.iconContainerWidth}px`;
126
iconContainer.style.height = `${this.iconContainerHeight}px`;
127
iconContainer.title = icon.id;
128
iconContainer.role = 'button';
129
iconContainer.setAttribute('aria-setsize', `${icons.length}`);
130
iconContainer.setAttribute('aria-posinset', `${index + 1}`);
131
dom.append(iconContainer, dom.$(ThemeIcon.asCSSSelector(icon)));
132
renderedIcons.push({ icon, element: iconContainer, highlightMatches: matches[index] });
133
134
disposables.add(dom.addDisposableListener(iconContainer, dom.EventType.CLICK, (e: MouseEvent) => {
135
e.stopPropagation();
136
this.setSelection(index);
137
}));
138
139
if (icon === focusedIcon) {
140
focusedIconIndex = index;
141
}
142
}
143
} else {
144
const noResults = localize('iconSelect.noResults', "No results");
145
dom.append(container, dom.$('.icon-no-results', undefined, noResults));
146
alert(noResults);
147
}
148
149
this.renderedIcons.splice(0, this.renderedIcons.length, ...renderedIcons);
150
this.focusIcon(focusedIconIndex);
151
152
return disposables;
153
}
154
155
private focusIcon(index: number): void {
156
const existing = this.renderedIcons[this.focusedItemIndex];
157
if (existing) {
158
existing.element.classList.remove('focused');
159
}
160
161
this.focusedItemIndex = index;
162
const renderedItem = this.renderedIcons[index];
163
164
if (renderedItem) {
165
renderedItem.element.classList.add('focused');
166
}
167
168
if (this.inputBox) {
169
if (renderedItem) {
170
this.inputBox.inputElement.setAttribute('aria-activedescendant', renderedItem.element.id);
171
} else {
172
this.inputBox.inputElement.removeAttribute('aria-activedescendant');
173
}
174
}
175
176
if (this.iconIdElement) {
177
if (renderedItem) {
178
this.iconIdElement.set(renderedItem.icon.id, renderedItem.highlightMatches);
179
} else {
180
this.iconIdElement.set('');
181
}
182
}
183
184
this.reveal(index);
185
}
186
187
private reveal(index: number): void {
188
if (!this.scrollableElement) {
189
return;
190
}
191
if (index < 0 || index >= this.renderedIcons.length) {
192
return;
193
}
194
const element = this.renderedIcons[index].element;
195
if (!element) {
196
return;
197
}
198
const { height } = this.scrollableElement.getScrollDimensions();
199
const { scrollTop } = this.scrollableElement.getScrollPosition();
200
if (element.offsetTop + this.iconContainerHeight > scrollTop + height) {
201
this.scrollableElement.setScrollPosition({ scrollTop: element.offsetTop + this.iconContainerHeight - height });
202
} else if (element.offsetTop < scrollTop) {
203
this.scrollableElement.setScrollPosition({ scrollTop: element.offsetTop });
204
}
205
}
206
207
private matchesContiguous(word: string, wordToMatchAgainst: string): IMatch[] | null {
208
const matchIndex = wordToMatchAgainst.toLowerCase().indexOf(word.toLowerCase());
209
if (matchIndex !== -1) {
210
return [{ start: matchIndex, end: matchIndex + word.length }];
211
}
212
return null;
213
}
214
215
layout(dimension: dom.Dimension): void {
216
this.domNode.style.width = `${dimension.width}px`;
217
this.domNode.style.height = `${dimension.height}px`;
218
219
const iconsContainerWidth = dimension.width - 30;
220
this.numberOfElementsPerRow = Math.floor(iconsContainerWidth / this.iconContainerWidth);
221
if (this.numberOfElementsPerRow === 0) {
222
throw new Error('Insufficient width');
223
}
224
225
const extraSpace = iconsContainerWidth % this.iconContainerWidth;
226
const iconElementMargin = Math.floor(extraSpace / this.numberOfElementsPerRow);
227
for (const { element } of this.renderedIcons) {
228
element.style.marginRight = `${iconElementMargin}px`;
229
}
230
231
const containerPadding = extraSpace % this.numberOfElementsPerRow;
232
if (this.iconsContainer) {
233
this.iconsContainer.style.paddingLeft = `${Math.floor(containerPadding / 2)}px`;
234
this.iconsContainer.style.paddingRight = `${Math.ceil(containerPadding / 2)}px`;
235
}
236
237
if (this.scrollableElement) {
238
this.scrollableElement.getDomNode().style.height = `${this.iconIdElement ? dimension.height - 80 : dimension.height - 40}px`;
239
this.scrollableElement.scanDomNode();
240
}
241
}
242
243
getFocus(): number[] {
244
return [this.focusedItemIndex];
245
}
246
247
setSelection(index: number): void {
248
if (index < 0 || index >= this.renderedIcons.length) {
249
throw new Error(`Invalid index ${index}`);
250
}
251
this.focusIcon(index);
252
this._onDidSelect.fire(this.renderedIcons[index].icon);
253
}
254
255
clearInput(): void {
256
if (this.inputBox) {
257
this.inputBox.value = '';
258
}
259
}
260
261
focus(): void {
262
this.inputBox?.focus();
263
this.focusIcon(0);
264
}
265
266
focusNext(): void {
267
this.focusIcon((this.focusedItemIndex + 1) % this.renderedIcons.length);
268
}
269
270
focusPrevious(): void {
271
this.focusIcon((this.focusedItemIndex - 1 + this.renderedIcons.length) % this.renderedIcons.length);
272
}
273
274
focusNextRow(): void {
275
let nextRowIndex = this.focusedItemIndex + this.numberOfElementsPerRow;
276
if (nextRowIndex >= this.renderedIcons.length) {
277
nextRowIndex = (nextRowIndex + 1) % this.numberOfElementsPerRow;
278
nextRowIndex = nextRowIndex >= this.renderedIcons.length ? 0 : nextRowIndex;
279
}
280
this.focusIcon(nextRowIndex);
281
}
282
283
focusPreviousRow(): void {
284
let previousRowIndex = this.focusedItemIndex - this.numberOfElementsPerRow;
285
if (previousRowIndex < 0) {
286
const numberOfRows = Math.floor(this.renderedIcons.length / this.numberOfElementsPerRow);
287
previousRowIndex = this.focusedItemIndex + (this.numberOfElementsPerRow * numberOfRows) - 1;
288
previousRowIndex = previousRowIndex < 0
289
? this.renderedIcons.length - 1
290
: previousRowIndex >= this.renderedIcons.length
291
? previousRowIndex - this.numberOfElementsPerRow
292
: previousRowIndex;
293
}
294
this.focusIcon(previousRowIndex);
295
}
296
297
getFocusedIcon(): ThemeIcon {
298
return this.renderedIcons[this.focusedItemIndex].icon;
299
}
300
301
}
302
303