Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/actionWidget/browser/tabbedActionListWidget.ts
13397 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 '../../../base/browser/dom.js';
7
import { IListAccessibilityProvider } from '../../../base/browser/ui/list/listWidget.js';
8
import { Radio } from '../../../base/browser/ui/radio/radio.js';
9
import { KeyCode } from '../../../base/common/keyCodes.js';
10
import { Disposable, DisposableStore, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js';
11
import { IContextViewService } from '../../contextview/browser/contextView.js';
12
import { IInstantiationService } from '../../instantiation/common/instantiation.js';
13
import { ActionList, IActionListDelegate, IActionListItem, IActionListOptions } from './actionList.js';
14
import './tabbedActionListWidget.css';
15
16
/**
17
* Result of {@link ITabbedActionListShowOptions.buildItems}. The list
18
* options are recomputed on every tab switch so callers can vary filter
19
* visibility, width, etc. by tab.
20
*/
21
export interface ITabbedActionListBuildResult<T> {
22
readonly items: readonly IActionListItem<T>[];
23
readonly listOptions?: IActionListOptions;
24
}
25
26
/**
27
* Options for {@link TabbedActionListWidget.show}. The widget renders a
28
* tab bar above an `ActionList` inside a single popup. Consumers describe
29
* how to compute items for each tab; the widget handles tab switching and
30
* lifecycle internally.
31
*/
32
export interface ITabbedActionListShowOptions<T> {
33
/** Logical user / source identifier passed through to {@link ActionList}. */
34
readonly user: string;
35
/** Element the popup is anchored to. */
36
readonly anchor: HTMLElement;
37
/** Tab labels rendered in order. Localize at the call site. */
38
readonly tabs: readonly string[];
39
/** Initially active tab. Must be present in {@link tabs}. */
40
readonly initialTab: string;
41
/** Computes the list items and per-tab options shown when the given tab is active. */
42
buildItems(activeTab: string): ITabbedActionListBuildResult<T>;
43
/** Item delegate (selection, hide, focus). */
44
readonly delegate: IActionListDelegate<T>;
45
/** Optional accessibility provider passed to the underlying list. */
46
readonly accessibilityProvider?: Partial<IListAccessibilityProvider<IActionListItem<T>>>;
47
/** Optional fixed popup width. */
48
readonly width?: number;
49
/** Optional class name to add to the tab bar element (in addition to `.tabbed-action-list-tabbar`). Must be a single class. */
50
readonly tabBarClassName?: string;
51
/** Fired with the new tab when the user switches tabs. */
52
onDidChangeTab?(tab: string): void;
53
/** Fired when the popup hides for any reason. */
54
onHide?(): void;
55
}
56
57
/**
58
* Composite popup widget that renders a horizontal tab bar above an
59
* {@link ActionList}. Owns its own context-view lifecycle and swap state;
60
* consumers describe the data and react to tab changes via callbacks.
61
*
62
* Bypasses `IActionWidgetService` so this widget can compose with any
63
* caller-driven state without extending the platform action widget API.
64
*/
65
export class TabbedActionListWidget extends Disposable {
66
67
private readonly _activePopup = this._register(new MutableDisposable());
68
private _swappingTab = false;
69
70
get isVisible(): boolean {
71
return !!this._activePopup.value;
72
}
73
74
constructor(
75
@IContextViewService private readonly _contextViewService: IContextViewService,
76
@IInstantiationService private readonly _instantiationService: IInstantiationService,
77
) {
78
super();
79
}
80
81
/**
82
* Shows the popup anchored to {@link ITabbedActionListShowOptions.anchor}.
83
* If a popup is already visible, it is replaced in place.
84
*/
85
show<T>(options: ITabbedActionListShowOptions<T>): void {
86
const isSwap = this.isVisible;
87
if (isSwap) {
88
this._swappingTab = true;
89
this._activePopup.value = undefined;
90
}
91
92
let activeTab = options.initialTab;
93
const popupDisposables = new DisposableStore();
94
95
const hide = () => {
96
if (this._activePopup.value === popupDisposables) {
97
this._activePopup.value = undefined;
98
}
99
};
100
101
// Reserve the disposable slot up-front so any synchronous hide
102
// triggered during render (e.g. an immediate selection) finds the
103
// expected disposable to clear.
104
this._activePopup.value = popupDisposables;
105
popupDisposables.add(toDisposable(() => {
106
this._contextViewService.hideContextView();
107
}));
108
109
let listRef: ActionList<T> | undefined;
110
111
this._contextViewService.showContextView({
112
getAnchor: () => options.anchor,
113
render: (container: HTMLElement) => {
114
const renderDisposables = new DisposableStore();
115
116
const widget = dom.append(container, dom.$('.action-widget'));
117
118
const tabBar = dom.append(widget, dom.$('.tabbed-action-list-tabbar'));
119
if (options.tabBarClassName) {
120
tabBar.classList.add(options.tabBarClassName);
121
}
122
const radio = renderDisposables.add(new Radio({
123
items: options.tabs.map(t => ({ text: t, tooltip: t, isActive: t === activeTab })),
124
}));
125
tabBar.appendChild(radio.domNode);
126
127
const activateTab = (next: string) => {
128
if (next === activeTab) {
129
return;
130
}
131
activeTab = next;
132
options.onDidChangeTab?.(next);
133
this.show({ ...options, initialTab: next });
134
};
135
136
renderDisposables.add(radio.onDidSelect(index => {
137
const next = options.tabs[index];
138
if (next) {
139
activateTab(next);
140
}
141
}));
142
143
const { items, listOptions } = options.buildItems(activeTab);
144
const list = renderDisposables.add(this._instantiationService.createInstance(
145
ActionList<T>,
146
options.user,
147
false,
148
items,
149
options.delegate,
150
options.accessibilityProvider,
151
listOptions,
152
options.anchor,
153
));
154
listRef = list;
155
156
if (list.filterContainer) {
157
widget.appendChild(list.filterContainer);
158
}
159
widget.appendChild(list.domNode);
160
161
const width = list.layout(0);
162
widget.style.width = `${options.width ?? width}px`;
163
list.focus();
164
165
// Keyboard nav. Bound to the popup widget so we don't
166
// observe unrelated document-wide keypresses.
167
renderDisposables.add(dom.addStandardDisposableListener(widget, 'keydown', e => {
168
const target = e.target as HTMLElement | null;
169
const onTabBar = !!target?.closest('.tabbed-action-list-tabbar');
170
const onEditable = !!target?.closest('input, textarea, [contenteditable="true"]');
171
172
if (e.keyCode === KeyCode.Escape) {
173
dom.EventHelper.stop(e, true);
174
hide();
175
return;
176
}
177
if (e.keyCode === KeyCode.Enter && !onTabBar) {
178
dom.EventHelper.stop(e, true);
179
list.acceptSelected();
180
return;
181
}
182
if (e.keyCode === KeyCode.UpArrow && !onTabBar) {
183
dom.EventHelper.stop(e, true);
184
list.focusPrevious();
185
return;
186
}
187
if (e.keyCode === KeyCode.DownArrow && !onTabBar) {
188
dom.EventHelper.stop(e, true);
189
list.focusNext();
190
return;
191
}
192
if (e.keyCode !== KeyCode.LeftArrow && e.keyCode !== KeyCode.RightArrow) {
193
return;
194
}
195
if (onEditable && !onTabBar) {
196
return;
197
}
198
const currentIndex = options.tabs.indexOf(activeTab);
199
if (currentIndex < 0) {
200
return;
201
}
202
const delta = e.keyCode === KeyCode.RightArrow ? 1 : -1;
203
const nextIndex = (currentIndex + delta + options.tabs.length) % options.tabs.length;
204
e.preventDefault();
205
e.stopPropagation();
206
activateTab(options.tabs[nextIndex]);
207
}));
208
209
// Dismiss when focus leaves the popup. Suppressed during a
210
// tab swap so the teardown of the previous popup doesn't
211
// take the new one down with it.
212
const focusTracker = renderDisposables.add(dom.trackFocus(container));
213
renderDisposables.add(focusTracker.onDidBlur(() => {
214
if (this._swappingTab) {
215
return;
216
}
217
const activeElement = dom.getActiveElement();
218
if (activeElement && (activeElement.closest('.action-widget-hover') || activeElement.closest('.action-list-submenu-panel'))) {
219
return;
220
}
221
hide();
222
}));
223
224
return renderDisposables;
225
},
226
onHide: () => {
227
listRef = undefined;
228
// Skip consumer callbacks during a tab swap — we are about
229
// to re-show with the same anchor, so the consumer should
230
// not e.g. refocus the trigger button between hide and show.
231
if (this._swappingTab) {
232
return;
233
}
234
// External dismissal (Escape, click outside) — clear our
235
// own tracker so `isVisible` reflects reality. Done before
236
// firing consumer callbacks in case they re-show.
237
if (this._activePopup.value === popupDisposables) {
238
this._activePopup.value = undefined;
239
}
240
options.delegate.onHide?.();
241
options.onHide?.();
242
},
243
get anchorPosition() { return listRef?.anchorPosition; },
244
}, undefined, false);
245
246
if (isSwap) {
247
this._swappingTab = false;
248
}
249
}
250
251
hide(): void {
252
this._activePopup.value = undefined;
253
}
254
255
override dispose(): void {
256
this._activePopup.value = undefined;
257
super.dispose();
258
}
259
}
260
261