@jakelazaroff/jsonelement

0.2.0 • Public • Published

JSONElement

npm version

JSONElement is a web component for declaratively building JSON in JavaScript apps. It's focused on replacing imperative "effect" APIs such as useEffect in React, $effect in Svelte or attributeChangedCallback in web components.

You use JSONElement by creating your own custom elements that inherit from it to define schemas. Here's a simple example of how you might use it to define a GeoJson object:

class GeoJsonProperties extends JSONElement {
  static tag = "geojson-properties";

  static schema = {
    name: String
  };
}

You'd use that element in your HTML like this:

<geojson-properties name="Disney World Orlando"></geojson-properties>

Here's how that element might fit into a larger schema, wrapping a JavaScript library like MapLibre to show locations on a map without writing any imperative JavaScript:

<map-libre>
  <maplibre-source id="places" type="geojson">
    <geojson-featurecollection>
      <geojson-feature slot="features">
        <geojson-properties slot="properties" name="Disney World Orlando"></geojson-properties>
        <geojson-point slot="geometry" lon="-81.56" lat="28.38"></geojson-point>
      </geojson-feature>
      <geojson-feature slot="features">
        <geojson-properties slot="properties" name="Disneyland Anaheim"></geojson-properties>
        <geojson-point slot="geometry" lon="-117.91" lat="33.81"></geojson-point>
      </geojson-feature>
    </geojson-featurecollection>
  </maplibre-source>
</map-libre>

Compare that to this longer and more imperative React code…

import maplibre from "maplibre-gl";

interface Place {
  name: string;
  lon: number;
  lat: number;
}

function Map({ places }: { places: Place[] }) {
  const map = useRef<maplibre.Map>();

  useEffect(() => {
    if (!map.current) return;

    const data = {
      type: "FeatureCollection",
      features: places.map(el => ({
        type: "Feature",
        geometry: { type: "Point", coordinates: [Number(place.lon), Number(place.lat)] },
        properties: { name: place.innerHTML }
      }))
    };

    const source = map.getSource(places);
    if (source) source.setData(data);
    else map.addSource("places", { type: "geojson", data });
  }, [places]);

  return (
      <div
        ref={el => {
          if (!el) map.current?.remove();
          map.current = new maplibre.Map({ container: el });
        }}
      >
      </div>
  );
}

…or this vanilla JavaScript code that queries for the relevant data in the DOM.

<ul>
  <li class="place" data-lon="-81.56" data-lat="28.38">Disney World Orlando</li>
  <li class="place" data-lon="-81.56" data-lat="28.38">Disneyland Anaheim</li>
</ul>
<div id="map"></div>
<script type="module">
  const data = {
    type: "FeatureCollection",
    features: [...document.querySelectorAll(".place")].map(el => ({
      type: "Feature",
      geometry: { type: "Point", coordinates: [Number(el.dataset.lon), Number(el.dataset.lat)] },
      properties: { name: place.innerHTML }
    }))
  };

  const map = new maplibre.Map({ container: "map" });
  map.addSource("places", { type: "geojson", data });
</script>

In both React and HTML with vanilla JavaScript, you need to switch between two different languages. You also need to use imperative, side-effectful code to instantiate the map. None of the above is easily taken out of context or composed elsewhere.

Of course, under the hood in the first example the maplibre-* element is built using code like in the latter two. That's where JSONElement comes in: you can use it to easily transform elements in the DOM to build more declarative components.

Getting Started

JSONElement doesn't provide any custom elements that you use directly. Instead, you use it to create your own custom elements that reflect a schema.

For example, here's how you might use JSONElement to represent the JSON on the GeoJson homepage:

<geojson-feature>
  <geojson-point slot="geometry" lon="125.6" lat="10.1"></geojson-point>
  <geojson-properties slot="properties" name="Dinagat Islands"></geojson-properties>
</geojson-feature>

This involves three custom elements: <geojson-feature>, <geojson-point> and <geojson-properties>.

import JSONElement from "./json-element.js";

class GeoJsonFeature extends JSONElement {
  static tag = "geojson-feature";

  static get schema() {
    return {
      type: "Feature",
      geometry: GeoJsonPoint,
      properties: GeoJsonProperties
    };
  }
}

class GeoJsonPoint extends JSONElement {
  static tag = "geojson-point";

  static schema = {
    type: "Point",
    lon: Number,
    lat: Number
  };

  get json() {
    const { lon, lat, ...json } = super.json;
    return { ...json, coordinates: [lon, lat] };
  }
}

class GeoJsonProperties extends JSONElement {
  static tag = "geojson-properties";

  static schema = {
    name: String
  };
}

GeoJsonFeature.register();
GeoJsonPoint.register();
GeoJsonProperties.register();

There are three main properties in a JSONElement subclass:

  • tag is a static string property that determines the custom element tag name.
  • schema is a static object property that determines the keys and value types of the resulting JSON.
  • json is an instance property that returns the actual JSON.

To get a component's JSON, just check the json property:

const feature = document.querySelector("geojson-feature");

console.log(feature.json);

