Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts
5244 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 { localize } from '../../../../nls.js';
7
import * as arrays from '../../../common/arrays.js';
8
import { Emitter, Event } from '../../../common/event.js';
9
import { KeyCode, KeyCodeUtils } from '../../../common/keyCodes.js';
10
import { Disposable, DisposableStore, IDisposable } from '../../../common/lifecycle.js';
11
import { isMacintosh } from '../../../common/platform.js';
12
import { ScrollbarVisibility } from '../../../common/scrollable.js';
13
import * as cssJs from '../../cssValue.js';
14
import * as dom from '../../dom.js';
15
import * as domStylesheetsJs from '../../domStylesheets.js';
16
import { DomEmitter } from '../../event.js';
17
import { StandardKeyboardEvent } from '../../keyboardEvent.js';
18
import { IRenderedMarkdown, MarkdownActionHandler, renderMarkdown } from '../../markdownRenderer.js';
19
import { AnchorPosition, IContextViewProvider } from '../contextview/contextview.js';
20
import type { IManagedHover } from '../hover/hover.js';
21
import { getBaseLayerHoverDelegate } from '../hover/hoverDelegate2.js';
22
import { getDefaultHoverDelegate } from '../hover/hoverDelegateFactory.js';
23
import { IListEvent, IListRenderer, IListVirtualDelegate } from '../list/list.js';
24
import { List } from '../list/listWidget.js';
25
import { ISelectBoxDelegate, ISelectBoxOptions, ISelectBoxStyles, ISelectData, ISelectOptionItem } from './selectBox.js';
26
import './selectBoxCustom.css';
27
28
29
const $ = dom.$;
30
31
const SELECT_OPTION_ENTRY_TEMPLATE_ID = 'selectOption.entry.template';
32
33
interface ISelectListTemplateData {
34
root: HTMLElement;
35
text: HTMLElement;
36
detail: HTMLElement;
37
decoratorRight: HTMLElement;
38
}
39
40
class SelectListRenderer implements IListRenderer<ISelectOptionItem, ISelectListTemplateData> {
41
42
get templateId(): string { return SELECT_OPTION_ENTRY_TEMPLATE_ID; }
43
44
renderTemplate(container: HTMLElement): ISelectListTemplateData {
45
const data: ISelectListTemplateData = Object.create(null);
46
data.root = container;
47
data.text = dom.append(container, $('.option-text'));
48
data.detail = dom.append(container, $('.option-detail'));
49
data.decoratorRight = dom.append(container, $('.option-decorator-right'));
50
51
return data;
52
}
53
54
renderElement(element: ISelectOptionItem, index: number, templateData: ISelectListTemplateData): void {
55
const data: ISelectListTemplateData = templateData;
56
57
const text = element.text;
58
const detail = element.detail;
59
const decoratorRight = element.decoratorRight;
60
61
const isDisabled = element.isDisabled;
62
63
data.text.textContent = text;
64
data.detail.textContent = !!detail ? detail : '';
65
data.decoratorRight.textContent = !!decoratorRight ? decoratorRight : '';
66
67
// pseudo-select disabled option
68
if (isDisabled) {
69
data.root.classList.add('option-disabled');
70
} else {
71
// Make sure we do class removal from prior template rendering
72
data.root.classList.remove('option-disabled');
73
}
74
}
75
76
disposeTemplate(_templateData: ISelectListTemplateData): void {
77
// noop
78
}
79
}
80
81
export class SelectBoxList extends Disposable implements ISelectBoxDelegate, IListVirtualDelegate<ISelectOptionItem> {
82
83
private static readonly DEFAULT_DROPDOWN_MINIMUM_BOTTOM_MARGIN = 32;
84
private static readonly DEFAULT_DROPDOWN_MINIMUM_TOP_MARGIN = 2;
85
private static readonly DEFAULT_MINIMUM_VISIBLE_OPTIONS = 3;
86
87
private _isVisible: boolean;
88
private selectBoxOptions: ISelectBoxOptions;
89
private selectElement: HTMLSelectElement;
90
private container?: HTMLElement;
91
private options: ISelectOptionItem[] = [];
92
private selected: number;
93
private readonly _onDidSelect: Emitter<ISelectData>;
94
private readonly styles: ISelectBoxStyles;
95
private listRenderer!: SelectListRenderer;
96
private contextViewProvider!: IContextViewProvider;
97
private selectDropDownContainer!: HTMLElement;
98
private styleElement!: HTMLStyleElement;
99
private selectList!: List<ISelectOptionItem>;
100
private selectDropDownListContainer!: HTMLElement;
101
private widthControlElement!: HTMLElement;
102
private _currentSelection = 0;
103
private _dropDownPosition!: AnchorPosition;
104
private _hasDetails: boolean = false;
105
private selectionDetailsPane!: HTMLElement;
106
private readonly _selectionDetailsDisposables = this._register(new DisposableStore());
107
private _skipLayout: boolean = false;
108
private _cachedMaxDetailsHeight?: number;
109
private _hover?: IManagedHover;
110
111
private _sticky: boolean = false; // for dev purposes only
112
113
constructor(options: ISelectOptionItem[], selected: number, contextViewProvider: IContextViewProvider, styles: ISelectBoxStyles, selectBoxOptions?: ISelectBoxOptions) {
114
115
super();
116
this._isVisible = false;
117
this.styles = styles;
118
119
this.selectBoxOptions = selectBoxOptions || Object.create(null);
120
121
if (typeof this.selectBoxOptions.minBottomMargin !== 'number') {
122
this.selectBoxOptions.minBottomMargin = SelectBoxList.DEFAULT_DROPDOWN_MINIMUM_BOTTOM_MARGIN;
123
} else if (this.selectBoxOptions.minBottomMargin < 0) {
124
this.selectBoxOptions.minBottomMargin = 0;
125
}
126
127
this.selectElement = document.createElement('select');
128
this.selectElement.className = 'monaco-select-box';
129
130
if (typeof this.selectBoxOptions.ariaLabel === 'string') {
131
this.selectElement.setAttribute('aria-label', this.selectBoxOptions.ariaLabel);
132
}
133
134
if (typeof this.selectBoxOptions.ariaDescription === 'string') {
135
this.selectElement.setAttribute('aria-description', this.selectBoxOptions.ariaDescription);
136
}
137
138
this._onDidSelect = new Emitter<ISelectData>();
139
this._register(this._onDidSelect);
140
141
this.registerListeners();
142
this.constructSelectDropDown(contextViewProvider);
143
144
this.selected = selected || 0;
145
146
if (options) {
147
this.setOptions(options, selected);
148
}
149
150
this.initStyleSheet();
151
152
}
153
154
private setTitle(title: string): void {
155
if (!this._hover && title) {
156
this._hover = this._register(getBaseLayerHoverDelegate().setupManagedHover(getDefaultHoverDelegate('mouse'), this.selectElement, title));
157
} else if (this._hover) {
158
this._hover.update(title);
159
}
160
}
161
162
// IDelegate - List renderer
163
164
getHeight(): number {
165
return 22;
166
}
167
168
getTemplateId(): string {
169
return SELECT_OPTION_ENTRY_TEMPLATE_ID;
170
}
171
172
private constructSelectDropDown(contextViewProvider: IContextViewProvider) {
173
174
// SetUp ContextView container to hold select Dropdown
175
this.contextViewProvider = contextViewProvider;
176
this.selectDropDownContainer = dom.$('.monaco-select-box-dropdown-container');
177
178
// Setup container for select option details
179
this.selectionDetailsPane = dom.append(this.selectDropDownContainer, $('.select-box-details-pane'));
180
181
// Create span flex box item/div we can measure and control
182
const widthControlOuterDiv = dom.append(this.selectDropDownContainer, $('.select-box-dropdown-container-width-control'));
183
const widthControlInnerDiv = dom.append(widthControlOuterDiv, $('.width-control-div'));
184
this.widthControlElement = document.createElement('span');
185
this.widthControlElement.className = 'option-text-width-control';
186
dom.append(widthControlInnerDiv, this.widthControlElement);
187
188
// Always default to below position
189
this._dropDownPosition = AnchorPosition.BELOW;
190
191
// Inline stylesheet for themes
192
this.styleElement = domStylesheetsJs.createStyleSheet(this.selectDropDownContainer);
193
194
// Prevent dragging of dropdown #114329
195
this.selectDropDownContainer.setAttribute('draggable', 'true');
196
this._register(dom.addDisposableListener(this.selectDropDownContainer, dom.EventType.DRAG_START, (e) => {
197
dom.EventHelper.stop(e, true);
198
}));
199
}
200
201
private registerListeners() {
202
203
// Parent native select keyboard listeners
204
205
this._register(dom.addStandardDisposableListener(this.selectElement, 'change', (e) => {
206
this.selected = e.target.selectedIndex;
207
this._onDidSelect.fire({
208
index: e.target.selectedIndex,
209
selected: e.target.value
210
});
211
if (!!this.options[this.selected] && !!this.options[this.selected].text) {
212
this.setTitle(this.options[this.selected].text);
213
}
214
}));
215
216
// Have to implement both keyboard and mouse controllers to handle disabled options
217
// Intercept mouse events to override normal select actions on parents
218
219
this._register(dom.addDisposableListener(this.selectElement, dom.EventType.CLICK, (e) => {
220
dom.EventHelper.stop(e);
221
222
if (this._isVisible) {
223
this.hideSelectDropDown(true);
224
} else {
225
this.showSelectDropDown();
226
}
227
}));
228
229
this._register(dom.addDisposableListener(this.selectElement, dom.EventType.MOUSE_DOWN, (e) => {
230
dom.EventHelper.stop(e);
231
}));
232
233
// Intercept touch events
234
// The following implementation is slightly different from the mouse event handlers above.
235
// Use the following helper variable, otherwise the list flickers.
236
let listIsVisibleOnTouchStart: boolean;
237
this._register(dom.addDisposableListener(this.selectElement, 'touchstart', (e) => {
238
listIsVisibleOnTouchStart = this._isVisible;
239
}));
240
this._register(dom.addDisposableListener(this.selectElement, 'touchend', (e) => {
241
dom.EventHelper.stop(e);
242
243
if (listIsVisibleOnTouchStart) {
244
this.hideSelectDropDown(true);
245
} else {
246
this.showSelectDropDown();
247
}
248
}));
249
250
// Intercept keyboard handling
251
252
this._register(dom.addDisposableListener(this.selectElement, dom.EventType.KEY_DOWN, (e: KeyboardEvent) => {
253
const event = new StandardKeyboardEvent(e);
254
let showDropDown = false;
255
256
// Create and drop down select list on keyboard select
257
if (isMacintosh) {
258
if (event.keyCode === KeyCode.DownArrow || event.keyCode === KeyCode.UpArrow || event.keyCode === KeyCode.Space || event.keyCode === KeyCode.Enter) {
259
showDropDown = true;
260
}
261
} else {
262
if (event.keyCode === KeyCode.DownArrow && event.altKey || event.keyCode === KeyCode.UpArrow && event.altKey || event.keyCode === KeyCode.Space || event.keyCode === KeyCode.Enter) {
263
showDropDown = true;
264
}
265
}
266
267
if (showDropDown) {
268
this.showSelectDropDown();
269
dom.EventHelper.stop(e, true);
270
}
271
}));
272
}
273
274
public get onDidSelect(): Event<ISelectData> {
275
return this._onDidSelect.event;
276
}
277
278
public setOptions(options: ISelectOptionItem[], selected?: number): void {
279
if (!arrays.equals(this.options, options)) {
280
this.options = options;
281
this.selectElement.options.length = 0;
282
this._hasDetails = false;
283
this._cachedMaxDetailsHeight = undefined;
284
285
this.options.forEach((option, index) => {
286
this.selectElement.add(this.createOption(option.text, index, option.isDisabled));
287
if (typeof option.description === 'string') {
288
this._hasDetails = true;
289
}
290
});
291
}
292
293
if (selected !== undefined) {
294
this.select(selected);
295
// Set current = selected since this is not necessarily a user exit
296
this._currentSelection = this.selected;
297
}
298
}
299
300
public setEnabled(enable: boolean): void {
301
this.selectElement.disabled = !enable;
302
}
303
304
private setOptionsList() {
305
306
// Mirror options in drop-down
307
// Populate select list for non-native select mode
308
this.selectList?.splice(0, this.selectList.length, this.options);
309
}
310
311
public select(index: number): void {
312
313
if (index >= 0 && index < this.options.length) {
314
this.selected = index;
315
} else if (index > this.options.length - 1) {
316
// Adjust index to end of list
317
// This could make client out of sync with the select
318
this.select(this.options.length - 1);
319
} else if (this.selected < 0) {
320
this.selected = 0;
321
}
322
323
this.selectElement.selectedIndex = this.selected;
324
if (!!this.options[this.selected] && !!this.options[this.selected].text) {
325
this.setTitle(this.options[this.selected].text);
326
}
327
}
328
329
public setAriaLabel(label: string): void {
330
this.selectBoxOptions.ariaLabel = label;
331
this.selectElement.setAttribute('aria-label', this.selectBoxOptions.ariaLabel);
332
}
333
334
public focus(): void {
335
if (this.selectElement) {
336
this.selectElement.tabIndex = 0;
337
this.selectElement.focus();
338
}
339
}
340
341
public blur(): void {
342
if (this.selectElement) {
343
this.selectElement.tabIndex = -1;
344
this.selectElement.blur();
345
}
346
}
347
348
public setFocusable(focusable: boolean): void {
349
this.selectElement.tabIndex = focusable ? 0 : -1;
350
}
351
352
public render(container: HTMLElement): void {
353
this.container = container;
354
container.classList.add('select-container');
355
container.appendChild(this.selectElement);
356
this.styleSelectElement();
357
}
358
359
private initStyleSheet(): void {
360
361
const content: string[] = [];
362
363
// Style non-native select mode
364
365
if (this.styles.listFocusBackground) {
366
content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.focused { background-color: ${this.styles.listFocusBackground} !important; }`);
367
}
368
369
if (this.styles.listFocusForeground) {
370
content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.focused { color: ${this.styles.listFocusForeground} !important; }`);
371
}
372
373
if (this.styles.decoratorRightForeground) {
374
content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row:not(.focused) .option-decorator-right { color: ${this.styles.decoratorRightForeground}; }`);
375
}
376
377
if (this.styles.selectBackground && this.styles.selectBorder && this.styles.selectBorder !== this.styles.selectBackground) {
378
content.push(`.monaco-select-box-dropdown-container { border: 1px solid ${this.styles.selectBorder} } `);
379
content.push(`.monaco-select-box-dropdown-container > .select-box-details-pane.border-top { border-top: 1px solid ${this.styles.selectBorder} } `);
380
content.push(`.monaco-select-box-dropdown-container > .select-box-details-pane.border-bottom { border-bottom: 1px solid ${this.styles.selectBorder} } `);
381
382
}
383
else if (this.styles.selectListBorder) {
384
content.push(`.monaco-select-box-dropdown-container > .select-box-details-pane.border-top { border-top: 1px solid ${this.styles.selectListBorder} } `);
385
content.push(`.monaco-select-box-dropdown-container > .select-box-details-pane.border-bottom { border-bottom: 1px solid ${this.styles.selectListBorder} } `);
386
}
387
388
// Hover foreground - ignore for disabled options
389
if (this.styles.listHoverForeground) {
390
content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row:not(.option-disabled):not(.focused):hover { color: ${this.styles.listHoverForeground} !important; }`);
391
}
392
393
// Hover background - ignore for disabled options
394
if (this.styles.listHoverBackground) {
395
content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row:not(.option-disabled):not(.focused):hover { background-color: ${this.styles.listHoverBackground} !important; }`);
396
}
397
398
// Match quick input outline styles - ignore for disabled options
399
if (this.styles.listFocusOutline) {
400
content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.focused { outline: 1.6px dotted ${this.styles.listFocusOutline} !important; outline-offset: -1.6px !important; }`);
401
}
402
403
if (this.styles.listHoverOutline) {
404
content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row:not(.option-disabled):not(.focused):hover { outline: 1.6px dashed ${this.styles.listHoverOutline} !important; outline-offset: -1.6px !important; }`);
405
}
406
407
// Clear list styles on focus and on hover for disabled options
408
content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.option-disabled.focused { background-color: transparent !important; color: inherit !important; outline: none !important; }`);
409
content.push(`.monaco-select-box-dropdown-container > .select-box-dropdown-list-container .monaco-list .monaco-list-row.option-disabled:hover { background-color: transparent !important; color: inherit !important; outline: none !important; }`);
410
411
this.styleElement.textContent = content.join('\n');
412
}
413
414
private styleSelectElement(): void {
415
const background = this.styles.selectBackground ?? '';
416
const foreground = this.styles.selectForeground ?? '';
417
const border = this.styles.selectBorder ?? '';
418
419
this.selectElement.style.backgroundColor = background;
420
this.selectElement.style.color = foreground;
421
this.selectElement.style.borderColor = border;
422
}
423
424
private styleList() {
425
const background = this.styles.selectBackground ?? '';
426
427
const listBackground = cssJs.asCssValueWithDefault(this.styles.selectListBackground, background);
428
this.selectDropDownListContainer.style.backgroundColor = listBackground;
429
this.selectionDetailsPane.style.backgroundColor = listBackground;
430
const optionsBorder = this.styles.focusBorder ?? '';
431
this.selectDropDownContainer.style.outlineColor = optionsBorder;
432
this.selectDropDownContainer.style.outlineOffset = '-1px';
433
434
this.selectList.style(this.styles);
435
}
436
437
private createOption(value: string, index: number, disabled?: boolean): HTMLOptionElement {
438
const option = document.createElement('option');
439
option.value = value;
440
option.text = value;
441
option.disabled = !!disabled;
442
443
return option;
444
}
445
446
// ContextView dropdown methods
447
448
private showSelectDropDown() {
449
this.selectionDetailsPane.textContent = '';
450
451
if (!this.contextViewProvider || this._isVisible) {
452
return;
453
}
454
455
// Lazily create and populate list only at open, moved from constructor
456
this.createSelectList(this.selectDropDownContainer);
457
this.setOptionsList();
458
459
// This allows us to flip the position based on measurement
460
// Set drop-down position above/below from required height and margins
461
// If pre-layout cannot fit at least one option do not show drop-down
462
463
this.contextViewProvider.showContextView({
464
getAnchor: () => this.selectElement,
465
render: (container: HTMLElement) => this.renderSelectDropDown(container, true),
466
layout: () => {
467
this.layoutSelectDropDown();
468
},
469
onHide: () => {
470
this.selectDropDownContainer.classList.remove('visible');
471
},
472
anchorPosition: this._dropDownPosition
473
}, this.selectBoxOptions.optionsAsChildren ? this.container : undefined);
474
475
// Hide so we can relay out
476
this._isVisible = true;
477
this.hideSelectDropDown(false);
478
479
this.contextViewProvider.showContextView({
480
getAnchor: () => this.selectElement,
481
render: (container: HTMLElement) => this.renderSelectDropDown(container),
482
layout: () => this.layoutSelectDropDown(),
483
onHide: () => {
484
this.selectDropDownContainer.classList.remove('visible');
485
},
486
anchorPosition: this._dropDownPosition
487
}, this.selectBoxOptions.optionsAsChildren ? this.container : undefined);
488
489
// Track initial selection the case user escape, blur
490
this._currentSelection = this.selected;
491
this._isVisible = true;
492
this.selectElement.setAttribute('aria-expanded', 'true');
493
}
494
495
private hideSelectDropDown(focusSelect: boolean) {
496
if (!this.contextViewProvider || !this._isVisible) {
497
return;
498
}
499
500
this._isVisible = false;
501
this.selectElement.setAttribute('aria-expanded', 'false');
502
503
if (focusSelect) {
504
this.selectElement.focus();
505
}
506
507
this.contextViewProvider.hideContextView();
508
}
509
510
private renderSelectDropDown(container: HTMLElement, preLayoutPosition?: boolean): IDisposable {
511
container.appendChild(this.selectDropDownContainer);
512
513
// Pre-Layout allows us to change position
514
this.layoutSelectDropDown(preLayoutPosition);
515
516
return {
517
dispose: () => {
518
// contextView will dispose itself if moving from one View to another
519
this.selectDropDownContainer.remove(); // remove to take out the CSS rules we add
520
}
521
};
522
}
523
524
// Iterate over detailed descriptions, find max height
525
private measureMaxDetailsHeight(): number {
526
let maxDetailsPaneHeight = 0;
527
this.options.forEach((_option, index) => {
528
this.updateDetail(index);
529
530
if (this.selectionDetailsPane.offsetHeight > maxDetailsPaneHeight) {
531
maxDetailsPaneHeight = this.selectionDetailsPane.offsetHeight;
532
}
533
});
534
535
return maxDetailsPaneHeight;
536
}
537
538
private layoutSelectDropDown(preLayoutPosition?: boolean): boolean {
539
540
// Avoid recursion from layout called in onListFocus
541
if (this._skipLayout) {
542
return false;
543
}
544
545
// Layout ContextView drop down select list and container
546
// Have to manage our vertical overflow, sizing, position below or above
547
// Position has to be determined and set prior to contextView instantiation
548
549
if (this.selectList) {
550
551
// Make visible to enable measurements
552
this.selectDropDownContainer.classList.add('visible');
553
554
const window = dom.getWindow(this.selectElement);
555
const selectPosition = dom.getDomNodePagePosition(this.selectElement);
556
const maxSelectDropDownHeightBelow = (window.innerHeight - selectPosition.top - selectPosition.height - (this.selectBoxOptions.minBottomMargin || 0));
557
const maxSelectDropDownHeightAbove = (selectPosition.top - SelectBoxList.DEFAULT_DROPDOWN_MINIMUM_TOP_MARGIN);
558
559
// Determine optimal width - min(longest option), opt(parent select, excluding margins), max(ContextView controlled)
560
const selectWidth = this.selectElement.offsetWidth;
561
const selectMinWidth = this.setWidthControlElement(this.widthControlElement);
562
const selectOptimalWidth = `${Math.max(selectMinWidth, Math.round(selectWidth))}px`;
563
564
this.selectDropDownContainer.style.width = selectOptimalWidth;
565
566
// Get initial list height and determine space above and below
567
this.selectList.getHTMLElement().style.height = '';
568
this.selectList.layout();
569
let listHeight = this.selectList.contentHeight;
570
571
if (this._hasDetails && this._cachedMaxDetailsHeight === undefined) {
572
this._cachedMaxDetailsHeight = this.measureMaxDetailsHeight();
573
}
574
const maxDetailsPaneHeight = this._hasDetails ? this._cachedMaxDetailsHeight! : 0;
575
576
const minRequiredDropDownHeight = listHeight + maxDetailsPaneHeight;
577
const maxVisibleOptionsBelow = ((Math.floor((maxSelectDropDownHeightBelow - maxDetailsPaneHeight) / this.getHeight())));
578
const maxVisibleOptionsAbove = ((Math.floor((maxSelectDropDownHeightAbove - maxDetailsPaneHeight) / this.getHeight())));
579
580
// If we are only doing pre-layout check/adjust position only
581
// Calculate vertical space available, flip up if insufficient
582
// Use reflected padding on parent select, ContextView style
583
// properties not available before DOM attachment
584
585
if (preLayoutPosition) {
586
587
// Check if select moved out of viewport , do not open
588
// If at least one option cannot be shown, don't open the drop-down or hide/remove if open
589
590
if ((selectPosition.top + selectPosition.height) > (window.innerHeight - 22)
591
|| selectPosition.top < SelectBoxList.DEFAULT_DROPDOWN_MINIMUM_TOP_MARGIN
592
|| ((maxVisibleOptionsBelow < 1) && (maxVisibleOptionsAbove < 1))) {
593
// Indicate we cannot open
594
return false;
595
}
596
597
// Determine if we have to flip up
598
// Always show complete list items - never more than Max available vertical height
599
if (maxVisibleOptionsBelow < SelectBoxList.DEFAULT_MINIMUM_VISIBLE_OPTIONS
600
&& maxVisibleOptionsAbove > maxVisibleOptionsBelow
601
&& this.options.length > maxVisibleOptionsBelow
602
) {
603
this._dropDownPosition = AnchorPosition.ABOVE;
604
this.selectDropDownListContainer.remove();
605
this.selectionDetailsPane.remove();
606
this.selectDropDownContainer.appendChild(this.selectionDetailsPane);
607
this.selectDropDownContainer.appendChild(this.selectDropDownListContainer);
608
609
this.selectionDetailsPane.classList.remove('border-top');
610
this.selectionDetailsPane.classList.add('border-bottom');
611
612
} else {
613
this._dropDownPosition = AnchorPosition.BELOW;
614
this.selectDropDownListContainer.remove();
615
this.selectionDetailsPane.remove();
616
this.selectDropDownContainer.appendChild(this.selectDropDownListContainer);
617
this.selectDropDownContainer.appendChild(this.selectionDetailsPane);
618
619
this.selectionDetailsPane.classList.remove('border-bottom');
620
this.selectionDetailsPane.classList.add('border-top');
621
}
622
// Do full layout on showSelectDropDown only
623
return true;
624
}
625
626
// Check if select out of viewport or cutting into status bar
627
if ((selectPosition.top + selectPosition.height) > (window.innerHeight - 22)
628
|| selectPosition.top < SelectBoxList.DEFAULT_DROPDOWN_MINIMUM_TOP_MARGIN
629
|| (this._dropDownPosition === AnchorPosition.BELOW && maxVisibleOptionsBelow < 1)
630
|| (this._dropDownPosition === AnchorPosition.ABOVE && maxVisibleOptionsAbove < 1)) {
631
// Cannot properly layout, close and hide
632
this.hideSelectDropDown(true);
633
return false;
634
}
635
636
// SetUp list dimensions and layout - account for container padding
637
// Use position to check above or below available space
638
if (this._dropDownPosition === AnchorPosition.BELOW) {
639
if (this._isVisible && maxVisibleOptionsBelow + maxVisibleOptionsAbove < 1) {
640
// If drop-down is visible, must be doing a DOM re-layout, hide since we don't fit
641
// Hide drop-down, hide contextview, focus on parent select
642
this.hideSelectDropDown(true);
643
return false;
644
}
645
646
// Adjust list height to max from select bottom to margin (default/minBottomMargin)
647
if (minRequiredDropDownHeight > maxSelectDropDownHeightBelow) {
648
listHeight = (maxVisibleOptionsBelow * this.getHeight());
649
}
650
} else {
651
if (minRequiredDropDownHeight > maxSelectDropDownHeightAbove) {
652
listHeight = (maxVisibleOptionsAbove * this.getHeight());
653
}
654
}
655
656
// Set adjusted list height and relayout
657
this.selectList.layout(listHeight);
658
this.selectList.domFocus();
659
660
// Finally set focus on selected item
661
if (this.selectList.length > 0) {
662
this.selectList.setFocus([this.selected || 0]);
663
this.selectList.reveal(this.selectList.getFocus()[0] || 0);
664
}
665
666
if (this._hasDetails) {
667
// Leave the selectDropDownContainer to size itself according to children (list + details) - #57447
668
this.selectList.getHTMLElement().style.height = `${listHeight}px`;
669
this.selectDropDownContainer.style.height = '';
670
} else {
671
this.selectDropDownContainer.style.height = `${listHeight}px`;
672
}
673
674
this.updateDetail(this.selected);
675
676
this.selectDropDownContainer.style.width = selectOptimalWidth;
677
this.selectDropDownListContainer.setAttribute('tabindex', '0');
678
679
return true;
680
} else {
681
return false;
682
}
683
}
684
685
private setWidthControlElement(container: HTMLElement): number {
686
let elementWidth = 0;
687
688
if (container) {
689
let longest = 0;
690
let longestLength = 0;
691
692
this.options.forEach((option, index) => {
693
const detailLength = !!option.detail ? option.detail.length : 0;
694
const rightDecoratorLength = !!option.decoratorRight ? option.decoratorRight.length : 0;
695
696
const len = option.text.length + detailLength + rightDecoratorLength;
697
if (len > longestLength) {
698
longest = index;
699
longestLength = len;
700
}
701
});
702
703
704
container.textContent = this.options[longest].text + (!!this.options[longest].decoratorRight ? `${this.options[longest].decoratorRight} ` : '');
705
elementWidth = dom.getTotalWidth(container);
706
}
707
708
return elementWidth;
709
}
710
711
private createSelectList(parent: HTMLElement): void {
712
713
// If we have already constructive list on open, skip
714
if (this.selectList) {
715
return;
716
}
717
718
// SetUp container for list
719
this.selectDropDownListContainer = dom.append(parent, $('.select-box-dropdown-list-container'));
720
721
this.listRenderer = new SelectListRenderer();
722
723
this.selectList = this._register(new List('SelectBoxCustom', this.selectDropDownListContainer, this, [this.listRenderer], {
724
useShadows: false,
725
verticalScrollMode: ScrollbarVisibility.Visible,
726
keyboardSupport: false,
727
mouseSupport: false,
728
accessibilityProvider: {
729
getAriaLabel: element => {
730
let label = element.text;
731
if (element.detail) {
732
label += `. ${element.detail}`;
733
}
734
735
if (element.decoratorRight) {
736
label += `. ${element.decoratorRight}`;
737
}
738
739
if (element.description) {
740
label += `. ${element.description}`;
741
}
742
743
return label;
744
},
745
getWidgetAriaLabel: () => localize({ key: 'selectBox', comment: ['Behave like native select dropdown element.'] }, "Select Box"),
746
getRole: () => isMacintosh ? '' : 'option',
747
getWidgetRole: () => 'listbox'
748
}
749
}));
750
if (this.selectBoxOptions.ariaLabel) {
751
this.selectList.ariaLabel = this.selectBoxOptions.ariaLabel;
752
}
753
754
// SetUp list keyboard controller - control navigation, disabled items, focus
755
const onKeyDown = this._register(new DomEmitter(this.selectDropDownListContainer, 'keydown'));
756
const onSelectDropDownKeyDown = Event.chain(onKeyDown.event, $ =>
757
$.filter(() => this.selectList.length > 0)
758
.map(e => new StandardKeyboardEvent(e))
759
);
760
761
this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.Enter))(this.onEnter, this));
762
this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.Tab))(this.onEnter, this)); // Tab should behave the same as enter, #79339
763
this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.Escape))(this.onEscape, this));
764
this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.UpArrow))(this.onUpArrow, this));
765
this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.DownArrow))(this.onDownArrow, this));
766
this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.PageDown))(this.onPageDown, this));
767
this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.PageUp))(this.onPageUp, this));
768
this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.Home))(this.onHome, this));
769
this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => e.keyCode === KeyCode.End))(this.onEnd, this));
770
this._register(Event.chain(onSelectDropDownKeyDown, $ => $.filter(e => (e.keyCode >= KeyCode.Digit0 && e.keyCode <= KeyCode.KeyZ) || (e.keyCode >= KeyCode.Semicolon && e.keyCode <= KeyCode.NumpadDivide)))(this.onCharacter, this));
771
772
// SetUp list mouse controller - control navigation, disabled items, focus
773
this._register(dom.addDisposableListener(this.selectList.getHTMLElement(), dom.EventType.POINTER_UP, e => this.onPointerUp(e)));
774
775
this._register(this.selectList.onMouseOver(e => typeof e.index !== 'undefined' && this.selectList.setFocus([e.index])));
776
this._register(this.selectList.onDidChangeFocus(e => this.onListFocus(e)));
777
778
this._register(dom.addDisposableListener(this.selectDropDownContainer, dom.EventType.FOCUS_OUT, e => {
779
if (!this._isVisible || dom.isAncestor(e.relatedTarget as HTMLElement, this.selectDropDownContainer)) {
780
return;
781
}
782
this.onListBlur();
783
}));
784
785
this.selectList.getHTMLElement().setAttribute('aria-label', this.selectBoxOptions.ariaLabel || '');
786
this.selectList.getHTMLElement().setAttribute('aria-expanded', 'true');
787
788
this.styleList();
789
}
790
791
// List methods
792
793
// List mouse controller - active exit, select option, fire onDidSelect if change, return focus to parent select
794
// Also takes in touchend events
795
private onPointerUp(e: PointerEvent): void {
796
797
if (!this.selectList.length) {
798
return;
799
}
800
801
dom.EventHelper.stop(e);
802
803
const target = <Element>e.target;
804
if (!target) {
805
return;
806
}
807
808
// Check our mouse event is on an option (not scrollbar)
809
if (target.classList.contains('slider')) {
810
return;
811
}
812
813
const listRowElement = target.closest('.monaco-list-row');
814
815
if (!listRowElement) {
816
return;
817
}
818
const index = Number(listRowElement.getAttribute('data-index'));
819
const disabled = listRowElement.classList.contains('option-disabled');
820
821
// Ignore mouse selection of disabled options
822
if (index >= 0 && index < this.options.length && !disabled) {
823
this.selected = index;
824
this.select(this.selected);
825
826
this.selectList.setFocus([this.selected]);
827
this.selectList.reveal(this.selectList.getFocus()[0]);
828
829
// Only fire if selection change
830
if (this.selected !== this._currentSelection) {
831
// Set current = selected
832
this._currentSelection = this.selected;
833
834
this._onDidSelect.fire({
835
index: this.selectElement.selectedIndex,
836
selected: this.options[this.selected].text
837
838
});
839
if (!!this.options[this.selected] && !!this.options[this.selected].text) {
840
this.setTitle(this.options[this.selected].text);
841
}
842
}
843
844
this.hideSelectDropDown(true);
845
}
846
}
847
848
// List Exit - passive - implicit no selection change, hide drop-down
849
private onListBlur(): void {
850
if (this._sticky) { return; }
851
if (this.selected !== this._currentSelection) {
852
// Reset selected to current if no change
853
this.select(this._currentSelection);
854
}
855
856
this.hideSelectDropDown(false);
857
}
858
859
860
private renderDescriptionMarkdown(text: string, actionHandler?: MarkdownActionHandler): IRenderedMarkdown {
861
const cleanRenderedMarkdown = (element: Node) => {
862
for (let i = 0; i < element.childNodes.length; i++) {
863
const child = <Element>element.childNodes.item(i);
864
865
const tagName = child.tagName && child.tagName.toLowerCase();
866
if (tagName === 'img') {
867
child.remove();
868
} else {
869
cleanRenderedMarkdown(child);
870
}
871
}
872
};
873
874
const rendered = renderMarkdown({ value: text, supportThemeIcons: true }, { actionHandler });
875
876
rendered.element.classList.add('select-box-description-markdown');
877
cleanRenderedMarkdown(rendered.element);
878
879
return rendered;
880
}
881
882
// List Focus Change - passive - update details pane with newly focused element's data
883
private onListFocus(e: IListEvent<ISelectOptionItem>) {
884
// Skip during initial layout
885
if (!this._isVisible || !this._hasDetails) {
886
return;
887
}
888
889
this.updateDetail(e.indexes[0]);
890
}
891
892
private updateDetail(selectedIndex: number): void {
893
// Reset
894
this._selectionDetailsDisposables.clear();
895
this.selectionDetailsPane.textContent = '';
896
897
const option = this.options[selectedIndex];
898
const description = option?.description ?? '';
899
const descriptionIsMarkdown = option?.descriptionIsMarkdown ?? false;
900
901
if (description) {
902
if (descriptionIsMarkdown) {
903
const actionHandler = option.descriptionMarkdownActionHandler;
904
const result = this._selectionDetailsDisposables.add(this.renderDescriptionMarkdown(description, actionHandler));
905
this.selectionDetailsPane.appendChild(result.element);
906
} else {
907
this.selectionDetailsPane.textContent = description;
908
}
909
this.selectionDetailsPane.style.display = 'block';
910
} else {
911
this.selectionDetailsPane.style.display = 'none';
912
}
913
914
// Avoid recursion
915
this._skipLayout = true;
916
this.contextViewProvider.layout();
917
this._skipLayout = false;
918
}
919
920
// List keyboard controller
921
922
// List exit - active - hide ContextView dropdown, reset selection, return focus to parent select
923
private onEscape(e: StandardKeyboardEvent): void {
924
dom.EventHelper.stop(e);
925
926
// Reset selection to value when opened
927
this.select(this._currentSelection);
928
this.hideSelectDropDown(true);
929
}
930
931
// List exit - active - hide ContextView dropdown, return focus to parent select, fire onDidSelect if change
932
private onEnter(e: StandardKeyboardEvent): void {
933
dom.EventHelper.stop(e);
934
935
// Only fire if selection change
936
if (this.selected !== this._currentSelection) {
937
this._currentSelection = this.selected;
938
this._onDidSelect.fire({
939
index: this.selectElement.selectedIndex,
940
selected: this.options[this.selected].text
941
});
942
if (!!this.options[this.selected] && !!this.options[this.selected].text) {
943
this.setTitle(this.options[this.selected].text);
944
}
945
}
946
947
this.hideSelectDropDown(true);
948
}
949
950
// List navigation - have to handle a disabled option (jump over)
951
private onDownArrow(e: StandardKeyboardEvent): void {
952
if (this.selected < this.options.length - 1) {
953
dom.EventHelper.stop(e, true);
954
955
// Skip disabled options
956
const nextOptionDisabled = this.options[this.selected + 1].isDisabled;
957
958
if (nextOptionDisabled && this.options.length > this.selected + 2) {
959
this.selected += 2;
960
} else if (nextOptionDisabled) {
961
return;
962
} else {
963
this.selected++;
964
}
965
966
// Set focus/selection - only fire event when closing drop-down or on blur
967
this.select(this.selected);
968
this.selectList.setFocus([this.selected]);
969
this.selectList.reveal(this.selectList.getFocus()[0]);
970
}
971
}
972
973
private onUpArrow(e: StandardKeyboardEvent): void {
974
if (this.selected > 0) {
975
dom.EventHelper.stop(e, true);
976
// Skip disabled options
977
const previousOptionDisabled = this.options[this.selected - 1].isDisabled;
978
if (previousOptionDisabled && this.selected > 1) {
979
this.selected -= 2;
980
} else {
981
this.selected--;
982
}
983
// Set focus/selection - only fire event when closing drop-down or on blur
984
this.select(this.selected);
985
this.selectList.setFocus([this.selected]);
986
this.selectList.reveal(this.selectList.getFocus()[0]);
987
}
988
}
989
990
private onPageUp(e: StandardKeyboardEvent): void {
991
dom.EventHelper.stop(e);
992
993
this.selectList.focusPreviousPage();
994
995
// Allow scrolling to settle
996
setTimeout(() => {
997
this.selected = this.selectList.getFocus()[0];
998
999
// Shift selection down if we land on a disabled option
1000
if (this.options[this.selected].isDisabled && this.selected < this.options.length - 1) {
1001
this.selected++;
1002
this.selectList.setFocus([this.selected]);
1003
}
1004
this.selectList.reveal(this.selected);
1005
this.select(this.selected);
1006
}, 1);
1007
}
1008
1009
private onPageDown(e: StandardKeyboardEvent): void {
1010
dom.EventHelper.stop(e);
1011
1012
this.selectList.focusNextPage();
1013
1014
// Allow scrolling to settle
1015
setTimeout(() => {
1016
this.selected = this.selectList.getFocus()[0];
1017
1018
// Shift selection up if we land on a disabled option
1019
if (this.options[this.selected].isDisabled && this.selected > 0) {
1020
this.selected--;
1021
this.selectList.setFocus([this.selected]);
1022
}
1023
this.selectList.reveal(this.selected);
1024
this.select(this.selected);
1025
}, 1);
1026
}
1027
1028
private onHome(e: StandardKeyboardEvent): void {
1029
dom.EventHelper.stop(e);
1030
1031
if (this.options.length < 2) {
1032
return;
1033
}
1034
this.selected = 0;
1035
if (this.options[this.selected].isDisabled && this.selected > 1) {
1036
this.selected++;
1037
}
1038
this.selectList.setFocus([this.selected]);
1039
this.selectList.reveal(this.selected);
1040
this.select(this.selected);
1041
}
1042
1043
private onEnd(e: StandardKeyboardEvent): void {
1044
dom.EventHelper.stop(e);
1045
1046
if (this.options.length < 2) {
1047
return;
1048
}
1049
this.selected = this.options.length - 1;
1050
if (this.options[this.selected].isDisabled && this.selected > 1) {
1051
this.selected--;
1052
}
1053
this.selectList.setFocus([this.selected]);
1054
this.selectList.reveal(this.selected);
1055
this.select(this.selected);
1056
}
1057
1058
// Mimic option first character navigation of native select
1059
private onCharacter(e: StandardKeyboardEvent): void {
1060
const ch = KeyCodeUtils.toString(e.keyCode);
1061
let optionIndex = -1;
1062
1063
for (let i = 0; i < this.options.length - 1; i++) {
1064
optionIndex = (i + this.selected + 1) % this.options.length;
1065
if (this.options[optionIndex].text.charAt(0).toUpperCase() === ch && !this.options[optionIndex].isDisabled) {
1066
this.select(optionIndex);
1067
this.selectList.setFocus([optionIndex]);
1068
this.selectList.reveal(this.selectList.getFocus()[0]);
1069
dom.EventHelper.stop(e);
1070
break;
1071
}
1072
}
1073
}
1074
1075
public override dispose(): void {
1076
this.hideSelectDropDown(false);
1077
super.dispose();
1078
}
1079
}
1080
1081