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