Path: blob/main/src/vs/base/browser/ui/dialog/dialog.ts
5258 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 './dialog.css';6import { localize } from '../../../../nls.js';7import { $, addDisposableListener, addStandardDisposableListener, clearNode, EventHelper, EventType, getWindow, hide, isActiveElement, isAncestor, show } from '../../dom.js';8import { StandardKeyboardEvent } from '../../keyboardEvent.js';9import { ActionBar } from '../actionbar/actionbar.js';10import { ButtonBar, ButtonBarAlignment, ButtonWithDescription, ButtonWithDropdown, IButton, IButtonStyles, IButtonWithDropdownOptions } from '../button/button.js';11import { ICheckboxStyles, Checkbox } from '../toggle/toggle.js';12import { IInputBoxStyles, InputBox } from '../inputbox/inputBox.js';13import { Action, toAction } from '../../../common/actions.js';14import { Codicon } from '../../../common/codicons.js';15import { ThemeIcon } from '../../../common/themables.js';16import { KeyCode, KeyMod } from '../../../common/keyCodes.js';17import { mnemonicButtonLabel } from '../../../common/labels.js';18import { Disposable, toDisposable } from '../../../common/lifecycle.js';19import { isLinux, isMacintosh, isWindows } from '../../../common/platform.js';20import { isActionProvider } from '../dropdown/dropdown.js';2122export interface IDialogInputOptions {23readonly placeholder?: string;24readonly type?: 'text' | 'password';25readonly value?: string;26}2728export enum DialogContentsAlignment {29/**30* Dialog contents align from left to right (icon, message, buttons on a separate row).31*32* Note: this is the default alignment for dialogs.33*/34Horizontal = 0,3536/**37* Dialog contents align from top to bottom (icon, message, buttons stack on top of each other)38*/39Vertical40}4142export interface IDialogOptions {43readonly cancelId?: number;44readonly detail?: string;45readonly alignment?: DialogContentsAlignment;46readonly checkboxLabel?: string;47readonly checkboxChecked?: boolean;48readonly type?: 'none' | 'info' | 'error' | 'question' | 'warning' | 'pending';49readonly extraClasses?: string[];50readonly inputs?: IDialogInputOptions[];51readonly keyEventProcessor?: (event: StandardKeyboardEvent) => void;52readonly renderBody?: (container: HTMLElement) => void;53readonly renderFooter?: (container: HTMLElement) => void;54readonly icon?: ThemeIcon;55readonly buttonOptions?: Array<undefined | { sublabel?: string; styleButton?: (button: IButton) => void }>;56readonly primaryButtonDropdown?: IButtonWithDropdownOptions;57readonly disableCloseAction?: boolean;58readonly disableCloseButton?: boolean;59readonly disableDefaultAction?: boolean;60readonly buttonStyles: IButtonStyles;61readonly checkboxStyles: ICheckboxStyles;62readonly inputBoxStyles: IInputBoxStyles;63readonly dialogStyles: IDialogStyles;64}6566export interface IDialogResult {67readonly button: number;68readonly checkboxChecked?: boolean;69readonly values?: string[];70}7172export interface IDialogStyles {73readonly dialogForeground: string | undefined;74readonly dialogBackground: string | undefined;75readonly dialogShadow: string | undefined;76readonly dialogBorder: string | undefined;77readonly errorIconForeground: string | undefined;78readonly warningIconForeground: string | undefined;79readonly infoIconForeground: string | undefined;80readonly textLinkForeground: string | undefined;81}8283interface ButtonMapEntry {84readonly label: string;85readonly index: number;86}8788export class Dialog extends Disposable {8990private readonly element: HTMLElement;9192private readonly shadowElement: HTMLElement;93private modalElement: HTMLElement | undefined;94private readonly buttonsContainer: HTMLElement;95private readonly messageDetailElement: HTMLElement;96private readonly messageContainer: HTMLElement;97private readonly footerContainer: HTMLElement | undefined;98private readonly iconElement: HTMLElement;99private readonly checkbox: Checkbox | undefined;100private readonly toolbarContainer: HTMLElement;101private buttonBar: ButtonBar | undefined;102private focusToReturn: HTMLElement | undefined;103private readonly inputs: InputBox[];104private readonly buttons: string[];105private readonly buttonStyles: IButtonStyles;106107constructor(private container: HTMLElement, private message: string, buttons: string[] | undefined, private readonly options: IDialogOptions) {108super();109110// Modal background blocker111this.modalElement = this.container.appendChild($(`.monaco-dialog-modal-block.dimmed`));112this._register(addStandardDisposableListener(this.modalElement, EventType.CLICK, e => {113if (e.target === this.modalElement) {114this.element.focus(); // guide users back into the dialog if clicked elsewhere115}116}));117118// Dialog Box119this.shadowElement = this.modalElement.appendChild($('.dialog-shadow'));120this.element = this.shadowElement.appendChild($('.monaco-dialog-box'));121if (options.alignment === DialogContentsAlignment.Vertical) {122this.element.classList.add('align-vertical');123}124if (options.extraClasses) {125this.element.classList.add(...options.extraClasses);126}127this.element.setAttribute('role', 'dialog');128this.element.tabIndex = -1;129hide(this.element);130131// Footer132if (this.options.renderFooter) {133this.footerContainer = this.element.appendChild($('.dialog-footer-row'));134135const customFooter = this.footerContainer.appendChild($('#monaco-dialog-footer.dialog-footer'));136this.options.renderFooter(customFooter);137138// eslint-disable-next-line no-restricted-syntax139for (const el of this.footerContainer.querySelectorAll('a')) {140el.tabIndex = 0;141}142}143144// Buttons145this.buttonStyles = options.buttonStyles;146147if (Array.isArray(buttons) && buttons.length > 0) {148this.buttons = buttons;149} else if (!this.options.disableDefaultAction) {150this.buttons = [localize('ok', "OK")];151} else {152this.buttons = [];153}154const buttonsRowElement = this.element.appendChild($('.dialog-buttons-row'));155this.buttonsContainer = buttonsRowElement.appendChild($('.dialog-buttons'));156157// Message158const messageRowElement = this.element.appendChild($('.dialog-message-row'));159this.iconElement = messageRowElement.appendChild($('#monaco-dialog-icon.dialog-icon'));160this.iconElement.setAttribute('aria-label', this.getIconAriaLabel());161this.messageContainer = messageRowElement.appendChild($('.dialog-message-container'));162163if (this.options.detail || this.options.renderBody) {164const messageElement = this.messageContainer.appendChild($('.dialog-message'));165const messageTextElement = messageElement.appendChild($('#monaco-dialog-message-text.dialog-message-text'));166messageTextElement.innerText = this.message;167}168169this.messageDetailElement = this.messageContainer.appendChild($('#monaco-dialog-message-detail.dialog-message-detail'));170if (this.options.detail || !this.options.renderBody) {171this.messageDetailElement.innerText = this.options.detail ? this.options.detail : message;172} else {173this.messageDetailElement.style.display = 'none';174}175176if (this.options.renderBody) {177const customBody = this.messageContainer.appendChild($('#monaco-dialog-message-body.dialog-message-body'));178this.options.renderBody(customBody);179180// eslint-disable-next-line no-restricted-syntax181for (const el of this.messageContainer.querySelectorAll('a')) {182el.tabIndex = 0;183}184}185186// Inputs187if (this.options.inputs) {188this.inputs = this.options.inputs.map(input => {189const inputRowElement = this.messageContainer.appendChild($('.dialog-message-input'));190191const inputBox = this._register(new InputBox(inputRowElement, undefined, {192placeholder: input.placeholder,193type: input.type ?? 'text',194inputBoxStyles: options.inputBoxStyles195}));196197if (input.value) {198inputBox.value = input.value;199}200201return inputBox;202});203} else {204this.inputs = [];205}206207// Checkbox208if (this.options.checkboxLabel) {209const checkboxRowElement = this.messageContainer.appendChild($('.dialog-checkbox-row'));210211const checkbox = this.checkbox = this._register(212new Checkbox(this.options.checkboxLabel, !!this.options.checkboxChecked, options.checkboxStyles)213);214215checkboxRowElement.appendChild(checkbox.domNode);216217const checkboxMessageElement = checkboxRowElement.appendChild($('.dialog-checkbox-message'));218checkboxMessageElement.innerText = this.options.checkboxLabel;219this._register(addDisposableListener(checkboxMessageElement, EventType.CLICK, () => checkbox.checked = !checkbox.checked));220}221222// Toolbar223const toolbarRowElement = this.element.appendChild($('.dialog-toolbar-row'));224this.toolbarContainer = toolbarRowElement.appendChild($('.dialog-toolbar'));225226this.applyStyles();227}228229private getIconAriaLabel(): string {230let typeLabel = localize('dialogInfoMessage', 'Info');231switch (this.options.type) {232case 'error':233typeLabel = localize('dialogErrorMessage', 'Error');234break;235case 'warning':236typeLabel = localize('dialogWarningMessage', 'Warning');237break;238case 'pending':239typeLabel = localize('dialogPendingMessage', 'In Progress');240break;241case 'none':242case 'info':243case 'question':244default:245break;246}247248return typeLabel;249}250251updateMessage(message: string): void {252this.messageDetailElement.innerText = message;253}254255async show(): Promise<IDialogResult> {256this.focusToReturn = this.container.ownerDocument.activeElement as HTMLElement;257258return new Promise<IDialogResult>(resolve => {259clearNode(this.buttonsContainer);260261const close = () => {262resolve({263button: this.options.cancelId || 0,264checkboxChecked: this.checkbox ? this.checkbox.checked : undefined265});266return;267};268this._register(toDisposable(close));269270const buttonBar = this.buttonBar = this._register(new ButtonBar(this.buttonsContainer, { alignment: this.options?.alignment === DialogContentsAlignment.Vertical ? ButtonBarAlignment.Vertical : ButtonBarAlignment.Horizontal }));271const buttonMap = this.rearrangeButtons(this.buttons, this.options.cancelId);272273const onButtonClick = (index: number) => {274resolve({275button: buttonMap[index].index,276checkboxChecked: this.checkbox ? this.checkbox.checked : undefined,277values: this.inputs.length > 0 ? this.inputs.map(input => input.value) : undefined278});279};280281// Buttons282buttonMap.forEach((_, index) => {283const primary = buttonMap[index].index === 0;284285let button: IButton;286const buttonOptions = this.options.buttonOptions?.[buttonMap[index]?.index];287if (primary && this.options?.primaryButtonDropdown) {288const actions = isActionProvider(this.options.primaryButtonDropdown.actions) ? this.options.primaryButtonDropdown.actions.getActions() : this.options.primaryButtonDropdown.actions;289button = this._register(buttonBar.addButtonWithDropdown({290...this.options.primaryButtonDropdown,291...this.buttonStyles,292dropdownLayer: 2600, // ensure the dropdown is above the dialog293actions: actions.map(action => toAction({294...action,295run: async () => {296await action.run();297298onButtonClick(index);299}300}))301}));302} else if (buttonOptions?.sublabel) {303button = this._register(buttonBar.addButtonWithDescription({ secondary: !primary, ...this.buttonStyles }));304} else {305button = this._register(buttonBar.addButton({ secondary: !primary, ...this.buttonStyles }));306}307308if (buttonOptions?.styleButton) {309buttonOptions.styleButton(button);310}311312button.label = mnemonicButtonLabel(buttonMap[index].label, true);313if (button instanceof ButtonWithDescription) {314if (buttonOptions?.sublabel) {315button.description = buttonOptions?.sublabel;316}317}318this._register(button.onDidClick(e => {319if (e) {320EventHelper.stop(e);321}322323onButtonClick(index);324}));325});326327// Handle keyboard events globally: Tab, Arrow-Left/Right328const window = getWindow(this.container);329this._register(addDisposableListener(window, 'keydown', e => {330const evt = new StandardKeyboardEvent(e);331332if (evt.equals(KeyMod.Alt)) {333evt.preventDefault();334}335336if (evt.equals(KeyCode.Enter)) {337338// Enter in input field should OK the dialog339if (this.inputs.some(input => input.hasFocus())) {340EventHelper.stop(e);341342resolve({343button: buttonMap.find(button => button.index !== this.options.cancelId)?.index ?? 0,344checkboxChecked: this.checkbox ? this.checkbox.checked : undefined,345values: this.inputs.length > 0 ? this.inputs.map(input => input.value) : undefined346});347}348349return; // leave default handling350}351352// Cmd+D (trigger the "no"/"do not save"-button) (macOS only)353if (isMacintosh && evt.equals(KeyMod.CtrlCmd | KeyCode.KeyD)) {354EventHelper.stop(e);355356const noButton = buttonMap.find(button => button.index === 1 && button.index !== this.options.cancelId);357if (noButton) {358resolve({359button: noButton.index,360checkboxChecked: this.checkbox ? this.checkbox.checked : undefined,361values: this.inputs.length > 0 ? this.inputs.map(input => input.value) : undefined362});363}364365return; // leave default handling366}367368if (evt.equals(KeyCode.Space)) {369return; // leave default handling370}371372let eventHandled = false;373374// Focus: Next / Previous375if (evt.equals(KeyCode.Tab) || evt.equals(KeyCode.RightArrow) || evt.equals(KeyMod.Shift | KeyCode.Tab) || evt.equals(KeyCode.LeftArrow)) {376377// Build a list of focusable elements in their visual order378const focusableElements: { focus: () => void }[] = [];379let focusedIndex = -1;380381if (this.messageContainer) {382// eslint-disable-next-line no-restricted-syntax383const links = this.messageContainer.querySelectorAll('a');384for (const link of links) {385focusableElements.push(link);386if (isActiveElement(link)) {387focusedIndex = focusableElements.length - 1;388}389}390}391392for (const input of this.inputs) {393focusableElements.push(input);394if (input.hasFocus()) {395focusedIndex = focusableElements.length - 1;396}397}398399if (this.checkbox) {400focusableElements.push(this.checkbox);401if (this.checkbox.hasFocus()) {402focusedIndex = focusableElements.length - 1;403}404}405406if (this.buttonBar) {407for (const button of this.buttonBar.buttons) {408if (button instanceof ButtonWithDropdown) {409focusableElements.push(button.primaryButton);410if (button.primaryButton.hasFocus()) {411focusedIndex = focusableElements.length - 1;412}413focusableElements.push(button.dropdownButton);414if (button.dropdownButton.hasFocus()) {415focusedIndex = focusableElements.length - 1;416}417} else {418focusableElements.push(button);419if (button.hasFocus()) {420focusedIndex = focusableElements.length - 1;421}422}423}424}425426if (this.footerContainer) {427// eslint-disable-next-line no-restricted-syntax428const links = this.footerContainer.querySelectorAll('a');429for (const link of links) {430focusableElements.push(link);431if (isActiveElement(link)) {432focusedIndex = focusableElements.length - 1;433}434}435}436437// Focus next element (with wrapping)438if (evt.equals(KeyCode.Tab) || evt.equals(KeyCode.RightArrow)) {439const newFocusedIndex = (focusedIndex + 1) % focusableElements.length;440focusableElements[newFocusedIndex].focus();441}442443// Focus previous element (with wrapping)444else {445if (focusedIndex === -1) {446focusedIndex = focusableElements.length; // default to focus last element if none have focus447}448449let newFocusedIndex = focusedIndex - 1;450if (newFocusedIndex === -1) {451newFocusedIndex = focusableElements.length - 1;452}453454focusableElements[newFocusedIndex].focus();455}456457eventHandled = true;458}459460if (eventHandled) {461EventHelper.stop(e, true);462} else if (this.options.keyEventProcessor) {463this.options.keyEventProcessor(evt);464}465}, true));466467this._register(addDisposableListener(window, 'keyup', e => {468EventHelper.stop(e, true);469const evt = new StandardKeyboardEvent(e);470471if (!this.options.disableCloseAction && evt.equals(KeyCode.Escape)) {472close();473}474}, true));475476// Detect focus out477this._register(addDisposableListener(this.element, 'focusout', e => {478if (!!e.relatedTarget && !!this.element) {479if (!isAncestor(e.relatedTarget as HTMLElement, this.element)) {480this.focusToReturn = e.relatedTarget as HTMLElement;481482if (e.target) {483(e.target as HTMLElement).focus();484EventHelper.stop(e, true);485}486}487}488}, false));489490const spinModifierClassName = 'codicon-modifier-spin';491492this.iconElement.classList.remove(...ThemeIcon.asClassNameArray(Codicon.dialogError), ...ThemeIcon.asClassNameArray(Codicon.dialogWarning), ...ThemeIcon.asClassNameArray(Codicon.dialogInfo), ...ThemeIcon.asClassNameArray(Codicon.loading), spinModifierClassName);493494if (this.options.icon) {495this.iconElement.classList.add(...ThemeIcon.asClassNameArray(this.options.icon));496} else {497switch (this.options.type) {498case 'error':499this.iconElement.classList.add(...ThemeIcon.asClassNameArray(Codicon.dialogError));500break;501case 'warning':502this.iconElement.classList.add(...ThemeIcon.asClassNameArray(Codicon.dialogWarning));503break;504case 'pending':505this.iconElement.classList.add(...ThemeIcon.asClassNameArray(Codicon.loading), spinModifierClassName);506break;507case 'none':508this.iconElement.classList.add('no-codicon');509break;510case 'info':511case 'question':512default:513this.iconElement.classList.add(...ThemeIcon.asClassNameArray(Codicon.dialogInfo));514break;515}516}517518if (!this.options.disableCloseAction && !this.options.disableCloseButton) {519const actionBar = this._register(new ActionBar(this.toolbarContainer, {}));520521const action = this._register(new Action('dialog.close', localize('dialogClose', "Close Dialog"), ThemeIcon.asClassName(Codicon.dialogClose), true, async () => {522resolve({523button: this.options.cancelId || 0,524checkboxChecked: this.checkbox ? this.checkbox.checked : undefined525});526}));527528actionBar.push(action, { icon: true, label: false });529}530531this.applyStyles();532533this.element.setAttribute('aria-modal', 'true');534this.element.setAttribute('aria-labelledby', 'monaco-dialog-icon monaco-dialog-message-text');535this.element.setAttribute('aria-describedby', 'monaco-dialog-icon monaco-dialog-message-text monaco-dialog-message-detail monaco-dialog-message-body monaco-dialog-footer');536show(this.element);537538// Focus first element (input or button)539if (this.inputs.length > 0) {540this.inputs[0].focus();541this.inputs[0].select();542} else {543buttonMap.forEach((value, index) => {544if (value.index === 0) {545buttonBar.buttons[index].focus();546}547});548}549});550}551552private applyStyles() {553const style = this.options.dialogStyles;554555const fgColor = style.dialogForeground;556const bgColor = style.dialogBackground;557const shadowColor = style.dialogShadow ? `0 0px 8px ${style.dialogShadow}` : '';558const border = style.dialogBorder ? `1px solid ${style.dialogBorder}` : '';559const linkFgColor = style.textLinkForeground;560561this.shadowElement.style.boxShadow = shadowColor;562563this.element.style.color = fgColor ?? '';564this.element.style.backgroundColor = bgColor ?? '';565this.element.style.border = border;566567if (linkFgColor) {568// eslint-disable-next-line no-restricted-syntax569for (const el of [...this.messageContainer.getElementsByTagName('a'), ...this.footerContainer?.getElementsByTagName('a') ?? []]) {570el.style.color = linkFgColor;571}572}573574let color;575switch (this.options.type) {576case 'none':577break;578case 'error':579color = style.errorIconForeground;580break;581case 'warning':582color = style.warningIconForeground;583break;584default:585color = style.infoIconForeground;586break;587}588if (color) {589this.iconElement.style.color = color;590}591}592593override dispose(): void {594super.dispose();595596if (this.modalElement) {597this.modalElement.remove();598this.modalElement = undefined;599}600601if (this.focusToReturn && isAncestor(this.focusToReturn, this.container.ownerDocument.body)) {602this.focusToReturn.focus();603this.focusToReturn = undefined;604}605}606607private rearrangeButtons(buttons: Array<string>, cancelId: number | undefined): ButtonMapEntry[] {608609// Maps each button to its current label and old index610// so that when we move them around it's not a problem611const buttonMap: ButtonMapEntry[] = buttons.map((label, index) => ({ label, index }));612613if (buttons.length < 2 || this.options.alignment === DialogContentsAlignment.Vertical) {614return buttonMap; // only need to rearrange if there are 2+ buttons and the alignment is left-to-right615}616617if (isMacintosh || isLinux) {618619// Linux: the GNOME HIG (https://developer.gnome.org/hig/patterns/feedback/dialogs.html?highlight=dialog)620// recommend the following:621// "Always ensure that the cancel button appears first, before the affirmative button. In left-to-right622// locales, this is on the left. This button order ensures that users become aware of, and are reminded623// of, the ability to cancel prior to encountering the affirmative button."624625// macOS: the HIG (https://developer.apple.com/design/human-interface-guidelines/components/presentation/alerts)626// recommend the following:627// "Place buttons where people expect. In general, place the button people are most likely to choose on the trailing side in a628// row of buttons or at the top in a stack of buttons. Always place the default button on the trailing side of a row or at the629// top of a stack. Cancel buttons are typically on the leading side of a row or at the bottom of a stack."630631if (typeof cancelId === 'number' && buttonMap[cancelId]) {632const cancelButton = buttonMap.splice(cancelId, 1)[0];633buttonMap.splice(1, 0, cancelButton);634}635636buttonMap.reverse();637} else if (isWindows) {638639// Windows: the HIG (https://learn.microsoft.com/en-us/windows/win32/uxguide/win-dialog-box)640// recommend the following:641// "One of the following sets of concise commands: Yes/No, Yes/No/Cancel, [Do it]/Cancel,642// [Do it]/[Don't do it], [Do it]/[Don't do it]/Cancel."643644if (typeof cancelId === 'number' && buttonMap[cancelId]) {645const cancelButton = buttonMap.splice(cancelId, 1)[0];646buttonMap.push(cancelButton);647}648}649650return buttonMap;651}652}653654655