Path: blob/main/src/vs/base/browser/ui/actionbar/actionbar.ts
5283 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 * as DOM from '../../dom.js';6import { StandardKeyboardEvent } from '../../keyboardEvent.js';7import { ActionViewItem, BaseActionViewItem, IActionViewItemOptions } from './actionViewItems.js';8import { createInstantHoverDelegate } from '../hover/hoverDelegateFactory.js';9import { IHoverDelegate } from '../hover/hoverDelegate.js';10import { ActionRunner, IAction, IActionRunner, IRunEvent, Separator } from '../../../common/actions.js';11import { Emitter } from '../../../common/event.js';12import { KeyCode, KeyMod } from '../../../common/keyCodes.js';13import { Disposable, DisposableMap, DisposableStore, dispose, IDisposable } from '../../../common/lifecycle.js';14import * as types from '../../../common/types.js';15import './actionbar.css';1617export interface IActionViewItem extends IDisposable {18action: IAction;19actionRunner: IActionRunner;20setActionContext(context: unknown): void;21render(element: HTMLElement): void;22isEnabled(): boolean;23focus(fromRight?: boolean): void; // TODO@isidorn what is this?24blur(): void;25showHover?(): void;26}2728export interface IActionViewItemProvider {29(action: IAction, options: IActionViewItemOptions): IActionViewItem | undefined;30}3132export const enum ActionsOrientation {33HORIZONTAL,34VERTICAL,35}3637export interface ActionTrigger {38keys?: KeyCode[];39keyDown: boolean;40}4142export interface IActionBarOptions {43readonly orientation?: ActionsOrientation;44readonly context?: unknown;45readonly actionViewItemProvider?: IActionViewItemProvider;46readonly actionRunner?: IActionRunner;47readonly ariaLabel?: string;48readonly ariaRole?: string;49readonly triggerKeys?: ActionTrigger;50readonly allowContextMenu?: boolean;51readonly preventLoopNavigation?: boolean;52readonly focusOnlyEnabledItems?: boolean;53readonly hoverDelegate?: IHoverDelegate;54/**55* If true, toggled primary items are highlighted with a background color.56* Some action bars exclusively use icon states, we don't want to enable this for them.57* Thus, this is opt-in.58*/59readonly highlightToggledItems?: boolean;60}6162export interface IActionOptions extends IActionViewItemOptions {63index?: number;64}6566export class ActionBar extends Disposable implements IActionRunner {6768private readonly options: IActionBarOptions;69private readonly _hoverDelegate: IHoverDelegate;7071private _actionRunner: IActionRunner;72private readonly _actionRunnerDisposables = this._register(new DisposableStore());73private _context: unknown;74private readonly _orientation: ActionsOrientation;75private readonly _triggerKeys: {76keys: KeyCode[];77keyDown: boolean;78};7980// View Items81viewItems: IActionViewItem[];82private readonly viewItemDisposables = this._register(new DisposableMap<IActionViewItem>());83private previouslyFocusedItem?: number;84protected focusedItem?: number;85private focusTracker: DOM.IFocusTracker;8687// Trigger Key Tracking88private triggerKeyDown: boolean = false;8990private focusable: boolean = true;9192// Elements93domNode: HTMLElement;94protected readonly actionsList: HTMLElement;9596private readonly _onDidBlur = this._register(new Emitter<void>());97get onDidBlur() { return this._onDidBlur.event; }9899private readonly _onDidCancel = this._register(new Emitter<void>({ onWillAddFirstListener: () => this.cancelHasListener = true }));100get onDidCancel() { return this._onDidCancel.event; }101private cancelHasListener = false;102103private readonly _onDidRun = this._register(new Emitter<IRunEvent>());104get onDidRun() { return this._onDidRun.event; }105106private readonly _onWillRun = this._register(new Emitter<IRunEvent>());107get onWillRun() { return this._onWillRun.event; }108109constructor(container: HTMLElement, options: IActionBarOptions = {}) {110super();111112this.options = options;113this._context = options.context ?? null;114this._orientation = this.options.orientation ?? ActionsOrientation.HORIZONTAL;115this._triggerKeys = {116keyDown: this.options.triggerKeys?.keyDown ?? false,117keys: this.options.triggerKeys?.keys ?? [KeyCode.Enter, KeyCode.Space]118};119120this._hoverDelegate = options.hoverDelegate ?? this._register(createInstantHoverDelegate());121122if (this.options.actionRunner) {123this._actionRunner = this.options.actionRunner;124} else {125this._actionRunner = new ActionRunner();126this._actionRunnerDisposables.add(this._actionRunner);127}128129this._actionRunnerDisposables.add(this._actionRunner.onDidRun(e => this._onDidRun.fire(e)));130this._actionRunnerDisposables.add(this._actionRunner.onWillRun(e => this._onWillRun.fire(e)));131132this.viewItems = [];133this.focusedItem = undefined;134135this.domNode = document.createElement('div');136this.domNode.className = 'monaco-action-bar';137138let previousKeys: KeyCode[];139let nextKeys: KeyCode[];140141switch (this._orientation) {142case ActionsOrientation.HORIZONTAL:143previousKeys = [KeyCode.LeftArrow];144nextKeys = [KeyCode.RightArrow];145break;146case ActionsOrientation.VERTICAL:147previousKeys = [KeyCode.UpArrow];148nextKeys = [KeyCode.DownArrow];149this.domNode.className += ' vertical';150break;151}152153this._register(DOM.addDisposableListener(this.domNode, DOM.EventType.KEY_DOWN, e => {154const event = new StandardKeyboardEvent(e);155let eventHandled = true;156const focusedItem = typeof this.focusedItem === 'number' ? this.viewItems[this.focusedItem] : undefined;157158if (previousKeys && (event.equals(previousKeys[0]) || event.equals(previousKeys[1]))) {159eventHandled = this.focusPrevious();160} else if (nextKeys && (event.equals(nextKeys[0]) || event.equals(nextKeys[1]))) {161eventHandled = this.focusNext();162} else if (event.equals(KeyCode.Escape) && this.cancelHasListener) {163this._onDidCancel.fire();164} else if (event.equals(KeyCode.Home)) {165eventHandled = this.focusFirst();166} else if (event.equals(KeyCode.End)) {167eventHandled = this.focusLast();168} else if (event.equals(KeyCode.Tab) && focusedItem instanceof BaseActionViewItem && focusedItem.trapsArrowNavigation) {169// Tab, so forcibly focus next #219199170eventHandled = this.focusNext(undefined, true);171} else if (this.isTriggerKeyEvent(event)) {172// Staying out of the else branch even if not triggered173if (this._triggerKeys.keyDown) {174this.doTrigger(event);175} else {176this.triggerKeyDown = true;177}178} else {179eventHandled = false;180}181182if (eventHandled) {183event.preventDefault();184event.stopPropagation();185}186}));187188this._register(DOM.addDisposableListener(this.domNode, DOM.EventType.KEY_UP, e => {189const event = new StandardKeyboardEvent(e);190191// Run action on Enter/Space192if (this.isTriggerKeyEvent(event)) {193if (!this._triggerKeys.keyDown && this.triggerKeyDown) {194this.triggerKeyDown = false;195this.doTrigger(event);196}197198event.preventDefault();199event.stopPropagation();200}201202// Recompute focused item203else if (event.equals(KeyCode.Tab) || event.equals(KeyMod.Shift | KeyCode.Tab) || event.equals(KeyCode.UpArrow) || event.equals(KeyCode.DownArrow) || event.equals(KeyCode.LeftArrow) || event.equals(KeyCode.RightArrow)) {204this.updateFocusedItem();205}206}));207208this.focusTracker = this._register(DOM.trackFocus(this.domNode));209this._register(this.focusTracker.onDidBlur(() => {210if (DOM.getActiveElement() === this.domNode || !DOM.isAncestor(DOM.getActiveElement(), this.domNode)) {211this._onDidBlur.fire();212this.previouslyFocusedItem = this.focusedItem;213this.focusedItem = undefined;214this.triggerKeyDown = false;215}216}));217218this._register(this.focusTracker.onDidFocus(() => this.updateFocusedItem()));219220this.actionsList = document.createElement('ul');221this.actionsList.className = 'actions-container';222if (this.options.highlightToggledItems) {223this.actionsList.classList.add('highlight-toggled');224}225this.actionsList.setAttribute('role', this.options.ariaRole || 'toolbar');226227if (this.options.ariaLabel) {228this.actionsList.setAttribute('aria-label', this.options.ariaLabel);229}230231this.domNode.appendChild(this.actionsList);232233container.appendChild(this.domNode);234}235236private refreshRole(): void {237if (this.length() >= 1) {238this.actionsList.setAttribute('role', this.options.ariaRole || 'toolbar');239} else {240this.actionsList.setAttribute('role', 'presentation');241}242}243244setAriaLabel(label: string): void {245if (label) {246this.actionsList.setAttribute('aria-label', label);247} else {248this.actionsList.removeAttribute('aria-label');249}250}251252// Some action bars should not be focusable at times253// When an action bar is not focusable make sure to make all the elements inside it not focusable254// When an action bar is focusable again, make sure the first item can be focused255setFocusable(focusable: boolean): void {256this.focusable = focusable;257if (this.focusable) {258const firstEnabled = this.viewItems.find(vi => vi instanceof BaseActionViewItem && vi.isEnabled());259if (firstEnabled instanceof BaseActionViewItem) {260firstEnabled.setFocusable(true);261}262} else {263this.viewItems.forEach(vi => {264if (vi instanceof BaseActionViewItem) {265vi.setFocusable(false);266}267});268}269}270271private isTriggerKeyEvent(event: StandardKeyboardEvent): boolean {272let ret = false;273this._triggerKeys.keys.forEach(keyCode => {274ret = ret || event.equals(keyCode);275});276277return ret;278}279280private updateFocusedItem(): void {281for (let i = 0; i < this.actionsList.children.length; i++) {282const elem = this.actionsList.children[i];283if (DOM.isAncestor(DOM.getActiveElement(), elem)) {284this.focusedItem = i;285this.viewItems[this.focusedItem]?.showHover?.();286break;287}288}289}290291get context(): unknown {292return this._context;293}294295set context(context: unknown) {296this._context = context;297this.viewItems.forEach(i => i.setActionContext(context));298}299300get actionRunner(): IActionRunner {301return this._actionRunner;302}303304set actionRunner(actionRunner: IActionRunner) {305this._actionRunner = actionRunner;306307// when setting a new `IActionRunner` make sure to dispose old listeners and308// start to forward events from the new listener309this._actionRunnerDisposables.clear();310this._actionRunnerDisposables.add(this._actionRunner.onDidRun(e => this._onDidRun.fire(e)));311this._actionRunnerDisposables.add(this._actionRunner.onWillRun(e => this._onWillRun.fire(e)));312this.viewItems.forEach(item => item.actionRunner = actionRunner);313}314315getContainer(): HTMLElement {316return this.domNode;317}318319hasAction(action: IAction): boolean {320return this.viewItems.findIndex(candidate => candidate.action.id === action.id) !== -1;321}322323getAction(indexOrElement: number | HTMLElement): IAction | undefined {324325// by index326if (typeof indexOrElement === 'number') {327return this.viewItems[indexOrElement]?.action;328}329330// by element331if (DOM.isHTMLElement(indexOrElement)) {332while (indexOrElement.parentElement !== this.actionsList) {333if (!indexOrElement.parentElement) {334return undefined;335}336indexOrElement = indexOrElement.parentElement;337}338for (let i = 0; i < this.actionsList.childNodes.length; i++) {339if (this.actionsList.childNodes[i] === indexOrElement) {340return this.viewItems[i].action;341}342}343}344345return undefined;346}347348push(arg: IAction | ReadonlyArray<IAction>, options: IActionOptions = {}): void {349const actions: ReadonlyArray<IAction> = Array.isArray(arg) ? arg : [arg];350351let index = types.isNumber(options.index) ? options.index : null;352353actions.forEach((action: IAction) => {354const actionViewItemElement = document.createElement('li');355actionViewItemElement.className = 'action-item';356actionViewItemElement.setAttribute('role', 'presentation');357358let item: IActionViewItem | undefined;359360const viewItemOptions: IActionViewItemOptions = { hoverDelegate: this._hoverDelegate, ...options, isTabList: this.options.ariaRole === 'tablist' };361if (this.options.actionViewItemProvider) {362item = this.options.actionViewItemProvider(action, viewItemOptions);363}364365if (!item) {366item = new ActionViewItem(this.context, action, viewItemOptions);367}368369// Prevent native context menu on actions370if (!this.options.allowContextMenu) {371this.viewItemDisposables.set(item, DOM.addDisposableListener(actionViewItemElement, DOM.EventType.CONTEXT_MENU, (e: DOM.EventLike) => {372DOM.EventHelper.stop(e, true);373}));374}375376item.actionRunner = this._actionRunner;377item.setActionContext(this.context);378item.render(actionViewItemElement);379380if (index === null || index < 0 || index >= this.actionsList.children.length) {381this.actionsList.appendChild(actionViewItemElement);382this.viewItems.push(item);383} else {384this.actionsList.insertBefore(actionViewItemElement, this.actionsList.children[index]);385this.viewItems.splice(index, 0, item);386index++;387}388});389390// We need to allow for the first enabled item to be focused on using tab navigation #106441391if (this.focusable) {392let didFocus = false;393for (const item of this.viewItems) {394if (!(item instanceof BaseActionViewItem)) {395continue;396}397398let focus: boolean;399if (didFocus) {400focus = false; // already focused an item401} else if (item.action.id === Separator.ID) {402focus = false; // never focus a separator403} else if (!item.isEnabled() && this.options.focusOnlyEnabledItems) {404focus = false; // never focus a disabled item405} else {406focus = true;407}408409if (focus) {410item.setFocusable(true);411didFocus = true;412} else {413item.setFocusable(false);414}415}416}417418if (typeof this.focusedItem === 'number') {419// After a clear actions might be re-added to simply toggle some actions. We should preserve focus #97128420this.focus(this.focusedItem);421}422this.refreshRole();423}424425getWidth(index: number): number {426if (index >= 0 && index < this.actionsList.children.length) {427const item = this.actionsList.children.item(index);428if (item) {429return item.clientWidth;430}431}432433return 0;434}435436getHeight(index: number): number {437if (index >= 0 && index < this.actionsList.children.length) {438const item = this.actionsList.children.item(index);439if (item) {440return item.clientHeight;441}442}443444return 0;445}446447pull(index: number): void {448if (index >= 0 && index < this.viewItems.length) {449this.actionsList.childNodes[index].remove();450this.viewItemDisposables.deleteAndDispose(this.viewItems[index]);451dispose(this.viewItems.splice(index, 1));452this.refreshRole();453}454}455456clear(): void {457if (this.isEmpty()) {458return;459}460461this.viewItems = dispose(this.viewItems);462this.viewItemDisposables.clearAndDisposeAll();463DOM.clearNode(this.actionsList);464this.refreshRole();465}466467length(): number {468return this.viewItems.length;469}470471isEmpty(): boolean {472return this.viewItems.length === 0;473}474475isFocused(index?: number): boolean {476return index === undefined477? DOM.isAncestor(DOM.getActiveElement(), this.domNode)478: DOM.isAncestor(DOM.getActiveElement(), this.actionsList.children[index]);479}480481focus(index?: number): void;482focus(selectFirst?: boolean): void;483focus(arg?: number | boolean): void {484let selectFirst: boolean = false;485let index: number | undefined = undefined;486if (arg === undefined) {487selectFirst = true;488} else if (typeof arg === 'number') {489index = arg;490} else if (typeof arg === 'boolean') {491selectFirst = arg;492}493494if (selectFirst && typeof this.focusedItem === 'undefined') {495const firstEnabled = this.viewItems.findIndex(item => item.isEnabled());496// Focus the first enabled item497this.focusedItem = firstEnabled === -1 ? undefined : firstEnabled;498this.updateFocus(undefined, undefined, true);499} else {500if (index !== undefined) {501this.focusedItem = index;502}503504this.updateFocus(undefined, undefined, true);505}506}507508private focusFirst(): boolean {509this.focusedItem = this.length() - 1;510return this.focusNext(true);511}512513private focusLast(): boolean {514this.focusedItem = 0;515return this.focusPrevious(true);516}517518protected focusNext(forceLoop?: boolean, forceFocus?: boolean): boolean {519if (typeof this.focusedItem === 'undefined') {520this.focusedItem = this.viewItems.length - 1;521} else if (this.viewItems.length <= 1) {522return false;523}524525const startIndex = this.focusedItem;526let item: IActionViewItem;527do {528529if (!forceLoop && this.options.preventLoopNavigation && this.focusedItem + 1 >= this.viewItems.length) {530this.focusedItem = startIndex;531return false;532}533534this.focusedItem = (this.focusedItem + 1) % this.viewItems.length;535item = this.viewItems[this.focusedItem];536} while (this.focusedItem !== startIndex && ((this.options.focusOnlyEnabledItems && !item.isEnabled()) || item.action.id === Separator.ID));537538this.updateFocus(undefined, undefined, forceFocus);539return true;540}541542protected focusPrevious(forceLoop?: boolean): boolean {543if (typeof this.focusedItem === 'undefined') {544this.focusedItem = 0;545} else if (this.viewItems.length <= 1) {546return false;547}548549const startIndex = this.focusedItem;550let item: IActionViewItem;551552do {553this.focusedItem = this.focusedItem - 1;554if (this.focusedItem < 0) {555if (!forceLoop && this.options.preventLoopNavigation) {556this.focusedItem = startIndex;557return false;558}559560this.focusedItem = this.viewItems.length - 1;561}562item = this.viewItems[this.focusedItem];563} while (this.focusedItem !== startIndex && ((this.options.focusOnlyEnabledItems && !item.isEnabled()) || item.action.id === Separator.ID));564565566this.updateFocus(true);567return true;568}569570protected updateFocus(fromRight?: boolean, preventScroll?: boolean, forceFocus: boolean = false): void {571if (typeof this.focusedItem === 'undefined') {572this.actionsList.focus({ preventScroll });573}574575if (this.previouslyFocusedItem !== undefined && this.previouslyFocusedItem !== this.focusedItem) {576this.viewItems[this.previouslyFocusedItem]?.blur();577}578const actionViewItem = this.focusedItem !== undefined ? this.viewItems[this.focusedItem] : undefined;579if (actionViewItem) {580let focusItem = true;581582if (!types.isFunction(actionViewItem.focus)) {583focusItem = false;584}585586if (this.options.focusOnlyEnabledItems && types.isFunction(actionViewItem.isEnabled) && !actionViewItem.isEnabled()) {587focusItem = false;588}589590if (actionViewItem.action.id === Separator.ID) {591focusItem = false;592}593if (!focusItem) {594this.actionsList.focus({ preventScroll });595this.previouslyFocusedItem = undefined;596} else if (forceFocus || this.previouslyFocusedItem !== this.focusedItem) {597actionViewItem.focus(fromRight);598this.previouslyFocusedItem = this.focusedItem;599}600if (focusItem) {601actionViewItem.showHover?.();602}603}604}605606private doTrigger(event: StandardKeyboardEvent): void {607if (typeof this.focusedItem === 'undefined') {608return; //nothing to focus609}610611// trigger action612const actionViewItem = this.viewItems[this.focusedItem];613if (actionViewItem instanceof BaseActionViewItem) {614const context = (actionViewItem._context === null || actionViewItem._context === undefined) ? event : actionViewItem._context;615this.run(actionViewItem._action, context);616}617}618619async run(action: IAction, context?: unknown): Promise<void> {620await this._actionRunner.run(action, context);621}622623override dispose(): void {624this._context = undefined;625this.viewItems = dispose(this.viewItems);626this.getContainer().remove();627super.dispose();628}629}630631export function prepareActions(actions: IAction[]): IAction[] {632if (!actions.length) {633return actions;634}635636// Clean up leading separators637let firstIndexOfAction = -1;638for (let i = 0; i < actions.length; i++) {639if (actions[i].id === Separator.ID) {640continue;641}642643firstIndexOfAction = i;644break;645}646647if (firstIndexOfAction === -1) {648return [];649}650651actions = actions.slice(firstIndexOfAction);652653// Clean up trailing separators654for (let h = actions.length - 1; h >= 0; h--) {655const isSeparator = actions[h].id === Separator.ID;656if (isSeparator) {657actions.splice(h, 1);658} else {659break;660}661}662663// Clean up separator duplicates664let foundAction = false;665for (let k = actions.length - 1; k >= 0; k--) {666const isSeparator = actions[k].id === Separator.ID;667if (isSeparator && !foundAction) {668actions.splice(k, 1);669} else if (!isSeparator) {670foundAction = true;671} else if (isSeparator) {672foundAction = false;673}674}675676return actions;677}678679680