chrome-devtools-frontend
Version:
Chrome DevTools UI
270 lines (245 loc) • 10.3 kB
text/typescript
// Copyright 2026 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// Side-effect import: registers the `<devtools-menu-button>` custom element
// used by `PLUS_BUTTON_VIEW` below. The named imports are type-only.
import './ContextMenu.js';
import * as Host from '../../core/host/host.js';
import * as i18n from '../../core/i18n/i18n.js';
import type * as Platform from '../../core/platform/platform.js';
import {Directives, html, render} from '../lit/lit.js';
import type {ContextMenu, MenuButton} from './ContextMenu.js';
import type {View} from './View.js';
import {ViewLocationValues} from './ViewRegistration.js';
const UIStrings = {
/**
* @description Default tooltip / accessible name of the "plus" button shown
* after the visible tabs in a tab strip. Clicking it opens a menu listing
* tools that are not currently shown as a visible tab.
*/
moreTools: 'More tools',
} as const;
const str_ = i18n.i18n.registerUIStrings('ui/legacy/PlusButton.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
/** Declarative configuration for the plus button. */
export interface PlusButtonOptions {
title?: Platform.UIString.LocalizedString;
jslogContext?: string;
}
/**
* Minimal `TabbedPane` surface read by the populator. Defined as an
* interface so test doubles can satisfy it without an `as unknown as
* TabbedPane` double-cast.
*/
export interface PlusButtonTabbedPane {
element: HTMLElement;
hiddenTabs(): ReadonlyArray<{id: string, title: string, jslogContext?: string}>;
hasTab(id: string): boolean;
firstHiddenTabIndex(): number;
moveTab(tabId: string, newIndex: number): void;
selectTab(tabId: string, userGesture?: boolean, forceFocus?: boolean): boolean;
}
export interface PlusButtonMenuContext {
tabbedPane: PlusButtonTabbedPane;
location: string;
/**
* Production callers pass `() => location.views.values()` (NOT
* `manager.viewsForLocation(location)`) so views moved in via
* `appendView` are reflected immediately. Called fresh on every
* menu open.
*/
views: () => Iterable<View>;
manager: {
viewsForLocation(location: string): View[],
moveView(viewId: string, locationName: string): void,
};
showView: (view: View) => void;
}
interface AddToolEntry {
title: string;
jslogContext: string;
isPreviewFeature: boolean;
action: () => void;
}
export interface OverflowTabModel {
id: string;
title: string;
jslogContext?: string;
}
export interface PlusButtonMenuModel {
overflowTabs: readonly OverflowTabModel[];
addToolEntries: readonly AddToolEntry[];
}
/**
* Presenter (MVP) for the plus-button menu. {@link buildModel} is called
* fresh on every menu open so newly-registered views — or views that
* just left the visible tab strip — are reflected immediately.
*/
export class PlusButtonPresenter {
readonly #context: PlusButtonMenuContext;
constructor(context: PlusButtonMenuContext) {
this.#context = context;
}
buildModel(): PlusButtonMenuModel {
const {tabbedPane, location, views, manager} = this.#context;
const overflowTabs: readonly OverflowTabModel[] =
tabbedPane.hiddenTabs().map(tab => ({id: tab.id, title: tab.title, jslogContext: tab.jslogContext}));
const addToolEntries: AddToolEntry[] = [];
// Seed dedup sets from the overflowed tabs so an addable entry
// (e.g. a closeable view in the other main location) sharing an id
// or title with an overflowed tab is not listed twice.
const seenIds = new Set<string>(overflowTabs.map(tab => tab.id));
const seenTitles = new Set<string>(overflowTabs.map(tab => tab.title));
for (const view of views()) {
// Skip views that already have a tab. Hidden tabs are already listed
// in the overflow section above, and visible tabs are accessible
// directly in the tab strip.
if (tabbedPane.hasTab(view.viewId())) {
continue;
}
// Transient views are not user-addable.
if (view.isTransient()) {
continue;
}
if (seenIds.has(view.viewId()) || seenTitles.has(view.title())) {
continue;
}
seenIds.add(view.viewId());
seenTitles.add(view.title());
const isIssuesPane = view.viewId() === 'issues-pane';
addToolEntries.push({
title: view.title(),
jslogContext: view.viewId(),
isPreviewFeature: view.isPreviewFeature(),
action: () => {
if (isIssuesPane) {
// Distinct from `HAMBURGER_MENU` so plus-button opens are
// not conflated with three-dot-menu opens in the dashboard.
Host.userMetrics.issuesPanelOpenedFrom(Host.UserMetrics.IssueOpener.MORE_TOOLS_MENU);
}
this.#context.showView(view);
},
});
}
// Offer cross-location moves between the two main surfaces: the
// panel plus button lists drawer views and vice versa.
const otherLocation = location === ViewLocationValues.PANEL ? ViewLocationValues.DRAWER_VIEW :
location === ViewLocationValues.DRAWER_VIEW ? ViewLocationValues.PANEL :
null;
if (otherLocation) {
for (const view of manager.viewsForLocation(otherLocation)) {
// Non-closeable views (e.g. Console) cannot be moved between
// locations, so they're excluded here. They still appear in the
// overflow section when their own location's tab strip overflows.
if (view.isTransient() || !view.isCloseable() || seenIds.has(view.viewId()) || seenTitles.has(view.title())) {
continue;
}
seenIds.add(view.viewId());
seenTitles.add(view.title());
const viewId = view.viewId();
addToolEntries.push({
title: view.title(),
jslogContext: viewId,
isPreviewFeature: view.isPreviewFeature(),
action: () => manager.moveView(viewId, location),
});
}
}
addToolEntries.sort((a, b) => a.title.localeCompare(b.title));
return {overflowTabs, addToolEntries};
}
}
/**
* Renders the plus-button menu by asking {@link PlusButtonPresenter}
* for a model and pushing it into `contextMenu`. Overflowed tabs (in
* tab order) come first, followed by deduplicated "add tool" entries
* sorted alphabetically.
*/
export function populatePlusButtonMenu(contextMenu: ContextMenu, context: PlusButtonMenuContext): void {
const model = new PlusButtonPresenter(context).buildModel();
const hasOverflow = model.overflowTabs.length > 0;
// When there are no overflowed tabs, surface the add-tool entries in
// the default section so they are not visually demoted to a footer.
for (const tab of model.overflowTabs) {
contextMenu.defaultSection().appendItem(
tab.title, () => revealOverflowTab(context.tabbedPane, tab.id), {jslogContext: tab.jslogContext ?? tab.id});
}
const addToolSection = hasOverflow ? contextMenu.footerSection() : contextMenu.defaultSection();
for (const entry of model.addToolEntries) {
addToolSection.appendItem(
entry.title, entry.action, {isPreviewFeature: entry.isPreviewFeature, jslogContext: entry.jslogContext});
}
}
/**
* Reveals an overflowed tab and persists its new position via
* `moveTab(firstHidden - 1)` so the tab stays in the visible region
* after a reload — independent of any runtime `currentTab` /
* `lastSelectedOverflowTab` priority logic. The previously-last-visible
* tab is pushed to the start of the overflow region, matching the
* intuition that the newly opened tab replaces the one the user
* implicitly stopped using.
*
* Exported only for testing.
*/
export function revealOverflowTab(tabbedPane: PlusButtonTabbedPane, tabId: string): void {
const firstHidden = tabbedPane.firstHiddenTabIndex();
if (firstHidden > 0) {
// `firstHidden - 1` is the index of the last currently-visible tab.
tabbedPane.moveTab(tabId, firstHidden - 1);
}
tabbedPane.selectTab(tabId, /* userGesture */ true, /* forceFocus */ true);
}
interface PlusButtonViewInput {
title: string;
jslogContext: string;
populateMenuCall: (menu: ContextMenu) => void;
}
/**
* Standard `(input, output, target)` view function so `Lit.render` is
* called inside a view (per `@devtools/no-lit-render-outside-of-view`).
* `output.button` is captured via `ref` to avoid a `querySelector`
* round-trip in {@link installPlusButton}.
*
* `slot` is set declaratively in the template so the attribute is
* present on the very first connection — the first `slotchange` then
* sees the button as the trailing-slot target and no extra layout pass
* is needed.
*/
export const PLUS_BUTTON_VIEW =
(input: PlusButtonViewInput, output: {button?: MenuButton}, target: HTMLElement): void => {
render(
html`
<devtools-menu-button
${Directives.ref(el => {
output.button = el as MenuButton | undefined;
})}
slot="trailing-button"
.iconName=${'plus'}
.title=${input.title}
.jslogContext=${input.jslogContext}
.populateMenuCall=${input.populateMenuCall}>
</devtools-menu-button>`,
target);
};
/**
* Renders a `<devtools-menu-button>` configured as the plus button into
* `tabbedPane`'s `trailing-button` slot and returns the slotted host.
* The returned `MenuButton` is used by the next CL to toggle visibility
* (e.g. when the drawer is minimized).
*/
export function installPlusButton(context: PlusButtonMenuContext, options: PlusButtonOptions = {}): MenuButton {
const output: {button?: MenuButton} = {};
// `render` is synchronous and the `ref` directive fires during render,
// so `output.button` is assigned by the time the view returns.
PLUS_BUTTON_VIEW(
{
title: options.title ?? i18nString(UIStrings.moreTools),
jslogContext: options.jslogContext ?? '',
populateMenuCall: menu => populatePlusButtonMenu(menu, context),
},
output, context.tabbedPane.element);
if (!output.button) {
throw new Error('installPlusButton: ref directive did not capture <devtools-menu-button>');
}
return output.button;
}