diff --git a/src/vs/base/browser/ui/button/button.css b/src/vs/base/browser/ui/button/button.css index 6d6823524840abe899f9a6bfe9ada90d5220d836..3e1e08155542c8ffa26e780ca8ade1be8f8703a4 100644 --- a/src/vs/base/browser/ui/button/button.css +++ b/src/vs/base/browser/ui/button/button.css @@ -10,11 +10,14 @@ padding: 4px; text-align: center; cursor: pointer; - outline-offset: 2px !important; justify-content: center; align-items: center; } +.monaco-button { + outline-offset: 2px !important; +} + .monaco-text-button:hover { text-decoration: none !important; } @@ -24,7 +27,15 @@ cursor: default; } -.monaco-button > .codicon { +.monaco-text-button > .codicon { margin: 0 0.2em; color: inherit !important; } + +.monaco-button-dropdown { + display: flex; +} + +.monaco-button-dropdown > .monaco-dropdown-button { + margin-left: 1px; +} diff --git a/src/vs/base/browser/ui/button/button.ts b/src/vs/base/browser/ui/button/button.ts index a3ca3ac973ddb1d2f2121695ddd36f55d559b308..f2e25e442ac380671ffe0982f56324ea57ce9828 100644 --- a/src/vs/base/browser/ui/button/button.ts +++ b/src/vs/base/browser/ui/button/button.ts @@ -9,10 +9,12 @@ import { KeyCode } from 'vs/base/common/keyCodes'; import { Color } from 'vs/base/common/color'; import { mixin } from 'vs/base/common/objects'; import { Event as BaseEvent, Emitter } from 'vs/base/common/event'; -import { Disposable } from 'vs/base/common/lifecycle'; +import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; import { Gesture, EventType as TouchEventType } from 'vs/base/browser/touch'; import { renderCodicons } from 'vs/base/browser/codicons'; import { addDisposableListener, IFocusTracker, EventType, EventHelper, trackFocus, reset, removeTabIndexAndUpdateFocus } from 'vs/base/browser/dom'; +import { IContextMenuProvider } from 'vs/base/browser/contextmenu'; +import { IAction } from 'vs/base/common/actions'; export interface IButtonOptions extends IButtonStyles { readonly title?: boolean | string; @@ -36,7 +38,18 @@ const defaultOptions: IButtonStyles = { buttonForeground: Color.white }; -export class Button extends Disposable { +export interface IButton extends IDisposable { + readonly element: HTMLElement; + readonly onDidClick: BaseEvent; + label: string; + icon: string; + enabled: boolean; + style(styles: IButtonStyles): void; + focus(): void; + hasFocus(): boolean; +} + +export class Button extends Disposable implements IButton { private _element: HTMLElement; private options: IButtonOptions; @@ -189,7 +202,7 @@ export class Button extends Disposable { } set icon(iconClassName: string) { - this._element.classList.add(iconClassName); + this._element.classList.add(...iconClassName.split(' ')); } set enabled(value: boolean) { @@ -217,47 +230,122 @@ export class Button extends Disposable { } } -export class ButtonGroup extends Disposable { - private _buttons: Button[] = []; +export interface IButtonWithDropdownOptions extends IButtonOptions { + readonly contextMenuProvider: IContextMenuProvider; + readonly actions: IAction[]; +} + +export class ButtonWithDropdown extends Disposable implements IButton { + + private readonly button: Button; + private readonly dropdownButton: Button; + + readonly element: HTMLElement; + readonly onDidClick: BaseEvent; - constructor(container: HTMLElement, count: number, options?: IButtonOptions) { + constructor(container: HTMLElement, options: IButtonWithDropdownOptions) { super(); - this.create(container, count, options); + this.element = document.createElement('div'); + this.element.classList.add('monaco-button-dropdown'); + container.appendChild(this.element); + + this.button = this._register(new Button(this.element, options)); + this.onDidClick = this.button.onDidClick; + + this.dropdownButton = this._register(new Button(this.element, { ...options, title: false, supportCodicons: true })); + this.dropdownButton.element.classList.add('monaco-dropdown-button'); + this.dropdownButton.icon = 'codicon codicon-chevron-down'; + this._register(this.dropdownButton.onDidClick(() => { + options.contextMenuProvider.showContextMenu({ + getAnchor: () => this.dropdownButton.element, + getActions: () => options.actions, + onHide: () => this.dropdownButton.element.setAttribute('aria-expanded', 'false') + }); + this.dropdownButton.element.setAttribute('aria-expanded', 'true'); + })); + } + + set label(value: string) { + this.button.label = value; + } + + set icon(iconClassName: string) { + this.button.icon = iconClassName; + } + + set enabled(enabled: boolean) { + this.button.enabled = enabled; + this.dropdownButton.enabled = enabled; + } + + get enabled(): boolean { + return this.button.enabled; + } + + style(styles: IButtonStyles): void { + this.button.style(styles); + this.dropdownButton.style(styles); + } + + focus(): void { + this.button.focus(); + } + + hasFocus(): boolean { + return this.button.hasFocus() || this.dropdownButton.hasFocus(); + } +} + +export class ButtonBar extends Disposable { + + private _buttons: IButton[] = []; + + constructor(private readonly container: HTMLElement) { + super(); } - get buttons(): Button[] { + get buttons(): IButton[] { return this._buttons; } - private create(container: HTMLElement, count: number, options?: IButtonOptions): void { - for (let index = 0; index < count; index++) { - const button = this._register(new Button(container, options)); - this._buttons.push(button); - - // Implement keyboard access in buttons if there are multiple - if (count > 1) { - this._register(addDisposableListener(button.element, EventType.KEY_DOWN, e => { - const event = new StandardKeyboardEvent(e); - let eventHandled = true; - - // Next / Previous Button - let buttonIndexToFocus: number | undefined; - if (event.equals(KeyCode.LeftArrow)) { - buttonIndexToFocus = index > 0 ? index - 1 : this._buttons.length - 1; - } else if (event.equals(KeyCode.RightArrow)) { - buttonIndexToFocus = index === this._buttons.length - 1 ? 0 : index + 1; - } else { - eventHandled = false; - } - - if (eventHandled && typeof buttonIndexToFocus === 'number') { - this._buttons[buttonIndexToFocus].focus(); - EventHelper.stop(e, true); - } - - })); + addButton(options?: IButtonOptions): IButton { + const button = this._register(new Button(this.container, options)); + this.pushButton(button); + return button; + } + + addButtonWithDropdown(options: IButtonWithDropdownOptions): IButton { + const button = this._register(new ButtonWithDropdown(this.container, options)); + this.pushButton(button); + return button; + } + + private pushButton(button: IButton): void { + this._buttons.push(button); + + const index = this._buttons.length - 1; + this._register(addDisposableListener(button.element, EventType.KEY_DOWN, e => { + const event = new StandardKeyboardEvent(e); + let eventHandled = true; + + // Next / Previous Button + let buttonIndexToFocus: number | undefined; + if (event.equals(KeyCode.LeftArrow)) { + buttonIndexToFocus = index > 0 ? index - 1 : this._buttons.length - 1; + } else if (event.equals(KeyCode.RightArrow)) { + buttonIndexToFocus = index === this._buttons.length - 1 ? 0 : index + 1; + } else { + eventHandled = false; } - } + + if (eventHandled && typeof buttonIndexToFocus === 'number') { + this._buttons[buttonIndexToFocus].focus(); + EventHelper.stop(e, true); + } + + })); + } + } diff --git a/src/vs/base/browser/ui/dialog/dialog.ts b/src/vs/base/browser/ui/dialog/dialog.ts index 097e879e30da43a585c535272502c22f76660a40..21d3092acd17dd85927f1b2657fc1eae08648ee4 100644 --- a/src/vs/base/browser/ui/dialog/dialog.ts +++ b/src/vs/base/browser/ui/dialog/dialog.ts @@ -11,7 +11,7 @@ import { domEvent } from 'vs/base/browser/event'; import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; import { Color } from 'vs/base/common/color'; -import { ButtonGroup, IButtonStyles } from 'vs/base/browser/ui/button/button'; +import { ButtonBar, IButtonStyles } from 'vs/base/browser/ui/button/button'; import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; import { Action } from 'vs/base/common/actions'; import { mnemonicButtonLabel } from 'vs/base/common/labels'; @@ -74,7 +74,7 @@ export class Dialog extends Disposable { private readonly iconElement: HTMLElement; private readonly checkbox: SimpleCheckbox | undefined; private readonly toolbarContainer: HTMLElement; - private buttonGroup: ButtonGroup | undefined; + private buttonBar: ButtonBar | undefined; private styles: IDialogStyles | undefined; private focusToReturn: HTMLElement | undefined; private readonly inputs: InputBox[]; @@ -173,11 +173,12 @@ export class Dialog extends Disposable { return new Promise((resolve) => { clearNode(this.buttonsContainer); - const buttonGroup = this.buttonGroup = this._register(new ButtonGroup(this.buttonsContainer, this.buttons.length, { title: true })); + const buttonBar = this.buttonBar = this._register(new ButtonBar(this.buttonsContainer)); const buttonMap = this.rearrangeButtons(this.buttons, this.options.cancelId); // Handle button clicks - buttonGroup.buttons.forEach((button, index) => { + buttonMap.forEach((entry, index) => { + const button = this._register(buttonBar.addButton({ title: true })); button.label = mnemonicButtonLabel(buttonMap[index].label, true); this._register(button.onDidClick(e => { @@ -237,8 +238,8 @@ export class Dialog extends Disposable { } } - if (this.buttonGroup) { - for (const button of this.buttonGroup.buttons) { + if (this.buttonBar) { + for (const button of this.buttonBar.buttons) { focusableElements.push(button); if (button.hasFocus()) { focusedIndex = focusableElements.length - 1; @@ -349,7 +350,7 @@ export class Dialog extends Disposable { } else { buttonMap.forEach((value, index) => { if (value.index === 0) { - buttonGroup.buttons[index].focus(); + buttonBar.buttons[index].focus(); } }); } @@ -371,8 +372,8 @@ export class Dialog extends Disposable { this.element.style.backgroundColor = bgColor?.toString() ?? ''; this.element.style.border = border; - if (this.buttonGroup) { - this.buttonGroup.buttons.forEach(button => button.style(style)); + if (this.buttonBar) { + this.buttonBar.buttons.forEach(button => button.style(style)); } if (this.checkbox) { diff --git a/src/vs/base/common/actions.ts b/src/vs/base/common/actions.ts index b0badf977b34135b9cb4b6920fec117a0d86fd2c..8be6f19aff5ac1cc593f3f8f54465e392445e693 100644 --- a/src/vs/base/common/actions.ts +++ b/src/vs/base/common/actions.ts @@ -235,6 +235,17 @@ export class Separator extends Action { } } +export class ActionWithMenuAction extends Action { + + get actions(): IAction[] { + return this._actions; + } + + constructor(id: string, private _actions: IAction[], label?: string, cssClass?: string, enabled?: boolean, actionCallback?: (event?: any) => Promise) { + super(id, label, cssClass, enabled, actionCallback); + } +} + export class SubmenuAction extends Action { get actions(): IAction[] { diff --git a/src/vs/platform/notification/common/notification.ts b/src/vs/platform/notification/common/notification.ts index cc3cb107774147aabd869a02fe9f74926f5ad187..74c26363838617bc47e63d26c9dafdcc84fbe6b1 100644 --- a/src/vs/platform/notification/common/notification.ts +++ b/src/vs/platform/notification/common/notification.ts @@ -114,6 +114,8 @@ export interface INotificationActions { /** * Primary actions show up as buttons as part of the message and will close * the notification once clicked. + * + * Pass `ActionWithMenuAction` for an action that has additional menu actions. */ readonly primary?: ReadonlyArray; @@ -209,29 +211,45 @@ export interface INotificationHandle { close(): void; } -export interface IPromptChoice { +interface IBasePromptChoice { /** * Label to show for the choice to the user. */ readonly label: string; + /** + * Whether to keep the notification open after the choice was selected + * by the user. By default, will close the notification upon click. + */ + readonly keepOpen?: boolean; + + /** + * Triggered when the user selects the choice. + */ + run: () => void; +} + +export interface IPromptChoice extends IBasePromptChoice { + /** * Primary choices show up as buttons in the notification below the message. * Secondary choices show up under the gear icon in the header of the notification. */ readonly isSecondary?: boolean; +} + +export interface IPromptChoiceWithMenu extends IPromptChoice { /** - * Whether to keep the notification open after the choice was selected - * by the user. By default, will close the notification upon click. + * Additional choices those will be shown in the dropdown menu for this choice. */ - readonly keepOpen?: boolean; + readonly menu: IBasePromptChoice[]; /** - * Triggered when the user selects the choice. + * Menu is not supported on secondary choices */ - run: () => void; + readonly isSecondary: false | undefined; } export interface IPromptOptions extends INotificationProperties { @@ -327,7 +345,7 @@ export interface INotificationService { * * @returns a handle on the notification to e.g. hide it or update message, buttons, etc. */ - prompt(severity: Severity, message: string, choices: IPromptChoice[], options?: IPromptOptions): INotificationHandle; + prompt(severity: Severity, message: string, choices: (IPromptChoice | IPromptChoiceWithMenu)[], options?: IPromptOptions): INotificationHandle; /** * Shows a status message in the status area with the provided text. diff --git a/src/vs/workbench/browser/parts/notifications/media/notificationsList.css b/src/vs/workbench/browser/parts/notifications/media/notificationsList.css index c6be6928d495672c8b02e4dc25f946f652a8fdec..dc1fb11e68c6c403e0467cbdc5f0ce82f612832f 100644 --- a/src/vs/workbench/browser/parts/notifications/media/notificationsList.css +++ b/src/vs/workbench/browser/parts/notifications/media/notificationsList.css @@ -105,18 +105,23 @@ overflow: hidden; } -.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-buttons-container .monaco-button { +.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-buttons-container > .monaco-button-dropdown, +.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-buttons-container > .monaco-button { + margin: 4px 5px; /* allows button focus outline to be visible */ +} + +.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-buttons-container .monaco-text-button { width: fit-content; width: -moz-fit-content; padding: 5px 10px; - margin: 4px 5px; /* allows button focus outline to be visible */ + display: inline-block; /* to enable ellipsis in text overflow */ font-size: 12px; overflow: hidden; text-overflow: ellipsis; } -.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-buttons-container .monaco-text-button { - display: inline-block; /* to enable ellipsis in text overflow */ +.monaco-workbench .notifications-list-container .notification-list-item .notification-list-item-buttons-container .monaco-dropdown-button { + padding: 5px } /** Notification: Progress */ diff --git a/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts b/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts index a78fd31991af3a8c601bf887f09dee38826307fa..b56a1cf09f50137dc6b19b4b73d81688fda2bcb9 100644 --- a/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts +++ b/src/vs/workbench/browser/parts/notifications/notificationsViewer.ts @@ -8,11 +8,11 @@ import { clearNode, addDisposableListener, EventType, EventHelper, $ } from 'vs/ import { IOpenerService } from 'vs/platform/opener/common/opener'; import { URI } from 'vs/base/common/uri'; import { localize } from 'vs/nls'; -import { ButtonGroup } from 'vs/base/browser/ui/button/button'; +import { ButtonBar } from 'vs/base/browser/ui/button/button'; import { attachButtonStyler, attachProgressBarStyler } from 'vs/platform/theme/common/styler'; import { IThemeService } from 'vs/platform/theme/common/themeService'; import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; -import { IAction, IActionRunner } from 'vs/base/common/actions'; +import { ActionRunner, ActionWithMenuAction, IAction, IActionRunner } from 'vs/base/common/actions'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; import { dispose, DisposableStore, Disposable } from 'vs/base/common/lifecycle'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; @@ -285,7 +285,8 @@ export class NotificationTemplateRenderer extends Disposable { @IOpenerService private readonly openerService: IOpenerService, @IInstantiationService private readonly instantiationService: IInstantiationService, @IThemeService private readonly themeService: IThemeService, - @IKeybindingService private readonly keybindingService: IKeybindingService + @IKeybindingService private readonly keybindingService: IKeybindingService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, ) { super(); @@ -441,27 +442,39 @@ export class NotificationTemplateRenderer extends Disposable { const primaryActions = notification.actions ? notification.actions.primary : undefined; if (notification.expanded && isNonEmptyArray(primaryActions)) { - const buttonGroup = new ButtonGroup(this.template.buttonsContainer, primaryActions.length, { title: true /* assign titles to buttons in case they overflow */ }); - buttonGroup.buttons.forEach((button, index) => { - const action = primaryActions[index]; - button.label = action.label; - - this.inputDisposables.add(button.onDidClick(e => { - EventHelper.stop(e, true); - + const that = this; + const actionRunner: IActionRunner = new class extends ActionRunner { + protected async runAction(action: IAction): Promise { // Run action - this.actionRunner.run(action, notification); + that.actionRunner.run(action, notification); // Hide notification (unless explicitly prevented) if (!(action instanceof ChoiceAction) || !action.keepOpen) { notification.close(); } + } + }(); + const buttonToolbar = this.inputDisposables.add(new ButtonBar(this.template.buttonsContainer)); + for (const action of primaryActions) { + const buttonOptions = { title: true, /* assign titles to buttons in case they overflow */ }; + const dropdownActions = action instanceof ChoiceAction ? action.menu + : action instanceof ActionWithMenuAction ? action.actions : undefined; + const button = this.inputDisposables.add( + dropdownActions + ? buttonToolbar.addButtonWithDropdown({ + ...buttonOptions, + contextMenuProvider: this.contextMenuService, + actions: dropdownActions, + }) + : buttonToolbar.addButton(buttonOptions)); + button.label = action.label; + this.inputDisposables.add(button.onDidClick(e => { + EventHelper.stop(e, true); + actionRunner.run(action); })); this.inputDisposables.add(attachButtonStyler(button, this.themeService)); - }); - - this.inputDisposables.add(buttonGroup); + } } } diff --git a/src/vs/workbench/common/notifications.ts b/src/vs/workbench/common/notifications.ts index b9fb58ac44c3d41d5a46349d74b6da1a64d8afdd..e7df714866004f768362877725d53e96c83dfd34 100644 --- a/src/vs/workbench/common/notifications.ts +++ b/src/vs/workbench/common/notifications.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { INotification, INotificationHandle, INotificationActions, INotificationProgress, NoOpNotification, Severity, NotificationMessage, IPromptChoice, IStatusMessageOptions, NotificationsFilter, INotificationProgressProperties } from 'vs/platform/notification/common/notification'; +import { INotification, INotificationHandle, INotificationActions, INotificationProgress, NoOpNotification, Severity, NotificationMessage, IPromptChoice, IStatusMessageOptions, NotificationsFilter, INotificationProgressProperties, IPromptChoiceWithMenu } from 'vs/platform/notification/common/notification'; import { toErrorMessage } from 'vs/base/common/errorMessage'; import { Event, Emitter } from 'vs/base/common/event'; import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; @@ -695,6 +695,7 @@ export class ChoiceAction extends Action { readonly onDidRun = this._onDidRun.event; private readonly _keepOpen: boolean; + private readonly _menu: ChoiceAction[] | undefined; constructor(id: string, choice: IPromptChoice) { super(id, choice.label, undefined, true, async () => { @@ -707,6 +708,11 @@ export class ChoiceAction extends Action { }); this._keepOpen = !!choice.keepOpen; + this._menu = !choice.isSecondary && (choice).menu ? (choice).menu.map((c, index) => new ChoiceAction(`${id}.${index}`, c)) : undefined; + } + + get menu(): ChoiceAction[] | undefined { + return this._menu; } get keepOpen(): boolean { diff --git a/src/vs/workbench/contrib/extensions/browser/extensionRecommendationNotificationService.ts b/src/vs/workbench/contrib/extensions/browser/extensionRecommendationNotificationService.ts index dca92acdb04c6348a541c41f0ec438927566ce31..ab902f413c848b2d09c8d6bd1a97f0d0217c4e9a 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionRecommendationNotificationService.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionRecommendationNotificationService.ts @@ -15,7 +15,7 @@ import { IConfigurationService } from 'vs/platform/configuration/common/configur import { areSameExtensions } from 'vs/platform/extensionManagement/common/extensionManagementUtil'; import { IExtensionRecommendationNotificationService, RecommendationsNotificationResult, RecommendationSource } from 'vs/platform/extensionRecommendations/common/extensionRecommendations'; import { IInstantiationService, optional } from 'vs/platform/instantiation/common/instantiation'; -import { INotificationHandle, INotificationService, IPromptChoice, Severity } from 'vs/platform/notification/common/notification'; +import { INotificationHandle, INotificationService, IPromptChoice, IPromptChoiceWithMenu, Severity } from 'vs/platform/notification/common/notification'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IUserDataAutoSyncEnablementService, IUserDataSyncResourceEnablementService, SyncResource } from 'vs/platform/userDataSync/common/userDataSync'; @@ -241,7 +241,7 @@ export class ExtensionRecommendationNotificationService implements IExtensionRec { onDidInstallRecommendedExtensions, onDidShowRecommendedExtensions, onDidCancelRecommendedExtensions, onDidNeverShowRecommendedExtensionsAgain }: RecommendationsNotificationActions): CancelablePromise { return createCancelablePromise(async token => { let accepted = false; - const choices: IPromptChoice[] = []; + const choices: (IPromptChoice | IPromptChoiceWithMenu)[] = []; const installExtensions = async (isMachineScoped?: boolean) => { this.runAction(this.instantiationService.createInstance(SearchExtensionsAction, searchValue)); onDidInstallRecommendedExtensions(extensions); @@ -252,14 +252,12 @@ export class ExtensionRecommendationNotificationService implements IExtensionRec }; choices.push({ label: localize('install', "Install"), - run: () => installExtensions() - }); - if (this.userDataAutoSyncEnablementService.isEnabled() && this.userDataSyncResourceEnablementService.isResourceEnabled(SyncResource.Extensions)) { - choices.push({ + run: () => installExtensions(), + menu: this.userDataAutoSyncEnablementService.isEnabled() && this.userDataSyncResourceEnablementService.isResourceEnabled(SyncResource.Extensions) ? [{ label: localize('install and do no sync', "Install (Do not sync)"), run: () => installExtensions(true) - }); - } + }] : undefined, + }); choices.push(...[{ label: localize('show recommendations', "Show Recommendations"), run: async () => {