Skip to content

Commit

Permalink
Provide examples of using web components with bindings (#1004)
Browse files Browse the repository at this point in the history
* provide examples of using web components with bindings

* use html template tag for syntax highlight

* add custom component example to readme
  • Loading branch information
alexeyraspopov authored Feb 5, 2025
1 parent 2cbed21 commit a96effe
Show file tree
Hide file tree
Showing 3 changed files with 208 additions and 1 deletion.
72 changes: 72 additions & 0 deletions src/webview/bindings/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,75 @@ dispose();
```

This will clean up all the reactive subscriptions and event listeners.

## Creating custom components

Following example explains how
[custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements)
can be used for defining a custom component with its own lifecycle.

```js
import { ObservableScope } from "inertial";
import { applyBindings, html } from "./bindings";

// Using custom elements, we define custom component as a class that extends HTMLElement
// On the inside, it behaves just like another class
// Custom elements provide certain methods that can be defined to react on host events: element attached/detached, attributes changed
class CustomComponent extends HTMLElement {
// Each instance of a custom component going to have its own scope for reactive values so they don't interfere
os = ObservableScope();

// Using the scope, we define internal state in the same way we define a View Model
internalState = this.os.signal(/* initial state */);

// The parent scope needs a way to pass data down to a custom component
// Here we use property setters so any kind of data can be passed, unlike attributes that only handle strings
// Following setter triggers when you do `element.someValue = ...`
// which is what happens when you bind `data-prop-some-value="..."`
set someValue(value) {
// Triggering changes in internal state is how we update the component based on what the parent scope needs
this.internalState(value);
}

// Since the class context is being used as View Model for its own template bindings,
// we can define custom methods to describe certain behaviors outside of the template
someMethod() {
// We can use custom events to trigger reaction in the parent scope
// that can bind to the event e.g. `data-on-something="this.handle(event.detail)"`
this.dispatchEvent(new CustomEvent("something", { detail: info }));
}

// To make it easier to navigate, I defined the component's template as a separate property
// It uses JS template strings for convenient multiline editing
// `html` tag (imported from bindings module) does nothing in runtime, but can enable HTML syntax highlight
template = html`
<p data-text="this.internalState()"></p>
<button data-on-click="this.someMethod()"></button>
`;

// This part is what makes everything work
// This method is triggered by the browser when the custom element is attached to the page
connectedCallback() {
// Here we hide the internal template from the parent scope, so parent bindings don't see internal template
const shadow = this.attachShadow({ mode: "open" });
// The shadow DOM of an element receives the template defined earlier
shadow.innerHTML = this.template;
// And gets bindings applied, using the instance of the class as view model
applyBindings(shadow, this.os, this);
}
}

// Here we register the class we implement as a custom HTML element so it can be used in a parent template
customElements.define("custom-component", CustomComponent);
```

The example component going to be used in another template:

```html
<!-- per example above, using property binding to pass data down -->
<!-- and using event binding to react to internal events bubbling up -->
<custom-component
data-prop-some-value="this.state()"
data-prop-on-something="this.handleEvent(event.detail)"
></custom-component>
```
5 changes: 4 additions & 1 deletion src/webview/bindings/bindings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { type Scope } from "inertial";

export function applyBindings(root: Element, os: Scope, vm: object) {
/** Provides no special treatment to template strings, but enables syntax highlight for HTML inside JS. */
export const html = String.raw;

export function applyBindings(root: Element | ShadowRoot, os: Scope, vm: object) {
let tree = walk(root);
let disposables: Array<() => void> = [];
for (let node: Node | null = tree.currentNode; node != null; node = tree.nextNode()) {
Expand Down
132 changes: 132 additions & 0 deletions src/webview/bindings/custom-elements.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { test } from "rollwright";
import { expect } from "@playwright/test";
import replace from "@rollup/plugin-replace";
import esbuild from "rollup-plugin-esbuild";

test.use({
plugins: [
esbuild({ jsx: "automatic", target: "es2022", exclude: [/node_modules/] }),
replace({
"process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV),
preventAssignment: true,
}),
],
});

test("custom element with properties passed down", async ({ execute, page }) => {
/* This is a basic example of custom component having its own lifecycle and
internal state, while being provided with values from the outside. */

await execute(async () => {
const { ObservableScope } = await import("inertial");
const { applyBindings, html } = await import("./bindings");

class XCounter extends HTMLElement {
os = ObservableScope();
value = this.os.signal(0);

// This is sort of a public API of the component. Use `data-prop-counter` to bind it
// Note: we're binding a property, not an attribute (data-attr-*).
set counter(value: number) {
this.value(value);
}

template = html`
<output data-text="this.value()"></output>
<button data-on-click="this.value(v => v + 1)">Increment</button>
`;

connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = this.template;
applyBindings(shadow, this.os, this);
}
}

customElements.define("x-counter", XCounter);

return XCounter;
});

/* Here we make use of the custom component several times with different input parameters */
await execute(async () => {
const { ObservableScope } = await import("inertial");
const { applyBindings, html } = await import("./bindings");
const root = document.createElement("main");
root.innerHTML = html`
<x-counter data-prop-counter="13"></x-counter>
<hr />
<x-counter data-prop-counter="20"></x-counter>
`;
document.body.append(root);
const os = ObservableScope();
applyBindings(root, os, {});
});

/* We assert that the components manage their own state in isolation */
await page.locator("button").first().click();
await expect(page.locator("output")).toHaveText(["14", "20"]);
await page.locator("button").last().click();
await expect(page.locator("output")).toHaveText(["14", "21"]);
});

test("custom elements with events bubbling up", async ({ execute, page }) => {
/* This is an example in which custom component provides feedback to the parent
scope via dispatching an event. A custom event is being dispatched on the custom
element itself, so the parent scope can use `data-on-*` binding to handle it. */

await execute(async () => {
const { ObservableScope } = await import("inertial");
const { applyBindings, html } = await import("./bindings");

class CustomForm extends HTMLElement {
os = ObservableScope();
value = this.os.signal("");

template = html`
<input
type="text"
data-value="this.value()"
data-on-change="this.value(event.target.value)"
data-on-blur="this.handelBlur()"
/>
`;

handelBlur() {
this.dispatchEvent(new CustomEvent("bubble", { detail: this.value() }));
}

connectedCallback() {
const shadow = this.attachShadow({ mode: "open" });
shadow.innerHTML = this.template;
applyBindings(shadow, this.os, this);
}
}

customElements.define("custom-form", CustomForm);
});

const vm = await execute(async () => {
const { ObservableScope } = await import("inertial");
const { applyBindings, html } = await import("./bindings");
const { fake } = await import("sinon");
const root = document.createElement("main");
root.innerHTML = html`
<custom-form data-on-bubble="this.handleBubble(event.detail)"></custom-form>
`;
document.body.append(root);
const os = ObservableScope();
const vm: Record<string, any> = {
result: os.signal(""),
handleBubble: fake((value: string) => vm.result(value)),
};
applyBindings(root, os, vm);
return vm;
});

await page.locator("input").fill("hello");
await page.locator("input").blur();

expect(await vm.evaluate((vm) => vm.handleBubble.callCount)).toBe(1);
expect(await vm.evaluate((vm) => vm.result())).toBe("hello");
});

0 comments on commit a96effe

Please sign in to comment.