0.4.13 • Published 6 years ago

html-api v0.4.13

Weekly downloads
12
License
MIT
Repository
github
Last release
6 years ago

HTML API

JavaScript Style Guide npm

This package makes it easy to give your JavaScript-generated widgets a clean, declarative HTML API.

It features

  • a hybrid interface, allowing to change option values via a JavaScript API as well as by changing data-* attributes
  • observation of the option attributes, allowing to react to changes
  • decent presets and extensibility for type checking and casting
  • support for all modern browsers down to IE 11
  • reasonably small size: it's 3.8 KB minified & gzipped


Motivation

This package helps developers to provide an easy, declarative configuration interface for their component-like entities—let's call them "widgets"—purely via HTML.

Imagine an accordion widget.

<section id="my-accordion" class="accordion">
...
</section>

The way you would usually let users configure and initialize your widget on a per-instance basis is an additional inline <script>, especially if your content is generated by something like a CMS. It may look something like this:

<script>
new Accordion('#my-accordion', {
  swipeTime: 0.8,
  allowMultiple: true
})
</script>

This is however inconvenient to write, a little obstrusive to read and relatively hard to maintain on the client side, especially for non-developer users.

With this package, you can use the following little block of JavaScript inside your widget

const api = htmlApi({
  swipeTime: {
    type: Number,
    default: 0.8
  },
  allowMultiple: Boolean
})('.accordion')

to make all accordions configurable like so:

<section class="accordion" data-swipe-time="0.5" data-allow-multiple>
...
</section>

This allows users of your widget to configure it exclusively in HTML, without ever having to write a line of JavaScript.

At the same time, the more powerful JavaScript-side API is open to you as the widget developer, featuring many goodies explained below:

// Access the element-level API for the first .accordion
const elementApi = api.for(document.querySelector('.accordion'))

elementApi.options.swipeTime // 0.5
elementApi.options.multiple // true

Installation

Install it from npm:

npm install --save html-api

Include in the browser

You can use this package in your browser with one of the following snippets:

  • The most common version. Compiled to ES5, runs in all major browsers down to IE 11:

    <script src="node_modules/html-api/dist/browser.min.js"></script>
    
    <!-- or from CDN: -->
    
    <script src="https://unpkg.com/html-api"></script>
  • Not transpiled to ES5, runs in browsers that support ES2015:

    <script src="node_modules/html-api/dist/browser.es2015.min.js"></script>
    
    <!-- or from CDN: -->
    
    <script src="https://unpkg.com/html-api/dist/browser.es2015.min.js"></script>
  • If you're really living on the bleeding edge and use ES modules directly in the browser, you can import the package as well:

    import htmlApi from "./node_modules/html-api/dist/browser.module.min.js"

    As opposed to the snippets above, this will not create a global htmlApi function.

Include in Node.js

To make this package part of your build chain, you can require it in Node:

const htmlApi = require('html-api')

If you need this to work in Node.js v4 or below, try this instead:

var htmlApi = require('html-api/dist/cjs.es5')

Note however that the package won't work when run directly in Node since it does rely on browser features like the DOM and MutationObserver (for which at the time of writing no Node implementation is available).

Usage

Once you have somehow obtained the htmlApi function, you can use it to define an HTML API.

Let's take a look at the most basic example with the following markup and JS code:

<button class="btn" data-label="I'm a magic button!"></button>
/*
 * Define an HTML API with only a `label` option which must
 * be a string, and assign it to all .btn elements
 */
htmlApi({
  label: {
    type: String,
    required: true
  }
})('.btn')

/*
 * The `change:label` event will tell whenever the `label` option
 * changes on any `.btn`.
 * It will also trigger when the API is first applied to an element
 * to get an option's initial value.
 */
.on('change:label', event => {
  event.element.textContent = event.value
})

That will make our button be labeled with a cheerful I'm a magic button!.

If now, in any way, the button's data-label attribute value would be changed to "I'm batman.", the change listener will trigger and the button label will update accordingly.

You can try out this example on Codepen.

