Path: blob/main/src/vs/base/browser/ui/dialog/dialog.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 './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);137138for (const el of this.footerContainer.querySelectorAll('a')) {139el.tabIndex = 0;140}141}142143// Buttons144this.buttonStyles = options.buttonStyles;145146if (Array.isArray(buttons) && buttons.length > 0) {147this.buttons = buttons;148} else if (!this.options.disableDefaultAction) {149this.buttons = [localize('ok', "OK")];150} else {151this.buttons = [];152}153const buttonsRowElement = this.element.appendChild($('.dialog-buttons-row'));154this.buttonsContainer = buttonsRowElement.appendChild($('.dialog-buttons'));155156// Message157const messageRowElement = this.element.appendChild($('.dialog-message-row'));158this.iconElement = messageRowElement.appendChild($('#monaco-dialog-icon.dialog-icon'));159this.iconElement.setAttribute('aria-label', this.getIconAriaLabel());160this.messageContainer = messageRowElement.appendChild($('.dialog-message-container'));161162if (this.options.detail || this.options.renderBody) {163const messageElement = this.messageContainer.appendChild($('.dialog-message'));164const messageTextElement = messageElement.appendChild($('#monaco-dialog-message-text.dialog-message-text'));165messageTextElement.innerText = this.message;166}167168this.messageDetailElement = this.messageContainer.appendChild($('#monaco-dialog-message-detail.dialog-message-detail'));169if (this.options.detail || !this.options.renderBody) {170this.messageDetailElement.innerText = this.options.detail ? this.options.detail : message;171} else {172this.messageDetailElement.style.display = 'none';173}174175if (this.options.renderBody) {176const customBody = this.messageContainer.appendChild($('#monaco-dialog-message-body.dialog-message-body'));177this.options.renderBody(customBody);178179for (const el of this.messageContainer.querySelectorAll('a')) {180el.tabIndex = 0;181}182}183184// Inputs185if (this.options.inputs) {186this.inputs = this.options.inputs.map(input => {187const inputRowElement = this.messageContainer.appendChild($('.dialog-message-input'));188189const inputBox = this._register(new InputBox(inputRowElement, undefined, {190placeholder: input.placeholder,191type: input.type ?? 'text',192inputBoxStyles: options.inputBoxStyles193}));194195if (input.value) {196inputBox.value = input.value;197}198199return inputBox;200});201} else {202this.inputs = [];203}204205// Checkbox206if (this.options.checkboxLabel) {207const checkboxRowElement = this.messageContainer.appendChild($('.dialog-checkbox-row'));208209const checkbox = this.checkbox = this._register(210new Checkbox(this.options.checkboxLabel, !!this.options.checkboxChecked, options.checkboxStyles)211);212213checkboxRowElement.appendChild(checkbox.domNode);214215const checkboxMessageElement = checkboxRowElement.appendChild($('.dialog-checkbox-message'));216checkboxMessageElement.innerText = this.options.checkboxLabel;217this._register(addDisposableListener(checkboxMessageElement, EventType.CLICK, () => checkbox.checked = !checkbox.checked));218}219220// Toolbar221const toolbarRowElement = this.element.appendChild($('.dialog-toolbar-row'));222this.toolbarContainer = toolbarRowElement.appendChild($('.dialog-toolbar'));223224this.applyStyles();225}226227private getIconAriaLabel(): string {228let typeLabel = localize('dialogInfoMessage', 'Info');229switch (this.options.type) {230case 'error':231typeLabel = localize('dialogErrorMessage', 'Error');232break;233case 'warning':234typeLabel = localize('dialogWarningMessage', 'Warning');235break;236case 'pending':237typeLabel = localize('dialogPendingMessage', 'In Progress');238break;239case 'none':240case 'info':241case 'question':242default:243break;244}245246return typeLabel;247}248249updateMessage(message: string): void {250this.messageDetailElement.innerText = message;251}252253async show(): Promise<IDialogResult> {254this.focusToReturn = this.container.ownerDocument.activeElement as HTMLElement;255256return new Promise<IDialogResult>(resolve => {257clearNode(this.buttonsContainer);258259const close = () => {260resolve({261button: this.options.cancelId || 0,262checkboxChecked: this.checkbox ? this.checkbox.checked : undefined263});264return;265};266this._register(toDisposable(close));267268const buttonBar = this.buttonBar = this._register(new ButtonBar(this.buttonsContainer, { alignment: this.options?.alignment === DialogContentsAlignment.Vertical ? ButtonBarAlignment.Vertical : ButtonBarAlignment.Horizontal }));269const buttonMap = this.rearrangeButtons(this.buttons, this.options.cancelId);270271const onButtonClick = (index: number) => {272resolve({273button: buttonMap[index].index,274checkboxChecked: this.checkbox ? this.checkbox.checked : undefined,275values: this.inputs.length > 0 ? this.inputs.map(input => input.value) : undefined276});277};278279// Buttons280buttonMap.forEach((_, index) => {281const primary = buttonMap[index].index === 0;282283let button: IButton;284const buttonOptions = this.options.buttonOptions?.[buttonMap[index]?.index];285if (primary && this.options?.primaryButtonDropdown) {286const actions = isActionProvider(this.options.primaryButtonDropdown.actions) ? this.options.primaryButtonDropdown.actions.getActions() : this.options.primaryButtonDropdown.actions;287button = this._register(buttonBar.addButtonWithDropdown({288...this.options.primaryButtonDropdown,289...this.buttonStyles,290dropdownLayer: 2600, // ensure the dropdown is above the dialog291actions: actions.map(action => toAction({292...action,293run: async () => {294await action.run();295296onButtonClick(index);297}298}))299}));300} else if (buttonOptions?.sublabel) {301button = this._register(buttonBar.addButtonWithDescription({ secondary: !primary, ...this.buttonStyles }));302} else {303button = this._register(buttonBar.addButton({ secondary: !primary, ...this.buttonStyles }));304}305306if (buttonOptions?.styleButton) {307buttonOptions.styleButton(button);308}309310button.label = mnemonicButtonLabel(buttonMap[index].label, true);311if (button instanceof ButtonWithDescription) {312if (buttonOptions?.sublabel) {313button.description = buttonOptions?.sublabel;314}315}316this._register(button.onDidClick(e => {317if (e) {318EventHelper.stop(e);319}320321onButtonClick(index);322}));323});324325// Handle keyboard events globally: Tab, Arrow-Left/Right326const window = getWindow(this.container);327this._register(addDisposableListener(window, 'keydown', e => {328const evt = new StandardKeyboardEvent(e);329330if (evt.equals(KeyMod.Alt)) {331evt.preventDefault();332}333334if (evt.equals(KeyCode.Enter)) {335336// Enter in input field should OK the dialog337if (this.inputs.some(input => input.hasFocus())) {338EventHelper.stop(e);339340resolve({341button: buttonMap.find(button => button.index !== this.options.cancelId)?.index ?? 0,342checkboxChecked: this.checkbox ? this.checkbox.checked : undefined,343values: this.inputs.length > 0 ? this.inputs.map(input => input.value) : undefined344});345}346347return; // leave default handling348}349350// Cmd+D (trigger the "no"/"do not save"-button) (macOS only)351if (isMacintosh && evt.equals(KeyMod.CtrlCmd | KeyCode.KeyD)) {352EventHelper.stop(e);353354const noButton = buttonMap.find(button => button.index === 1 && button.index !== this.options.cancelId);355if (noButton) {356resolve({357button: noButton.index,358checkboxChecked: this.checkbox ? this.checkbox.checked : undefined,359values: this.inputs.length > 0 ? this.inputs.map(input => input.value) : undefined360});361}362363return; // leave default handling364}365366if (evt.equals(KeyCode.Space)) {367return; // leave default handling368}369370let eventHandled = false;371372// Focus: Next / Previous373if (evt.equals(KeyCode.Tab) || evt.equals(KeyCode.RightArrow) || evt.equals(KeyMod.Shift | KeyCode.Tab) || evt.equals(KeyCode.LeftArrow)) {374375// Build a list of focusable elements in their visual order376const focusableElements: { focus: () => void }[] = [];377let focusedIndex = -1;378379if (this.messageContainer) {380const links = this.messageContainer.querySelectorAll('a');381for (const link of links) {382focusableElements.push(link);383if (isActiveElement(link)) {384focusedIndex = focusableElements.length - 1;385}386}387}388389for (const input of this.inputs) {390focusableElements.push(input);391if (input.hasFocus()) {392focusedIndex = focusableElements.length - 1;393}394}395396if (this.checkbox) {397focusableElements.push(this.checkbox);398if (this.checkbox.hasFocus()) {399focusedIndex = focusableElements.length - 1;400}401}402403if (this.buttonBar) {404for (const button of this.buttonBar.buttons) {405if (button instanceof ButtonWithDropdown) {406focusableElements.push(button.primaryButton);407if (button.primaryButton.hasFocus()) {408focusedIndex = focusableElements.length - 1;409}410focusableElements.push(button.dropdownButton);411if (button.dropdownButton.hasFocus()) {412focusedIndex = focusableElements.length - 1;413}414} else {415focusableElements.push(button);416if (button.hasFocus()) {417focusedIndex = focusableElements.length - 1;418}419}420}421}422423if (this.footerContainer) {424const links = this.footerContainer.querySelectorAll('a');425for (const link of links) {426focusableElements.push(link);427if (isActiveElement(link)) {428focusedIndex = focusableElements.length - 1;429}430}431}432433// Focus next element (with wrapping)434if (evt.equals(KeyCode.Tab) || evt.equals(KeyCode.RightArrow)) {435const newFocusedIndex = (focusedIndex + 1) % focusableElements.length;436focusableElements[newFocusedIndex].focus();437}438439// Focus previous element (with wrapping)440else {441if (focusedIndex === -1) {442focusedIndex = focusableElements.length; // default to focus last element if none have focus443}444445let newFocusedIndex = focusedIndex - 1;446if (newFocusedIndex === -1) {447newFocusedIndex = focusableElements.length - 1;448}449450focusableElements[newFocusedIndex].focus();451}452453eventHandled = true;454}455456if (eventHandled) {457EventHelper.stop(e, true);458} else if (this.options.keyEventProcessor) {459this.options.keyEventProcessor(evt);460}461}, true));462463this._register(addDisposableListener(window, 'keyup', e => {464EventHelper.stop(e, true);465const evt = new StandardKeyboardEvent(e);466467if (!this.options.disableCloseAction && evt.equals(KeyCode.Escape)) {468close();469}470}, true));471472// Detect focus out473this._register(addDisposableListener(this.element, 'focusout', e => {474if (!!e.relatedTarget && !!this.element) {475if (!isAncestor(e.relatedTarget as HTMLElement, this.element)) {476this.focusToReturn = e.relatedTarget as HTMLElement;477478if (e.target) {479(e.target as HTMLElement).focus();480EventHelper.stop(e, true);481}482}483}484}, false));485486const spinModifierClassName = 'codicon-modifier-spin';487488this.iconElement.classList.remove(...ThemeIcon.asClassNameArray(Codicon.dialogError), ...ThemeIcon.asClassNameArray(Codicon.dialogWarning), ...ThemeIcon.asClassNameArray(Codicon.dialogInfo), ...ThemeIcon.asClassNameArray(Codicon.loading), spinModifierClassName);489490if (this.options.icon) {491this.iconElement.classList.add(...ThemeIcon.asClassNameArray(this.options.icon));492} else {493switch (this.options.type) {494case 'error':495this.iconElement.classList.add(...ThemeIcon.asClassNameArray(Codicon.dialogError));496break;497case 'warning':498this.iconElement.classList.add(...ThemeIcon.asClassNameArray(Codicon.dialogWarning));499break;500case 'pending':501this.iconElement.classList.add(...ThemeIcon.asClassNameArray(Codicon.loading), spinModifierClassName);502break;503case 'none':504this.iconElement.classList.add('no-codicon');505break;506case 'info':507case 'question':508default:509this.iconElement.classList.add(...ThemeIcon.asClassNameArray(Codicon.dialogInfo));510break;511}512}513514if (!this.options.disableCloseAction && !this.options.disableCloseButton) {515const actionBar = this._register(new ActionBar(this.toolbarContainer, {}));516517const action = this._register(new Action('dialog.close', localize('dialogClose', "Close Dialog"), ThemeIcon.asClassName(Codicon.dialogClose), true, async () => {518resolve({519button: this.options.cancelId || 0,520checkboxChecked: this.checkbox ? this.checkbox.checked : undefined521});522}));523524actionBar.push(action, { icon: true, label: false });525}526527this.applyStyles();528529this.element.setAttribute('aria-modal', 'true');530this.element.setAttribute('aria-labelledby', 'monaco-dialog-icon monaco-dialog-message-text');531this.element.setAttribute('aria-describedby', 'monaco-dialog-icon monaco-dialog-message-text monaco-dialog-message-detail monaco-dialog-message-body monaco-dialog-footer');532show(this.element);533534// Focus first element (input or button)535if (this.inputs.length > 0) {536this.inputs[0].focus();537this.inputs[0].select();538} else {539buttonMap.forEach((value, index) => {540if (value.index === 0) {541buttonBar.buttons[index].focus();542}543});544}545});546}547548private applyStyles() {549const style = this.options.dialogStyles;550551const fgColor = style.dialogForeground;552const bgColor = style.dialogBackground;553const shadowColor = style.dialogShadow ? `0 0px 8px ${style.dialogShadow}` : '';554const border = style.dialogBorder ? `1px solid ${style.dialogBorder}` : '';555const linkFgColor = style.textLinkForeground;556557this.shadowElement.style.boxShadow = shadowColor;558559this.element.style.color = fgColor ?? '';560this.element.style.backgroundColor = bgColor ?? '';561this.element.style.border = border;562563if (linkFgColor) {564for (const el of [...this.messageContainer.getElementsByTagName('a'), ...this.footerContainer?.getElementsByTagName('a') ?? []]) {565el.style.color = linkFgColor;566}567}568569let color;570switch (this.options.type) {571case 'none':572break;573case 'error':574color = style.errorIconForeground;575break;576case 'warning':577color = style.warningIconForeground;578break;579default:580color = style.infoIconForeground;581break;582}583if (color) {584this.iconElement.style.color = color;585}586}587588override dispose(): void {589super.dispose();590591if (this.modalElement) {592this.modalElement.remove();593this.modalElement = undefined;594}595596if (this.focusToReturn && isAncestor(this.focusToReturn, this.container.ownerDocument.body)) {597this.focusToReturn.focus();598this.focusToReturn = undefined;599}600}601602private rearrangeButtons(buttons: Array<string>, cancelId: number | undefined): ButtonMapEntry[] {603604// Maps each button to its current label and old index605// so that when we move them around it's not a problem606const buttonMap: ButtonMapEntry[] = buttons.map((label, index) => ({ label, index }));607608if (buttons.length < 2 || this.options.alignment === DialogContentsAlignment.Vertical) {609return buttonMap; // only need to rearrange if there are 2+ buttons and the alignment is left-to-right610}611612if (isMacintosh || isLinux) {613614// Linux: the GNOME HIG (https://developer.gnome.org/hig/patterns/feedback/dialogs.html?highlight=dialog)615// recommend the following:616// "Always ensure that the cancel button appears first, before the affirmative button. In left-to-right617// locales, this is on the left. This button order ensures that users become aware of, and are reminded618// of, the ability to cancel prior to encountering the affirmative button."619620// macOS: the HIG (https://developer.apple.com/design/human-interface-guidelines/components/presentation/alerts)621// recommend the following:622// "Place buttons where people expect. In general, place the button people are most likely to choose on the trailing side in a623// 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 the624// top of a stack. Cancel buttons are typically on the leading side of a row or at the bottom of a stack."625626if (typeof cancelId === 'number' && buttonMap[cancelId]) {627const cancelButton = buttonMap.splice(cancelId, 1)[0];628buttonMap.splice(1, 0, cancelButton);629}630631buttonMap.reverse();632} else if (isWindows) {633634// Windows: the HIG (https://learn.microsoft.com/en-us/windows/win32/uxguide/win-dialog-box)635// recommend the following:636// "One of the following sets of concise commands: Yes/No, Yes/No/Cancel, [Do it]/Cancel,637// [Do it]/[Don't do it], [Do it]/[Don't do it]/Cancel."638639if (typeof cancelId === 'number' && buttonMap[cancelId]) {640const cancelButton = buttonMap.splice(cancelId, 1)[0];641buttonMap.push(cancelButton);642}643}644645return buttonMap;646}647}648649650