⚙️ 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 to reload the page through an element with key="global-error"
which you may style.
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 browser JavaScript in javascript="${javascript`___`}"
attributes may run on the same element on Live Navigation and on Live Connection updates. If you used something like addEventListener()
the same event listener would be added repeated. Instead, you should use something like the onclick
property.
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 async function liveConnection(requestId, { reload = false });
Open a Live Connection to the server.
If a connection can’t be established, then an error message is shown in an element with key="global-error"
which you may style.
If the content
of the meta tag <meta name="version" content="___" />
has changed, a Live Connection update doesn’t happen. Instead, an error message is shown in an element with key="global-error"
which you may style.
If reload
is true
then the page reloads when the connection is closed and reopened, because presumably the server has been restarted after a code modification during development.
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 an element with key="global-error"
which you may style.
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
element into the contents of the to
element with minimal DOM manipulation by using a diffing algorithm.
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
from.liveConnectionUpdate
or any offrom
’s parents isnew Set(["style", "hidden", "disabled", "value", "checked"])
or any subset thereof, the mentioned attributes and properties are updated even in a Live Connection update (normally these attributes and properties 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. -
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.
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 = "tippy",
content,
...tippyProps
});
Create a Tippy.js tippy. This is different from calling Tippy’s constructor for the following reasons:
-
If
tippy()
is called multiple times on the sameelement
with the sameelementProperty
, then it doesn’t create new tippys butmount()
s thecontent
. -
The defaults are different:
-
ignoreAttributes
: Set totrue
because we activetippy()
via JavaScript, not HTMLdata
attributes. -
arrow
: Set tofalse
, because most user interfacetippy()
s don’t use an arrow. -
offset
: Set to[0, 0]
, becausearrow
has been set tofalse
so it doesn’t make sense to distance thetippy()
from the trigger. -
touch
: Set is only set totrue
by default if:-
interactive
is set totrue
, because most noninteractivetippy()
s don’t work well on touch devices (see https://atomiks.github.io/tippyjs/v6/misc/#touch-devices), but most interactivetippy()
s work well on touch devices (usually they’re menus). -
trigger
is set to"manual"
, because we understand it to mean that you want to control the showing of thetippy()
programmatically.
-
-
hideOnClick
: Set tofalse
iftrigger
is"manual"
, because we understand it to mean that you want to control the showing of thetippy()
programmatically. -
duration
: Respectprefers-reduced-motion: reduce
by default.
-
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.