From 5352c43ae6de6e09b7589087fc5d8d7a6ff03854 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Mon, 26 Oct 2020 07:30:53 +0100 Subject: [PATCH] dialog - add support for showing inputs --- src/vs/base/browser/ui/dialog/dialog.css | 13 +- src/vs/base/browser/ui/dialog/dialog.ts | 319 ++++++++++++++--------- src/vs/platform/theme/common/styler.ts | 8 +- 3 files changed, 220 insertions(+), 120 deletions(-) diff --git a/src/vs/base/browser/ui/dialog/dialog.css b/src/vs/base/browser/ui/dialog/dialog.css index 0a0c821553b..1f1180b8f3a 100644 --- a/src/vs/base/browser/ui/dialog/dialog.css +++ b/src/vs/base/browser/ui/dialog/dialog.css @@ -2,6 +2,7 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ + /** Dialog: Modal Block */ .monaco-dialog-modal-block { position: fixed; @@ -46,7 +47,6 @@ margin-left: 4px; } - /** Dialog: Message Row */ .monaco-dialog-box .dialog-message-row { display: flex; @@ -100,6 +100,7 @@ outline-style: solid; } +/** Dialog: Checkbox */ .monaco-dialog-box .dialog-message-row .dialog-message-container .dialog-checkbox-row { padding: 15px 0px 0px; display: flex; @@ -112,6 +113,16 @@ -ms-user-select: none; } +/** Dialog: Input */ +.monaco-dialog-box .dialog-message-row .dialog-message-container .dialog-message-input { + padding: 15px 0px 0px; + display: flex; +} + +.monaco-dialog-box .dialog-message-row .dialog-message-container .dialog-message-input .monaco-inputbox { + flex: 1; +} + /** Dialog: Buttons Row */ .monaco-dialog-box > .dialog-buttons-row { display: flex; diff --git a/src/vs/base/browser/ui/dialog/dialog.ts b/src/vs/base/browser/ui/dialog/dialog.ts index e2b3862aadc..097e879e30d 100644 --- a/src/vs/base/browser/ui/dialog/dialog.ts +++ b/src/vs/base/browser/ui/dialog/dialog.ts @@ -18,34 +18,46 @@ import { mnemonicButtonLabel } from 'vs/base/common/labels'; import { isMacintosh, isLinux } from 'vs/base/common/platform'; import { SimpleCheckbox, ISimpleCheckboxStyles } from 'vs/base/browser/ui/checkbox/checkbox'; import { Codicon, registerIcon } from 'vs/base/common/codicons'; +import { InputBox } from 'vs/base/browser/ui/inputbox/inputBox'; + +export interface IDialogInputOptions { + readonly placeholder?: string; + readonly type?: 'text' | 'password'; + readonly value?: string; +} export interface IDialogOptions { - cancelId?: number; - detail?: string; - checkboxLabel?: string; - checkboxChecked?: boolean; - type?: 'none' | 'info' | 'error' | 'question' | 'warning' | 'pending'; - keyEventProcessor?: (event: StandardKeyboardEvent) => void; + readonly cancelId?: number; + readonly detail?: string; + readonly checkboxLabel?: string; + readonly checkboxChecked?: boolean; + readonly type?: 'none' | 'info' | 'error' | 'question' | 'warning' | 'pending'; + readonly inputs?: IDialogInputOptions[]; + readonly keyEventProcessor?: (event: StandardKeyboardEvent) => void; } export interface IDialogResult { - button: number; - checkboxChecked?: boolean; + readonly button: number; + readonly checkboxChecked?: boolean; + readonly values?: string[]; } export interface IDialogStyles extends IButtonStyles, ISimpleCheckboxStyles { - dialogForeground?: Color; - dialogBackground?: Color; - dialogShadow?: Color; - dialogBorder?: Color; - errorIconForeground?: Color; - warningIconForeground?: Color; - infoIconForeground?: Color; + readonly dialogForeground?: Color; + readonly dialogBackground?: Color; + readonly dialogShadow?: Color; + readonly dialogBorder?: Color; + readonly errorIconForeground?: Color; + readonly warningIconForeground?: Color; + readonly infoIconForeground?: Color; + readonly inputBackground?: Color; + readonly inputForeground?: Color; + readonly inputBorder?: Color; } interface ButtonMapEntry { - label: string; - index: number; + readonly label: string; + readonly index: number; } const dialogErrorIcon = registerIcon('dialog-error', Codicon.error); @@ -54,30 +66,30 @@ const dialogInfoIcon = registerIcon('dialog-info', Codicon.info); const dialogCloseIcon = registerIcon('dialog-close', Codicon.close); export class Dialog extends Disposable { - private element: HTMLElement | undefined; - private shadowElement: HTMLElement | undefined; - private modal: HTMLElement | undefined; - private buttonsContainer: HTMLElement | undefined; - private messageDetailElement: HTMLElement | undefined; - private iconElement: HTMLElement | undefined; - private checkbox: SimpleCheckbox | undefined; - private toolbarContainer: HTMLElement | undefined; + private readonly element: HTMLElement; + private readonly shadowElement: HTMLElement; + private modalElement: HTMLElement | undefined; + private readonly buttonsContainer: HTMLElement; + private readonly messageDetailElement: HTMLElement; + private readonly iconElement: HTMLElement; + private readonly checkbox: SimpleCheckbox | undefined; + private readonly toolbarContainer: HTMLElement; private buttonGroup: ButtonGroup | undefined; private styles: IDialogStyles | undefined; private focusToReturn: HTMLElement | undefined; - private checkboxHasFocus: boolean = false; - private buttons: string[]; + private readonly inputs: InputBox[]; + private readonly buttons: string[]; constructor(private container: HTMLElement, private message: string, buttons: string[], private options: IDialogOptions) { super(); - this.modal = this.container.appendChild($(`.monaco-dialog-modal-block${options.type === 'pending' ? '.dimmed' : ''}`)); - this.shadowElement = this.modal.appendChild($('.dialog-shadow')); + + this.modalElement = this.container.appendChild($(`.monaco-dialog-modal-block${options.type === 'pending' ? '.dimmed' : ''}`)); + this.shadowElement = this.modalElement.appendChild($('.dialog-shadow')); this.element = this.shadowElement.appendChild($('.monaco-dialog-box')); this.element.setAttribute('role', 'dialog'); hide(this.element); - // If no button is provided, default to OK - this.buttons = buttons.length ? buttons : [nls.localize('ok', "OK")]; + this.buttons = buttons.length ? buttons : [nls.localize('ok', "OK")]; // If no button is provided, default to OK const buttonsRowElement = this.element.appendChild($('.dialog-buttons-row')); this.buttonsContainer = buttonsRowElement.appendChild($('.dialog-buttons')); @@ -94,6 +106,25 @@ export class Dialog extends Disposable { this.messageDetailElement = messageContainer.appendChild($('.dialog-message-detail')); this.messageDetailElement.innerText = this.options.detail ? this.options.detail : message; + if (this.options.inputs) { + this.inputs = this.options.inputs.map(input => { + const inputRowElement = messageContainer.appendChild($('.dialog-message-input')); + + const inputBox = this._register(new InputBox(inputRowElement, undefined, { + placeholder: input.placeholder, + type: input.type ?? 'text', + })); + + if (input.value) { + inputBox.value = input.value; + } + + return inputBox; + }); + } else { + this.inputs = []; + } + if (this.options.checkboxLabel) { const checkboxRowElement = messageContainer.appendChild($('.dialog-checkbox-row')); @@ -133,75 +164,112 @@ export class Dialog extends Disposable { } updateMessage(message: string): void { - if (this.messageDetailElement) { - this.messageDetailElement.innerText = message; - } + this.messageDetailElement.innerText = message; } async show(): Promise { this.focusToReturn = document.activeElement as HTMLElement; return new Promise((resolve) => { - if (!this.element || !this.buttonsContainer || !this.iconElement || !this.toolbarContainer) { - resolve({ button: 0 }); - return; - } - clearNode(this.buttonsContainer); - let focusedButton = 0; - const buttonGroup = this.buttonGroup = new ButtonGroup(this.buttonsContainer, this.buttons.length, { title: true }); + const buttonGroup = this.buttonGroup = this._register(new ButtonGroup(this.buttonsContainer, this.buttons.length, { title: true })); const buttonMap = this.rearrangeButtons(this.buttons, this.options.cancelId); - // Set focused button to UI index - buttonMap.forEach((value, index) => { - if (value.index === 0) { - focusedButton = index; - } - }); - + // Handle button clicks buttonGroup.buttons.forEach((button, index) => { button.label = mnemonicButtonLabel(buttonMap[index].label, true); this._register(button.onDidClick(e => { EventHelper.stop(e); - resolve({ button: buttonMap[index].index, checkboxChecked: this.checkbox ? this.checkbox.checked : undefined }); + + resolve({ + button: buttonMap[index].index, + checkboxChecked: this.checkbox ? this.checkbox.checked : undefined, + values: this.inputs.length > 0 ? this.inputs.map(input => input.value) : undefined + }); })); }); + // Handle keyboard events gloably: Tab, Arrow-Left/Right this._register(domEvent(window, 'keydown', true)((e: KeyboardEvent) => { const evt = new StandardKeyboardEvent(e); - if (evt.equals(KeyCode.Enter) || evt.equals(KeyCode.Space)) { - return; + + if (evt.equals(KeyCode.Enter)) { + + // Enter in input field should OK the dialog + if (this.inputs.some(input => input.hasFocus())) { + EventHelper.stop(e); + + resolve({ + button: buttonMap.find(button => button.index !== this.options.cancelId)?.index ?? 0, + checkboxChecked: this.checkbox ? this.checkbox.checked : undefined, + values: this.inputs.length > 0 ? this.inputs.map(input => input.value) : undefined + }); + } + + return; // leave default handling + } + + if (evt.equals(KeyCode.Space)) { + return; // leave default handling } let eventHandled = false; - if (evt.equals(KeyMod.Shift | KeyCode.Tab) || evt.equals(KeyCode.LeftArrow)) { - if (!this.checkboxHasFocus && focusedButton === 0) { - if (this.checkbox) { - this.checkbox.domNode.focus(); + + // Focus: Next / Previous + if (evt.equals(KeyCode.Tab) || evt.equals(KeyCode.RightArrow) || evt.equals(KeyMod.Shift | KeyCode.Tab) || evt.equals(KeyCode.LeftArrow)) { + + // Build a list of focusable elements in their visual order + const focusableElements: { focus: () => void }[] = []; + let focusedIndex = -1; + for (const input of this.inputs) { + focusableElements.push(input); + if (input.hasFocus()) { + focusedIndex = focusableElements.length - 1; } - this.checkboxHasFocus = true; - } else { - focusedButton = (this.checkboxHasFocus ? 0 : focusedButton) + buttonGroup.buttons.length - 1; - focusedButton = focusedButton % buttonGroup.buttons.length; - buttonGroup.buttons[focusedButton].focus(); - this.checkboxHasFocus = false; } - eventHandled = true; - } else if (evt.equals(KeyCode.Tab) || evt.equals(KeyCode.RightArrow)) { - if (!this.checkboxHasFocus && focusedButton === buttonGroup.buttons.length - 1) { - if (this.checkbox) { - this.checkbox.domNode.focus(); + if (this.checkbox) { + focusableElements.push(this.checkbox); + if (this.checkbox.hasFocus()) { + focusedIndex = focusableElements.length - 1; + } + } + + if (this.buttonGroup) { + for (const button of this.buttonGroup.buttons) { + focusableElements.push(button); + if (button.hasFocus()) { + focusedIndex = focusableElements.length - 1; + } + } + } + + // Focus next element (with wrapping) + if (evt.equals(KeyCode.Tab) || evt.equals(KeyCode.RightArrow)) { + if (focusedIndex === -1) { + focusedIndex = 0; // default to focus first element if none have focus } - this.checkboxHasFocus = true; - } else { - focusedButton = this.checkboxHasFocus ? 0 : focusedButton + 1; - focusedButton = focusedButton % buttonGroup.buttons.length; - buttonGroup.buttons[focusedButton].focus(); - this.checkboxHasFocus = false; + + const newFocusedIndex = (focusedIndex + 1) % focusableElements.length; + focusableElements[newFocusedIndex].focus(); } + + // Focus previous element (with wrapping) + else { + if (focusedIndex === -1) { + focusedIndex = focusableElements.length; // default to focus last element if none have focus + } + + let newFocusedIndex = focusedIndex - 1; + if (newFocusedIndex === -1) { + newFocusedIndex = focusableElements.length - 1; + } + + focusableElements[newFocusedIndex].focus(); + } + eventHandled = true; } @@ -217,10 +285,14 @@ export class Dialog extends Disposable { const evt = new StandardKeyboardEvent(e); if (evt.equals(KeyCode.Escape)) { - resolve({ button: this.options.cancelId || 0, checkboxChecked: this.checkbox ? this.checkbox.checked : undefined }); + resolve({ + button: this.options.cancelId || 0, + checkboxChecked: this.checkbox ? this.checkbox.checked : undefined + }); } })); + // Detect focus out this._register(domEvent(this.element, 'focusout', false)((e: FocusEvent) => { if (!!e.relatedTarget && !!this.element) { if (!isAncestor(e.relatedTarget as HTMLElement, this.element)) { @@ -254,12 +326,14 @@ export class Dialog extends Disposable { break; } - const actionBar = new ActionBar(this.toolbarContainer, {}); + const actionBar = this._register(new ActionBar(this.toolbarContainer, {})); - const action = new Action('dialog.close', nls.localize('dialogClose', "Close Dialog"), dialogCloseIcon.classNames, true, () => { - resolve({ button: this.options.cancelId || 0, checkboxChecked: this.checkbox ? this.checkbox.checked : undefined }); - return Promise.resolve(); - }); + const action = this._register(new Action('dialog.close', nls.localize('dialogClose', "Close Dialog"), dialogCloseIcon.classNames, true, async () => { + resolve({ + button: this.options.cancelId || 0, + checkboxChecked: this.checkbox ? this.checkbox.checked : undefined + }); + })); actionBar.push(action, { icon: true, label: false, }); @@ -268,8 +342,17 @@ export class Dialog extends Disposable { this.element.setAttribute('aria-label', this.getAriaLabel()); show(this.element); - // Focus first element - buttonGroup.buttons[focusedButton].focus(); + // Focus first element (input or button) + if (this.inputs.length > 0) { + this.inputs[0].focus(); + this.inputs[0].select(); + } else { + buttonMap.forEach((value, index) => { + if (value.index === 0) { + buttonGroup.buttons[index].focus(); + } + }); + } }); } @@ -282,60 +365,59 @@ export class Dialog extends Disposable { const shadowColor = style.dialogShadow ? `0 0px 8px ${style.dialogShadow}` : ''; const border = style.dialogBorder ? `1px solid ${style.dialogBorder}` : ''; - if (this.shadowElement) { - this.shadowElement.style.boxShadow = shadowColor; - } + this.shadowElement.style.boxShadow = shadowColor; - if (this.element) { - this.element.style.color = fgColor?.toString() ?? ''; - this.element.style.backgroundColor = bgColor?.toString() ?? ''; - this.element.style.border = border; + this.element.style.color = fgColor?.toString() ?? ''; + this.element.style.backgroundColor = bgColor?.toString() ?? ''; + this.element.style.border = border; - if (this.buttonGroup) { - this.buttonGroup.buttons.forEach(button => button.style(style)); - } + if (this.buttonGroup) { + this.buttonGroup.buttons.forEach(button => button.style(style)); + } - if (this.checkbox) { - this.checkbox.style(style); - } + if (this.checkbox) { + this.checkbox.style(style); + } - if (this.messageDetailElement && fgColor && bgColor) { - const messageDetailColor = fgColor.transparent(.9); - this.messageDetailElement.style.color = messageDetailColor.makeOpaque(bgColor).toString(); - } + if (fgColor && bgColor) { + const messageDetailColor = fgColor.transparent(.9); + this.messageDetailElement.style.color = messageDetailColor.makeOpaque(bgColor).toString(); + } - if (this.iconElement) { - let color; - switch (this.options.type) { - case 'error': - color = style.errorIconForeground; - break; - case 'warning': - color = style.warningIconForeground; - break; - default: - color = style.infoIconForeground; - break; - } - if (color) { - this.iconElement.style.color = color.toString(); - } - } + let color; + switch (this.options.type) { + case 'error': + color = style.errorIconForeground; + break; + case 'warning': + color = style.warningIconForeground; + break; + default: + color = style.infoIconForeground; + break; + } + if (color) { + this.iconElement.style.color = color.toString(); } + for (const input of this.inputs) { + input.style(style); + } } } style(style: IDialogStyles): void { this.styles = style; + this.applyStyles(); } dispose(): void { super.dispose(); - if (this.modal) { - this.modal.remove(); - this.modal = undefined; + + if (this.modalElement) { + this.modalElement.remove(); + this.modalElement = undefined; } if (this.focusToReturn && isAncestor(this.focusToReturn, document.body)) { @@ -346,9 +428,10 @@ export class Dialog extends Disposable { private rearrangeButtons(buttons: Array, cancelId: number | undefined): ButtonMapEntry[] { const buttonMap: ButtonMapEntry[] = []; + // Maps each button to its current label and old index so that when we move them around it's not a problem buttons.forEach((button, index) => { - buttonMap.push({ label: button, index: index }); + buttonMap.push({ label: button, index }); }); // macOS/linux: reverse button order diff --git a/src/vs/platform/theme/common/styler.ts b/src/vs/platform/theme/common/styler.ts index c8317017afe..90bb201972c 100644 --- a/src/vs/platform/theme/common/styler.ts +++ b/src/vs/platform/theme/common/styler.ts @@ -360,6 +360,9 @@ export interface IDialogStyleOverrides extends IButtonStyleOverrides { errorIconForeground?: ColorIdentifier; warningIconForeground?: ColorIdentifier; infoIconForeground?: ColorIdentifier; + inputBackground?: ColorIdentifier; + inputForeground?: ColorIdentifier; + inputBorder?: ColorIdentifier; } export const defaultDialogStyles = { @@ -376,7 +379,10 @@ export const defaultDialogStyles = { checkboxForeground: simpleCheckboxForeground, errorIconForeground: problemsErrorIconForeground, warningIconForeground: problemsWarningIconForeground, - infoIconForeground: problemsInfoIconForeground + infoIconForeground: problemsInfoIconForeground, + inputBackground: inputBackground, + inputForeground: inputForeground, + inputBorder: inputBorder }; -- GitLab