Note that, because we have set the required flag on the label option to true, we enforce a data-label attribute to always be set. Removing the attribute in this setup would raise an error.

Read and write options

Of course as a widget developer, you could get your options directly from the data-* attributes. However, to make use of features like type casting, you'll have to access them via the JavaScript API.

Let's again take our button example from above:

const api = htmlApi({ label: ... })('.btn')

We have now created an API, reading options from all .btn elements. However, to read (and write) the options of a concrete button element, we need to access the element-based API via the for() method:

const elementApi = api.for(document.querySelector('.btn'))

// read the `label` option
elementApi.options.label // "I'm a magic button!"

// write the `label` option
elementApi.options.label = "I'm batman."

Type constraints

One of the core features of this package is type casting—converting options of various types to strings, i.e. to values of data-* attributes ("serialize"), and evaluating them back to their original type ("unserialize").

Basic constraints

The examples above introduced the simplest of types: String. However, there are many more:

htmlApi({
  // This is the shorthand way to assign a type
  myOption: Type
})

Instead of Type, you could use one of the following:

  • null

    Enforces a value set through elementApi.options.myOption to be...

    null.

    Unserializes the data-my-option attribute by...

    returning null if the serialized value is "null" and throwing an error otherwise.

    Note that every option will be considered nullable if neither the definition marks it as required nor it has a defined default value.

  • Boolean

    Enforces a value set through elementApi.options.myOption to be...

    a boolean: true or false.

    Unserializes the data-my-option attribute by...

    evaluating it as follows:

    • "true" and "" (the latter being equivalent to just adding the attribute at all, as in <input data-my-option>) will evaluate to true
    • "false" and the absence of the attribute will evaluate to false
  • Number

    Enforces a value set through elementApi.options.myOption to be...

    of type number, including Infinity but not NaN.

    Unserializes the data-my-option attribute by...

    calling +value, which will cast a numeric string to an actual number.

  • Array

    Enforces a value set through elementApi.options.myOption to be...

    an array.

    Unserializes the data-my-option attribute by...

    parsing it as JSON.

  • Object

    Enforces a value set through elementApi.options.myOption to be...

    a plain object.

    Unserializes the data-my-option attribute by...

    parsing it as JSON.

  • Function

    Enforces a value set through elementApi.options.myOption to be...

    a function.

    Unserializes the data-my-option attribute by...

    eval()ing it.

    The serialization is done via the function's .toString() method (which is not yet standardized but still works in all tested browsers so far).

    Be aware that because eval() changes pretty much the whole environment of your function, you should only use functions that do not rely on anything but their very own parameter values.

  • htmlApi.Enum(string1, string2, string3, ...)

    Enforces a value set through elementApi.options.myOption to be...

    a string, and as such, one of the provided parameters.

  • htmlApi.Integer

    Enforces a value set through elementApi.options.myOption to be...

    an integer.

    Its range can be additionally constrained by using

    • htmlApi.Integer.min(lowerBound)
    • htmlApi.Integer.max(upperBound) or
    • htmlApi.Integer.min(lowerBound).max(upperBound)
  • htmlApi.Float

    Enforces a value set through elementApi.options.myOption to be...

    any finite number.

    Its range can be additionally constrained by using

    • htmlApi.Float.min(lowerBound)
    • htmlApi.Float.max(upperBound) or
    • htmlApi.Float.min(lowerBound).max(upperBound)

Union constraints

You can use an array of multiple type constraints to make an option valid if it matches any of them.

If you, for example, would like to have an option for your widget that defines the framerate at which animations will be performed, you could do it like this:

const {Integer, Enum} = htmlApi

htmlApi({
  framerate: [ Integer.min(1), Enum('max') ]
})

This would allow the data-framerate to either take any integer value from 1 upwards or max.


Union type constraints are powerful. However, be careful when using them, especially if String is one of them.

If you define an option like the following:

myOption: [Number, String]

you should be aware that the number 5 and the string "5" do serialize to the same value (which is "5").

