Path: blob/main/src/vs/base/browser/ui/button/button.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { IContextMenuProvider } from '../../contextmenu.js';6import { addDisposableListener, EventHelper, EventType, IFocusTracker, isActiveElement, reset, trackFocus, $ } from '../../dom.js';7import { StandardKeyboardEvent } from '../../keyboardEvent.js';8import { renderMarkdown, renderAsPlaintext } from '../../markdownRenderer.js';9import { Gesture, EventType as TouchEventType } from '../../touch.js';10import { createInstantHoverDelegate, getDefaultHoverDelegate } from '../hover/hoverDelegateFactory.js';11import { IHoverDelegate } from '../hover/hoverDelegate.js';12import { renderLabelWithIcons } from '../iconLabel/iconLabels.js';13import { Action, IAction, IActionRunner } from '../../../common/actions.js';14import { Codicon } from '../../../common/codicons.js';15import { Color } from '../../../common/color.js';16import { Event as BaseEvent, Emitter } from '../../../common/event.js';17import { IMarkdownString, isMarkdownString, markdownStringEqual } from '../../../common/htmlContent.js';18import { KeyCode } from '../../../common/keyCodes.js';19import { Disposable, DisposableStore, IDisposable } from '../../../common/lifecycle.js';20import { ThemeIcon } from '../../../common/themables.js';21import './button.css';22import { localize } from '../../../../nls.js';23import type { IManagedHover } from '../hover/hover.js';24import { getBaseLayerHoverDelegate } from '../hover/hoverDelegate2.js';25import { IActionProvider } from '../dropdown/dropdown.js';26import { safeSetInnerHtml, DomSanitizerConfig } from '../../domSanitize.js';2728export interface IButtonOptions extends Partial<IButtonStyles> {29readonly title?: boolean | string;30/**31* Will fallback to `title` if not set.32*/33readonly ariaLabel?: string;34readonly supportIcons?: boolean;35readonly supportShortLabel?: boolean;36readonly secondary?: boolean;37readonly hoverDelegate?: IHoverDelegate;38readonly disabled?: boolean;39}4041export interface IButtonStyles {42readonly buttonBackground: string | undefined;43readonly buttonHoverBackground: string | undefined;44readonly buttonForeground: string | undefined;45readonly buttonSeparator: string | undefined;46readonly buttonSecondaryBackground: string | undefined;47readonly buttonSecondaryHoverBackground: string | undefined;48readonly buttonSecondaryForeground: string | undefined;49readonly buttonBorder: string | undefined;50}5152export const unthemedButtonStyles: IButtonStyles = {53buttonBackground: '#0E639C',54buttonHoverBackground: '#006BB3',55buttonSeparator: Color.white.toString(),56buttonForeground: Color.white.toString(),57buttonBorder: undefined,58buttonSecondaryBackground: undefined,59buttonSecondaryForeground: undefined,60buttonSecondaryHoverBackground: undefined61};6263export interface IButton extends IDisposable {64readonly element: HTMLElement;65readonly onDidClick: BaseEvent<Event | undefined>;6667set label(value: string | IMarkdownString);68set icon(value: ThemeIcon);69set enabled(value: boolean);70set checked(value: boolean);7172focus(): void;73hasFocus(): boolean;74}7576export interface IButtonWithDescription extends IButton {77description: string;78}7980// Only allow a very limited set of inline html tags81const buttonSanitizerConfig = Object.freeze<DomSanitizerConfig>({82allowedTags: {83override: ['b', 'i', 'u', 'code', 'span'],84},85allowedAttributes: {86override: ['class'],87},88});8990export class Button extends Disposable implements IButton {9192protected options: IButtonOptions;93protected _element: HTMLElement;94protected _label: string | IMarkdownString = '';95protected _labelElement: HTMLElement | undefined;96protected _labelShortElement: HTMLElement | undefined;97private _hover: IManagedHover | undefined;9899private _onDidClick = this._register(new Emitter<Event>());100get onDidClick(): BaseEvent<Event> { return this._onDidClick.event; }101102private _onDidEscape = this._register(new Emitter<Event>());103get onDidEscape(): BaseEvent<Event> { return this._onDidEscape.event; }104105private focusTracker: IFocusTracker;106107constructor(container: HTMLElement, options: IButtonOptions) {108super();109110this.options = options;111112this._element = document.createElement('a');113this._element.classList.add('monaco-button');114this._element.tabIndex = 0;115this._element.setAttribute('role', 'button');116117this._element.classList.toggle('secondary', !!options.secondary);118const background = options.secondary ? options.buttonSecondaryBackground : options.buttonBackground;119const foreground = options.secondary ? options.buttonSecondaryForeground : options.buttonForeground;120121this._element.style.color = foreground || '';122this._element.style.backgroundColor = background || '';123124if (options.supportShortLabel) {125this._labelShortElement = document.createElement('div');126this._labelShortElement.classList.add('monaco-button-label-short');127this._element.appendChild(this._labelShortElement);128129this._labelElement = document.createElement('div');130this._labelElement.classList.add('monaco-button-label');131this._element.appendChild(this._labelElement);132133this._element.classList.add('monaco-text-button-with-short-label');134}135136if (typeof options.title === 'string') {137this.setTitle(options.title);138}139140if (typeof options.ariaLabel === 'string') {141this._element.setAttribute('aria-label', options.ariaLabel);142}143container.appendChild(this._element);144this.enabled = !options.disabled;145146this._register(Gesture.addTarget(this._element));147148[EventType.CLICK, TouchEventType.Tap].forEach(eventType => {149this._register(addDisposableListener(this._element, eventType, e => {150if (!this.enabled) {151EventHelper.stop(e);152return;153}154155this._onDidClick.fire(e);156}));157});158159this._register(addDisposableListener(this._element, EventType.KEY_DOWN, e => {160const event = new StandardKeyboardEvent(e);161let eventHandled = false;162if (this.enabled && (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space))) {163this._onDidClick.fire(e);164eventHandled = true;165} else if (event.equals(KeyCode.Escape)) {166this._onDidEscape.fire(e);167this._element.blur();168eventHandled = true;169}170171if (eventHandled) {172EventHelper.stop(event, true);173}174}));175176this._register(addDisposableListener(this._element, EventType.MOUSE_OVER, e => {177if (!this._element.classList.contains('disabled')) {178this.updateBackground(true);179}180}));181182this._register(addDisposableListener(this._element, EventType.MOUSE_OUT, e => {183this.updateBackground(false); // restore standard styles184}));185186// Also set hover background when button is focused for feedback187this.focusTracker = this._register(trackFocus(this._element));188this._register(this.focusTracker.onDidFocus(() => { if (this.enabled) { this.updateBackground(true); } }));189this._register(this.focusTracker.onDidBlur(() => { if (this.enabled) { this.updateBackground(false); } }));190}191192public override dispose(): void {193super.dispose();194this._element.remove();195}196197protected getContentElements(content: string): HTMLElement[] {198const elements: HTMLSpanElement[] = [];199for (let segment of renderLabelWithIcons(content)) {200if (typeof (segment) === 'string') {201segment = segment.trim();202203// Ignore empty segment204if (segment === '') {205continue;206}207208// Convert string segments to <span> nodes209const node = document.createElement('span');210node.textContent = segment;211elements.push(node);212} else {213elements.push(segment);214}215}216217return elements;218}219220private updateBackground(hover: boolean): void {221let background;222if (this.options.secondary) {223background = hover ? this.options.buttonSecondaryHoverBackground : this.options.buttonSecondaryBackground;224} else {225background = hover ? this.options.buttonHoverBackground : this.options.buttonBackground;226}227if (background) {228this._element.style.backgroundColor = background;229}230}231232get element(): HTMLElement {233return this._element;234}235236set label(value: string | IMarkdownString) {237if (this._label === value) {238return;239}240241if (isMarkdownString(this._label) && isMarkdownString(value) && markdownStringEqual(this._label, value)) {242return;243}244245this._element.classList.add('monaco-text-button');246const labelElement = this.options.supportShortLabel ? this._labelElement! : this._element;247248if (isMarkdownString(value)) {249const rendered = renderMarkdown(value, undefined, document.createElement('span'));250rendered.dispose();251252// Don't include outer `<p>`253const root = rendered.element.querySelector('p')?.innerHTML;254if (root) {255safeSetInnerHtml(labelElement, root, buttonSanitizerConfig);256} else {257reset(labelElement);258}259} else {260if (this.options.supportIcons) {261reset(labelElement, ...this.getContentElements(value));262} else {263labelElement.textContent = value;264}265}266267let title: string = '';268if (typeof this.options.title === 'string') {269title = this.options.title;270} else if (this.options.title) {271title = renderAsPlaintext(value);272}273274this.setTitle(title);275276this._setAriaLabel();277278this._label = value;279}280281get label(): string | IMarkdownString {282return this._label;283}284285set labelShort(value: string) {286if (!this.options.supportShortLabel || !this._labelShortElement) {287return;288}289290if (this.options.supportIcons) {291reset(this._labelShortElement, ...this.getContentElements(value));292} else {293this._labelShortElement.textContent = value;294}295}296297protected _setAriaLabel(): void {298if (typeof this.options.ariaLabel === 'string') {299this._element.setAttribute('aria-label', this.options.ariaLabel);300} else if (typeof this.options.title === 'string') {301this._element.setAttribute('aria-label', this.options.title);302}303}304305set icon(icon: ThemeIcon) {306this._setAriaLabel();307308const oldIcons = Array.from(this._element.classList).filter(item => item.startsWith('codicon-'));309this._element.classList.remove(...oldIcons);310this._element.classList.add(...ThemeIcon.asClassNameArray(icon));311}312313set enabled(value: boolean) {314if (value) {315this._element.classList.remove('disabled');316this._element.setAttribute('aria-disabled', String(false));317this._element.tabIndex = 0;318} else {319this._element.classList.add('disabled');320this._element.setAttribute('aria-disabled', String(true));321}322}323324get enabled() {325return !this._element.classList.contains('disabled');326}327328set checked(value: boolean) {329if (value) {330this._element.classList.add('checked');331this._element.setAttribute('aria-checked', 'true');332} else {333this._element.classList.remove('checked');334this._element.setAttribute('aria-checked', 'false');335}336}337338get checked() {339return this._element.classList.contains('checked');340}341342setTitle(title: string) {343if (!this._hover && title !== '') {344this._hover = this._register(getBaseLayerHoverDelegate().setupManagedHover(this.options.hoverDelegate ?? getDefaultHoverDelegate('element'), this._element, title));345} else if (this._hover) {346this._hover.update(title);347}348}349350focus(): void {351this._element.focus();352}353354hasFocus(): boolean {355return isActiveElement(this._element);356}357}358359export interface IButtonWithDropdownOptions extends IButtonOptions {360readonly contextMenuProvider: IContextMenuProvider;361readonly actions: readonly IAction[] | IActionProvider;362readonly actionRunner?: IActionRunner;363readonly addPrimaryActionToDropdown?: boolean;364/**365* dropdown menus with higher layers are rendered higher in z-index order366*/367readonly dropdownLayer?: number;368}369370export class ButtonWithDropdown extends Disposable implements IButton {371372readonly primaryButton: Button;373private readonly action: Action;374readonly dropdownButton: Button;375private readonly separatorContainer: HTMLDivElement;376private readonly separator: HTMLDivElement;377378readonly element: HTMLElement;379private readonly _onDidClick = this._register(new Emitter<Event | undefined>());380readonly onDidClick = this._onDidClick.event;381382constructor(container: HTMLElement, options: IButtonWithDropdownOptions) {383super();384385this.element = document.createElement('div');386this.element.classList.add('monaco-button-dropdown');387container.appendChild(this.element);388389if (!options.hoverDelegate) {390options = { ...options, hoverDelegate: this._register(createInstantHoverDelegate()) };391}392393this.primaryButton = this._register(new Button(this.element, options));394this._register(this.primaryButton.onDidClick(e => this._onDidClick.fire(e)));395this.action = this._register(new Action('primaryAction', renderAsPlaintext(this.primaryButton.label), undefined, true, async () => this._onDidClick.fire(undefined)));396397this.separatorContainer = document.createElement('div');398this.separatorContainer.classList.add('monaco-button-dropdown-separator');399400this.separator = document.createElement('div');401this.separatorContainer.appendChild(this.separator);402this.element.appendChild(this.separatorContainer);403404// Separator styles405const border = options.buttonBorder;406if (border) {407this.separatorContainer.style.borderTop = '1px solid ' + border;408this.separatorContainer.style.borderBottom = '1px solid ' + border;409}410411const buttonBackground = options.secondary ? options.buttonSecondaryBackground : options.buttonBackground;412this.separatorContainer.style.backgroundColor = buttonBackground ?? '';413this.separator.style.backgroundColor = options.buttonSeparator ?? '';414415this.dropdownButton = this._register(new Button(this.element, { ...options, title: localize("button dropdown more actions", 'More Actions...'), supportIcons: true }));416this.dropdownButton.element.setAttribute('aria-haspopup', 'true');417this.dropdownButton.element.setAttribute('aria-expanded', 'false');418this.dropdownButton.element.classList.add('monaco-dropdown-button');419this.dropdownButton.icon = Codicon.dropDownButton;420this._register(this.dropdownButton.onDidClick(e => {421const actions = Array.isArray(options.actions) ? options.actions : (options.actions as IActionProvider).getActions();422options.contextMenuProvider.showContextMenu({423getAnchor: () => this.dropdownButton.element,424getActions: () => options.addPrimaryActionToDropdown === false ? [...actions] : [this.action, ...actions],425actionRunner: options.actionRunner,426onHide: () => this.dropdownButton.element.setAttribute('aria-expanded', 'false'),427layer: options.dropdownLayer428});429this.dropdownButton.element.setAttribute('aria-expanded', 'true');430}));431}432433override dispose() {434super.dispose();435this.element.remove();436}437438set label(value: string) {439this.primaryButton.label = value;440this.action.label = value;441}442443set icon(icon: ThemeIcon) {444this.primaryButton.icon = icon;445}446447set enabled(enabled: boolean) {448this.primaryButton.enabled = enabled;449this.dropdownButton.enabled = enabled;450451this.element.classList.toggle('disabled', !enabled);452}453454get enabled(): boolean {455return this.primaryButton.enabled;456}457458set checked(value: boolean) {459this.primaryButton.checked = value;460}461462get checked() {463return this.primaryButton.checked;464}465466focus(): void {467this.primaryButton.focus();468}469470hasFocus(): boolean {471return this.primaryButton.hasFocus() || this.dropdownButton.hasFocus();472}473}474475export class ButtonWithDescription implements IButtonWithDescription {476477private _button: Button;478private _element: HTMLElement;479private _descriptionElement: HTMLElement;480481constructor(container: HTMLElement, private readonly options: IButtonOptions) {482this._element = document.createElement('div');483this._element.classList.add('monaco-description-button');484this._button = new Button(this._element, options);485486this._descriptionElement = document.createElement('div');487this._descriptionElement.classList.add('monaco-button-description');488this._element.appendChild(this._descriptionElement);489490container.appendChild(this._element);491}492493get onDidClick(): BaseEvent<Event | undefined> {494return this._button.onDidClick;495}496497get element(): HTMLElement {498return this._element;499}500501set label(value: string) {502this._button.label = value;503}504505set icon(icon: ThemeIcon) {506this._button.icon = icon;507}508509get enabled(): boolean {510return this._button.enabled;511}512513set enabled(enabled: boolean) {514this._button.enabled = enabled;515}516517set checked(value: boolean) {518this._button.checked = value;519}520521get checked(): boolean {522return this._button.checked;523}524525focus(): void {526this._button.focus();527}528hasFocus(): boolean {529return this._button.hasFocus();530}531dispose(): void {532this._button.dispose();533}534535set description(value: string) {536if (this.options.supportIcons) {537reset(this._descriptionElement, ...renderLabelWithIcons(value));538} else {539this._descriptionElement.textContent = value;540}541}542}543544export enum ButtonBarAlignment {545Horizontal = 0,546Vertical547}548549export class ButtonBar {550551private readonly _buttons: IButton[] = [];552private readonly _buttonStore = new DisposableStore();553554constructor(private readonly container: HTMLElement, private readonly options?: { alignment?: ButtonBarAlignment }) { }555556dispose(): void {557this._buttonStore.dispose();558}559560get buttons(): IButton[] {561return this._buttons;562}563564clear(): void {565this._buttonStore.clear();566this._buttons.length = 0;567}568569addButton(options: IButtonOptions): IButton {570const button = this._buttonStore.add(new Button(this.container, options));571this.pushButton(button);572return button;573}574575addButtonWithDescription(options: IButtonOptions): IButtonWithDescription {576const button = this._buttonStore.add(new ButtonWithDescription(this.container, options));577this.pushButton(button);578return button;579}580581addButtonWithDropdown(options: IButtonWithDropdownOptions): IButton {582const button = this._buttonStore.add(new ButtonWithDropdown(this.container, options));583this.pushButton(button);584return button;585}586587private pushButton(button: IButton): void {588this._buttons.push(button);589590const index = this._buttons.length - 1;591this._buttonStore.add(addDisposableListener(button.element, EventType.KEY_DOWN, e => {592const event = new StandardKeyboardEvent(e);593let eventHandled = true;594595// Next / Previous Button596let buttonIndexToFocus: number | undefined;597if (event.equals(this.options?.alignment === ButtonBarAlignment.Vertical ? KeyCode.UpArrow : KeyCode.LeftArrow)) {598buttonIndexToFocus = index > 0 ? index - 1 : this._buttons.length - 1;599} else if (event.equals(this.options?.alignment === ButtonBarAlignment.Vertical ? KeyCode.DownArrow : KeyCode.RightArrow)) {600buttonIndexToFocus = index === this._buttons.length - 1 ? 0 : index + 1;601} else {602eventHandled = false;603}604605if (eventHandled && typeof buttonIndexToFocus === 'number') {606this._buttons[buttonIndexToFocus].focus();607EventHelper.stop(e, true);608}609610}));611}612}613614/**615* 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.616*/617export class ButtonWithIcon extends Button {618private readonly _iconElement: HTMLElement;619private readonly _mdlabelElement: HTMLElement;620621public get labelElement() { return this._mdlabelElement; }622623constructor(container: HTMLElement, options: IButtonOptions) {624super(container, options);625626if (options.supportShortLabel) {627throw new Error('ButtonWithIcon does not support short labels');628}629630this._element.classList.add('monaco-icon-button');631this._iconElement = $('');632this._mdlabelElement = $('.monaco-button-mdlabel');633this._element.append(this._iconElement, this._mdlabelElement);634}635636override get label(): IMarkdownString | string {637return super.label;638}639640override set label(value: IMarkdownString | string) {641if (this._label === value) {642return;643}644645if (isMarkdownString(this._label) && isMarkdownString(value) && markdownStringEqual(this._label, value)) {646return;647}648649this._element.classList.add('monaco-text-button');650if (isMarkdownString(value)) {651const rendered = renderMarkdown(value, undefined, document.createElement('span'));652rendered.dispose();653654const root = rendered.element.querySelector('p')?.innerHTML;655if (root) {656safeSetInnerHtml(this._mdlabelElement, root, buttonSanitizerConfig);657} else {658reset(this._mdlabelElement);659}660} else {661if (this.options.supportIcons) {662reset(this._mdlabelElement, ...this.getContentElements(value));663} else {664this._mdlabelElement.textContent = value;665}666}667668let title: string = '';669if (typeof this.options.title === 'string') {670title = this.options.title;671} else if (this.options.title) {672title = renderAsPlaintext(value);673}674675this.setTitle(title);676this._setAriaLabel();677this._label = value;678}679680override get icon(): ThemeIcon {681return super.icon;682}683684override set icon(icon: ThemeIcon) {685this._iconElement.classList.value = '';686this._iconElement.classList.add(...ThemeIcon.asClassNameArray(icon));687this._setAriaLabel();688}689}690691692