chrome-devtools-frontend
Version:
Chrome DevTools UI
230 lines (204 loc) • 7.75 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.
/* eslint-disable @devtools/no-imperative-dom-api */
import type * as Lit from '../../ui/lit/lit.js';
import {createIcon, type Icon} from '../kit/kit.js';
import * as VisualLogging from '../visual_logging/visual_logging.js';
import * as ARIAUtils from './ARIAUtils.js';
import type * as Toolbar from './Toolbar.js';
import {createTextChild} from './UIUtils.js';
import type {View} from './View.js';
import viewContainersStyles from './viewContainers.css.js';
import {type AnyWidget, VBox} from './Widget.js';
type CreateToolbarFn = (toolbarItems: Toolbar.ToolbarItem[]|Lit.TemplateResult) => Element|null;
type SetWidgetForViewFn = (view: View, widget: AnyWidget) => void;
export class ExpandableContainerWidget extends VBox {
private titleElement: HTMLDivElement;
private readonly titleExpandIcon: Icon;
private readonly view: View;
private widget?: AnyWidget;
private materializePromise?: Promise<void>;
constructor(
view: View,
private readonly createToolbar: CreateToolbarFn,
private readonly setWidgetForView: SetWidgetForViewFn,
private readonly onVisibilityChanged?: (isExpanded: boolean) => void,
) {
super({useShadowDom: true});
this.element.classList.add('flex-none');
this.registerRequiredCSS(viewContainersStyles);
this.onVisibilityChanged = onVisibilityChanged;
this.createToolbar = createToolbar;
this.titleElement = document.createElement('div');
this.titleElement.classList.add('expandable-view-title');
this.titleElement.setAttribute('jslog', `${VisualLogging.sectionHeader().context(view.viewId()).track({
click: true,
keydown: 'Enter|Space|ArrowLeft|ArrowRight',
})}`);
ARIAUtils.markAsTreeitem(this.titleElement);
this.titleExpandIcon = createIcon('triangle-right', 'title-expand-icon');
this.titleElement.appendChild(this.titleExpandIcon);
const titleText = view.title();
createTextChild(this.titleElement, titleText);
ARIAUtils.setLabel(this.titleElement, titleText);
ARIAUtils.setExpanded(this.titleElement, false);
this.titleElement.tabIndex = 0;
self.onInvokeElement(this.titleElement, this.toggleExpanded.bind(this));
this.titleElement.addEventListener('keydown', this.onTitleKeyDown.bind(this), false);
this.contentElement.insertBefore(this.titleElement, this.contentElement.firstChild);
ARIAUtils.setControls(this.titleElement, this.contentElement.createChild('slot'));
this.view = view;
expandableContainerForView.set(view, this);
}
isExpanded(): boolean {
return this.titleElement.classList.contains('expanded');
}
override wasShown(): void {
super.wasShown();
if (this.widget && this.materializePromise) {
void this.materializePromise.then(() => {
if (this.isExpanded() && this.widget) {
this.widget.show(this.element);
}
});
}
}
private materialize(): Promise<void> {
if (this.materializePromise) {
return this.materializePromise;
}
// TODO(crbug.com/1006759): Transform to async-await
const promises = [];
promises.push(this.view.toolbarItems().then(toolbarItems => {
const toolbarElement = this.createToolbar(toolbarItems);
if (toolbarElement) {
this.titleElement.appendChild(toolbarElement);
}
}));
promises.push(this.view.widget().then(widget => {
this.widget = widget;
this.setWidgetForView(this.view, widget);
}));
this.materializePromise = Promise.all(promises).then(() => {});
return this.materializePromise;
}
expand(): Promise<void> {
if (this.isExpanded()) {
return this.materialize();
}
this.titleElement.classList.add('expanded');
ARIAUtils.setExpanded(this.titleElement, true);
this.titleExpandIcon.name = 'triangle-down';
this.onVisibilityChanged?.(true);
return this.materialize().then(() => {
if (this.isExpanded() && this.widget) {
this.widget.show(this.element);
}
});
}
private collapse(): void {
if (!this.isExpanded()) {
return;
}
this.titleElement.classList.remove('expanded');
ARIAUtils.setExpanded(this.titleElement, false);
this.titleExpandIcon.name = 'triangle-right';
this.onVisibilityChanged?.(false);
void this.materialize().then(() => {
if (this.widget) {
this.widget.detach();
}
});
}
private toggleExpanded(event: Event): void {
if (event.type === 'keydown' && event.target !== this.titleElement) {
return;
}
if (this.isExpanded()) {
this.collapse();
} else {
void this.expand();
}
}
private onTitleKeyDown(event: Event): void {
if (event.target !== this.titleElement) {
return;
}
const keyEvent = (event as KeyboardEvent);
if (keyEvent.key === 'ArrowLeft') {
this.collapse();
} else if (keyEvent.key === 'ArrowRight') {
if (!this.isExpanded()) {
void this.expand();
} else if (this.widget) {
this.widget.focus();
}
}
}
}
const expandableContainerForView = new WeakMap<View, ExpandableContainerWidget>();
export class StackedPane extends VBox {
readonly expandableContainers = new Map<string, ExpandableContainerWidget>();
constructor(
private readonly createToolbar: CreateToolbarFn,
private readonly setWidgetForView: SetWidgetForViewFn,
private readonly onViewVisibilityChanged?: (viewId: string, isExpanded: boolean) => void,
) {
super();
this.createToolbar = createToolbar;
this.onViewVisibilityChanged = onViewVisibilityChanged;
ARIAUtils.markAsTree(this.element);
}
appendView(view: View, insertBefore?: View|null): void {
let container = this.expandableContainers.get(view.viewId());
if (!container) {
container =
new ExpandableContainerWidget(view, this.createToolbar, this.setWidgetForView,
isExpanded => this.onViewVisibilityChanged?.(view.viewId(), isExpanded));
let beforeElement: Node|null = null;
if (insertBefore) {
const beforeContainer = expandableContainerForView.get(insertBefore);
beforeElement = beforeContainer ? beforeContainer.element : null;
}
container.show(this.contentElement, beforeElement);
this.expandableContainers.set(view.viewId(), container);
}
}
override wasShown(): void {
super.wasShown();
for (const [viewId, container] of this.expandableContainers) {
if (container.isExpanded()) {
this.onViewVisibilityChanged?.(viewId, true);
}
}
}
override willHide(): void {
super.willHide();
for (const [viewId, container] of this.expandableContainers) {
if (container.isExpanded()) {
this.onViewVisibilityChanged?.(viewId, false);
}
}
}
removeView(view: View): void {
const container = this.expandableContainers.get(view.viewId());
if (container) {
container.detach();
this.expandableContainers.delete(view.viewId());
}
}
async expandView(view: View): Promise<void> {
const container = this.expandableContainers.get(view.viewId());
if (container) {
await container.expand();
}
}
isViewExpanded(viewId: string): boolean {
const container = this.expandableContainers.get(viewId);
return container ? container.isExpanded() : false;
}
getContainerForView(view: View): ExpandableContainerWidget|undefined {
return this.expandableContainers.get(view.viewId());
}
}