Register Merkur widget as custom element

Merkur widgets can be registered as custom elements. This is useful for cases where SSR is not required. We assume that your widget is served as a single JavaScript file with defined assets.

Installation

To easily register a Merkur widget as a custom element, use the @merkur/integration-custom-element module. This module is designed for client-side usage only.

npm i @merkur/integration-custom-element --save

How to modify the default Merkur template

The default Merkur template is prepared for SSR. In the following sections, we will remove unnecessary parts and files to reconfigure the template for client-side usage only. First, create a new Merkur widget as described in Getting started.

Server part

After creating a new Merkur widget, update your playground template by creating the /server/playground/templates/body.ejs and /server/playground/templates/footer.ejs files. You can also run merkur custom playground:body and merkur custom playground:footer to generate these files. Then, modify the files as follows:

// body.ejs
<{package.name}></{package-name}> // e.g., <merkur-widget></merkur-widget>
// footer.ejs
// keep empty

This changes the logic for reviving the widget in the playground by adding only the custom element with the name from package.json to the body of the HTML. The custom element will automatically revive the Merkur widget. You can now remove other files in the /server/* folder.

CLI configuration

Update the merkur.config.mjs file to include @merkur/integration-custom-element/cli in the extends field.

/**
 * @type import('@merkur/cli').defineConfig
 */
export default function () {
  return {
    extends: ['@merkur/preact/cli', '@merkur/integration-custom-element/cli'],
  };
}

The @merkur/integration-custom-element/cli modifies the default @merkur/cli configuration by:

Widget part

The default Merkur template uses the config npm module for resolving the application environment. However, the config module does not work in the browser. To address this, add support for application environments in the client solution with custom elements.

  1. Create a new config folder in /src/.
  2. Inside the config folder, create a file /src/config/index.js with the following code:
import { deepMerge } from '@merkur/integration-custom-element';

import production from './production';
import development from './development';

let environment = null;
if (process.env.NODE_ENV === 'production') {
  environment = production;
} else {
  environment = deepMerge(production, development);
}

export { environment };
  1. Create production and development environment files in /src/config/production.js and /src/config/development.js. For example, /src/config/production.js:
export default {
  environment: 'production',
  cdn: 'http://localhost:4444',
  widget: {
    apiUrl: 'https://api.github.com/',
  },
};
  1. Add the resolved environment to the widget’s props.environment property in /src/widget.js. Since custom elements do not support Merkur slots, set slotFactories to an empty array. You can also remove the src/components/slots folder.

  2. To inline the CSS bundle into the resulting JS file, add the following import and define an inlineStyle asset:

import cssBundle from '@merkur/integration-custom-element/cssBundle';

assets: [
  {
    name: 'widget.css',
    type: 'inlineStyle',
    source: cssBundle,
  },
],
  1. Finally, register your widget as a custom element using the registerCustomElement method:
import { registerCustomElement } from '@merkur/integration-custom-element';

// ...existing code...

registerCustomElement({ widgetDefinition });

Callbacks

The registerCustomElement method accepts a callbacks object that allows you to hook into the lifecycle of the custom element. These callbacks include:

Each callback receives the widget instance, the shadow DOM, and the custom element as arguments.

Example

Here is an example of how to use the callbacks object:

import { registerCustomElement } from '@merkur/integration-custom-element';
import widgetDefinition from './widget';

registerCustomElement({
  widgetDefinition,
  callbacks: {
    constructor(widget, { shadow, customElement }) {
      console.log('Custom element created:', customElement);
    },
    connectedCallback(widget, { shadow, customElement }) {
      console.log('Custom element added to DOM:', customElement);
    },
    disconnectedCallback(widget, { shadow, customElement }) {
      console.log('Custom element removed from DOM:', customElement);
    },
    adoptedCallback(widget, { shadow, customElement }) {
      console.log('Custom element moved to a new document:', customElement);
    },
    attributeChangedCallback(widget, name, oldValue, newValue, { shadow, customElement }) {
      console.log(`Attribute "${name}" changed from "${oldValue}" to "${newValue}"`);
    },
    mount(widget, { shadow, customElement }) {
      console.log('Widget mounted:', widget);
    },
    remount(widget, { shadow, customElement }) {
      console.log('Widget remounted:', widget);
    },
    getInstance() {
      console.log('Retrieving existing widget instance');
      return null; // Return an existing widget instance if available
    },
  },
});

This example demonstrates how to log messages during each lifecycle event of the custom element. You can replace the console.log statements with your own logic to handle these events.

widget.root and widget.customElement

These properties are automatically set when the widget is registered as a custom element and can be used to manage the widget’s lifecycle or interact with the DOM.

Default propagation of attributes to widget props

When a custom element is registered, its attributes are automatically propagated to the widget’s props object.

How it works

  1. The observedAttributes property in the registerCustomElement options specifies which attributes the custom element observes. These attributes are automatically monitored for changes.
  2. When an observed attribute changes, the attributeChangedCallback is triggered. This callback updates the corresponding property in the widget’s props object.
  3. Attribute names are automatically converted to camelCase.
  4. The attributesParser function can be used to customize how attributes are processed. For example, you can parse specific attributes like JSON strings.

Example

import { registerCustomElement } from '@merkur/integration-custom-element';
import widgetDefinition from './widget';

registerCustomElement({
  widgetDefinition,
  observedAttributes: ['title', 'theme', 'long-name', 'config'], // Attributes to observe
  attributesParser: {
    config: (value) => JSON.parse(value); 
  }
});

In this example: