⚙️ 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.
Forms in pages using Live Navigation must not use <form>
. Instead, they must use <div type="form">
(in block contexts) or <span type="form">
(in inline contexts).
Reasons
<form>
triggers browser validation, which has some issues, including being too permissive with<input type="email">
(allowing, for example,localhost
), being too rigid in custom validation, and so forth. We implement our own validation system invalidate()
.<form>
’smethod="___"
must be either"GET"
or"POST"
, but<div type="form">
supports any HTTP verb.<div type="form">
sets theCSRF-Protection
HTTP header to satisfy@radically-straightforward/server
’s CSRF Protection mechanism.<div type="form">
s may be nested.
Example
<div type="form" method="PATCH" action="/"> <input type="text" name="example" placeholder="Name…" /> <button type="submit">Submit</button> </div>
Note:
<button>
s must have an explicittype="submit"
.
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.
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.
Unlike most events, browsers don’t support handling the focusin
and focusout
events with element.onfocusin
/element.onfocusout
properties—browsers require the use of addEventListener()
. We add support for these properties, which are convenient because: 1. They bubble, unlike the related focus
and blur
events; and 2. Setting event handler properties is idempotent, which is required by javascript="___"
snippets.
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 documentMount(content);
Note: This is a low-level function used by Live Navigation and Live Connection updates.
Similar to mount()
, but suited for morphing the entire document
, for example, documentMount()
dispatches the DOMContentLoaded
event.
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 mount(element, content);
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 morph(from, to);
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).
Elements may define a state="___"
attribute, typically through the state___()
functions below, which is not morphed and is meant to include browser state, for example, whether a sidebar is open.
In general, the following attributes aren’t morphed: state
, style
, hidden
, open
, and disabled
.
Elements may set a morph
attribute, which when false
prevents the element from being morphed. This is useful, for example, for elements that have been mount()
ed and shouldn’t be removed.
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.
Note: Elements may define an
onremove()
function, which is called before the element is removed during morphing. This is useful, for example, to prevent leaks of attachedIntersectionObserver
s andMutationObserver
s by callingIntersectionObserver.disconnect()
andMutationObserver.disconnect()
.
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 different from React in that it works with the DOM, not a Virtual DOM.
export function execute(element);
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:
javascript.execute(
document
.querySelector("body")
.insertAdjacentElement(
"afterbegin",
javascript.stringToElement(html`<div javascript="___"></div>`),
),
);
export function documentStringToElement(string);
Similar to stringToElement()
but for a string
which is a whole document, for example, starting with <!DOCTYPE html>
. document.adoptNode()
is used so that the resulting element belongs to the current document
.
export function stringToElements(string, { svg = false });
Convert a string into a DOM element. The string may have multiple siblings without a common parent, so stringToElements()
returns a <div>
containing the elements. If svg
is true
, then the element is created in the SVG namespace, which is necessary for SVG elements to be drawn by the browser, and the container is an <svg>
tag instead of a <div>
.
export function stringToElement(string, options = {});
A specialized version of stringToElements()
for when the string
is a single element and the wrapper <div>
is unnecessary.
export function isModified(element, { includeSubforms = false } = {});
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 will be lost if you continue.” dialog that @radically-straightforward/javascript
enables by default.
export function reset(element, { includeSubforms = false } = {});
Reset form fields from element
and its children()
using their defaultValue
and defaultChecked
properties, including dispatching the input
and change
events.
export function validate(element, { includeSubforms = false } = {});
Validate element
(usually a <div type="form">
) and its children()
.
Validation errors are reported with popover()
s with the .popover--error
class, which you may style.
You may set the disabled
attribute on a parent element to disable an entire subtree.
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 serialize(element, { includeSubforms = false } = {});
Produce a FormData
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 the behavior of new FormData(form)
.
export function relativizeDateTimeElement(
element,
dateString,
{ capitalize = false, ...relativizeDateTimeOptions } = {},
);
Keep an element updated with the relative datetime. See relativizeDateTime()
(which provides the relative datetime) and backgroundJob()
(which provides the background job management).
Example
const date = new Date(Date.now() - 10 * 60 * 60 * 1000);
html`
<span
javascript="${javascript`
javascript.relativizeDateTimeElement(this, ${date.toISOString()});
javascript.popover({ element: this });
`}"
></span>
<span
type="popover"
javascript="${javascript`
this.textContent = javascript.localizeDateTime(${date.toISOString()});
`}"
></span>
`;
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 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 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, for example, 2024-04-03 15:20 UTC
.
export function stateAdd(element, token);
Add a token
to the state="___"
attribute
The state="___"
attribute is meant to be used to hold browser state, for example, whether a sidebar is open.
The state="___"
attribute is similar to the class="___"
attribute, and the state___()
functions are similar to the classList
property. The main difference is that morph()
preserves state="___"
on Live Connection updates.
The state="___"
attribute is different from the style="___"
attribute in that state="___"
contains token
s which may be addressed in CSS with the [state~="___"]
selector and style="___"
contains CSS directly.
export function stateRemove(element, token);
See stateAdd()
.
export function stateToggle(element, token);
See stateAdd()
.
export function popover({
element,
target = element.nextElementSibling,
trigger = "hover",
remainOpenWhileFocused = false,
placement = trigger === "hover"
? "top"
: trigger === "click"
? "bottom-start"
: trigger === "showOnce"
? "top"
: trigger === "none"
? "top"
: (() => {
throw new Error();
})(),
onshow,
onhide,
});
Create a popover (tooltip, dropdown menu, and so forth).
Note: The
target.popoverTriggerElement
property is set to refer toelement
.
Parameters
-
element
: The element that is used as reference when positioning the popover and that triggers the popover open. -
target
: The element that contains the popover contents. It must have thetype="popover"
type, and it may have one of the.popover--<color>
classes (see@radically-straightforward/javascript/static/index.css
). As a special case, iftrigger
is set to"showOnce"
, thentarget
may be a string which is turned into a DOM element bypopover()
. -
trigger
: One of the following:-
"hover"
: Show the popover on theelement.onmouseenter
orelement.onfocusin
events and hide the popover on theelement.onmouseleave
orelement.onfocusout
events. Thetarget
must not contain elements that may be focused (for example,<button>
,<input>
, and so forth), otherwise keyboard navigation is broken. OnisTouch
devices,"hover"
popovers don’t show up because they often conflict with"click"
popovers. -
"click"
: Show the popover on theelement.onclick
event. When to hide the popover depends on theremainOpenWhileFocused
. IfremainOpenWhileFocused
isfalse
(the default), then the next click anywhere will close the popover—this is useful for dropdown menus with<button>
s. IfremainOpenWhileFocused
istrue
, then only clicks outside of the popover will close it—this is useful for dropdown menus with<input>
s. IfremainOpenWhileFocused
istrue
and you need to close the popover programmatically, you may send aclick
event to an element out of the popover, for example,document.querySelector("body").click()
. -
"showOnce"
: Show the popover right away, and hide it (and remove it from the DOM) on the nextpointerdown
orkeydown
event. -
"none"
: Showing and hiding the popover is the responsibility of the caller using thetarget.showPopover()
andtarget.hidePopover()
functions.
-
-
remainOpenWhileFocused
: See discussion ontrigger: "click"
. This parameter is ignored iftrigger
is something else. -
placement
: One of Floating UI’splacement
s.
Example
html`
<button
type="button"
javascript="${javascript`
javascript.popover({ element: this });
`}"
>
Example of an element
</button>
<div type="popover">Example of a popover.</div>
`;
Implementation notes
This is inspired by the Popover API and CSS anchor positioning, but it doesn’t follow the browser implementation exactly. First, because not all browsers support these APIs yet and the polyfills don’t work well enough (for example, they don’t support position-try
). Second, because the APIs can be a bit awkward to use, for example, asking for you to come up with anchor-name
s, and using HTML attributes instead of CSS & JavaScript.
We use Floating UI for positioning and provide an API reminiscent of the discontinued Tippy.js. The major difference is that in Tippy.js the content
is kept out of the DOM while the popover is hidden, while we keep the target
in the DOM (just hidden). This allows, for example, the popover to contain form fields which are submitted on form submission, and it makes inspecting and debugging easier. We also support fewer features and less customization, for example, there isn’t the concept of interactive
separate of trigger
, so you can’t create an interactive "hover"
popover.
export function parents(element);
Returns an array of parents, including element
itself.
export function children(element, { includeSubforms = true } = {});
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 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
’sisConnected
isfalse
, the background job isstop()
ped.
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 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.
export let isTouch;
Whether the device has a touch screen, as opposed to a mouse. This is useful, for example, to disable popover()
s triggered by "hover"
. See https://github.com/atomiks/tippyjs/blob/ad85f6feb79cf6c5853c43bf1b2a50c4fa98e7a1/src/bindGlobalEventListeners.ts#L7-L18.