@nejs/html
v3.0.6
Published
A very convenient way to generate HTML elements using JavaScript
Downloads
491
Readme
ne-html
A very convenient way to generate HTML elements using JavaScript
Overview
Creating elements in a pure JavaScript environment can be a troublesome affair. It requires a lot of steps per element and nesting and creating many can quickly become something you don't want to deal with. In environments such as Angular, or React, working with web components can mean additional steps as well.
While React and other frameworks are lighter weight than something like Angular, working with pure JavaScript in the latter space might be desirable in some cases. An example is when other frameworks want to manage two way binding and find themselves battling an Angular component that wishes to do the same.
HTML
allows creating many HTML elements in JavaScript with little
fuss and in a manner that other devs on your team can read legibly.
Additionally, it provides a mechanism that allows you to register a previously created element (within the same runtime) that can be given a name. This composite element can then be parameterized and created in a clean way without having to deal directly with things like webcomponents.
Finally, there is support for attaching a shadowDOM to your created elements and inserting other elements quickly and easily.
Table of Contents
Installing
Install to your npm project using
npm i @nejs/html
Or if you want to directly use it on a webpage, see the jsdelivr section below.
Using jsdelivr cdn
JS Delivr makes it very easy to grab a version of the library right from the browser. To fetch the latest ESM module version you can write some code like the following and add it your code
const {
HTML,
commands,
} = await import (
'https://cdn.jsdelivr.net/gh/nyteshade/ne-html/dist/esm/html.js'
);
Or in non ESM environments, the script can be added using a script tag, which
will create the var nejs_html
which will have the object properties HTML
and commands
exported.
<script type="application/javascript" src="https://cdn.jsdelivr.net/gh/nyteshade/ne-html/dist/@nejs/html.bundle.latest.js"></script>
If you would prefer to create and append the script tag using JavaScript, here is a nice little pasteable script.
async function nsJsHtmlScript(useDocument) {
const doc = useDocument ?? top.document;
const deferred = { promise: undefined };
const jsDelivrBase = 'https://cdn.jsdelivr.net';
const url = new URL(
'/gh/nyteshade/ne-html/dist/@nejs/html.bundle.latest.js',
jsDelivrBase
);
deferred.promise = new Promise((resolve, reject) => {
Object.assign(deferred, { resolve, reject })
})
const scriptTag = document.createElement('script');
scriptTag.setAttribute('type', 'application/javascript');
scriptTag.setAttribute('src', url.href);
scriptTag.onload = function() {
deferred.resolve(globalThis?.nejs?.html);
}
doc.body.append(scriptTag);
return deferred.promise;
}
const { HTML, commands } = await nsJsHtmlScript();
Getting Started
In its base form, the syntax looks a bit like this:
const element = HTML.create('div', 'text node content');
This is far more terse than the equivalent
const element = document.createElement('div');
element.append(document.createTextNode('text node content'));
And while this is not a big deal, this is also a very small example, and almost never do you need a contrived example as simple as this. When you need to set attributes and styles, gets even more complex.
Let's look at this MDN example:
<div class="preference">
<label for="cheese">Do you like cheese?</label>
<input type="checkbox" name="cheese" id="cheese" />
</div>
<div class="preference">
<label for="peas">Do you like peas?</label>
<input type="checkbox" name="peas" id="peas" />
</div>
Creating this with JavaScript would be something like the following:
const preference1 = document.createElement('div');
const label1 = document.createElement('label');
const textInput1 = document.createElement('input');
const preference2 = document.createElement('div');
const label2 = document.createElement('label');
const textInput2 = document.createElement('input');
label1.append(document.createTextNode('Do you like cheese?'));
label1.setAttribute('for', 'cheese');
textInput1.setAttribute('type', 'checkbox');
textInput1.setAttribute('name', 'cheese');
textInput1.setAttribute('id', 'cheese');
preference1.classList.add('preference');
preference1.append(label1, textInput1);
label2.append(document.createTextNode('Do you like peas?'));
label2.setAttribute('for', 'peas');
textInput2.setAttribute('type', 'checkbox');
textInput2.setAttribute('name', 'peas');
textInput2.setAttribute('id', 'peas');
preference2.classList.add('preference');
preference2.append(label2, textInput1);
However, using the HTML
class, it looks more like this:
const preference1 = HTML.div({
class: 'preference',
children: [
HTML.label({for: 'cheese', content: 'Do you like cheese?' }),
HTML.input({type: 'checkbox', id: 'cheese', name: 'cheese' }),
]
});
const preference2 = HTML.div({
class: 'preference',
children: [
HTML.label({for: 'peas', content: 'Do you like peas?' }),
HTML.input({type: 'checkbox', id: 'peas', name: 'peas' }),
]
});
API
The HTML.create()
method is the primary interface. As hinted above,
you can use the tag name as a property off of HTML, but it is actually
a proxied shortcut. It also uses the HTML.create()
method.
HTML.create()
Creates an HTML element based on specified options, applying attributes, styles, content, and potentially a shadow DOM with custom CSS variables.
Strings supplied for 'content' will be wrapped in a DOM TextNo rather than applied as innerHTML; this is intentional. The HTML.create() method can receive its input in one of two manners, either in normal ordered input format (as with most functions) or it can be given a name and then supplied an obje with more values. This latter format provides more flexibility and some helper values that translate automatically into their expected locations on the created element.
Ordered Parameters (only name is required)
name
- the tag namecontent
- optional string of content for the tagstyle
- an object with style properties that will be applied as a style attribute. So{ fontName: 'courier' } becomes
`attributes
- an object of additional tag attributes that are applied usingelement.setAttribute
. So an object with{ id: 'identifier' }
becomes<tag id="identifier">
webComponentName
- this must be supplied if the element being created is a web component. This should be a string that will automatically be converted to{ is: value }
before being used withdocument.createElement()
useDocument
- a way to specify an alternatedocument
object than the globaldocument
. Can be used to denote a frame'sdocument
or another window'sdocument
children
- an array ofHTMLElement
object instances tha or strings that will be appended to the created element.shadow
- if preset, this should be either an object or an array. If supplied as an array, it will representHTMLElement
instances that are inserted into the attach shadowRoot of the created element. If it is an object it should have the keysoptions
andchildren
. Omitting either value will result in defaults being added. The default shadow dom options are{ mode: 'open', clonable: true, slotAssignment: 'named' }
. These can be overridden individually or as a whole by specifying each and their new value in `{shadow: {options: { ... }}}
Object Parameters (only name is required)
name
config
The config can process the above ordered properties when given as object keys. These map, in the same order, as the ordered parameters that were just described above:content
,style
,attributes
,webComponentName
,useDocument
,children
,shadow
Additionally, the following properties can be supplied
class
: this property becomes aclass="value"
attribute on the resulting element.classes
: this must be an array of strings, non-string values will be ignored. Each will be joined with a space in between and appended to any previously existing class attribute. If no previous class attribute exists, one will be created. If the resulting filter of non-strings leaves an empty string, and not previous class attribute was otherwise specified, no class attribute will be createdataset
: any object supplied here will have its keys and values set on the element.dataset object. When assigning keys and values to this object in a browser they become attributes on a tag with the 'data-' prefix. These values must be valid names or an error will occur. Camel cased values will be shown with dashes. So'greatScott'
become<tag data-great-scott>
.
Finally, any properties in the config object that have not been
mentioned, will be translated to an attribute of the resulting
element. So HTML.div({id: 'fun'})
becomes <div id="fun"></div>
Some Examples:
// Create a simple element with content:
const element = HTML.create('div', {content: 'Hello, world!'})
document.body.appendChild(element);
// Using the prototype chain proxy, simpler syntax can be
// achieved.
const element = HTML.div('Hello world');
// <div>Hello world</div>
// This syntax is also overloaded for convenience in many ways
const element = HTML.div([
HTML.label({ for: 'input-name', content: 'Name' }),
HTML.input({ id: 'input-name, type: 'text' }),
])
// <div>
// <label for="input-name">Name</label>
// <input type="text" id="input-name">
// </div>
HTML[commands.register] ↩︎ Back to ◌ Top ◌ API
Registers a factory function under a given name with optional configuration, binding context, and additional arguments. This method stores the factory function and its associated data in a centralized storage, allowing for retrieval and utilization elsewhere in the application. For read only or otherwise immutable components, the factories can be fairly simply. Simply stamping down a new component as one might expect.
For composite components that support dynamism in their creation a more complex factory is often required, but also engenders greater reusability. See example
- { string } name - The unique name to register the factory function under.
- { Function } factoryFunction - The factory function to register.
- { Object } [config={}] - Optional configuration object for the factory function.
- { any } thisArg - The value of
this
to be used when invoking the factory function. - { ...any } args - Additional arguments to pass to the factory function upon invocation.
HTML[commands.register](
'SmRedButton',
buttonFactory,
{color: 'red'},
this,
'small'
);
const button = HTML.SmRedButton();
// This registers a `buttonFactory` under the name 'SmRedButton'
// with a configuration specifying the color as 'red', binds
// `this` for context, and passes 'small' as an argument.
HTML[commands.registered] ↩︎ Back to ◌ Top ◌ API
Invoking the HTML[commands.registered]()
function will return a
Iterable that can be walked or converted to an array using either
[...HTML[commands.registered]()]
or Array.from(HTML[commands.registered]())
.
The elements inside are a set of entries, in the format:
[[registeredName, registeredMetadata]]
Each registeredMetadata
is a Map
that has at least these four keys
registered for each composite component
factory
- the function that generates html elements when invokedconfig
- the preset configuration that allows you to, by default, get different output from the same function under differently registered namesthisArg
- an optionalthis
for thefactory
function execution. Note: if you supply a big arrow function (() => {}
) then you will not be able to apply athisArg
to its execution. Use a normal function if this is a need you have.args
- note that arguments here are essential the first n-arguments in order, but if your factory doesn't support working with configs
Effectively, when a composite element is created, this data is used like this:
(...dynamicArgs) => factory.call(
thisArg,
config,
...args,
...dynamicArgs
)
Where args
are the arguments at the time of registration, and
dynamicArgs
are those passed into the call.
const button = HTML.SmRedButton(clickHandler);
// would result in an invocation similar to the following, assuming
// you were using the example used in HTML[commands.registered]
factory.call(thisArg, {color: 'red'}, 'small', clickHandler);
A note on the proxy in the prototype (what?!)
The HTML
class has a proxy inserted in its prototype chain. This
proxy allows you to do the magic that is HTML.div
instead of the
more verbose HTML.create('div')
.
The way it works is that the proxy checks for previously registered
composite elements (see below) and then it checks if the property
was 'create' (as in HTML.create
). If it was neither of those,
then it invokes HTML.create
using the property name as the
tagName
.
Since there is no div
property on the HTML
class, it considers this
property access to evaluate to a bound version of HTML.create
with
the already supplied first parameter, the tag name, of div
.
What are the commands
symbols?
In some of these examples you'll see an import like
import {HTML, commands} from '@nejs/html';
Well as noted above, we don't want to pollute the HTML
property
space with anything that might be a tag you want to use in your
application. The proxy in the prototype, checks for string
properties,
not symbols.
Ala, extra functionality via the magic of the Symbol
class. If you
don't know what a symbol is in JavaScript, take a look at the
MDN site for Symbol.
The only real command of import for external users is the register
command.
commands.register
- this is used to tellHTML
that you have a factory function that when invoked will return a validHTMLElement
. While no type checking is done, failure to follow this pattern may result in confusion.
Composite elements
Sometimes you may have created a custom element that you want to reuse,
again without creating something like an Angular component or installing
React. In this case, the HTML
class allows you to register a factory
function that returns an element.
Let's take the above example with a label and checkbox input type. If you'll recall it looked like this.
const preference1 = HTML.div({
class: 'preference',
children: [
HTML.label({for: 'cheese', content: 'Do you like cheese?' }),
HTML.input({type: 'checkbox', id: 'cheese', name: 'cheese' }),
]
});
Let's turn this into a composite, and reusable element. Factory functions receive a config object as their first parameter. This parameter provides a non-argument based method of customizing the output of the factory function.
Functionally, there are three key bits of information a labeled
checkbox needs to track. The id
, which needs to match the for
attribute of the label.. The name
attribute which is important
for the form to know which checkbox was checked. And lastly, the
text content for the label itself.
If we want to default to "preference" for the class of the containing div, then we don't need to do anything, but we can make it flexible by using that value unless its defined;
const defaultConfig = {
label: 'undefined',
name: 'undefined',
id: 'undefined',
class: 'preference',
}
function LabeledCheckbox(config = defaultConfig) {
const id = config?.id ?? Math.random().toString(36).slice(2);
const name = config?.name;
const labelText = config?.label;
const element = HTML.div([
HTML.label({ for: id, content: labelText }),
HTML.input({ type: 'checkbox', id, name })
]);
element.classList.add(config?.class ?? 'preference');
return element;
}
At this point, if we call LabeledCheckbox()
we will get the
elements but there are going to be a lot "undefined"
values that
we don't want and its pretty verbose to specify the config each time.
So lets register it as a factory and see what it looks like
HTML[commands.register]('LabeledCheckbox', LabeledCheckbox, {
label: 'Do you like cheese?',
name: 'cheese',
});
const checkbox1 = HTML.LabeledCheckbox();
This is pretty neat, but it will always ask the same question and
by itself, that's not very helpful. Let's modify our implementation
of LabeledCheckbox
.
function LabeledCheckbox(config = defaultConfig, ...args) {
const [argsLabel, argsName] = args;
const id = config?.id ?? Math.random().toString(36).slice(2);
const name = config?.name;
const labelText = argsLabel ?? config?.label ?? 'ERROR';
const element = HTML.div([
HTML.label({ for: id, content: labelText }),
HTML.input({ type: 'checkbox', id, name })
]);
element.classList.add(config?.class ?? 'preference');
return element;
}
Now if we register this modified version which takes into account the supplied arguments we get something more useful. Ideally, error checking and other measures should go into your factory functions.
const preferences = [
HTML.LabeledCheckbox('Do you like cheese?', 'cheese'),
HTML.LabeledCheckbox('Do you like peas?', 'peas'),
];
document.body.append(...preferences);
This is nice, neat, tidy and will feel a lot like using webcomponents, Angular components or React. But it will be plain JavaScript.
A final word about the shadowDOM
ShadowDOM elements allow you to simplify your usage of shadowRoot and attaching such to your elements after creation. While most of the elements that use a shadowRoot are registered webcomponents, you can add these to supported elements that are not such.
The following elements are allowed to have a shadow root attached
'article', 'aside', 'blockquote', 'body', 'div', 'footer',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'main', 'nav',
'p', 'section', 'span'
Additionally any element that has a tag name with a hyphen in it will be allowed by the browser.
Creating an element with a shadowRoot using HTML
is very simple. It
looks like this.
const shadyDiv = HTML.div({shadow: [
HTML.label({ for: 'name', content: 'What is your name?' }),
HTML.input({ type: 'text', placeholder: 'Your name' }),
]})
Inspecting shadyDiv
in a browser will often look like this:
<div>
#shadow-root (open)
</div>
This brings up an important note; the default options passed to
element.attachShadow
are:
{
mode: 'open',
clonable: true,
slotAssignment: 'named',
}
These can be overridden by using the full object syntax when
adding a shadowRoot
to your element using HTML
. You will
need to supply one of the default applied values with a different
value to change it.
const shadyDiv = HTML.div({shadow: {
options: {
mode: 'closed',
clonable: false,
slotAssignment: 'manual',
// ...any other attachShadow() config value
},
children: [
HTML.label({ for: 'name', content: 'What is your name?' }),
HTML.input({ type: 'text', placeholder: 'Your name' }),
],
}})
Changelog
3.0.6 - forgot to run npm build
3.0.5 - some additional protections put in place around cssVar.set, as well as some additional freaking TypeScript ensured values put into place.
3.0.3 - npmjs not responding properly, trying version bump
3.0.2 - More typescript crap
3.0.1 - Le sigh. Small oversight in
cssVar
property.3.0.0 - Provides more robust options.content conversion for text nodes. This will allow types to better and more naturally be converted whereas previously it was a bit too blind.
Additionally full documentation for registered composite components and all typed methods are added to the built html.d.ts file so that TypeScript users, misguided as they are, will see documentation in its full glory.
Major rev due to changing how levels and composite components are registered. Previously they were set to be invoked as a separate command but I realised that since we were deploying to a browser my previous concerns about horizontal scaling did not apply. Its not a server side component.
2.4.3 - Adding comments to html.d.ts and fixing a small type issue.
2.4.2 - TypeScript, you are the reason we cannot have nice things
2.4.1 - TypeScript, ever annoying, needs some hand crafted types for HTML since it uses a Proxy in its prototype chain
2.4.0 - Added unit tests for
HTML.Levels
and significantly improved documentation.2.3.0 - Added in the
HTML.Levels
composite element both as an example as well as the first use case upon which this library was heavily tested.2.1.0 - Added basic
vitest
tests and allowed registered composite elements to have a specified thisArg and variable parameters making them more reusable and configurable.All previous versions - anything prior to 2.1.0 is considered to be alpha and buggy. Only present for historical reasons.