posit-dev / shiny-bindings

Monorepo containing Javascript packages that simplify the experience of writing custom bindings for Shiny apps
10 stars 2 forks source link

Update core to not use webcomponents #1

Closed nstrayer closed 8 months ago

nstrayer commented 8 months ago

This PR updates the shiny-bindings-core pkg to not use web components for the basic makeInputBinding() and makeOutputBinding() functions. (The old webcomponents versions are now called make{Input,Output}BindingWebComponent())

The main goal here is to simplify the structure and make it less confusing what is going on under the hood. The API's for the main two functions are now almost as clean as the react bindings, with most logic coming in a single callback function that keeps the element as a scope-level variable.

New plain bindings API:

makeInputBinding() now looks very similar to the syntax of the react binding. See packages/core/README.md and packages/core/src/makeInputBinding.ts for more details.

makeInputBinding<number>({
  name: "custom-component-input",
  setup: (el, updateValue) => {
    let count = 0;
    el.innerHTML = `<button>Plain</button>`;
    const button = el.querySelector("button")!;
    button.addEventListener("click", () => {
      updateValue(++count);
    });
  },
});

makeOutputBinding() is similarly cleaned up. See packages/core/README.md and packages/core/src/makeOutputBinding.ts for more details.

makeOutputBinding<{ value: number }>({
  name: "custom-component-simple",
  setup: (el) => {
    return {
      onNewValue: (payload) => {
        el.innerHTML = `
          <span>I am a plain output with value:</span>
          <strong> ${payload.value} </strong>
        `;
      },
    };
  },
});

New demo project

To make it easier to develop, a third "package" has been added that is a python package that implements inputs and ouputs using the various binding helpers provided. It sits at packages/demo-pkg and has a readme with details about spinning up a live-reload dev environment.

image

Implementation notes

Both the input and output functions operate around the concept of a single closure being called for the binding. This allows users to store things like state variables in that closure along with referencing the same element variable. This was done by using a map keyed by the bound element within the make*Binding() functions.

E.g. in packages/core/src/makeOutputBinding.ts:

...
type BindingCallbacks<T> = {
  onNewValue: (x: T) => void;
};

...
class NewCustomBinding extends Shiny.OutputBinding {

  override find(scope: HTMLElement) {
    return $(scope).find(selector);
  }

  boundElements = new WeakMap<HTMLElement, BindingCallbacks<T>>();

  private getCallbacks(el: HTMLElement): BindingCallbacks<T> {
    // Get the callbacks for this element if they exist
    // If they don't exist, setup the element and get the callbacks
    if (!this.boundElements.has(el)) {
      this.boundElements.set(el, setup(el));
    }

    const callbacks = this.boundElements.get(el);
    if (typeof callbacks === "undefined") {
      throw new Error("Unable to get callbacks for element");
    }
    return callbacks;
  }

  override renderValue(el: HTMLElement, data: T): void {
    this.getCallbacks(el).onNewValue(data);
  }
}
...