Consequently, if you set your option's value to a numeric string (like in api.options.myOption = "5"), it will still be unserialized as the number 5.

Generally, serialized options are evaluated from the most narrow to the widest type constraint. For example, Number is more narrow than String because all serialized numbers can be deserialized as strings, but not all serialized strings be deserialized as numbers. This means that the attempt to unserialize a stringified option value check applicable type constraints in the following order:

  1. Custom type constraints
  2. null
  3. Boolean
  4. Number
  5. Array
  6. Object
  7. Function
  8. String

Of course, of this list, only those constraints that are given in an option's definition will be considered.

Custom constraints

You can define your own type constraints. They are just plain objects with a validate, a serialize and an unserialize method.

Since object interfaces in TypeScript are pretty concise and should be readable for most JS developers, here's the interface structure of such a constraint:

interface Constraint<Type> {
  /*
   * Checks if a value belongs to the defined type
   */
  validate (value: any): value is Type

  /*
   * Converts a value of the defined Type into a string
   */
  serialize (value: Type): string

  /*
   * The inverse of `serialize`: Converts a string back to the
   * defined Type. If the string does not belong to the Type
   * this method should throw an Error.
   */
  unserialize (serializedValue: string): Type
}

And since many people (me included) do learn things better by example, this is the structure of this package's built-in Number constraint:

{
  validate: value => typeof value === 'number' && !isNaN(value),
  serialize: number => String(number),
  unserialize: numericString => +numericString
}

Provide default values

If no appropriate data-* attribute for an option is set, its value will default to null.

However, an option definition may provide a default value that will be used instead:

const {Enum} = htmlApi

htmlApi({
  direction: {
    type: Enum('forwards', 'backwards', 'auto'),
    default: 'auto'
  }
})

Now whenever reading elementApi.options.direction without the data-direction attribute set, "auto" will be returned.

Note: Providing a default value for an option is mutually exclusive with marking it as required.

Require option attributes

If an option should neither have a defined default value nor default to null (which could be a potential type constraint violation), you may flag it as required:

const {Enum} = htmlApi

htmlApi(btn, {
  direction: {
    type: Enum('forwards', 'backwards'),
    required: true
  }
})

This will raise an error whenever the data-direction attribute is not set to a valid value.

Note: Marking an option as required is mutually exclusive with providing a default value.

Events

Both the api (returned by htmlApi(config)(elements)) and the elementApi (returned by api.for(element)) are event emitters. They offer on, once and off methods to handle messages coming from them.

Option value changes

Changing an option, either through the elementApi.options interface or through a data-* attribute, will emit two events: change and change:[optionName]

Let's say you somehow changed the previously unset option label to "Greetings, developer". Then you could react to this change by using one of the following snippets:

elementApi.on('change:label', event => {
  /*
   * The `event` object has the following properties:
   */

  /*
   * "Greetings, developer"
   */
  event.value

  /*
   * null
   */
  event.oldValue

  /*
   * `true` if this was triggered by the initialization of the API
   * and not by an actual change
   */
  event.initial
})

You could also listen to any option changes:

elementApi.on('change', event => {
  /*
   * The `event` object has the same properties as in `change:label`
   * and additionally:
   */

  /*
   * "label"
   */
  event.option
})

All those events will also be propagated to the api. That means, you could also do:

api.on('change:label', event => {
  /*
   * The `event` object has the same properties as in
   * elementApi.on('change:label'), and additionally:
   */

  /*
   * The element on which the change happened
   */
  event.element

  /*
   * The element API referring to that element
   */
  event.elementApi
})

The same goes for api.on('change').

Note: Please be aware that option changes will be grouped. That means that setting an option to two different values subsequently (i.e. in the same call stack) will only cause the last one to trigger a change with the oldValue on the event still being the value before the first change since the intermediate change did never apply.

New elements

If you applied your created HTML API to a selector string instead of a concrete element, this package will set up a MutationObserver to keep track of new elements on the website that match the selector.

When such an item enters the site's DOM, it will trigger a newElement event on the api:

