- name
- devtools-ui-widgets
- description
- Guidelines for building UI widgets using the MVP architecture in DevTools. Covers Widget lifecycle, lit-html views, and state management.
## UI Widget Framework Guide (MVP Architecture)
Adhere strictly to the Model-View-Presenter (MVP) architecture.
* **Models** (`front_end/models/`): Handle business logic and application state. MUST NOT have knowledge of the UI.
* **Presenters (`Widget`)**: Orchestrate data flow, manage UI-specific state, and handle interactions.
* **Views (`function`)**: Purely presentational. Stateless functions that receive data/callbacks and render using `lit-html`.
**Refactoring Note:** When refactoring legacy widgets to this framework, the widget's class name MUST NOT be changed. If you're refactoring a UI piece that uses shadow root (e.g. custom HTMLElements) to a Widget, make sure to update its styles to wrap the rules with a `@scope to (devtools-widget > *)` rule.
### Presenter (`Widget`) Rules
* Location: Co-located in the same file as its View.
* MUST extend a base `UI.Widget` class (e.g., `UI.Widget.Widget`). Note that `UI.Widget.Widget` is not an `HTMLElement` and must be appended via `.show()` or `UI.Widget.widget`
* Constructor MUST assign the injected view function to a private `#view` field.
* Constructor MUST call `super()`. If taking `element?: HTMLElement`, pass it to `super(element)`. `super(true)` is forbidden.
* Dependencies from `Universe` SHOULD be requested via static `INJECT`: `static override readonly INJECT = [...] as const;` and received as constructor parameter #2 (`[dep1, dep2]: UI.Widget.WidgetDependencies<typeof MyWidget>`).
* Styling MUST be handled within the View. `this.registerCSSFiles()` is forbidden.
* Initial render MUST be triggered in `override wasShown(): void` by calling `this.requestUpdate()`.
* MUST hold all UI state as private class fields (e.g., `#counter`).
* To schedule a UI update, MUST call `this.requestUpdate()`. `performUpdate()` MUST NOT be called directly.
### View (`function`) Rules
* MUST be a pure function, typically named `DEFAULT_VIEW`, in the same file as the Presenter.
* MUST use the signature: `(input: ViewInput, output: ViewOutput, target: HTMLElement)`.
* `input`: Data and event handlers from the Presenter.
* `output`: Callbacks for imperative actions (e.g., focus) provided to the Presenter. If unused, the `output` parameter should be typed as `undefined` in the view's signature, and `undefined` should be passed from the presenter.
* `target`: The HTML element to render into.
* The `input` parameter MUST NOT be destructured. Always use `input.propertyName`.
* Responsibility: Render a `lit-html` template into `target`.
* MUST NOT hold state, fetch data, or perform business logic.
#### Helper Functions in Views
Complex views can be broken down using helper functions defined inside `DEFAULT_VIEW` to access `input` and `output`.
```ts
// clang-format off
const DEFAULT_VIEW = (input: ViewInput, output: ViewOutput, target: HTMLElement): void => {
const renderHeader = (): LitTemplate => {
return html`<h1>${input.title}</h1>`;
};
// ...
render(html`
<div class="child-widget">
${renderHeader()}
</div>
`, target);
};
// clang-format on
```
### Composition
* To render a child widget, the parent's View MUST use the lit-html directive `UI.Widget.widget`.
* **Prefer `${widget(WidgetClass, {params})}` directly as a child expression** over `<devtools-widget ${widget(WidgetClass, {params})}></devtools-widget>`. Only use an explicit `<devtools-widget>` tag when you need to set other HTML attributes/classes, apply additional directives (such as `widgetRef` or `ref`), or provide child nodes.
* **Strongly prefer the two-argument `widget(WidgetClass, {params})` form** over the single-argument factory callback `widget(element => new WidgetClass(element, ...))`. The two-argument form automatically updates properties and calls `requestUpdate()` on subsequent renders, whereas a factory callback only runs once on initial instantiation.
* If a child widget currently exposes imperative setter methods (e.g., `setStorageKey(key)` or `setStorageBucket(bucket)`) instead of public properties/setters, convert them into standard TypeScript setters (`set storageKey(key)`, `set storageBucket(bucket)`) on the child widget so it can be instantiated and updated declaratively via the two-argument `widget(WidgetClass, {params})` directive.
* Configuration is passed via the parameters.
* Properties passed from a parent MUST be declared as public fields or setters on the child presenter class.
* The framework automatically updates these properties and calls `requestUpdate()` on the child when the parent re-renders.
* **Pre-existing Widget Instances (`WrapperWidget`):** To render an existing `UI.Widget` instance (such as locations created by `UI.ViewManager`: `TabbedViewLocation.tabbedPane()`, `StackLocation.widget()`) declaratively without creating a custom wrapper class, use `${widget(UI.Widget.WrapperWidget, {widget: myWidgetInstance})}`. `WrapperWidget` automatically forwards constraints, handles attach/detach lifecycle, and supports dynamic widget replacement.
## Refactoring Legacy Components
When migrating imperative components (extending `UI.VBox`, `UI.Panel`, or `HTMLElement`) to the MVP architecture:
1. **Analyze Rendering Logic:** Identify where DOM is created (constructor, `wasShown`, imperative methods). This logic moves to the `DEFAULT_VIEW`.
2. **Convert Base Class:**
* Prefer extending `UI.Widget.Widget`.
* **Special Case:** If the component *must* extend `UI.Panel.Panel` or `UI.Dialog.Dialog` (to retain specific functionality), you cannot use `requestUpdate()`. Instead, call `this.performUpdate()` directly on state changes.
3. **State Migration:** Move DOM-stored state to private class fields.
4. **Update Usage:** Replace `new MyComponent()` instantiations with declarative `widget(MyComponent, {params})` in parent templates.
5. **Scoping Styles:** Ensure your CSS file uses the `@scope` block to prevent style leaks:
```css
@scope to (devtools-widget > *) {
...
}
```
## Key Implementation Details (Gotchas)
### Styling Strategy
DevTools widgets typically render into Light DOM (the default for `UI.Widget`). To ensure styles are scoped to the component and do not leak into child widgets, wrap your CSS in an `@scope` block:
```css
/* myWidget.css */
@scope to (devtools-widget > *) {
.my-class { ... }
}
```
Then, include the styles in your `DEFAULT_VIEW`:
```ts
import myWidgetStyles from './myWidget.css.js';
// ...
render(html`
<style>${myWidgetStyles}</style>
<div class="my-widget">...</div>
`, target);
```
**Note:** The `{host: input}` option in `render()` is generally **not required** unless you are using specific Shadow DOM patterns that necessitate it. The `@scope` strategy is the preferred method for style isolation in DevTools widgets.
### Legacy Interop & Refs
* **Raw Elements:** Use `Directives.ref` (from `ui/lit/lit.js`) to obtain a reference to a raw `HTMLElement` if needed for imperative APIs (e.g., `splitWidget.installResizer(element)`).
* **Child Widgets:** Use `UI.Widget.widgetRef` to obtain the class instance of a child widget inside the view function if you need to call imperative methods on it. **Do NOT store child widget instances on `ViewOutput` as a final result** (doing so is only acceptable temporarily in intermediate migration CLs). Instead, either supply all needed configuration declaratively through setters/inputs on the child widget, or if imperative calls are strictly necessary, expose narrow action functions on `ViewOutput` (e.g., `output.doLayout = () => splitWidget.doLayout()`, `output.revealPosition = pos => childWidget.revealPosition(pos)`). Do not keep `get*ForTest()` methods that return child widget instances via `ViewOutput`; tests should query rendered `<devtools-widget>` elements via `.getWidget()` or assert on `ViewInput` via `createViewFunctionStub`.
### Dependencies
* **View Dependencies:** The `DEFAULT_VIEW` is typically a module-level function. Ensure all dependencies (enums, constants, other components) are imported at the top of the file so they are available in the function scope.
* **Presenter (`Universe`) Dependencies:** To access dependencies managed by `Universe` (e.g. `SDK.TargetManager.TargetManager`, `Common.Settings.Settings`), declare a static `INJECT` array on the `Widget` subclass:
```ts
export class MyWidget extends UI.Widget.Widget {
static override readonly INJECT = [SDK.TargetManager.TargetManager, Common.Settings.Settings] as const;
#targetManager: SDK.TargetManager.TargetManager;
#settings: Common.Settings.Settings;
#view: View;
constructor(
element?: HTMLElement,
[targetManager, settings]: UI.Widget.WidgetDependencies<typeof MyWidget> = [],
view: View = DEFAULT_VIEW,
) {
super(element);
this.#targetManager = targetManager;
this.#settings = settings;
this.#view = view;
}
}
```
`instantiateWidget` automatically resolves dependencies from `Universe` and passes them as constructor parameter #2. In unit tests using `renderElementIntoDOM`, `TestUniverse` handles injection automatically (override via `setTestUniverseForWidgets`).
## Step-by-Step Implementation Example
### 1\. Create the Widget File and Styles
Example: `front_end/panels/my_example/MyExampleWidget.ts`.
```css
/* front_end/panels/my_example/myExampleWidget.css */
@scope to (devtools-widget > *) {
p {
color: blue;
}
}
```
```ts
// front_end/panels/my_example/MyExampleWidget.ts
import {Directives, html, render} from '../../ui/lit/lit.js';
import * as UI from '../../ui/legacy/legacy.js';
import myExampleWidgetStyles from './myExampleWidget.css.js';
const {ref} = Directives;
// 1. Define Input data shape. (Assuming Child Widget configuration for completeness)
export interface ViewInput {
title: string;
counter: number;
onButtonClick: () => void;
}
// 2. Define Output imperative API shape.
export interface ViewOutput {
focusButton?: () => void;
}
// 3. Define the View function.
// clang-format off
const DEFAULT_VIEW = (input: ViewInput, output: ViewOutput, target: HTMLElement): void => {
render(html`
<style>${myExampleWidgetStyles}</style>
<div class="child-widget">
<h3>${input.title}</h3>
<p>Counter: ${input.counter}</p>
<button @click=${input.onButtonClick} ${ref(el => {
if (el) {
output.focusButton = () => el.focus();
}
})}>Increment</button>
</div>
`, target);
};
// clang-format on
// 4. Type alias for the view.
type View = typeof DEFAULT_VIEW;
// 5. Define the Presenter.
export class MyExampleWidget extends UI.Widget.Widget {
#counter: number = 0;
// Public field REQUIRED to receive data from a parent.
title: string = 'Default Title';
#view: View;
#viewOutput: ViewOutput = {};
constructor(element?: HTMLElement, view: View = DEFAULT_VIEW) {
super(element);
this.#view = view;
}
override wasShown(): void {
super.wasShown();
// Initial render trigger.
this.requestUpdate();
}
focus(): void {
this.#viewOutput.focusButton?.();
}
#handleButtonClick = (): void => {
this.#counter++;
this.requestUpdate();
}
override performUpdate(): void {
const viewInput = {
title: this.title,
counter: this.#counter,
onButtonClick: this.#handleButtonClick,
};
this.#view(viewInput, this.#viewOutput, this.contentElement);
}
}
```
### 2\. Compose Widgets (Parent Widget)
Example: `front_end/panels/parent/ParentWidget.ts`. Demonstrates passing state via `widgetConfig` and a view with no `output`.
```ts
// front_end/panels/parent/ParentWidget.ts
import {html, render} from '../../ui/lit/lit.js';
import * as UI from '../../ui/legacy/legacy.js';
import {MyExampleWidget} from '../my_example/MyExampleWidget.ts';
import parentWidgetStyles from './parentWidgetStyles.css';
const {widget} = UI.Widget;
interface ViewInput {
title: string;
onTitleChange: () => void;
}
// Parent View
// clang-format off
const DEFAULT_VIEW = (input: ViewInput, output: undefined, target: HTMLElement): void => {
render(html`
<style>${parentWidgetStyles}</style>
<div class="parent-container">
<h1>Parent Widget</h1>
<button @click=${input.onTitleChange}>Change Child Title</button>
<!-- Pass properties to the child widget. -->
${widget(MyExampleWidget, {title: input.title})}
</div>
`, target);
};
// clang-format on
type View = typeof DEFAULT_VIEW;
// Parent Presenter
export class ParentWidget extends UI.Widget.Widget {
#childTitle: string = 'Initial Title';
#view: View;
constructor(element?: HTMLElement, view: View = DEFAULT_VIEW) {
super(element);
this.#view = view;
}
override wasShown(): void {
super.wasShown();
this.requestUpdate();
}
#handleChangeTitleClick = () => {
this.#childTitle = `Title set at ${new Date().toLocaleTimeString()}`;
this.requestUpdate();
}
override performUpdate(): void {
this.#view({
title: this.#childTitle,
onTitleChange: this.#handleChangeTitleClick,
}, undefined, this.contentElement);
}
}
```
### 3\. Testing Widgets
Voir sur GitHub