Skip to content

Extensible DOM node creation library for JavaScript programmers

License

Notifications You must be signed in to change notification settings

foxbunny/hyperscribe

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

11 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Build Status

Hyperscribe

Extensible DOM node creation library for JavaScript programmers

Overview

Hyperscribe is a library of functions that can be used to construct plain vanilla DOM nodes. It works in any browser as well as on NodeJS with JSDOM. It's simple JavaScript, not JSX, so it compiles without any additional tooling.

The main motivation for Hyperscribe was to serve as a library that would be write-once-extend-forever. Extensibility of the library was prioritized over anything else, and new features can be added trivally thanks to its hooks system.

Installation

Install from the NPM repository with NPM:

npm install hyperscribe

or with Yarn:

yarn add hyperscribe

Quick tour

The library contains a generic function createElement, which can be imported and used for creating elements (surprise!).

import {createElement} from 'hyperscribe';

createElement('div', 'Hello, DIV!');

The first argument is the tag name, and the second is the child element, a text node.

If we want to add some properties to the nodes, we can do so using an object:

createElement('div', {class: 'test'}, 'Hello, DIV!');

The role of the arguments, except for the first one, are determined by their type, not position. The last example, and the following one produce identical results:

createElement('div', 'Hello, DIV!', {class: 'test'});

It is also possible to have multiple objects with properties. Here's an example:

createElement('div', {id: 'myDiv'}, 'Hello, DIV!', {class: 'test'});

Both objects are applied as expected. Note though that, if two objects contain the same properties, the latter one will override the previous one. It's generally not necessary nor recommeded to do this, though. There is no reason why you would not keep all properties in a single object, and before any children.

Tag shortcuts

It would be pretty horrible if we had to write createElement every time. We can alias it to something like a $ or _ to make things a bit better, but this library provides an even better solution: a version of createElement for each tag.

Here are some examples:

import {
  p,
  label,
  input,
  span,
} from 'hyperscribe';

p(
  {clas: 'field'},
  label(
    span(
      {class: ['labelText', 'required']},
      'Email:'
    ),
    input(
      {type: 'email', name: 'email', 'autofocus': true}
    ),
  ),
);

If you squint a bit, it even looks a bit like like HTML!

The only exception to the tag names is the <var> tag, which clashes with the JavaScript's var keyword and has therefore been renamed to just v.

Argument types

Regarless of how many there are and in what order they appear, all arguments (except for the very first one which is the tag name) are basically one of three types:

  • A plain object.
  • A function.
  • Everything else.

Plain objects are used as element properties. Functions are so-called hooks that are called with the newly created element. We will talk more about hooks later. All other types of objects, including non-plain objects, arrays, strings, boolean, and so on, are treated as children. We will discuss the semantics of each of the child types later.

Properties

The main difference between the properties we pass to the elements in Hyperscribe and the HTML attributes we commonly used in the HTML files is that these are all DOM node properties rather than attributes.

Some attributes have a dash in the name. For example, accept-charset on form elements. These must all be specified in camelCase, so acceptCharset is the correct property.

Another thing to keep in mind with properties is that it's possible to specify arbitrary non-standard properties and they all get assigned! For example:

import {span} from 'hyperscribe';

const s = span({foo: 'bar'});
console.log(s.foo); // logs 'bar'

