未验证 提交 63efefb8 编写于 作者: S Sandeep Somavarapu 提交者: GitHub

Merge pull request #111182 from microsoft/sandy081/notifications/dropdown

Implement split action with dropdown in notifications
......@@ -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;
}
......@@ -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<Event>;
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<Event>;
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);
}
}));
}
}
......@@ -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<IDialogResult>((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) {
......
......@@ -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<any>) {
super(id, label, cssClass, enabled, actionCallback);
}
}
export class SubmenuAction extends Action {
get actions(): IAction[] {
......
......@@ -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<IAction>;
......@@ -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.
......
......@@ -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 */
......
......@@ -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<void> {
// 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);
}
}
}
......
......@@ -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 && (<IPromptChoiceWithMenu>choice).menu ? (<IPromptChoiceWithMenu>choice).menu.map((c, index) => new ChoiceAction(`${id}.${index}`, c)) : undefined;
}
get menu(): ChoiceAction[] | undefined {
return this._menu;
}
get keepOpen(): boolean {
......
......@@ -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<RecommendationsNotificationResult> {
return createCancelablePromise<RecommendationsNotificationResult>(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 () => {
......
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册