html-api
v0.4.13
Published
Makes creating an HTML API a breeze
Downloads
6
Readme
HTML API
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 thelabel
option totrue
, we enforce adata-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
orfalse
.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 totrue
"false"
and the absence of the attribute will evaluate tofalse
Number
Enforces a value set through
elementApi.options.myOption
to be...of type
number
, includingInfinity
but notNaN
.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)
orhtmlApi.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)
orhtmlApi.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:
- Custom type constraints
null
Boolean
Number
Array
Object
Function
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 eitherone 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 benull
- the
one of the following built-in constraints:
htmlApi.Enum(string1, string2, string3, ...)
for one-of-the-defined stringshtmlApi.Integer
for an integer number whose range might be further constrained viahtmlApi.Integer.min(lowerBound)
htmlApi.Integer.max(upperBound)
orhtmlApi.Integer.min(lowerBound).max(upperBound)
htmlApi.Float
for a finite number whose range might be further constrained viahtmlApi.Float.min(lowerBound)
htmlApi.Float.max(upperBound)
orhtmlApi.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
andhtmlApi.Float
are not listed in theUnionType
definition since they are justConstraint
objects.
An
ApiFactory
is a function which takes elements and returns anApi
objectinterface 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 }