The for attribtue can be used as expected despite it being htmlFor when used as a DOM property. You can also use htmlFor and it has the same meaning (don't use both, though). Similarly, tabindex can be used as an alias for tabIndex.

Classes

The classes are specified using the class property. The usual approach is to use strings, but if we want to specify multiple classes, it may sometimes be more convenient to use arrays instead. For example:

import {div} from 'hyperscribe';
import css from './my.module.css';

div({class: [css.top, css.content]});

Style property

Unlike the style attribute, the style property does not support strings. Instead, we pass CSS rules as an object:

import {div} from 'hyperscribe';

const myWidth = 200;

div({style: {
  borderRadius: '5px',
  width: `${myWidth}px`,
}})

As with properties, any rules that have a dash in it must be camelCased (e.g., backgroundImage instead of background-image).

Event handlers

Event handlers are simply added using on* properties. For example, a click event handler is added using the onclick property. There is absolutely no magic behind this: the event listeners are literally assigned to those properties on the DOM element.

import {button} from 'hyperscribe';

button(
  {
    onclick: function () { 
      alert('clicked!'); 
    },
  }, 
  'Click me',
);

If you are used to addEventListeners() or even swear by it, this may be confusing or outright wrong. There is nothing inherently wrong about using the on* properties. They work just as well.

The only real limitation is that you can only have one listener per event type. This is effectively solved by using a function that will itself dispatch to several other functions as needed.

If, for any reason you decide that this approach is not good, you can use hooks:

import {button} from 'hyperscribe';

button(
  function (el) {
    el.addEventListener('click', function () {
      alert('clicked!');
    });
  },
  'Click me',
);

Data attribtues

In the past, it was very common to use data- attributes to store auxillary information in elements. To specify them using properties, we use the dataset property which is an object containing all the data properties we want.

import {div} from 'hyperscribe';

div({dataset: {
  foo: 'bar',
  bar: 'baz',
}});

Using non-string values in the dataset property should be avoided, as any and all values are coerced into strings. If you want to preserve JavaScript values, it is recommended to either keep them out of the DOM completely, or use a non-standard custom property.

ARIA attributes

ARIA attributes are special. They do not have a DOM property counterpart. They are handled through the role and aria property in hyperscribe, and are added via setAttribute(). When retrieving them, you will have to use getAttribute().

import {div} from 'hyperscribe';

div({
  role: 'progressbar',
  aria: {
    valuemin: 0,
    valuemax: 100,
    valuenow: 15,
  },
});

Children

Elemnts can have child nodes. Passing one of the following as an argument to createElement or one of the shortcuts will count as a child element:

  • Element object.
  • Comment object.
  • Text object.
  • Objects with toElement() method.
  • Objects with toString() method.
  • undefined and null.
  • Any types not explictly on this list.
  • An array is treated as an array of the above, recursively.

The Element, Comment and Text objects are added to the parent node directly.

Objects that have an toElement() method will be expected to return an Element or a Comment object and that will be appended to the parent node.

Objects that have a toString() will be added as text nodes where the return value of the method is used as the content.

undefined and null are rendered as HTML comments <!--undefined--> and <!--null--> respectively. This should make troubleshooting easier.

Any types that are not one of the above or an array will be coerced into a string and added as text nodes.

Arrays are flattened and each element of the array is added according to the above rules. This is done recursively.

When it comes to children, there are two special properties that any non-null child can have: onbeforeappend and onappend. If these properties are present and are functions, they will be called before and after the child is added to its parent, respectively. This applies not only to Element objects but also other objects, including arrays of children. For example:

import {div, span} from 'hyperscribe';

div(
  span({onappend: function (parent) {
    // This is called right after the `span` element is appeneded to `div`. The
    // only argument to this function is the parent element.
    console.log('My parent is', parent);
  }})
)

Hooks

Any argument to createElement or its shortcuts that is a function is called a hook. A hook is a simple and effective way to extend the behavior of createElement: it is just function that gets called with the element that is being created.

Let's say we want to collect refrences to elements like we can in Vue or React. This can be accomplished easily with a hook.

import {div} from 'hyperscribe';

function ref(refObj, name) {  // <-- this is a hook factory
  return function (el) {  // <-- this is the hook
    refObj[name] = el;
  };
}

const refs = {};

div(
  ref(refs, 'top'),
  div(
    ref(refs, 'inner'),
    'Hello, World!',
  ),
);

After the code above is executed, the refs object looks like this:

{
  top: [object HTMLDivElement],
  inner: [object HTMLDivElement],
}