Path: blob/main/src/vs/platform/actionWidget/browser/actionWidgetDropdown.ts
5220 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 { IActionWidgetService } from './actionWidget.js';6import { IAction } from '../../../base/common/actions.js';7import { BaseDropdown, IActionProvider, IBaseDropdownOptions } from '../../../base/browser/ui/dropdown/dropdown.js';8import { ActionListItemKind, IActionListDelegate, IActionListItem, IActionListItemHover } from './actionList.js';9import { ThemeIcon } from '../../../base/common/themables.js';10import { Codicon } from '../../../base/common/codicons.js';11import { getActiveElement, isHTMLElement } from '../../../base/browser/dom.js';12import { IKeybindingService } from '../../keybinding/common/keybinding.js';13import { IListAccessibilityProvider } from '../../../base/browser/ui/list/listWidget.js';14import { ITelemetryService } from '../../telemetry/common/telemetry.js';1516export interface IActionWidgetDropdownAction extends IAction {17category?: { label: string; order: number; showHeader?: boolean };18icon?: ThemeIcon;19description?: string;20/**21* Optional flyout hover configuration shown when focusing/hovering over the action.22*/23hover?: IActionListItemHover;24/**25* Optional toolbar actions shown when the item is focused or hovered.26*/27toolbarActions?: IAction[];28}2930// TODO @lramos15 - Should we just make IActionProvider templated?31export interface IActionWidgetDropdownActionProvider {32getActions(): IActionWidgetDropdownAction[];33}3435export interface IActionWidgetDropdownOptions extends IBaseDropdownOptions {36// These are the actions that are shown in the action widget split up by category37readonly actions?: IActionWidgetDropdownAction[];38readonly actionProvider?: IActionWidgetDropdownActionProvider;3940// These actions are those shown at the bottom of the action widget41readonly actionBarActions?: IAction[];42readonly actionBarActionProvider?: IActionProvider;43readonly showItemKeybindings?: boolean;4445// Function that returns the anchor element for the dropdown46getAnchor?: () => HTMLElement;4748/**49* Telemetry reporter configuration used when the dropdown closes. The `id` field is required50* and is used as the telemetry identifier; `name` is optional additional context. If not51* provided, no telemetry will be sent.52*/53readonly reporter?: { id: string; name?: string; includeOptions?: boolean };54}5556/**57* Action widget dropdown is a dropdown that uses the action widget under the hood to simulate a native dropdown menu58* The benefits of this include non native features such as headers, descriptions, icons, and button bar59*/60export class ActionWidgetDropdown extends BaseDropdown {6162private _enabled: boolean = true;6364constructor(65container: HTMLElement,66private readonly _options: IActionWidgetDropdownOptions,67@IActionWidgetService private readonly actionWidgetService: IActionWidgetService,68@IKeybindingService private readonly keybindingService: IKeybindingService,69@ITelemetryService private readonly telemetryService: ITelemetryService,70) {71super(container, _options);72}7374override show(): void {75if (!this._enabled) {76return;77}7879let actionBarActions = this._options.actionBarActions ?? this._options.actionBarActionProvider?.getActions() ?? [];80const actions = this._options.actions ?? this._options.actionProvider?.getActions() ?? [];8182// Track the currently selected option before opening83const optionBeforeOpen: IActionWidgetDropdownAction | undefined = actions.find(a => a.checked);84let selectedOption: IActionWidgetDropdownAction | undefined = optionBeforeOpen;8586const actionWidgetItems: IActionListItem<IActionWidgetDropdownAction>[] = [];8788const actionsByCategory = new Map<string, IActionWidgetDropdownAction[]>();89for (const action of actions) {90let category = action.category;91if (!category) {92category = { label: '', order: Number.MIN_SAFE_INTEGER };93}94if (!actionsByCategory.has(category.label)) {95actionsByCategory.set(category.label, []);96}97actionsByCategory.get(category.label)!.push(action);98}99100// Sort categories by order101const sortedCategories = Array.from(actionsByCategory.entries())102.sort((a, b) => {103const aOrder = a[1][0]?.category?.order ?? Number.MAX_SAFE_INTEGER;104const bOrder = b[1][0]?.category?.order ?? Number.MAX_SAFE_INTEGER;105return aOrder - bOrder;106});107108for (let i = 0; i < sortedCategories.length; i++) {109const [categoryLabel, categoryActions] = sortedCategories[i];110const showHeader = categoryActions[0]?.category?.showHeader ?? false;111if (showHeader && categoryLabel) {112actionWidgetItems.push({113kind: ActionListItemKind.Header,114label: categoryLabel,115canPreview: false,116disabled: false,117hideIcon: false,118});119}120121// Push actions for each category122for (const action of categoryActions) {123actionWidgetItems.push({124item: action,125tooltip: action.tooltip,126description: action.description,127hover: action.hover,128toolbarActions: action.toolbarActions,129kind: ActionListItemKind.Action,130canPreview: false,131group: { title: '', icon: action.icon ?? ThemeIcon.fromId(action.checked ? Codicon.check.id : Codicon.blank.id) },132disabled: !action.enabled,133hideIcon: false,134label: action.label,135keybinding: this._options.showItemKeybindings ?136this.keybindingService.lookupKeybinding(action.id) :137undefined,138});139}140141// Add separator after each category except the last one142if (i < sortedCategories.length - 1) {143actionWidgetItems.push({144label: '',145kind: ActionListItemKind.Separator,146canPreview: false,147disabled: false,148hideIcon: false,149});150}151}152153const previouslyFocusedElement = getActiveElement();154155156const actionWidgetDelegate: IActionListDelegate<IActionWidgetDropdownAction> = {157onSelect: (action, preview) => {158selectedOption = action;159this.actionWidgetService.hide();160action.run();161},162onHide: () => {163if (isHTMLElement(previouslyFocusedElement)) {164previouslyFocusedElement.focus();165}166this._emitCloseEvent(optionBeforeOpen, selectedOption);167}168};169170actionBarActions = actionBarActions.map(action => ({171...action,172run: async (...args: unknown[]) => {173this.actionWidgetService.hide();174return action.run(...args);175}176}));177178const accessibilityProvider: Partial<IListAccessibilityProvider<IActionListItem<IActionWidgetDropdownAction>>> = {179isChecked(element) {180return element.kind === ActionListItemKind.Action && !!element?.item?.checked;181},182getRole: (e) => {183switch (e.kind) {184case ActionListItemKind.Action:185return 'menuitemcheckbox';186case ActionListItemKind.Separator:187return 'separator';188default:189return 'separator';190}191},192getWidgetRole: () => 'menu',193};194195this.actionWidgetService.show<IActionWidgetDropdownAction>(196this._options.label ?? '',197false,198actionWidgetItems,199actionWidgetDelegate,200this._options.getAnchor?.() ?? this.element,201undefined,202actionBarActions,203accessibilityProvider204);205}206207setEnabled(enabled: boolean): void {208this._enabled = enabled;209}210211private _emitCloseEvent(optionBeforeOpen: IActionWidgetDropdownAction | undefined, selectedOption: IActionWidgetDropdownAction | undefined): void {212const optionBefore = optionBeforeOpen;213const optionAfter = selectedOption;214215if (this._options.reporter) {216this.telemetryService.publicLog2<ActionWidgetDropdownClosedEvent, ActionWidgetDropdownClosedClassification>(217'actionWidgetDropdownClosed',218{219id: this._options.reporter.id,220name: this._options.reporter.name,221selectionChanged: optionBefore?.id !== optionAfter?.id,222optionIdBefore: this._options.reporter.includeOptions ? optionBefore?.id : undefined,223optionIdAfter: this._options.reporter.includeOptions ? optionAfter?.id : undefined,224optionLabelBefore: this._options.reporter.includeOptions ? optionBefore?.label : undefined,225optionLabelAfter: this._options.reporter.includeOptions ? optionAfter?.label : undefined,226}227);228}229}230}231232type ActionWidgetDropdownClosedEvent = {233id: string;234name: string | undefined;235selectionChanged: boolean;236optionIdBefore: string | undefined;237optionIdAfter: string | undefined;238optionLabelBefore: string | undefined;239optionLabelAfter: string | undefined;240};241242type ActionWidgetDropdownClosedClassification = {243id: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The telemetry id of the dropdown picker.' };244name: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The telemetry name of the dropdown picker.' };245selectionChanged: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Whether the user changed the selected option.' };246optionIdBefore: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The option configured before opening the dropdown.' };247optionIdAfter: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The option configured after closing the dropdown.' };248optionLabelBefore: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The label of the option configured before opening the dropdown.' };249optionLabelAfter: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The label of the option configured after closing the dropdown.' };250owner: 'benibenj';251comment: 'Tracks action widget dropdown usage and selection changes.';252};253254255