⚙️ Browser JavaScript in Tagged Templates
$ npm install --save-dev @radically-straightforward/javascript
Note: We recommend installing
@radically-straightforward/javascript
as a development dependency because@radically-straightforward/build
removes thejavascript`___`
tagged templates from the server code and bundles the browser JavaScript.
Note: We recommend the es6-string-html Visual Studio Code extension to syntax highlight browser JavaScript in tagged templates.
import javascript, { JavaScript } from "@radically-straightforward/javascript";
export type JavaScript = string;
A type alias to make your type annotations more specific.
export default function javascript(
templateStrings: TemplateStringsArray,
...substitutions: any[]
): JavaScript;
A tagged template for browser JavaScript:
javascript`
console.log(${["Hello World", 2]});
`;
Note: Browser JavaScript is represented as a string and this tagged template works by performing string interpolation. The substitutions are
JSON.stringify()
ed. This is conceptually simple and fast. To extract and process the browser JavaScript refer to@radically-straightforward/build
.
css`
@import "@radically-straightforward/javascript/static/index.css";
`;
javascript`
import * as javascript from "@radically-straightforward/javascript/static/index.mjs";
`;
Importing this module enables the following features:
Detect that the user is following a link, submitting a form, or navigating in browser history and overwrite the browser’s default behavior: instead of loading an entire new page, morph()
the current page into the new one. This speeds up navigation because CSS and browser JavaScript are preserved. Also, it allows for preserving some browser state (for example, scroll position on a sidebar) through careful use of the same key="___"
attribute across pages.
Live Navigation enhances <form>
s in the following ways:
-
More
method
s are supported, includingPATCH
,PUT
,DELETE
, and so forth. This goes beyond the methodsGET
andPOST
that are supported by browsers by default. -
The
CSRF-Protection
HTTP header is set, to satisfy@radically-straightforward/server
’s CSRF Protection mechanism.
If the pages include the <meta name="version" content="___" />
meta tag and the versions differ, then Live Navigation is disabled and the user is alerted through a tippy()
to reload the page. The tippy()
is attached to an element with key="global-error"
or the <body>
’s first child.
When loading a new page, a progress bar is displayed on an element with key="progress-bar"
that is the last child of <body>
. This element may be styled via CSS.
An <a>
or <form>
may opt out of Live Navigation by setting the property element.liveNavigate = false
.
When the page is loaded, the browser JavaScript in the javascript="${javascript`___`}"
attribute is executed. This is made to work along with the @radically-straightforward/build
package, which extracts browser JavaScript from the server code.
The default browser form validation is limited in many ways:
-
Email verification in
<input type="email" />
is too permissive to be practical, allowing, for example, the email addressexample@localhost
, which is technically valid but undesirable. -
Custom validation is awkward to use.
-
It isn’t possible to control the style of the error messages.
@radically-straightforward/javascript
overwrites the default browser behavior and introduces a custom validation mechanism. See validate()
for more information.
If the user has filled a form but hasn’t submitted it and they try to leave the page, then @radically-straightforward/javascript
warns that they will lose data. See isModified()
for more information.
export const configuration;
Global configuration for browser JavaScript.
Example
javascript.configuration.environment = "development";
export async function liveConnection(requestId);
Open a Live Connection to the server.
If a connection can’t be established, then an error message is shown in a tippy()
. The tippy()
is attached to an element with key="global-error"
or the <body>
’s first child.
If the content
of the meta tag <meta name="version" content="___" />
has changed, a Live Connection update doesn’t happen. Instead, a message is shown in a tippy()
instructing to reload the page.
If configuration.environment === "development"
then the page reloads when the connection is closed and reopened, because presumably the server has been restarted after a code modification.
export function mount(element, content, event = undefined);
morph()
the element
container to include content
. execute()
the browser JavaScript in the element
. Protect the element
from changing in Live Connection updates.
export function documentMount(content, event = new Event("DOMContentLoaded"));
Note: This is a low-level function used by Live Navigation and Live Connection.
Similar to mount()
, but suited for morphing the entire document
. For example, it dispatches the event
to the window
.
If the document
and the content
have <meta name="version" content="___" />
with different content
s, then documentMount()
displays an error message in a tippy()
and doesn’t mount the new document. The tippy()
is attached to an element with key="global-error"
or the <body>
’s first child.
export function morph(from, to, event = undefined);
Note: This is a low-level function—in most cases you want to call
mount()
instead.
Morph the contents of the from
container element into the contents of the to
container element with minimal DOM manipulation by using a diffing algorithm.
If the to
element is a string, then it’s first converted into an element with stringToElement()
.
Elements may provide a key="___"
attribute to help identify them with respect to the diffing algorithm. This is similar to React’s key
s, but sibling elements may have the same key
(at the risk of potentially getting them mixed up if they’re reordered).
When morph()
is called to perform a Live Connection update (that is,event?.detail.liveConnectionUpdate
is true
), elements may set a liveConnectionUpdate
attribute, which controls the behavior of morph()
in the following ways:
-
When
from.liveConnectionUpdate
isfalse
,morph()
doesn’t do anything. This is useful for elements which contain browser state that must be preserved on Live Connection updates, for example, the container of dynamically-loaded content (seemount()
). -
When
fromChildNode.liveConnectionUpdate
isfalse
,morph()
doesn’t remove thatfromChildNode
even if it’s missing amongto
’s child nodes. This is useful for elements that should remain on the page but wouldn’t be sent by server again in a Live Connection update, for example, an indicator of unread messages. -
When
fromChildNode.liveConnectionUpdate
or any offromChildNode
’s parents isnew Set(["style", "hidden", "disabled", "value", "checked"])
or any subset thereof, the mentioned attributes are updated even in a Live Connection update (normally these attributes represent browser state and are skipped in Live Connection updates). This is useful, for example, for forms with hidden fields which must be updated by the server.
Note:
to
is expected to already belong to thedocument
. You may need to callimportNode()
oradoptNode()
on a node before passing it tomorph()
.documentStringToElement()
does that for you.
Note:
to
is mutated destructively in the process of morphing. Create a clone ofto
before passing it intomorph()
if you wish to continue using it.
Related Work
morph()
is different from from.innerHTML = to.innerHTML
because setting innerHTML
loses browser state, for example, form inputs, scrolling position, and so forth.
morph()
is different form morphdom
and its derivatives in the following ways:
-
morph()
deals better with insertions/deletions/moves in the middle of a list. In some situationsmorphdom
touches all subsequent elements, whilemorph()
tends to only touch the affected elements. -
morph()
supportskey="___"
instead ofmorphdom
’sid="___"
s.key
s don’t have to be unique across the document and don’t even have to be unique across the element siblings—they’re just a hint at the identity of the element that’s used in the diffing process. -
morph()
is aware of Live Connection updates,tippy()
s, and so forth.
export function execute(element, event = undefined);
Note: This is a low-level function—in most cases you want to call
mount()
instead.
Execute the functions defined by the javascript="___"
attribute, which is set by @radically-straightforward/build
when extracting browser JavaScript. You must call this when you insert new elements in the DOM, for example, when mounting content.
export function tippy({
event = undefined,
element,
elementProperty = "tooltip",
content,
...tippyProps
});
Create a Tippy.js tippy. This is different from calling Tippy’s constructor because if tippy()
is called multiple times on the same element
with the same elementProperty
, then it doesn’t create new tippys but mount()
s the content
.
export function validate(element);
Validate element
(usually a <form>
) and its children()
.
Validation errors are reported with tippy()
s with the error
theme.
Use <form novalidate>
to disable the native browser validation, which is too permissive on email addresses, is more limited in custom validation, and so forth.
You may set the disabled
attribute on a parent element to disable an entire subtree.
Use element.isValid = true
to force a subtree to be valid.
validate()
supports the required
and minlength
attributes, the type="email"
input type, and custom validation.
For custom validation, use the onvalidate
event and throw new ValidationError()
, for example:
html`
<input
type="text"
name="name"
required
javascript="${javascript`
this.onvalidate = () => {
if (this.value !== "Leandro")
throw new javascript.ValidationError("Invalid name.");
};
`}"
/>
`;
validate()
powers the custom validation that @radically-straightforward/javascript
enables by default.
export class ValidationError extends Error;
Custom error class for validate()
.
export function validateLocalizedDateTime(element);
Validate a form field that used localizeDateTime()
. The error is reported on the element
, but the UTC datetime that must be sent to the server is returned as a string that must be assigned to another form field, for example:
html`
<input type="hidden" name="datetime" value="${new Date().toISOString()}" />
<input
type="text"
required
javascript="${javascript`
this.value = javascript.localizeDateTime(this.previousElementSibling.value);
this.onvalidate = () => {
this.previousElementSibling.value = javascript.validateLocalizedDateTime(this);
};
`}"
/>
`;
export function serialize(element);
Produce a URLSearchParams
from the element
and its children()
.
You may set the disabled
attribute on a parent element to disable an entire subtree.
Other than that, serialize()
follows as best as possible the behavior of the URLSearchParams
produced by a browser form submission.
export function reset(element);
Reset form fields from element
and its children()
using their defaultValue
and defaultChecked
properties, including calling element.onchange()
when necessary.
export function isModified(element);
Detects whether there are form fields in element
and its children()
that are modified with respect to their defaultValue
and defaultChecked
properties.
You may set element.isModified = <true/false>
to force the result of isModified()
for element
and its children()
.
You may set the disabled
attribute on a parent element to disable an entire subtree.
isModified()
powers the “your changes may be lost, do you wish to leave this page?” dialog that @radically-straightforward/javascript
enables by default.
export function relativizeDateTimeElement(
element,
{ target = element, capitalize = false, ...relativizeDateTimeOptions } = {},
);
Given an element
with the datetime
attribute, relativizeDateTimeElement()
keeps it updated with a relative datetime. See relativizeDateTime()
, which provides the relative datetime, and backgroundJob()
, which provides the background job management.
Example
html`
<time
datetime="2024-04-03T14:51:45.604Z"
javascript="${javascript`
javascript.relativizeDateTimeElement(this);
`}"
></time>
`;
export function relativizeDateTime(dateString, { preposition = false } = {});
Returns a relative datetime, for example, just now
, 3 minutes ago
, in 3 minutes
, 3 hours ago
, in 3 hours
, yesterday
, tomorrow
, 3 days ago
, in 3 days
, on 2024-04-03
, and so forth.
-
preposition
: Whether to return2024-04-03
oron 2024-04-03
.
export function localizeDateTime(dateString);
Returns a localized datetime, for example, 2024-04-03 15:20
.
export function localizeDate(dateString);
Returns a localized date, for example, 2024-04-03
.
export function localizeTime(dateString);
Returns a localized time, for example, 15:20
.
export function formatUTCDateTime(dateString);
Format a datetime into a representation that is user friendly.
export function stringToElement(string);
Convert a string into a DOM element. The string may have multiple siblings without a common parent, so stringToElement()
returns a <div>
containing the elements.
export function documentStringToElement(string);
Similar to stringToElement()
but for a string
which is a whole document, for example, starting <!DOCTYPE html>
. document.adoptNode()
is used so that the resulting element belongs to the current document
.
export function backgroundJob(
element,
elementProperty,
utilitiesBackgroundJobOptions,
job,
);
This is an extension of @radically-straightforward/utilities
’s backgroundJob()
with the following additions:
-
If called multiple times, this version of
backgroundJob()
stop()
s the previous background job so that at most one background job is active at any given time. -
When the
element
is detached from the document, the background job isstop()
ped. SeeisAttached()
.
The background job object which offers the run()
and stop()
methods is available at element[name]
.
See, for example, relativizeDateTimeElement()
, which uses backgroundJob()
to periodically update a relative datetime, for example, “2 hours ago”.
export function isAttached(element);
Check whether the element
is attached to the document. This is different from the isConnected
property in the following ways:
-
It uses
parents()
, so it supportstippy()
s that aren’t showing but whosetarget
s are attached. -
You may force an element to be attached by setting
element.isAttached = true
on theelement
itself or on one of its parents.
See, for example, backgroundJob()
, which uses isAttached()
.
export function parents(element);
Returns an array of parents, including element
itself. It knows how to navigate up tippy()
s that aren’t showing.
export function children(element);
Returns an array of children, including element
itself.
export function nextSiblings(element);
Returns an array of sibling elements, including element
itself.
export function previousSiblings(element);
Returns an array of sibling elements, including element
itself.
export const isAppleDevice;
export const isSafari;
export let isPhysicalKeyboard;
Whether the user has a physical keyboard or a virtual keyboard on a phone screen. This isn’t 100% reliable, because it works by detecting presses of modifiers keys (for example, control
), but it works well enough.
export let shiftKey;
Whether the shift key is being held. Useful for events such as paste
, which don’t include the state of modifier keys.