Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/base/browser/ui/button/button.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 { IContextMenuProvider } from '../../contextmenu.js';
7
import { addDisposableListener, EventHelper, EventType, IFocusTracker, isActiveElement, reset, trackFocus, $ } from '../../dom.js';
8
import { StandardKeyboardEvent } from '../../keyboardEvent.js';
9
import { renderMarkdown, renderAsPlaintext } from '../../markdownRenderer.js';
10
import { Gesture, EventType as TouchEventType } from '../../touch.js';
11
import { createInstantHoverDelegate, getDefaultHoverDelegate } from '../hover/hoverDelegateFactory.js';
12
import { IHoverDelegate } from '../hover/hoverDelegate.js';
13
import { renderLabelWithIcons } from '../iconLabel/iconLabels.js';
14
import { Action, IAction, IActionRunner } from '../../../common/actions.js';
15
import { Codicon } from '../../../common/codicons.js';
16
import { Color } from '../../../common/color.js';
17
import { Event as BaseEvent, Emitter } from '../../../common/event.js';
18
import { IMarkdownString, isMarkdownString, markdownStringEqual } from '../../../common/htmlContent.js';
19
import { KeyCode } from '../../../common/keyCodes.js';
20
import { Disposable, DisposableStore, IDisposable } from '../../../common/lifecycle.js';
21
import { ThemeIcon } from '../../../common/themables.js';
22
import './button.css';
23
import { localize } from '../../../../nls.js';
24
import type { IManagedHover } from '../hover/hover.js';
25
import { getBaseLayerHoverDelegate } from '../hover/hoverDelegate2.js';
26
import { IActionProvider } from '../dropdown/dropdown.js';
27
import { safeSetInnerHtml, DomSanitizerConfig } from '../../domSanitize.js';
28
29
export interface IButtonOptions extends Partial<IButtonStyles> {
30
readonly title?: boolean | string;
31
/**
32
* Will fallback to `title` if not set.
33
*/
34
readonly ariaLabel?: string;
35
readonly supportIcons?: boolean;
36
readonly supportShortLabel?: boolean;
37
readonly secondary?: boolean;
38
readonly hoverDelegate?: IHoverDelegate;
39
readonly disabled?: boolean;
40
}
41
42
export interface IButtonStyles {
43
readonly buttonBackground: string | undefined;
44
readonly buttonHoverBackground: string | undefined;
45
readonly buttonForeground: string | undefined;
46
readonly buttonSeparator: string | undefined;
47
readonly buttonSecondaryBackground: string | undefined;
48
readonly buttonSecondaryHoverBackground: string | undefined;
49
readonly buttonSecondaryForeground: string | undefined;
50
readonly buttonBorder: string | undefined;
51
}
52
53
export const unthemedButtonStyles: IButtonStyles = {
54
buttonBackground: '#0E639C',
55
buttonHoverBackground: '#006BB3',
56
buttonSeparator: Color.white.toString(),
57
buttonForeground: Color.white.toString(),
58
buttonBorder: undefined,
59
buttonSecondaryBackground: undefined,
60
buttonSecondaryForeground: undefined,
61
buttonSecondaryHoverBackground: undefined
62
};
63
64
export interface IButton extends IDisposable {
65
readonly element: HTMLElement;
66
readonly onDidClick: BaseEvent<Event | undefined>;
67
68
set label(value: string | IMarkdownString);
69
set icon(value: ThemeIcon);
70
set enabled(value: boolean);
71
set checked(value: boolean);
72
73
focus(): void;
74
hasFocus(): boolean;
75
}
76
77
export interface IButtonWithDescription extends IButton {
78
description: string;
79
}
80
81
// Only allow a very limited set of inline html tags
82
const buttonSanitizerConfig = Object.freeze<DomSanitizerConfig>({
83
allowedTags: {
84
override: ['b', 'i', 'u', 'code', 'span'],
85
},
86
allowedAttributes: {
87
override: ['class'],
88
},
89
});
90
91
export class Button extends Disposable implements IButton {
92
93
protected options: IButtonOptions;
94
protected _element: HTMLElement;
95
protected _label: string | IMarkdownString = '';
96
protected _labelElement: HTMLElement | undefined;
97
protected _labelShortElement: HTMLElement | undefined;
98
private _hover: IManagedHover | undefined;
99
100
private _onDidClick = this._register(new Emitter<Event>());
101
get onDidClick(): BaseEvent<Event> { return this._onDidClick.event; }
102
103
private _onDidEscape = this._register(new Emitter<Event>());
104
get onDidEscape(): BaseEvent<Event> { return this._onDidEscape.event; }
105
106
private focusTracker: IFocusTracker;
107
108
constructor(container: HTMLElement, options: IButtonOptions) {
109
super();
110
111
this.options = options;
112
113
this._element = document.createElement('a');
114
this._element.classList.add('monaco-button');
115
this._element.tabIndex = 0;
116
this._element.setAttribute('role', 'button');
117
118
this._element.classList.toggle('secondary', !!options.secondary);
119
const background = options.secondary ? options.buttonSecondaryBackground : options.buttonBackground;
120
const foreground = options.secondary ? options.buttonSecondaryForeground : options.buttonForeground;
121
122
this._element.style.color = foreground || '';
123
this._element.style.backgroundColor = background || '';
124
125
if (options.supportShortLabel) {
126
this._labelShortElement = document.createElement('div');
127
this._labelShortElement.classList.add('monaco-button-label-short');
128
this._element.appendChild(this._labelShortElement);
129
130
this._labelElement = document.createElement('div');
131
this._labelElement.classList.add('monaco-button-label');
132
this._element.appendChild(this._labelElement);
133
134
this._element.classList.add('monaco-text-button-with-short-label');
135
}
136
137
if (typeof options.title === 'string') {
138
this.setTitle(options.title);
139
}
140
141
if (typeof options.ariaLabel === 'string') {
142
this._element.setAttribute('aria-label', options.ariaLabel);
143
}
144
container.appendChild(this._element);
145
this.enabled = !options.disabled;
146
147
this._register(Gesture.addTarget(this._element));
148
149
[EventType.CLICK, TouchEventType.Tap].forEach(eventType => {
150
this._register(addDisposableListener(this._element, eventType, e => {
151
if (!this.enabled) {
152
EventHelper.stop(e);
153
return;
154
}
155
156
this._onDidClick.fire(e);
157
}));
158
});
159
160
this._register(addDisposableListener(this._element, EventType.KEY_DOWN, e => {
161
const event = new StandardKeyboardEvent(e);
162
let eventHandled = false;
163
if (this.enabled && (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space))) {
164
this._onDidClick.fire(e);
165
eventHandled = true;
166
} else if (event.equals(KeyCode.Escape)) {
167
this._onDidEscape.fire(e);
168
this._element.blur();
169
eventHandled = true;
170
}
171
172
if (eventHandled) {
173
EventHelper.stop(event, true);
174
}
175
}));
176
177
this._register(addDisposableListener(this._element, EventType.MOUSE_OVER, e => {
178
if (!this._element.classList.contains('disabled')) {
179
this.updateBackground(true);
180
}
181
}));
182
183
this._register(addDisposableListener(this._element, EventType.MOUSE_OUT, e => {
184
this.updateBackground(false); // restore standard styles
185
}));
186
187
// Also set hover background when button is focused for feedback
188
this.focusTracker = this._register(trackFocus(this._element));
189
this._register(this.focusTracker.onDidFocus(() => { if (this.enabled) { this.updateBackground(true); } }));
190
this._register(this.focusTracker.onDidBlur(() => { if (this.enabled) { this.updateBackground(false); } }));
191
}
192
193
public override dispose(): void {
194
super.dispose();
195
this._element.remove();
196
}
197
198
protected getContentElements(content: string): HTMLElement[] {
199
const elements: HTMLSpanElement[] = [];
200
for (let segment of renderLabelWithIcons(content)) {
201
if (typeof (segment) === 'string') {
202
segment = segment.trim();
203
204
// Ignore empty segment
205
if (segment === '') {
206
continue;
207
}
208
209
// Convert string segments to <span> nodes
210
const node = document.createElement('span');
211
node.textContent = segment;
212
elements.push(node);
213
} else {
214
elements.push(segment);
215
}
216
}
217
218
return elements;
219
}
220
221
private updateBackground(hover: boolean): void {
222
let background;
223
if (this.options.secondary) {
224
background = hover ? this.options.buttonSecondaryHoverBackground : this.options.buttonSecondaryBackground;
225
} else {
226
background = hover ? this.options.buttonHoverBackground : this.options.buttonBackground;
227
}
228
if (background) {
229
this._element.style.backgroundColor = background;
230
}
231
}
232
233
get element(): HTMLElement {
234
return this._element;
235
}
236
237
set label(value: string | IMarkdownString) {
238
if (this._label === value) {
239
return;
240
}
241
242
if (isMarkdownString(this._label) && isMarkdownString(value) && markdownStringEqual(this._label, value)) {
243
return;
244
}
245
246
this._element.classList.add('monaco-text-button');
247
const labelElement = this.options.supportShortLabel ? this._labelElement! : this._element;
248
249
if (isMarkdownString(value)) {
250
const rendered = renderMarkdown(value, undefined, document.createElement('span'));
251
rendered.dispose();
252
253
// Don't include outer `<p>`
254
const root = rendered.element.querySelector('p')?.innerHTML;
255
if (root) {
256
safeSetInnerHtml(labelElement, root, buttonSanitizerConfig);
257
} else {
258
reset(labelElement);
259
}
260
} else {
261
if (this.options.supportIcons) {
262
reset(labelElement, ...this.getContentElements(value));
263
} else {
264
labelElement.textContent = value;
265
}
266
}
267
268
let title: string = '';
269
if (typeof this.options.title === 'string') {
270
title = this.options.title;
271
} else if (this.options.title) {
272
title = renderAsPlaintext(value);
273
}
274
275
this.setTitle(title);
276
277
this._setAriaLabel();
278
279
this._label = value;
280
}
281
282
get label(): string | IMarkdownString {
283
return this._label;
284
}
285
286
set labelShort(value: string) {
287
if (!this.options.supportShortLabel || !this._labelShortElement) {
288
return;
289
}
290
291
if (this.options.supportIcons) {
292
reset(this._labelShortElement, ...this.getContentElements(value));
293
} else {
294
this._labelShortElement.textContent = value;
295
}
296
}
297
298
protected _setAriaLabel(): void {
299
if (typeof this.options.ariaLabel === 'string') {
300
this._element.setAttribute('aria-label', this.options.ariaLabel);
301
} else if (typeof this.options.title === 'string') {
302
this._element.setAttribute('aria-label', this.options.title);
303
}
304
}
305
306
set icon(icon: ThemeIcon) {
307
this._setAriaLabel();
308
309
const oldIcons = Array.from(this._element.classList).filter(item => item.startsWith('codicon-'));
310
this._element.classList.remove(...oldIcons);
311
this._element.classList.add(...ThemeIcon.asClassNameArray(icon));
312
}
313
314
set enabled(value: boolean) {
315
if (value) {
316
this._element.classList.remove('disabled');
317
this._element.setAttribute('aria-disabled', String(false));
318
this._element.tabIndex = 0;
319
} else {
320
this._element.classList.add('disabled');
321
this._element.setAttribute('aria-disabled', String(true));
322
}
323
}
324
325
get enabled() {
326
return !this._element.classList.contains('disabled');
327
}
328
329
set checked(value: boolean) {
330
if (value) {
331
this._element.classList.add('checked');
332
this._element.setAttribute('aria-checked', 'true');
333
} else {
334
this._element.classList.remove('checked');
335
this._element.setAttribute('aria-checked', 'false');
336
}
337
}
338
339
get checked() {
340
return this._element.classList.contains('checked');
341
}
342
343
setTitle(title: string) {
344
if (!this._hover && title !== '') {
345
this._hover = this._register(getBaseLayerHoverDelegate().setupManagedHover(this.options.hoverDelegate ?? getDefaultHoverDelegate('element'), this._element, title));
346
} else if (this._hover) {
347
this._hover.update(title);
348
}
349
}
350
351
focus(): void {
352
this._element.focus();
353
}
354
355
hasFocus(): boolean {
356
return isActiveElement(this._element);
357
}
358
}
359
360
export interface IButtonWithDropdownOptions extends IButtonOptions {
361
readonly contextMenuProvider: IContextMenuProvider;
362
readonly actions: readonly IAction[] | IActionProvider;
363
readonly actionRunner?: IActionRunner;
364
readonly addPrimaryActionToDropdown?: boolean;
365
/**
366
* dropdown menus with higher layers are rendered higher in z-index order
367
*/
368
readonly dropdownLayer?: number;
369
}
370
371
export class ButtonWithDropdown extends Disposable implements IButton {
372
373
readonly primaryButton: Button;
374
private readonly action: Action;
375
readonly dropdownButton: Button;
376
private readonly separatorContainer: HTMLDivElement;
377
private readonly separator: HTMLDivElement;
378
379
readonly element: HTMLElement;
380
private readonly _onDidClick = this._register(new Emitter<Event | undefined>());
381
readonly onDidClick = this._onDidClick.event;
382
383
constructor(container: HTMLElement, options: IButtonWithDropdownOptions) {
384
super();
385
386
this.element = document.createElement('div');
387
this.element.classList.add('monaco-button-dropdown');
388
container.appendChild(this.element);
389
390
if (!options.hoverDelegate) {
391
options = { ...options, hoverDelegate: this._register(createInstantHoverDelegate()) };
392
}
393
394
this.primaryButton = this._register(new Button(this.element, options));
395
this._register(this.primaryButton.onDidClick(e => this._onDidClick.fire(e)));
396
this.action = this._register(new Action('primaryAction', renderAsPlaintext(this.primaryButton.label), undefined, true, async () => this._onDidClick.fire(undefined)));
397
398
this.separatorContainer = document.createElement('div');
399
this.separatorContainer.classList.add('monaco-button-dropdown-separator');
400
401
this.separator = document.createElement('div');
402
this.separatorContainer.appendChild(this.separator);
403
this.element.appendChild(this.separatorContainer);
404
405
// Separator styles
406
const border = options.buttonBorder;
407
if (border) {
408
this.separatorContainer.style.borderTop = '1px solid ' + border;
409
this.separatorContainer.style.borderBottom = '1px solid ' + border;
410
}
411
412
const buttonBackground = options.secondary ? options.buttonSecondaryBackground : options.buttonBackground;
413
this.separatorContainer.style.backgroundColor = buttonBackground ?? '';
414
this.separator.style.backgroundColor = options.buttonSeparator ?? '';
415
416
this.dropdownButton = this._register(new Button(this.element, { ...options, title: localize("button dropdown more actions", 'More Actions...'), supportIcons: true }));
417
this.dropdownButton.element.setAttribute('aria-haspopup', 'true');
418
this.dropdownButton.element.setAttribute('aria-expanded', 'false');
419
this.dropdownButton.element.classList.add('monaco-dropdown-button');
420
this.dropdownButton.icon = Codicon.dropDownButton;
421
this._register(this.dropdownButton.onDidClick(e => {
422
const actions = Array.isArray(options.actions) ? options.actions : (options.actions as IActionProvider).getActions();
423
options.contextMenuProvider.showContextMenu({
424
getAnchor: () => this.dropdownButton.element,
425
getActions: () => options.addPrimaryActionToDropdown === false ? [...actions] : [this.action, ...actions],
426
actionRunner: options.actionRunner,
427
onHide: () => this.dropdownButton.element.setAttribute('aria-expanded', 'false'),
428
layer: options.dropdownLayer
429
});
430
this.dropdownButton.element.setAttribute('aria-expanded', 'true');
431
}));
432
}
433
434
override dispose() {
435
super.dispose();
436
this.element.remove();
437
}
438
439
set label(value: string) {
440
this.primaryButton.label = value;
441
this.action.label = value;
442
}
443
444
set icon(icon: ThemeIcon) {
445
this.primaryButton.icon = icon;
446
}
447
448
set enabled(enabled: boolean) {
449
this.primaryButton.enabled = enabled;
450
this.dropdownButton.enabled = enabled;
451
452
this.element.classList.toggle('disabled', !enabled);
453
}
454
455
get enabled(): boolean {
456
return this.primaryButton.enabled;
457
}
458
459
set checked(value: boolean) {
460
this.primaryButton.checked = value;
461
}
462
463
get checked() {
464
return this.primaryButton.checked;
465
}
466
467
focus(): void {
468
this.primaryButton.focus();
469
}
470
471
hasFocus(): boolean {
472
return this.primaryButton.hasFocus() || this.dropdownButton.hasFocus();
473
}
474
}
475
476
export class ButtonWithDescription implements IButtonWithDescription {
477
478
private _button: Button;
479
private _element: HTMLElement;
480
private _descriptionElement: HTMLElement;
481
482
constructor(container: HTMLElement, private readonly options: IButtonOptions) {
483
this._element = document.createElement('div');
484
this._element.classList.add('monaco-description-button');
485
this._button = new Button(this._element, options);
486
487
this._descriptionElement = document.createElement('div');
488
this._descriptionElement.classList.add('monaco-button-description');
489
this._element.appendChild(this._descriptionElement);
490
491
container.appendChild(this._element);
492
}
493
494
get onDidClick(): BaseEvent<Event | undefined> {
495
return this._button.onDidClick;
496
}
497
498
get element(): HTMLElement {
499
return this._element;
500
}
501
502
set label(value: string) {
503
this._button.label = value;
504
}
505
506
set icon(icon: ThemeIcon) {
507
this._button.icon = icon;
508
}
509
510
get enabled(): boolean {
511
return this._button.enabled;
512
}
513
514
set enabled(enabled: boolean) {
515
this._button.enabled = enabled;
516
}
517
518
set checked(value: boolean) {
519
this._button.checked = value;
520
}
521
522
get checked(): boolean {
523
return this._button.checked;
524
}
525
526
focus(): void {
527
this._button.focus();
528
}
529
hasFocus(): boolean {
530
return this._button.hasFocus();
531
}
532
dispose(): void {
533
this._button.dispose();
534
}
535
536
set description(value: string) {
537
if (this.options.supportIcons) {
538
reset(this._descriptionElement, ...renderLabelWithIcons(value));
539
} else {
540
this._descriptionElement.textContent = value;
541
}
542
}
543
}
544
545
export enum ButtonBarAlignment {
546
Horizontal = 0,
547
Vertical
548
}
549
550
export class ButtonBar {
551
552
private readonly _buttons: IButton[] = [];
553
private readonly _buttonStore = new DisposableStore();
554
555
constructor(private readonly container: HTMLElement, private readonly options?: { alignment?: ButtonBarAlignment }) { }
556
557
dispose(): void {
558
this._buttonStore.dispose();
559
}
560
561
get buttons(): IButton[] {
562
return this._buttons;
563
}
564
565
clear(): void {
566
this._buttonStore.clear();
567
this._buttons.length = 0;
568
}
569
570
addButton(options: IButtonOptions): IButton {
571
const button = this._buttonStore.add(new Button(this.container, options));
572
this.pushButton(button);
573
return button;
574
}
575
576
addButtonWithDescription(options: IButtonOptions): IButtonWithDescription {
577
const button = this._buttonStore.add(new ButtonWithDescription(this.container, options));
578
this.pushButton(button);
579
return button;
580
}
581
582
addButtonWithDropdown(options: IButtonWithDropdownOptions): IButton {
583
const button = this._buttonStore.add(new ButtonWithDropdown(this.container, options));
584
this.pushButton(button);
585
return button;
586
}
587
588
private pushButton(button: IButton): void {
589
this._buttons.push(button);
590
591
const index = this._buttons.length - 1;
592
this._buttonStore.add(addDisposableListener(button.element, EventType.KEY_DOWN, e => {
593
const event = new StandardKeyboardEvent(e);
594
let eventHandled = true;
595
596
// Next / Previous Button
597
let buttonIndexToFocus: number | undefined;
598
if (event.equals(this.options?.alignment === ButtonBarAlignment.Vertical ? KeyCode.UpArrow : KeyCode.LeftArrow)) {
599
buttonIndexToFocus = index > 0 ? index - 1 : this._buttons.length - 1;
600
} else if (event.equals(this.options?.alignment === ButtonBarAlignment.Vertical ? KeyCode.DownArrow : KeyCode.RightArrow)) {
601
buttonIndexToFocus = index === this._buttons.length - 1 ? 0 : index + 1;
602
} else {
603
eventHandled = false;
604
}
605
606
if (eventHandled && typeof buttonIndexToFocus === 'number') {
607
this._buttons[buttonIndexToFocus].focus();
608
EventHelper.stop(e, true);
609
}
610
611
}));
612
}
613
}
614
615
/**
616
* This is a Button that supports an icon to the left, and markdown to the right, with proper separation and wrapping the markdown label, which Button doesn't do.
617
*/
618
export class ButtonWithIcon extends Button {
619
private readonly _iconElement: HTMLElement;
620
private readonly _mdlabelElement: HTMLElement;
621
622
public get labelElement() { return this._mdlabelElement; }
623
624
constructor(container: HTMLElement, options: IButtonOptions) {
625
super(container, options);
626
627
if (options.supportShortLabel) {
628
throw new Error('ButtonWithIcon does not support short labels');
629
}
630
631
this._element.classList.add('monaco-icon-button');
632
this._iconElement = $('');
633
this._mdlabelElement = $('.monaco-button-mdlabel');
634
this._element.append(this._iconElement, this._mdlabelElement);
635
}
636
637
override get label(): IMarkdownString | string {
638
return super.label;
639
}
640
641
override set label(value: IMarkdownString | string) {
642
if (this._label === value) {
643
return;
644
}
645
646
if (isMarkdownString(this._label) && isMarkdownString(value) && markdownStringEqual(this._label, value)) {
647
return;
648
}
649
650
this._element.classList.add('monaco-text-button');
651
if (isMarkdownString(value)) {
652
const rendered = renderMarkdown(value, undefined, document.createElement('span'));
653
rendered.dispose();
654
655
const root = rendered.element.querySelector('p')?.innerHTML;
656
if (root) {
657
safeSetInnerHtml(this._mdlabelElement, root, buttonSanitizerConfig);
658
} else {
659
reset(this._mdlabelElement);
660
}
661
} else {
662
if (this.options.supportIcons) {
663
reset(this._mdlabelElement, ...this.getContentElements(value));
664
} else {
665
this._mdlabelElement.textContent = value;
666
}
667
}
668
669
let title: string = '';
670
if (typeof this.options.title === 'string') {
671
title = this.options.title;
672
} else if (this.options.title) {
673
title = renderAsPlaintext(value);
674
}
675
676
this.setTitle(title);
677
this._setAriaLabel();
678
this._label = value;
679
}
680
681
override get icon(): ThemeIcon {
682
return super.icon;
683
}
684
685
override set icon(icon: ThemeIcon) {
686
this._iconElement.classList.value = '';
687
this._iconElement.classList.add(...ThemeIcon.asClassNameArray(icon));
688
this._setAriaLabel();
689
}
690
}
691
692