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