Path: blob/main/src/vs/base/browser/ui/button/button.ts
5221 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 { IAction, IActionRunner, toAction } 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 small?: boolean;38readonly hoverDelegate?: IHoverDelegate;39readonly disabled?: boolean;40}4142export interface IButtonStyles {43readonly buttonBackground: string | undefined;44readonly buttonHoverBackground: string | undefined;45readonly buttonForeground: string | undefined;46readonly buttonSeparator: string | undefined;47readonly buttonSecondaryBackground: string | undefined;48readonly buttonSecondaryHoverBackground: string | undefined;49readonly buttonSecondaryForeground: string | undefined;50readonly buttonSecondaryBorder: string | undefined;51readonly buttonBorder: string | undefined;52}5354export const unthemedButtonStyles: IButtonStyles = {55buttonBackground: '#0E639C',56buttonHoverBackground: '#006BB3',57buttonSeparator: Color.white.toString(),58buttonForeground: Color.white.toString(),59buttonBorder: undefined,60buttonSecondaryBackground: undefined,61buttonSecondaryForeground: undefined,62buttonSecondaryHoverBackground: undefined,63buttonSecondaryBorder: undefined64};6566export interface IButton extends IDisposable {67readonly element: HTMLElement;68readonly onDidClick: BaseEvent<Event | undefined>;6970set label(value: string | IMarkdownString);71set icon(value: ThemeIcon);72set enabled(value: boolean);73set checked(value: boolean);7475focus(): void;76hasFocus(): boolean;77}7879export interface IButtonWithDescription extends IButton {80description: string;81}8283// Only allow a very limited set of inline html tags84const buttonSanitizerConfig = Object.freeze<DomSanitizerConfig>({85allowedTags: {86override: ['b', 'i', 'u', 'code', 'span'],87},88allowedAttributes: {89override: ['class'],90},91});9293export class Button extends Disposable implements IButton {9495protected options: IButtonOptions;96protected _element: HTMLElement;97protected _label: string | IMarkdownString = '';98protected _labelElement: HTMLElement | undefined;99protected _labelShortElement: HTMLElement | undefined;100private _hover: IManagedHover | undefined;101102private _onDidClick = this._register(new Emitter<Event>());103get onDidClick(): BaseEvent<Event> { return this._onDidClick.event; }104105private _onDidEscape = this._register(new Emitter<Event>());106get onDidEscape(): BaseEvent<Event> { return this._onDidEscape.event; }107108private focusTracker: IFocusTracker;109110constructor(container: HTMLElement, options: IButtonOptions) {111super();112113this.options = options;114115this._element = document.createElement('a');116this._element.classList.add('monaco-button');117this._element.tabIndex = 0;118this._element.setAttribute('role', 'button');119120this._element.classList.toggle('secondary', !!options.secondary);121this._element.classList.toggle('small', !!options.small);122const background = options.secondary ? options.buttonSecondaryBackground : options.buttonBackground;123const foreground = options.secondary ? options.buttonSecondaryForeground : options.buttonForeground;124const border = options.secondary ? options.buttonSecondaryBorder : options.buttonBorder;125126this._element.style.color = foreground || '';127this._element.style.backgroundColor = background || '';128if (border) {129this._element.style.border = `1px solid ${border}`;130}131132if (options.supportShortLabel) {133this._labelShortElement = document.createElement('div');134this._labelShortElement.classList.add('monaco-button-label-short');135this._element.appendChild(this._labelShortElement);136137this._labelElement = document.createElement('div');138this._labelElement.classList.add('monaco-button-label');139this._element.appendChild(this._labelElement);140141this._element.classList.add('monaco-text-button-with-short-label');142}143144if (typeof options.title === 'string') {145this.setTitle(options.title);146}147148if (typeof options.ariaLabel === 'string') {149this._element.setAttribute('aria-label', options.ariaLabel);150}151container.appendChild(this._element);152this.enabled = !options.disabled;153154this._register(Gesture.addTarget(this._element));155156[EventType.CLICK, TouchEventType.Tap].forEach(eventType => {157this._register(addDisposableListener(this._element, eventType, e => {158if (!this.enabled) {159EventHelper.stop(e);160return;161}162163this._onDidClick.fire(e);164}));165});166167this._register(addDisposableListener(this._element, EventType.KEY_DOWN, e => {168const event = new StandardKeyboardEvent(e);169let eventHandled = false;170if (this.enabled && (event.equals(KeyCode.Enter) || event.equals(KeyCode.Space))) {171this._onDidClick.fire(e);172eventHandled = true;173} else if (event.equals(KeyCode.Escape)) {174this._onDidEscape.fire(e);175this._element.blur();176eventHandled = true;177}178179if (eventHandled) {180EventHelper.stop(event, true);181}182}));183184this._register(addDisposableListener(this._element, EventType.MOUSE_OVER, e => {185if (!this._element.classList.contains('disabled')) {186this.updateStyles(true);187}188}));189190this._register(addDisposableListener(this._element, EventType.MOUSE_OUT, e => {191this.updateStyles(false); // restore standard styles192}));193194// Also set hover background when button is focused for feedback195this.focusTracker = this._register(trackFocus(this._element));196this._register(this.focusTracker.onDidFocus(() => { if (this.enabled) { this.updateStyles(true); } }));197this._register(this.focusTracker.onDidBlur(() => { if (this.enabled) { this.updateStyles(false); } }));198}199200public override dispose(): void {201super.dispose();202this._element.remove();203}204205protected getContentElements(content: string): HTMLElement[] {206const elements: HTMLSpanElement[] = [];207for (let segment of renderLabelWithIcons(content)) {208if (typeof (segment) === 'string') {209segment = segment.trim();210211// Ignore empty segment212if (segment === '') {213continue;214}215216// Convert string segments to <span> nodes217const node = document.createElement('span');218node.textContent = segment;219elements.push(node);220} else {221elements.push(segment);222}223}224225return elements;226}227228private updateStyles(hover: boolean): void {229let background;230let foreground;231let border;232if (this.options.secondary) {233background = hover ? this.options.buttonSecondaryHoverBackground : this.options.buttonSecondaryBackground;234foreground = this.options.buttonSecondaryForeground;235border = this.options.buttonSecondaryBorder;236} else {237background = hover ? this.options.buttonHoverBackground : this.options.buttonBackground;238foreground = this.options.buttonForeground;239border = this.options.buttonBorder;240}241242this._element.style.backgroundColor = background || '';243this._element.style.color = foreground || '';244this._element.style.border = border ? `1px solid ${border}` : '';245}246247get element(): HTMLElement {248return this._element;249}250251set label(value: string | IMarkdownString) {252if (this._label === value) {253return;254}255256if (isMarkdownString(this._label) && isMarkdownString(value) && markdownStringEqual(this._label, value)) {257return;258}259260this._element.classList.add('monaco-text-button');261const labelElement = this.options.supportShortLabel ? this._labelElement! : this._element;262263if (isMarkdownString(value)) {264const rendered = renderMarkdown(value, undefined, document.createElement('span'));265rendered.dispose();266267// Don't include outer `<p>`268// eslint-disable-next-line no-restricted-syntax269const root = rendered.element.querySelector('p')?.innerHTML;270if (root) {271safeSetInnerHtml(labelElement, root, buttonSanitizerConfig);272} else {273reset(labelElement);274}275} else {276if (this.options.supportIcons) {277reset(labelElement, ...this.getContentElements(value));278} else {279labelElement.textContent = value;280}281}282283let title: string = '';284if (typeof this.options.title === 'string') {285title = this.options.title;286} else if (this.options.title) {287title = renderAsPlaintext(value);288}289290this.setTitle(title);291292this._setAriaLabel();293294this._label = value;295}296297get label(): string | IMarkdownString {298return this._label;299}300301set labelShort(value: string) {302if (!this.options.supportShortLabel || !this._labelShortElement) {303return;304}305306if (this.options.supportIcons) {307reset(this._labelShortElement, ...this.getContentElements(value));308} else {309this._labelShortElement.textContent = value;310}311}312313protected _setAriaLabel(): void {314if (typeof this.options.ariaLabel === 'string') {315this._element.setAttribute('aria-label', this.options.ariaLabel);316} else if (typeof this.options.title === 'string') {317this._element.setAttribute('aria-label', this.options.title);318}319}320321set icon(icon: ThemeIcon) {322this._setAriaLabel();323324const oldIcons = Array.from(this._element.classList).filter(item => item.startsWith('codicon-'));325this._element.classList.remove(...oldIcons);326this._element.classList.add(...ThemeIcon.asClassNameArray(icon));327}328329set enabled(value: boolean) {330if (value) {331this._element.classList.remove('disabled');332this._element.setAttribute('aria-disabled', String(false));333this._element.tabIndex = 0;334} else {335this._element.classList.add('disabled');336this._element.setAttribute('aria-disabled', String(true));337}338}339340get enabled() {341return !this._element.classList.contains('disabled');342}343344set secondary(value: boolean) {345this._element.classList.toggle('secondary', value);346(this.options as { secondary?: boolean }).secondary = value;347this.updateStyles(false);348}349350set checked(value: boolean) {351if (value) {352this._element.classList.add('checked');353this._element.setAttribute('aria-pressed', 'true');354} else {355this._element.classList.remove('checked');356this._element.setAttribute('aria-pressed', 'false');357}358}359360get checked() {361return this._element.classList.contains('checked');362}363364setTitle(title: string) {365if (!this._hover && title !== '') {366this._hover = this._register(getBaseLayerHoverDelegate().setupManagedHover(this.options.hoverDelegate ?? getDefaultHoverDelegate('element'), this._element, title));367} else if (this._hover) {368this._hover.update(title);369}370}371372focus(): void {373this._element.focus();374}375376hasFocus(): boolean {377return isActiveElement(this._element);378}379}380381export interface IButtonWithDropdownOptions extends IButtonOptions {382readonly contextMenuProvider: IContextMenuProvider;383readonly actions: readonly IAction[] | IActionProvider;384readonly actionRunner?: IActionRunner;385readonly addPrimaryActionToDropdown?: boolean;386/**387* dropdown menus with higher layers are rendered higher in z-index order388*/389readonly dropdownLayer?: number;390}391392export class ButtonWithDropdown extends Disposable implements IButton {393394readonly primaryButton: Button;395private readonly action: IAction;396readonly dropdownButton: Button;397private readonly separatorContainer: HTMLDivElement;398private readonly separator: HTMLDivElement;399400readonly element: HTMLElement;401private readonly _onDidClick = this._register(new Emitter<Event | undefined>());402readonly onDidClick = this._onDidClick.event;403404constructor(container: HTMLElement, options: IButtonWithDropdownOptions) {405super();406407this.element = document.createElement('div');408this.element.classList.add('monaco-button-dropdown');409container.appendChild(this.element);410411if (!options.hoverDelegate) {412options = { ...options, hoverDelegate: this._register(createInstantHoverDelegate()) };413}414415this.primaryButton = this._register(new Button(this.element, options));416this._register(this.primaryButton.onDidClick(e => this._onDidClick.fire(e)));417this.action = toAction({ id: 'primaryAction', label: renderAsPlaintext(this.primaryButton.label), run: async () => this._onDidClick.fire(undefined) });418419this.separatorContainer = document.createElement('div');420this.separatorContainer.classList.add('monaco-button-dropdown-separator');421422this.separator = document.createElement('div');423this.separatorContainer.appendChild(this.separator);424this.element.appendChild(this.separatorContainer);425426// Separator styles427const border = options.buttonBorder;428if (border) {429this.separatorContainer.style.borderTop = '1px solid ' + border;430this.separatorContainer.style.borderBottom = '1px solid ' + border;431}432433const buttonBackground = options.secondary ? options.buttonSecondaryBackground : options.buttonBackground;434this.separatorContainer.style.backgroundColor = buttonBackground ?? '';435this.separator.style.backgroundColor = options.buttonSeparator ?? '';436437this.dropdownButton = this._register(new Button(this.element, { ...options, title: localize("button dropdown more actions", 'More Actions...'), supportIcons: true }));438this.dropdownButton.element.setAttribute('aria-haspopup', 'true');439this.dropdownButton.element.setAttribute('aria-expanded', 'false');440this.dropdownButton.element.classList.add('monaco-dropdown-button');441this.dropdownButton.icon = Codicon.dropDownButton;442this._register(this.dropdownButton.onDidClick(e => {443const actions = Array.isArray(options.actions) ? options.actions : (options.actions as IActionProvider).getActions();444options.contextMenuProvider.showContextMenu({445getAnchor: () => this.dropdownButton.element,446getActions: () => options.addPrimaryActionToDropdown === false ? [...actions] : [this.action, ...actions],447actionRunner: options.actionRunner,448onHide: () => this.dropdownButton.element.setAttribute('aria-expanded', 'false'),449layer: options.dropdownLayer450});451this.dropdownButton.element.setAttribute('aria-expanded', 'true');452}));453}454455override dispose() {456super.dispose();457this.element.remove();458}459460set label(value: string) {461this.primaryButton.label = value;462this.action.label = value;463}464465set icon(icon: ThemeIcon) {466this.primaryButton.icon = icon;467}468469set enabled(enabled: boolean) {470this.primaryButton.enabled = enabled;471this.dropdownButton.enabled = enabled;472473this.element.classList.toggle('disabled', !enabled);474}475476get enabled(): boolean {477return this.primaryButton.enabled;478}479480set checked(value: boolean) {481this.primaryButton.checked = value;482}483484get checked() {485return this.primaryButton.checked;486}487488focus(): void {489this.primaryButton.focus();490}491492hasFocus(): boolean {493return this.primaryButton.hasFocus() || this.dropdownButton.hasFocus();494}495}496497export class ButtonWithDescription implements IButtonWithDescription {498499private _button: Button;500private _element: HTMLElement;501private _descriptionElement: HTMLElement;502503constructor(container: HTMLElement, private readonly options: IButtonOptions) {504this._element = document.createElement('div');505this._element.classList.add('monaco-description-button');506this._button = new Button(this._element, options);507508this._descriptionElement = document.createElement('div');509this._descriptionElement.classList.add('monaco-button-description');510this._element.appendChild(this._descriptionElement);511512container.appendChild(this._element);513}514515get onDidClick(): BaseEvent<Event | undefined> {516return this._button.onDidClick;517}518519get element(): HTMLElement {520return this._element;521}522523set label(value: string) {524this._button.label = value;525}526527set icon(icon: ThemeIcon) {528this._button.icon = icon;529}530531get enabled(): boolean {532return this._button.enabled;533}534535set enabled(enabled: boolean) {536this._button.enabled = enabled;537}538539set checked(value: boolean) {540this._button.checked = value;541}542543get checked(): boolean {544return this._button.checked;545}546547focus(): void {548this._button.focus();549}550hasFocus(): boolean {551return this._button.hasFocus();552}553dispose(): void {554this._button.dispose();555}556557set description(value: string) {558if (this.options.supportIcons) {559reset(this._descriptionElement, ...renderLabelWithIcons(value));560} else {561this._descriptionElement.textContent = value;562}563}564}565566export enum ButtonBarAlignment {567Horizontal = 0,568Vertical569}570571export class ButtonBar {572573private readonly _buttons: IButton[] = [];574private readonly _buttonStore = new DisposableStore();575576constructor(private readonly container: HTMLElement, private readonly options?: { alignment?: ButtonBarAlignment }) { }577578dispose(): void {579this._buttonStore.dispose();580}581582get buttons(): IButton[] {583return this._buttons;584}585586clear(): void {587this._buttonStore.clear();588this._buttons.length = 0;589}590591addButton(options: IButtonOptions): IButton {592const button = this._buttonStore.add(new Button(this.container, options));593this.pushButton(button);594return button;595}596597addButtonWithDescription(options: IButtonOptions): IButtonWithDescription {598const button = this._buttonStore.add(new ButtonWithDescription(this.container, options));599this.pushButton(button);600return button;601}602603addButtonWithDropdown(options: IButtonWithDropdownOptions): IButton {604const button = this._buttonStore.add(new ButtonWithDropdown(this.container, options));605this.pushButton(button);606return button;607}608609private pushButton(button: IButton): void {610this._buttons.push(button);611612const index = this._buttons.length - 1;613this._buttonStore.add(addDisposableListener(button.element, EventType.KEY_DOWN, e => {614const event = new StandardKeyboardEvent(e);615let eventHandled = true;616617// Next / Previous Button618let buttonIndexToFocus: number | undefined;619if (event.equals(this.options?.alignment === ButtonBarAlignment.Vertical ? KeyCode.UpArrow : KeyCode.LeftArrow)) {620buttonIndexToFocus = index > 0 ? index - 1 : this._buttons.length - 1;621} else if (event.equals(this.options?.alignment === ButtonBarAlignment.Vertical ? KeyCode.DownArrow : KeyCode.RightArrow)) {622buttonIndexToFocus = index === this._buttons.length - 1 ? 0 : index + 1;623} else {624eventHandled = false;625}626627if (eventHandled && typeof buttonIndexToFocus === 'number') {628this._buttons[buttonIndexToFocus].focus();629EventHelper.stop(e, true);630}631632}));633}634}635636/**637* 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.638*/639export class ButtonWithIcon extends Button {640private readonly _iconElement: HTMLElement;641private readonly _mdlabelElement: HTMLElement;642643public get labelElement() { return this._mdlabelElement; }644645public get iconElement() { return this._iconElement; }646647constructor(container: HTMLElement, options: IButtonOptions) {648super(container, options);649650if (options.supportShortLabel) {651throw new Error('ButtonWithIcon does not support short labels');652}653654this._element.classList.add('monaco-icon-button');655this._iconElement = $('');656this._mdlabelElement = $('.monaco-button-mdlabel');657this._element.append(this._iconElement, this._mdlabelElement);658}659660override get label(): IMarkdownString | string {661return super.label;662}663664override set label(value: IMarkdownString | string) {665if (this._label === value) {666return;667}668669if (isMarkdownString(this._label) && isMarkdownString(value) && markdownStringEqual(this._label, value)) {670return;671}672673this._element.classList.add('monaco-text-button');674if (isMarkdownString(value)) {675const rendered = renderMarkdown(value, undefined, document.createElement('span'));676rendered.dispose();677678// eslint-disable-next-line no-restricted-syntax679const root = rendered.element.querySelector('p')?.innerHTML;680if (root) {681safeSetInnerHtml(this._mdlabelElement, root, buttonSanitizerConfig);682} else {683reset(this._mdlabelElement);684}685} else {686if (this.options.supportIcons) {687reset(this._mdlabelElement, ...this.getContentElements(value));688} else {689this._mdlabelElement.textContent = value;690}691}692693let title: string = '';694if (typeof this.options.title === 'string') {695title = this.options.title;696} else if (this.options.title) {697title = renderAsPlaintext(value);698}699700this.setTitle(title);701this._setAriaLabel();702this._label = value;703}704705override get icon(): ThemeIcon {706return super.icon;707}708709override set icon(icon: ThemeIcon) {710this._iconElement.classList.value = '';711this._iconElement.classList.add(...ThemeIcon.asClassNameArray(icon));712this._setAriaLabel();713}714}715716717