Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/base/browser/ui/actionbar/actionbar.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import * as DOM from '../../dom.js';
7
import { StandardKeyboardEvent } from '../../keyboardEvent.js';
8
import { ActionViewItem, BaseActionViewItem, IActionViewItemOptions } from './actionViewItems.js';
9
import { createInstantHoverDelegate } from '../hover/hoverDelegateFactory.js';
10
import { IHoverDelegate } from '../hover/hoverDelegate.js';
11
import { ActionRunner, IAction, IActionRunner, IRunEvent, Separator } from '../../../common/actions.js';
12
import { Emitter } from '../../../common/event.js';
13
import { KeyCode, KeyMod } from '../../../common/keyCodes.js';
14
import { Disposable, DisposableMap, DisposableStore, dispose, IDisposable } from '../../../common/lifecycle.js';
15
import * as types from '../../../common/types.js';
16
import './actionbar.css';
17
18
export interface IActionViewItem extends IDisposable {
19
action: IAction;
20
actionRunner: IActionRunner;
21
setActionContext(context: unknown): void;
22
render(element: HTMLElement): void;
23
isEnabled(): boolean;
24
focus(fromRight?: boolean): void; // TODO@isidorn what is this?
25
blur(): void;
26
showHover?(): void;
27
}
28
29
export interface IActionViewItemProvider {
30
(action: IAction, options: IActionViewItemOptions): IActionViewItem | undefined;
31
}
32
33
export const enum ActionsOrientation {
34
HORIZONTAL,
35
VERTICAL,
36
}
37
38
export interface ActionTrigger {
39
keys?: KeyCode[];
40
keyDown: boolean;
41
}
42
43
export interface IActionBarOptions {
44
readonly orientation?: ActionsOrientation;
45
readonly context?: unknown;
46
readonly actionViewItemProvider?: IActionViewItemProvider;
47
readonly actionRunner?: IActionRunner;
48
readonly ariaLabel?: string;
49
readonly ariaRole?: string;
50
readonly triggerKeys?: ActionTrigger;
51
readonly allowContextMenu?: boolean;
52
readonly preventLoopNavigation?: boolean;
53
readonly focusOnlyEnabledItems?: boolean;
54
readonly hoverDelegate?: IHoverDelegate;
55
/**
56
* If true, toggled primary items are highlighted with a background color.
57
* Some action bars exclusively use icon states, we don't want to enable this for them.
58
* Thus, this is opt-in.
59
*/
60
readonly highlightToggledItems?: boolean;
61
}
62
63
export interface IActionOptions extends IActionViewItemOptions {
64
index?: number;
65
}
66
67
export class ActionBar extends Disposable implements IActionRunner {
68
69
private readonly options: IActionBarOptions;
70
private readonly _hoverDelegate: IHoverDelegate;
71
72
private _actionRunner: IActionRunner;
73
private readonly _actionRunnerDisposables = this._register(new DisposableStore());
74
private _context: unknown;
75
private readonly _orientation: ActionsOrientation;
76
private readonly _triggerKeys: {
77
keys: KeyCode[];
78
keyDown: boolean;
79
};
80
81
// View Items
82
viewItems: IActionViewItem[];
83
private readonly viewItemDisposables = this._register(new DisposableMap<IActionViewItem>());
84
private previouslyFocusedItem?: number;
85
protected focusedItem?: number;
86
private focusTracker: DOM.IFocusTracker;
87
88
// Trigger Key Tracking
89
private triggerKeyDown: boolean = false;
90
91
private focusable: boolean = true;
92
93
// Elements
94
domNode: HTMLElement;
95
protected readonly actionsList: HTMLElement;
96
97
private readonly _onDidBlur = this._register(new Emitter<void>());
98
get onDidBlur() { return this._onDidBlur.event; }
99
100
private readonly _onDidCancel = this._register(new Emitter<void>({ onWillAddFirstListener: () => this.cancelHasListener = true }));
101
get onDidCancel() { return this._onDidCancel.event; }
102
private cancelHasListener = false;
103
104
private readonly _onDidRun = this._register(new Emitter<IRunEvent>());
105
get onDidRun() { return this._onDidRun.event; }
106
107
private readonly _onWillRun = this._register(new Emitter<IRunEvent>());
108
get onWillRun() { return this._onWillRun.event; }
109
110
constructor(container: HTMLElement, options: IActionBarOptions = {}) {
111
super();
112
113
this.options = options;
114
this._context = options.context ?? null;
115
this._orientation = this.options.orientation ?? ActionsOrientation.HORIZONTAL;
116
this._triggerKeys = {
117
keyDown: this.options.triggerKeys?.keyDown ?? false,
118
keys: this.options.triggerKeys?.keys ?? [KeyCode.Enter, KeyCode.Space]
119
};
120
121
this._hoverDelegate = options.hoverDelegate ?? this._register(createInstantHoverDelegate());
122
123
if (this.options.actionRunner) {
124
this._actionRunner = this.options.actionRunner;
125
} else {
126
this._actionRunner = new ActionRunner();
127
this._actionRunnerDisposables.add(this._actionRunner);
128
}
129
130
this._actionRunnerDisposables.add(this._actionRunner.onDidRun(e => this._onDidRun.fire(e)));
131
this._actionRunnerDisposables.add(this._actionRunner.onWillRun(e => this._onWillRun.fire(e)));
132
133
this.viewItems = [];
134
this.focusedItem = undefined;
135
136
this.domNode = document.createElement('div');
137
this.domNode.className = 'monaco-action-bar';
138
139
let previousKeys: KeyCode[];
140
let nextKeys: KeyCode[];
141
142
switch (this._orientation) {
143
case ActionsOrientation.HORIZONTAL:
144
previousKeys = [KeyCode.LeftArrow];
145
nextKeys = [KeyCode.RightArrow];
146
break;
147
case ActionsOrientation.VERTICAL:
148
previousKeys = [KeyCode.UpArrow];
149
nextKeys = [KeyCode.DownArrow];
150
this.domNode.className += ' vertical';
151
break;
152
}
153
154
this._register(DOM.addDisposableListener(this.domNode, DOM.EventType.KEY_DOWN, e => {
155
const event = new StandardKeyboardEvent(e);
156
let eventHandled = true;
157
const focusedItem = typeof this.focusedItem === 'number' ? this.viewItems[this.focusedItem] : undefined;
158
159
if (previousKeys && (event.equals(previousKeys[0]) || event.equals(previousKeys[1]))) {
160
eventHandled = this.focusPrevious();
161
} else if (nextKeys && (event.equals(nextKeys[0]) || event.equals(nextKeys[1]))) {
162
eventHandled = this.focusNext();
163
} else if (event.equals(KeyCode.Escape) && this.cancelHasListener) {
164
this._onDidCancel.fire();
165
} else if (event.equals(KeyCode.Home)) {
166
eventHandled = this.focusFirst();
167
} else if (event.equals(KeyCode.End)) {
168
eventHandled = this.focusLast();
169
} else if (event.equals(KeyCode.Tab) && focusedItem instanceof BaseActionViewItem && focusedItem.trapsArrowNavigation) {
170
// Tab, so forcibly focus next #219199
171
eventHandled = this.focusNext(undefined, true);
172
} else if (this.isTriggerKeyEvent(event)) {
173
// Staying out of the else branch even if not triggered
174
if (this._triggerKeys.keyDown) {
175
this.doTrigger(event);
176
} else {
177
this.triggerKeyDown = true;
178
}
179
} else {
180
eventHandled = false;
181
}
182
183
if (eventHandled) {
184
event.preventDefault();
185
event.stopPropagation();
186
}
187
}));
188
189
this._register(DOM.addDisposableListener(this.domNode, DOM.EventType.KEY_UP, e => {
190
const event = new StandardKeyboardEvent(e);
191
192
// Run action on Enter/Space
193
if (this.isTriggerKeyEvent(event)) {
194
if (!this._triggerKeys.keyDown && this.triggerKeyDown) {
195
this.triggerKeyDown = false;
196
this.doTrigger(event);
197
}
198
199
event.preventDefault();
200
event.stopPropagation();
201
}
202
203
// Recompute focused item
204
else 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)) {
205
this.updateFocusedItem();
206
}
207
}));
208
209
this.focusTracker = this._register(DOM.trackFocus(this.domNode));
210
this._register(this.focusTracker.onDidBlur(() => {
211
if (DOM.getActiveElement() === this.domNode || !DOM.isAncestor(DOM.getActiveElement(), this.domNode)) {
212
this._onDidBlur.fire();
213
this.previouslyFocusedItem = this.focusedItem;
214
this.focusedItem = undefined;
215
this.triggerKeyDown = false;
216
}
217
}));
218
219
this._register(this.focusTracker.onDidFocus(() => this.updateFocusedItem()));
220
221
this.actionsList = document.createElement('ul');
222
this.actionsList.className = 'actions-container';
223
if (this.options.highlightToggledItems) {
224
this.actionsList.classList.add('highlight-toggled');
225
}
226
this.actionsList.setAttribute('role', this.options.ariaRole || 'toolbar');
227
228
if (this.options.ariaLabel) {
229
this.actionsList.setAttribute('aria-label', this.options.ariaLabel);
230
}
231
232
this.domNode.appendChild(this.actionsList);
233
234
container.appendChild(this.domNode);
235
}
236
237
private refreshRole(): void {
238
if (this.length() >= 1) {
239
this.actionsList.setAttribute('role', this.options.ariaRole || 'toolbar');
240
} else {
241
this.actionsList.setAttribute('role', 'presentation');
242
}
243
}
244
245
setAriaLabel(label: string): void {
246
if (label) {
247
this.actionsList.setAttribute('aria-label', label);
248
} else {
249
this.actionsList.removeAttribute('aria-label');
250
}
251
}
252
253
// Some action bars should not be focusable at times
254
// When an action bar is not focusable make sure to make all the elements inside it not focusable
255
// When an action bar is focusable again, make sure the first item can be focused
256
setFocusable(focusable: boolean): void {
257
this.focusable = focusable;
258
if (this.focusable) {
259
const firstEnabled = this.viewItems.find(vi => vi instanceof BaseActionViewItem && vi.isEnabled());
260
if (firstEnabled instanceof BaseActionViewItem) {
261
firstEnabled.setFocusable(true);
262
}
263
} else {
264
this.viewItems.forEach(vi => {
265
if (vi instanceof BaseActionViewItem) {
266
vi.setFocusable(false);
267
}
268
});
269
}
270
}
271
272
private isTriggerKeyEvent(event: StandardKeyboardEvent): boolean {
273
let ret = false;
274
this._triggerKeys.keys.forEach(keyCode => {
275
ret = ret || event.equals(keyCode);
276
});
277
278
return ret;
279
}
280
281
private updateFocusedItem(): void {
282
for (let i = 0; i < this.actionsList.children.length; i++) {
283
const elem = this.actionsList.children[i];
284
if (DOM.isAncestor(DOM.getActiveElement(), elem)) {
285
this.focusedItem = i;
286
this.viewItems[this.focusedItem]?.showHover?.();
287
break;
288
}
289
}
290
}
291
292
get context(): unknown {
293
return this._context;
294
}
295
296
set context(context: unknown) {
297
this._context = context;
298
this.viewItems.forEach(i => i.setActionContext(context));
299
}
300
301
get actionRunner(): IActionRunner {
302
return this._actionRunner;
303
}
304
305
set actionRunner(actionRunner: IActionRunner) {
306
this._actionRunner = actionRunner;
307
308
// when setting a new `IActionRunner` make sure to dispose old listeners and
309
// start to forward events from the new listener
310
this._actionRunnerDisposables.clear();
311
this._actionRunnerDisposables.add(this._actionRunner.onDidRun(e => this._onDidRun.fire(e)));
312
this._actionRunnerDisposables.add(this._actionRunner.onWillRun(e => this._onWillRun.fire(e)));
313
this.viewItems.forEach(item => item.actionRunner = actionRunner);
314
}
315
316
getContainer(): HTMLElement {
317
return this.domNode;
318
}
319
320
hasAction(action: IAction): boolean {
321
return this.viewItems.findIndex(candidate => candidate.action.id === action.id) !== -1;
322
}
323
324
getAction(indexOrElement: number | HTMLElement): IAction | undefined {
325
326
// by index
327
if (typeof indexOrElement === 'number') {
328
return this.viewItems[indexOrElement]?.action;
329
}
330
331
// by element
332
if (DOM.isHTMLElement(indexOrElement)) {
333
while (indexOrElement.parentElement !== this.actionsList) {
334
if (!indexOrElement.parentElement) {
335
return undefined;
336
}
337
indexOrElement = indexOrElement.parentElement;
338
}
339
for (let i = 0; i < this.actionsList.childNodes.length; i++) {
340
if (this.actionsList.childNodes[i] === indexOrElement) {
341
return this.viewItems[i].action;
342
}
343
}
344
}
345
346
return undefined;
347
}
348
349
push(arg: IAction | ReadonlyArray<IAction>, options: IActionOptions = {}): void {
350
const actions: ReadonlyArray<IAction> = Array.isArray(arg) ? arg : [arg];
351
352
let index = types.isNumber(options.index) ? options.index : null;
353
354
actions.forEach((action: IAction) => {
355
const actionViewItemElement = document.createElement('li');
356
actionViewItemElement.className = 'action-item';
357
actionViewItemElement.setAttribute('role', 'presentation');
358
359
let item: IActionViewItem | undefined;
360
361
const viewItemOptions: IActionViewItemOptions = { hoverDelegate: this._hoverDelegate, ...options, isTabList: this.options.ariaRole === 'tablist' };
362
if (this.options.actionViewItemProvider) {
363
item = this.options.actionViewItemProvider(action, viewItemOptions);
364
}
365
366
if (!item) {
367
item = new ActionViewItem(this.context, action, viewItemOptions);
368
}
369
370
// Prevent native context menu on actions
371
if (!this.options.allowContextMenu) {
372
this.viewItemDisposables.set(item, DOM.addDisposableListener(actionViewItemElement, DOM.EventType.CONTEXT_MENU, (e: DOM.EventLike) => {
373
DOM.EventHelper.stop(e, true);
374
}));
375
}
376
377
item.actionRunner = this._actionRunner;
378
item.setActionContext(this.context);
379
item.render(actionViewItemElement);
380
381
if (index === null || index < 0 || index >= this.actionsList.children.length) {
382
this.actionsList.appendChild(actionViewItemElement);
383
this.viewItems.push(item);
384
} else {
385
this.actionsList.insertBefore(actionViewItemElement, this.actionsList.children[index]);
386
this.viewItems.splice(index, 0, item);
387
index++;
388
}
389
});
390
391
// We need to allow for the first enabled item to be focused on using tab navigation #106441
392
if (this.focusable) {
393
let didFocus = false;
394
for (const item of this.viewItems) {
395
if (!(item instanceof BaseActionViewItem)) {
396
continue;
397
}
398
399
let focus: boolean;
400
if (didFocus) {
401
focus = false; // already focused an item
402
} else if (item.action.id === Separator.ID) {
403
focus = false; // never focus a separator
404
} else if (!item.isEnabled() && this.options.focusOnlyEnabledItems) {
405
focus = false; // never focus a disabled item
406
} else {
407
focus = true;
408
}
409
410
if (focus) {
411
item.setFocusable(true);
412
didFocus = true;
413
} else {
414
item.setFocusable(false);
415
}
416
}
417
}
418
419
if (typeof this.focusedItem === 'number') {
420
// After a clear actions might be re-added to simply toggle some actions. We should preserve focus #97128
421
this.focus(this.focusedItem);
422
}
423
this.refreshRole();
424
}
425
426
getWidth(index: number): number {
427
if (index >= 0 && index < this.actionsList.children.length) {
428
const item = this.actionsList.children.item(index);
429
if (item) {
430
return item.clientWidth;
431
}
432
}
433
434
return 0;
435
}
436
437
getHeight(index: number): number {
438
if (index >= 0 && index < this.actionsList.children.length) {
439
const item = this.actionsList.children.item(index);
440
if (item) {
441
return item.clientHeight;
442
}
443
}
444
445
return 0;
446
}
447
448
pull(index: number): void {
449
if (index >= 0 && index < this.viewItems.length) {
450
this.actionsList.childNodes[index].remove();
451
this.viewItemDisposables.deleteAndDispose(this.viewItems[index]);
452
dispose(this.viewItems.splice(index, 1));
453
this.refreshRole();
454
}
455
}
456
457
clear(): void {
458
if (this.isEmpty()) {
459
return;
460
}
461
462
this.viewItems = dispose(this.viewItems);
463
this.viewItemDisposables.clearAndDisposeAll();
464
DOM.clearNode(this.actionsList);
465
this.refreshRole();
466
}
467
468
length(): number {
469
return this.viewItems.length;
470
}
471
472
isEmpty(): boolean {
473
return this.viewItems.length === 0;
474
}
475
476
focus(index?: number): void;
477
focus(selectFirst?: boolean): void;
478
focus(arg?: number | boolean): void {
479
let selectFirst: boolean = false;
480
let index: number | undefined = undefined;
481
if (arg === undefined) {
482
selectFirst = true;
483
} else if (typeof arg === 'number') {
484
index = arg;
485
} else if (typeof arg === 'boolean') {
486
selectFirst = arg;
487
}
488
489
if (selectFirst && typeof this.focusedItem === 'undefined') {
490
const firstEnabled = this.viewItems.findIndex(item => item.isEnabled());
491
// Focus the first enabled item
492
this.focusedItem = firstEnabled === -1 ? undefined : firstEnabled;
493
this.updateFocus(undefined, undefined, true);
494
} else {
495
if (index !== undefined) {
496
this.focusedItem = index;
497
}
498
499
this.updateFocus(undefined, undefined, true);
500
}
501
}
502
503
private focusFirst(): boolean {
504
this.focusedItem = this.length() - 1;
505
return this.focusNext(true);
506
}
507
508
private focusLast(): boolean {
509
this.focusedItem = 0;
510
return this.focusPrevious(true);
511
}
512
513
protected focusNext(forceLoop?: boolean, forceFocus?: boolean): boolean {
514
if (typeof this.focusedItem === 'undefined') {
515
this.focusedItem = this.viewItems.length - 1;
516
} else if (this.viewItems.length <= 1) {
517
return false;
518
}
519
520
const startIndex = this.focusedItem;
521
let item: IActionViewItem;
522
do {
523
524
if (!forceLoop && this.options.preventLoopNavigation && this.focusedItem + 1 >= this.viewItems.length) {
525
this.focusedItem = startIndex;
526
return false;
527
}
528
529
this.focusedItem = (this.focusedItem + 1) % this.viewItems.length;
530
item = this.viewItems[this.focusedItem];
531
} while (this.focusedItem !== startIndex && ((this.options.focusOnlyEnabledItems && !item.isEnabled()) || item.action.id === Separator.ID));
532
533
this.updateFocus(undefined, undefined, forceFocus);
534
return true;
535
}
536
537
protected focusPrevious(forceLoop?: boolean): boolean {
538
if (typeof this.focusedItem === 'undefined') {
539
this.focusedItem = 0;
540
} else if (this.viewItems.length <= 1) {
541
return false;
542
}
543
544
const startIndex = this.focusedItem;
545
let item: IActionViewItem;
546
547
do {
548
this.focusedItem = this.focusedItem - 1;
549
if (this.focusedItem < 0) {
550
if (!forceLoop && this.options.preventLoopNavigation) {
551
this.focusedItem = startIndex;
552
return false;
553
}
554
555
this.focusedItem = this.viewItems.length - 1;
556
}
557
item = this.viewItems[this.focusedItem];
558
} while (this.focusedItem !== startIndex && ((this.options.focusOnlyEnabledItems && !item.isEnabled()) || item.action.id === Separator.ID));
559
560
561
this.updateFocus(true);
562
return true;
563
}
564
565
protected updateFocus(fromRight?: boolean, preventScroll?: boolean, forceFocus: boolean = false): void {
566
if (typeof this.focusedItem === 'undefined') {
567
this.actionsList.focus({ preventScroll });
568
}
569
570
if (this.previouslyFocusedItem !== undefined && this.previouslyFocusedItem !== this.focusedItem) {
571
this.viewItems[this.previouslyFocusedItem]?.blur();
572
}
573
const actionViewItem = this.focusedItem !== undefined ? this.viewItems[this.focusedItem] : undefined;
574
if (actionViewItem) {
575
let focusItem = true;
576
577
if (!types.isFunction(actionViewItem.focus)) {
578
focusItem = false;
579
}
580
581
if (this.options.focusOnlyEnabledItems && types.isFunction(actionViewItem.isEnabled) && !actionViewItem.isEnabled()) {
582
focusItem = false;
583
}
584
585
if (actionViewItem.action.id === Separator.ID) {
586
focusItem = false;
587
}
588
if (!focusItem) {
589
this.actionsList.focus({ preventScroll });
590
this.previouslyFocusedItem = undefined;
591
} else if (forceFocus || this.previouslyFocusedItem !== this.focusedItem) {
592
actionViewItem.focus(fromRight);
593
this.previouslyFocusedItem = this.focusedItem;
594
}
595
if (focusItem) {
596
actionViewItem.showHover?.();
597
}
598
}
599
}
600
601
private doTrigger(event: StandardKeyboardEvent): void {
602
if (typeof this.focusedItem === 'undefined') {
603
return; //nothing to focus
604
}
605
606
// trigger action
607
const actionViewItem = this.viewItems[this.focusedItem];
608
if (actionViewItem instanceof BaseActionViewItem) {
609
const context = (actionViewItem._context === null || actionViewItem._context === undefined) ? event : actionViewItem._context;
610
this.run(actionViewItem._action, context);
611
}
612
}
613
614
async run(action: IAction, context?: unknown): Promise<void> {
615
await this._actionRunner.run(action, context);
616
}
617
618
override dispose(): void {
619
this._context = undefined;
620
this.viewItems = dispose(this.viewItems);
621
this.getContainer().remove();
622
super.dispose();
623
}
624
}
625
626
export function prepareActions(actions: IAction[]): IAction[] {
627
if (!actions.length) {
628
return actions;
629
}
630
631
// Clean up leading separators
632
let firstIndexOfAction = -1;
633
for (let i = 0; i < actions.length; i++) {
634
if (actions[i].id === Separator.ID) {
635
continue;
636
}
637
638
firstIndexOfAction = i;
639
break;
640
}
641
642
if (firstIndexOfAction === -1) {
643
return [];
644
}
645
646
actions = actions.slice(firstIndexOfAction);
647
648
// Clean up trailing separators
649
for (let h = actions.length - 1; h >= 0; h--) {
650
const isSeparator = actions[h].id === Separator.ID;
651
if (isSeparator) {
652
actions.splice(h, 1);
653
} else {
654
break;
655
}
656
}
657
658
// Clean up separator duplicates
659
let foundAction = false;
660
for (let k = actions.length - 1; k >= 0; k--) {
661
const isSeparator = actions[k].id === Separator.ID;
662
if (isSeparator && !foundAction) {
663
actions.splice(k, 1);
664
} else if (!isSeparator) {
665
foundAction = true;
666
} else if (isSeparator) {
667
foundAction = false;
668
}
669
}
670
671
return actions;
672
}
673
674