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