Skip to content

Commit

Permalink
Add native support for event listeners and attributes (#389)
Browse files Browse the repository at this point in the history
* Support for remote attributes and event listeners

Add `getAttributeNames()` polyfill to support vitest assertions

Add event listener support

Add proper support for event listeners

Clean up some naming

Update READMEs

Fix Preact tests

More documentation

More documentation polish

Add missing generic argument

More docs polish

Fix `Event.bubbles` and `Event.composedPath()` implementations

Fix missing `connectedCallback()` and `disconnectedCallback)` calls

Added `bubbles` configuration option for `RemoteElement` events

Fixes to event dispatching (#403)

* Make immediatePropogationStopped private-ish

* Use workspace polyfill

* Stopping immediate propagation also stops regular propagation

* Assorted dispatching fixes

- Respect stopPropagation throughout both capturing and bubbling
- Call listeners on the target element in both the capturing and bubbling phases
- Simplify returning defaultPrevented

* Add changeset

* Better param name

* Fix lockfile

* Trying again…

Revert pnpm fixes

This reverts part of commit 0ce1450.

Minimal change to pnpm lockfile

* Update .changeset/slimy-lizards-tickle.md

Co-authored-by: Jake Archibald <jaffathecake@gmail.com>

* Only send AT_TARGET events to the remote environment

* Add more APIs for handling React/ Preact host handling of event listeners

* Add support for event properties

* Use weakmaps for storing private implementation details

* Export attribute and event listener updater

---------

Co-authored-by: Jake Archibald <jaffathecake@gmail.com>
  • Loading branch information
lemonmade and jakearchibald authored Aug 27, 2024
1 parent afcf2dc commit 2479b21
Show file tree
Hide file tree
Showing 48 changed files with 3,053 additions and 524 deletions.
157 changes: 157 additions & 0 deletions .changeset/slimy-lizards-tickle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
---
'@remote-dom/polyfill': minor
'@remote-dom/signals': minor
'@remote-dom/preact': minor
'@remote-dom/react': minor
'@remote-dom/core': minor
---

## Added native support for synchronizing attributes and event listeners

Previously, Remote DOM only offered “remote properties” as a way to synchronize element state between the host and remote environments. These remote properties effectively synchronize a subset of a custom element’s instance properties. The `RemoteElement` class offers [a declarative way to define the properties that should be synchronized](/packages/core/README.md#remote-properties).

```ts
import {RemoteElement} from '@remote-dom/core/elements';

class MyElement extends RemoteElement {
static get remoteProperties() {
return ['label'];
}
}

customElements.define('my-element', MyElement);

const myElement = document.createElement('my-element');
myElement.label = 'Hello, World!';
```

The same `remoteProperties` configuration can create special handling for attributes and event listeners. By default, a remote property is automatically updated when setting an [attribute](https://developer.mozilla.org/en-US/docs/Glossary/Attribute) of the same name:

```ts
const myElement = document.createElement('my-element');
myElement.setAttribute('label', 'Hello, World!');

// myElement.label === 'Hello, World!', and this value is synchronized
// with the host environment as a “remote property”
```

Similarly, a remote property can be automatically updated when adding an event listener based on a conventional `on` property naming prefix:

```ts
import {RemoteElement} from '@remote-dom/core/elements';

class MyElement extends RemoteElement {
static get remoteProperties() {
return {
onChange: {
event: true,
},
};
}
}

customElements.define('my-element', MyElement);

const myElement = document.createElement('my-element');

// This adds a callback property that is synchronized with the host environment
myElement.onChange = () => console.log('Changed!');

// And so does this, but using the `addEventListener` method instead
myElement.addEventListener('change', () => console.log('Changed!'));
```

These utilities are handy, but they don’t align with patterns in native DOM elements, particularly when it comes to events. Now, both of these can be represented in a fashion that is more conventional in HTML. The `remoteAttributes` configuration allows you to define a set of element attributes that will be synchronized directly the host environment, instead of being treated as instance properties:

```ts
import {RemoteElement} from '@remote-dom/core/elements';

class MyElement extends RemoteElement {
static get remoteAttributes() {
return ['label'];
}

// If you want to add instance properties, you can do it with getters and
// setters that manipulate the attribute value:
//
// get label() {
// return this.getAttribute('label');
// }
//
// set label(value) {
// this.setAttribute('label', value);
// }
}

customElements.define('my-element', MyElement);

const myElement = document.createElement('my-element');
myElement.setAttribute('label', 'Hello, World!');
```

Similarly, the `remoteEvents` configuration allows you to define a set of event listeners that will be synchronized directly with the host environment:

```ts
import {RemoteElement} from '@remote-dom/core/elements';

class MyElement extends RemoteElement {
static get remoteEvents() {
return ['change'];
}
}

customElements.define('my-element', MyElement);

const myElement = document.createElement('my-element');

// And so does this, but using the `addEventListener` method instead
myElement.addEventListener('change', () => console.log('Changed!'));

// No `myElement.onChange` property is created
```

The `remoteProperties` configuration will continue to be supported for cases where you want to synchronize instance properties. Because instance properties can be any JavaScript type, properties are the highest-fidelity field that can be synchronized between the remote and host environments. However, adding event listeners using the `remoteProperties.event` configuration is **deprecated and will be removed in the next major version**. You should use the `remoteEvents` configuration instead. If you were previously defining remote properties which only accepted strings, consider using the `remoteAttributes` configuration instead, which stores the value entirely in an HTML attribute instead.

This change is being released in a backwards-compatible way, so you can continue to use the existing `remoteProperties` configuration on host and/or remote environments without any code changes.

All host utilities have been updated to support the new `attributes` and `eventListeners` property that are synchronized with the remote environment. This includes updates to the [React](/packages/react/README.md#event-listener-props) and [Preact hosts to map events to conventional callback props](/packages/preact/README.md#event-listener-props), and updates to the [`DOMRemoteReceiver` class](/packages/core/README.md#domremotereceiver), which now applies fields to the host element exactly as they were applied in the remote environment:

```ts
// Remote environment:

class MyElement extends RemoteElement {
static get remoteEvents() {
return ['change'];
}
}

customElements.define('my-element', MyElement);

const myElement = document.createElement('my-element');

myElement.addEventListener('change', (event) => {
console.log('Changed! New value: ', event.detail);
});

// Host environment:

class MyElement extends HTMLElement {
connectedCallback() {
// Emit a change event on this element, with detail that will be passed
// to the remote environment
this.addEventListener('change', (event) => {
event.stopImmediatePropagation();

this.dispatchEvent(
new CustomEvent('change', {
detail: this.value,
}),
);
});
}

// Additional implementation details of the host custom element...
}

customElements.define('my-element', MyElement);
```
9 changes: 9 additions & 0 deletions .changeset/strong-sheep-sing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@remote-dom/polyfill': patch
---

Bug fixes to event dispatching

- Listeners on the target are now called during both the capture and bubble phases.
- `stopPropagation` now respected.
- `stopImmediatePropagation` now also stops regular propagation.
5 changes: 5 additions & 0 deletions .changeset/tiny-horses-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@remote-dom/polyfill': patch
---

Fix `Event.bubbles` and `Event.composedPath()` implementations
27 changes: 8 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,9 @@ Now, just mirroring HTML strings isn’t very useful. Remote DOM works best when

Remote DOM adopts the browser’s [native API for defining custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements) to represent these “remote custom elements”. To make it easy to define custom elements that can communicate their changes to the host, `@remote-dom/core` provides the [`RemoteElement` class](/packages/core/README.md#remoteelement). This class, which is a subclass of the browser’s [`HTMLElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement), lets you define how properties, attributes, methods, and event listeners on the element should be transferred.

To demonstrate, let’s imagine that we want to allow our remote environment to render a `ui-button` element. This element will have a `primary` property, which sets it to a more prominent visual style. It will also trigger a `click` event when clicked.
To demonstrate, let’s imagine that we want to allow our remote environment to render a `ui-button` element. This element will have a `primary` attribute, which sets it to a more prominent visual style. It will also trigger a `click` event when clicked.

First, we’ll create the remote environment’s version of `ui-button`. The remote version doesn’t have to worry about rendering any HTML — it’s only a signal to the host environment to render the “real” version. However, we do need to teach this element to communicate its `primary` property and `click` event to the host version of that element. We’ll do this using the [`RemoteElement` class provided by `@remote-dom/core`](/packages/core#remoteelement):
First, we’ll create the remote environment’s version of `ui-button`. The remote version doesn’t have to worry about rendering any HTML — it’s only a signal to the host environment to render the “real” version. However, we do need to teach this element to communicate its `primary` attribute and `click` event to the host version of that element. We’ll do this using the [`RemoteElement` class provided by `@remote-dom/core`](/packages/core#remoteelement):

```html
<!doctype html>
Expand All @@ -166,15 +166,12 @@ First, we’ll create the remote environment’s version of `ui-button`. The rem
// for `@remote-dom/core/elements`:
// https://github.com/Shopify/remote-dom/tree/main/packages/core#elements
class UIButton extends RemoteElement {
static get remoteProperties() {
return {
// A boolean property can be set either by setting the attribute to a non-empty
// value, or by setting the property to `true`.
primary: {type: Boolean},
// Remote DOM will convert the `click` event into an `onClick` property that
// is communicated to the host.
onClick: {event: true},
};
static get remoteAttributes() {
return ['primary'];
}
static get remoteEvents() {
return ['click'];
}
}
Expand Down Expand Up @@ -248,8 +245,6 @@ Finally, we need to provide a “real” implementation of our `ui-button` eleme
return ['primary'];
}
onClick;
connectedCallback() {
const primary = this.hasAttribute('primary') ?? false;
Expand All @@ -261,12 +256,6 @@ Finally, we need to provide a “real” implementation of our `ui-button` eleme
if (primary) {
root.querySelector('.Button').classList.add('Button--primary');
}
// We’ll listen for clicks on our button, and call the remote `onClick`
// property when it happens.
root.querySelector('button').addEventListener('click', () => {
this.onClick?.();
});
}
attributeChangedCallback(name, oldValue, newValue) {
Expand Down
8 changes: 0 additions & 8 deletions examples/custom-element/app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@
return ['primary'];
}

onClick;

connectedCallback() {
const primary = this.hasAttribute('primary') ?? false;

Expand Down Expand Up @@ -54,12 +52,6 @@
if (primary) {
root.querySelector('.Button').classList.add('Button--primary');
}

// We’ll listen for clicks on our button, and call the remote `onClick`
// property when it happens.
root.querySelector('button').addEventListener('click', () => {
this.onClick?.();
});
}

attributeChangedCallback(name, oldValue, newValue) {
Expand Down
17 changes: 10 additions & 7 deletions examples/custom-element/app/remote.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,17 @@
// for `@remote-dom/core/elements`:
// https://github.com/Shopify/remote-dom/tree/main/packages/core#elements
class UIButton extends RemoteElement {
static get remoteProperties() {
static get remoteAttributes() {
return ['primary'];
}

static get remoteEvents() {
return {
// A boolean property can be set either by setting the attribute to a non-empty
// value, or by setting the property to `true`.
primary: {type: Boolean},
// Remote DOM will convert the `click` event into an `onClick` property that
// is communicated to the host.
onClick: {event: true},
click: {
dispatchEvent(detail) {
console.log(`Event detail: `, detail);
},
},
};
}
}
Expand Down
25 changes: 13 additions & 12 deletions examples/kitchen-sink/app/remote/elements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
createRemoteElement,
RemoteRootElement,
RemoteFragmentElement,
type RemoteEvent,
} from '@remote-dom/core/elements';

import type {
Expand All @@ -24,23 +25,23 @@ export const Text = createRemoteElement<TextProperties>({
},
});

export const Button = createRemoteElement<ButtonProperties, {}, {modal?: true}>(
{
properties: {
onPress: {event: true},
},
slots: ['modal'],
},
);
export const Button = createRemoteElement<
ButtonProperties,
{},
{modal?: true},
{press(event: RemoteEvent): void}
>({
events: ['press'],
slots: ['modal'],
});

export const Modal = createRemoteElement<
ModalProperties,
ModalMethods,
{primaryAction?: true}
{primaryAction?: true},
{open(event: RemoteEvent): void; close(event: RemoteEvent): void}
>({
properties: {
onClose: {event: true},
},
events: ['close'],
slots: ['primaryAction'],
methods: ['open', 'close'],
});
Expand Down
13 changes: 11 additions & 2 deletions examples/kitchen-sink/app/remote/examples/preact.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,18 @@ import {
} from '../elements.ts';

const Text = createRemoteComponent('ui-text', TextElement);
const Button = createRemoteComponent('ui-button', ButtonElement);
const Button = createRemoteComponent('ui-button', ButtonElement, {
eventProps: {
onPress: {event: 'press'},
},
});
const Stack = createRemoteComponent('ui-stack', StackElement);
const Modal = createRemoteComponent('ui-modal', ModalElement);
const Modal = createRemoteComponent('ui-modal', ModalElement, {
eventProps: {
onOpen: {event: 'open'},
onClose: {event: 'close'},
},
});

export function renderUsingPreact(root: Element, api: RenderAPI) {
render(<App api={api} />, root);
Expand Down
13 changes: 11 additions & 2 deletions examples/kitchen-sink/app/remote/examples/react.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,18 @@ import {
} from '../elements.ts';

const Text = createRemoteComponent('ui-text', TextElement);
const Button = createRemoteComponent('ui-button', ButtonElement);
const Button = createRemoteComponent('ui-button', ButtonElement, {
eventProps: {
onPress: {event: 'press'},
},
});
const Stack = createRemoteComponent('ui-stack', StackElement);
const Modal = createRemoteComponent('ui-modal', ModalElement);
const Modal = createRemoteComponent('ui-modal', ModalElement, {
eventProps: {
onOpen: {event: 'open'},
onClose: {event: 'close'},
},
});

export function renderUsingReact(root: Element, api: RenderAPI) {
createRoot(root).render(<App api={api} />);
Expand Down
Loading

0 comments on commit 2479b21

Please sign in to comment.