Any time the JSON changes, components will emit a json-change event. You can listen to these on the root element of the JSONElement tree:

const feature = document.querySelector("geojson-feature");

feature.addEventListener("json-change", () => {
  console.log(feature.json);
});

If you need to know what changed, JSONElement can include an array of JSON Patch objects with the json-change event. This must be explicitly enabled by calling the enableDiff function from the package, and then setting a diff attribute on the element emitting the event:

import JSONElement, { enableDiff } from "./json-element.js";
enableDiff();
<geojson-feature diff>
  <!-- ... -->
</geojson-feature>

json-change events will now contain a patches property on their event detail, which is an array of JSON Patch objects describing the change.

const feature = document.querySelector("geojson-feature");

feature.addEventListener("json-change", event => {
  console.log(event.detail.patches);
});

Writing Schemas

Each JSONElement subclass has a schema that tells it how to convert its attributes and children into JavaScript objects. The schema is defined as an object on the static property schema. Each key corresponds to both the element's attribute or slot and the key in the resulting JavaScript object, while each value determines how it's read from the DOM.

Here's an example:

class ExampleSchema extends JSONElement {
  static tag = "example-schema";

  static schema = {
    literal: "string literal",
    bool: Boolean,
    num: Number,
    str: String,
    one: Object,
    many: Array,
    custom: value => `Custom value: ${value}`
  };
}

Writing the markup for that schema might look something like this…

<example-schema bool num="10" str="hello" custom="example!">
  <other-element slot="one" text="single object"></other-element>
  <other-element slot="many" text="uno"></other-element>
  <other-element slot="many" text="dos"></other-element>
  <other-element slot="many" text="tres"></other-element>
</example-schema>

…which would generate this JSON:

{
  "literal": "string literal",
  "bool": true,
  "num": 10,
  "str": "hello",
  "one": { "text": "single object" },
  "many": [{ "text": "uno" }, { "text": "dos" }, { "text": "tres" }],
  "custom": "Custom value: example!"
}

There are seven types of schema values:

Literal

If the schema value is a boolean, number, string or null literal, it will be included in the JSON with no need to add it to the markup.

class ExampleLiteral {
  static tag = "example-literal";

  static schema = {
    one: "hello, world",
    two: 2,
    three: true,
    four: null
  };
}

Boolean

If the schema value is the global Boolean function, the JSON value will be the corresponding attribute coerced to a boolean.

class ExampleBoolean {
  static tag = "example-boolean";

  static schema = {
    one: Boolean
  };
}

Number

If the schema value is the global Number function, the JSON value will be the corresponding attribute coerced to a number.

class ExampleNumber {
  static tag = "example-number";

  static schema = {
    one: Number
  };
}

String

If the schema value is the global String function, the JSON value will be the corresponding attribute coerced to a string.

class ExampleString {
  static tag = "example-string";

  static schema = {
    one: String
  };
}

Object

If the schema value is the global Object function, the JSON value will be the json property of the first element in the corresponding slot.

You can also use a subclass of JSONElement instead. This won't have any effect on the output, but it may make it easier to remember how different JSONElement subclasses relate to each other.

class ExampleObject {
  static tag = "example-object";

  static get schema() {
    return {
      one: Object,
      two: ExampleObjectNested
    };
  }
}

class ExampleObjectNested {
  static tag = "example-object-nested";

  static schema = {};
}

Array

If the schema value is the global Array function, the JSON value will be an array with the json properties of all elements in the corresponding slot.

You can also use an actual array containing subclasses of of JSONElement instead. As with objects, this won't have any effect on the output.

class ExampleArray {
  static tag = "example-array";

  static get schema() {
    return {
      one: Array,
      two: [ExampleArrayNested]
    };
  }
}

class ExampleArrayNested {
  static tag = "example-array-nested";

  static schema = {};
}

Custom

You can use a custom function if you need more control over the output. It will be called with two arguments: the string or null value of the attribute at the corresponding key, and an array of any elements in the slot named after the key.

If the function only uses the attribute, it should only take a single argument; this will prevent JSONElement from creating a slot for any nested elements.

class ExampleCustom {
  static tag = "example-custom";

  static schema = {
    attribute: value => `Hello, ${value}!`,
    elements: (_, els) => els.map(el => `This element has ${Object.keys(el.json).length} keys!`)
  };
}

Type safety

By default, the type of JSONElement subclasses' json property is any. If you want a stricter type, you can create a getter overriding the json property and use a third-party library such as Valibot or Zod to validate the resulting JSON:

import { boolean, number, object, parse, string } from "valibot";

class ExampleValidation {
  static tag = "example-validation";

  static schema = {
    one: String,
    two: Number,
    three: Boolean
  };

  get json() {
    const validator = object({
      one: string(),
      two: number(),
      three: boolean()
    });

    return parse(validator, super.json);
  }
}

Readme

Keywords

none

Package Sidebar

Install

npm i @jakelazaroff/jsonelement

Weekly Downloads

1

Version

0.2.0

License

MIT

Unpacked Size

64 kB

Total Files

12

Last publish

Collaborators

  • jakelazaroff