api.on('newElement', event => {
  /*
   * The `event` is an object with the following properties:
   */

  /*
   * The newly inserted element
   */
  event.element

  /*
   * The element API referring to that element
   */
  event.elementApi
})

Error handling

The elementApi also emits error events which will be triggered when a required option is missing or an option is set to a value not matching its type constraints:

elementApi.on('error', err => {
  /*
   * The `event` is an object with the following properties:
   */

  /*
   * What caused the error
   * "invalid-value-js", "invalid-value-html" or "missing-required"
   */
  error.type

  /*
   * Some details about the trigger
   * Just the option name for "missing-required", an object in
   * the form of { option, value } for the "invalid-value-*" types
   */
  error.details

  /*
   * A clear English message that tells what went wrong
   */
  error.message
})

As with the change events, all error events will be passed up to the api as well.

Formal definitions

To get a complete picture of what's possible with the htmlApi function, here's its signature:

htmlApi(options: { [option: string]: OptionDefinition|TypeConstraint }): ApiFactory

where

  • An OptionDefinition is a plain object matching the following interface:

    interface OptionDefinition {
      /*
       * A type constraint as defined below.
       * This *must* be set, otherwise the package will not know how
       * to serialize and unserialize option values.
       */
      type: TypeConstraint
    
      /*
       * Tells if the data attribute belonging to this option must
       * be set. If not set or set to `false`, the `default` option
       * will be used.
       */
      required?: boolean
    
      /*
       * A default value, applying when the according data-* attribute
       * is not set. If set, the option must not be `required`.
       */
      default?: any
    }
  • A TypeConstraint is either

    • one of the following constraint shorthands:

      • the Boolean constructor, allowing boolean values or
      • the Number constructor, allowing numeric values or
      • the String constructor, allowing strings or
      • the Array constructor, allowing arrays or
      • the Object constructor, allowing plain objects or
      • the Function constructor, allowing functions or
      • null, allowing the value to be null
    • one of the following built-in constraints:

      • htmlApi.Enum(string1, string2, string3, ...) for one-of-the-defined strings
      • htmlApi.Integer for an integer number whose range might be further constrained via
        • htmlApi.Integer.min(lowerBound)
        • htmlApi.Integer.max(upperBound) or
        • htmlApi.Integer.min(lowerBound).max(upperBound)
      • htmlApi.Float for a finite number whose range might be further constrained via
        • htmlApi.Float.min(lowerBound)
        • htmlApi.Float.max(upperBound) or
        • htmlApi.Float.min(lowerBound).max(upperBound)
    • a custom type Constraint, which is a plain object of the following structure:

      interface Constraint<Type> {
        /*
         * Checks if a value is of the defined type
         */
        validate (value: any): value is Type
      
        /*
         * Converts a value of the defined Type into a string
         */
        serialize (value: Type): string
      
        /*
         * The inverse of `serialize`: Converts a string back to the
         * defined Type. If the string can not be successfully
         * converted to the Type, this method should throw an Error.
         */
        unserialize (serializedValue: string): Type
      }

      This lets you easily define and use your own custom types!

      or

    • a union type, being a non-empty array of any of the above.

      The formal way to describe this would be:

      type UnionType = Array<
        typeof Boolean |
        typeof Number |
        typeof String |
        typeof Boolean |
        typeof Array |
        typeof Object |
        typeof Function |
        null |
        Constraint<any>
      >

      Note: htmlApi.Enum, htmlApi.Integer and htmlApi.Float are not listed in the UnionType definition since they are just Constraint objects.

  • An ApiFactory is a function which takes elements and returns an Api object

    interface ApiFactory {
      (elements: string|Element|Element[]|NodeList|HTMLCollection): Api
    }
  • An Api is a plain object of the following structure:

    interface Api {
      /*
       * An array of all elements the API applies to
       */
      elements: Element[]
    
      /*
       * Gets the element-based API for a certain element
       */
      for (element: Element): ElementApi
    
      /*
       * Adds a listener to the `change` or `error` event
       */
      on (
        event: "change",
        listener: (event: OptionChangeEvent & ElementRelatedEvent) => any
      ): this
      on (
        event: "change:[optionName]",
        listener: (event: ConcreteOptionChangeEvent & ElementRelatedEvent) => any
      ): this
      on (
        event: "error",
        listener: (event: ErrorEvent & ElementRelatedEvent) => any
      ): this
    
      /*
       * Like `on`, but listeners detach themselves after first use
       */
      once (
        event: "change",
        listener: (event: OptionChangeEvent & ElementRelatedEvent) => any
      ): this
      once (
        event: "change:[optionName]",
        listener: (event: ConcreteOptionChangeEvent & ElementRelatedEvent) => any
      ): this
      once (
        event: "error",
        listener: (event: ErrorEvent & ElementRelatedEvent) => any
      ): this
    
      /*
       * Removes listeners
       */
      off (
        event: "change",
        listener: (event: OptionChangeEvent & ElementRelatedEvent) => any
      ): this
      off (
        event: "change:[optionName]",
        listener: (event: ConcreteOptionChangeEvent & ElementRelatedEvent) => any
      ): this
      off (
        event: "error",
        listener: (event: ErrorEvent & ElementRelatedEvent) => any
      ): this
    
      /*
       * Destroys the API, disconnecting all MutationObservers
       * Also destroys all ElementApi objects
       */
      destroy (): void
    }
  • ElementApi is a plain object of the following structure:

    interface ElementApi {
      /*
       * An object with all defined options as properties
       */
      options: { [option: string]: any }
    
      /*
       * Adds a listener to the `change` or `error` event
       */
      on (
        event: "change",
        listener: (event: OptionChangeEvent) => any
      ): this
      on (
        event: "change:[optionName]",
        listener: (event: ConcreteOptionChangeEvent) => any
      ): this
      on (
        event: "error",
        listener: (event: ErrorEvent) => any
      ): this
    
      /*
       * Like `on`, but listeners detach themselves after first use
       */
      once (
        event: "change",
        listener: (event: OptionChangeEvent) => any
      ): this
      once (
        event: "change:[optionName]",
        listener: (event: ConcreteOptionChangeEvent) => any
      ): this
      once (
        event: "error",
        listener: (event: ErrorEvent) => any
      ): this
    
      /*
       * Removes listeners
       */
      off (
        event: "change",
        listener: (event: OptionChangeEvent) => any
      ): this
      off (
        event: "change:[optionName]",
        listener: (event: ConcreteOptionChangeEvent) => any
      ): this
      off (
        event: "error",
        listener: (event: ErrorEvent) => any
      ): this
    
      /*
       * Destroys the API, disconnecting all MutationObservers
       */
      destroy (): void
    }
  • There are several kinds of events mentioned above. They are all plain objects with different structure:

    interface ElementRelatedEvent {
      /*
       * The element the event refers to
       */
      element: Element,
    
      /*
       * The ElementApi for the given element
       */
      elementApi: ElementApi
    }

interface OptionChangeEvent { /* The new value of the option / value: any,

/*
 * The option's previous value
 */
oldValue: any

}

interface ConcreteOptionChangeEvent extends OptionChangeEvent { /* The name of the changed option / option: string }

interface ErrorEvent { type: "missing-required" | "invalid-value-js" | "invalid-value-html"

/*
 * A clear, English error message
 */
message: string

/*
 * Any details on the error
 */
details: any

}

0.4.13

6 years ago

0.4.12

6 years ago

0.4.11

7 years ago

0.4.10

7 years ago

0.4.9

7 years ago

0.4.8

7 years ago

0.4.7

7 years ago

0.4.6

7 years ago

0.4.5

7 years ago

0.4.4

7 years ago

0.4.3

7 years ago

0.4.2

7 years ago

0.4.1

7 years ago

0.4.0

7 years ago

0.2.6

7 years ago

0.2.5

7 years ago

0.2.4

7 years ago

0.2.2

7 years ago

0.2.1

7 years ago

0.2.0

7 years ago