Path: blob/main/src/vs/base/browser/ui/actionbar/actionbar.ts
3296 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}474475focus(index?: number): void;476focus(selectFirst?: boolean): void;477focus(arg?: number | boolean): void {478let selectFirst: boolean = false;479let index: number | undefined = undefined;480if (arg === undefined) {481selectFirst = true;482} else if (typeof arg === 'number') {483index = arg;484} else if (typeof arg === 'boolean') {485selectFirst = arg;486}487488if (selectFirst && typeof this.focusedItem === 'undefined') {489const firstEnabled = this.viewItems.findIndex(item => item.isEnabled());490// Focus the first enabled item491this.focusedItem = firstEnabled === -1 ? undefined : firstEnabled;492this.updateFocus(undefined, undefined, true);493} else {494if (index !== undefined) {495this.focusedItem = index;496}497498this.updateFocus(undefined, undefined, true);499}500}501502private focusFirst(): boolean {503this.focusedItem = this.length() - 1;504return this.focusNext(true);505}506507private focusLast(): boolean {508this.focusedItem = 0;509return this.focusPrevious(true);510}511512protected focusNext(forceLoop?: boolean, forceFocus?: boolean): boolean {513if (typeof this.focusedItem === 'undefined') {514this.focusedItem = this.viewItems.length - 1;515} else if (this.viewItems.length <= 1) {516return false;517}518519const startIndex = this.focusedItem;520let item: IActionViewItem;521do {522523if (!forceLoop && this.options.preventLoopNavigation && this.focusedItem + 1 >= this.viewItems.length) {524this.focusedItem = startIndex;525return false;526}527528this.focusedItem = (this.focusedItem + 1) % this.viewItems.length;529item = this.viewItems[this.focusedItem];530} while (this.focusedItem !== startIndex && ((this.options.focusOnlyEnabledItems && !item.isEnabled()) || item.action.id === Separator.ID));531532this.updateFocus(undefined, undefined, forceFocus);533return true;534}535536protected focusPrevious(forceLoop?: boolean): boolean {537if (typeof this.focusedItem === 'undefined') {538this.focusedItem = 0;539} else if (this.viewItems.length <= 1) {540return false;541}542543const startIndex = this.focusedItem;544let item: IActionViewItem;545546do {547this.focusedItem = this.focusedItem - 1;548if (this.focusedItem < 0) {549if (!forceLoop && this.options.preventLoopNavigation) {550this.focusedItem = startIndex;551return false;552}553554this.focusedItem = this.viewItems.length - 1;555}556item = this.viewItems[this.focusedItem];557} while (this.focusedItem !== startIndex && ((this.options.focusOnlyEnabledItems && !item.isEnabled()) || item.action.id === Separator.ID));558559560this.updateFocus(true);561return true;562}563564protected updateFocus(fromRight?: boolean, preventScroll?: boolean, forceFocus: boolean = false): void {565if (typeof this.focusedItem === 'undefined') {566this.actionsList.focus({ preventScroll });567}568569if (this.previouslyFocusedItem !== undefined && this.previouslyFocusedItem !== this.focusedItem) {570this.viewItems[this.previouslyFocusedItem]?.blur();571}572const actionViewItem = this.focusedItem !== undefined ? this.viewItems[this.focusedItem] : undefined;573if (actionViewItem) {574let focusItem = true;575576if (!types.isFunction(actionViewItem.focus)) {577focusItem = false;578}579580if (this.options.focusOnlyEnabledItems && types.isFunction(actionViewItem.isEnabled) && !actionViewItem.isEnabled()) {581focusItem = false;582}583584if (actionViewItem.action.id === Separator.ID) {585focusItem = false;586}587if (!focusItem) {588this.actionsList.focus({ preventScroll });589this.previouslyFocusedItem = undefined;590} else if (forceFocus || this.previouslyFocusedItem !== this.focusedItem) {591actionViewItem.focus(fromRight);592this.previouslyFocusedItem = this.focusedItem;593}594if (focusItem) {595actionViewItem.showHover?.();596}597}598}599600private doTrigger(event: StandardKeyboardEvent): void {601if (typeof this.focusedItem === 'undefined') {602return; //nothing to focus603}604605// trigger action606const actionViewItem = this.viewItems[this.focusedItem];607if (actionViewItem instanceof BaseActionViewItem) {608const context = (actionViewItem._context === null || actionViewItem._context === undefined) ? event : actionViewItem._context;609this.run(actionViewItem._action, context);610}611}612613async run(action: IAction, context?: unknown): Promise<void> {614await this._actionRunner.run(action, context);615}616617override dispose(): void {618this._context = undefined;619this.viewItems = dispose(this.viewItems);620this.getContainer().remove();621super.dispose();622}623}624625export function prepareActions(actions: IAction[]): IAction[] {626if (!actions.length) {627return actions;628}629630// Clean up leading separators631let firstIndexOfAction = -1;632for (let i = 0; i < actions.length; i++) {633if (actions[i].id === Separator.ID) {634continue;635}636637firstIndexOfAction = i;638break;639}640641if (firstIndexOfAction === -1) {642return [];643}644645actions = actions.slice(firstIndexOfAction);646647// Clean up trailing separators648for (let h = actions.length - 1; h >= 0; h--) {649const isSeparator = actions[h].id === Separator.ID;650if (isSeparator) {651actions.splice(h, 1);652} else {653break;654}655}656657// Clean up separator duplicates658let foundAction = false;659for (let k = actions.length - 1; k >= 0; k--) {660const isSeparator = actions[k].id === Separator.ID;661if (isSeparator && !foundAction) {662actions.splice(k, 1);663} else if (!isSeparator) {664foundAction = true;665} else if (isSeparator) {666foundAction = false;667}668}669670return actions;671}672673674