@radically-straightforward/javascript
v4.0.7
Published
⚙️ Browser JavaScript in Tagged Templates
Downloads
1,393
Maintainers
Readme
Radically Straightforward · JavaScript
⚙️ Browser JavaScript in Tagged Templates
Installation
$ 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.
Usage
import javascript, { JavaScript } from "@radically-straightforward/javascript";
JavaScript
export type JavaScript = string;
A type alias to make your type annotations more specific.
javascript()
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
.
Browser JavaScript
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:
Live Navigation
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 implementing 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 is set, 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.
An <a>
or <div type="form">
may opt out of Live Navigation by setting the property element.liveNavigate = false
.
Code Execution through the javascript="${javascript`___`}"
Attribute
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.
Custom Form Validation
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.
Warn about Unsaved Changes before Leaving the Page
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.
Add support for the element.onfocusin
and element.onfocusout
event handler properties
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.
liveConnection()
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.
documentMount()
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.
mount()
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.
morph()
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).
Elements may define a state="___"
attribute, typically through the state___()
methods below, which is not morphed on Live Connection updates, and is meant to include browser state, for example, whether a sidebar is open.
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(["state", "style", "hidden", "open", "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 the 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.
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 aware of Live Connection updates.
execute()
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:
javascript.execute(
document
.querySelector("body")
.insertAdjacentElement(
"afterbegin",
javascript.stringToElement(html`<div javascript="___"></div>`),
),
);
documentStringToElement()
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
.
stringToElements()
export function stringToElements(string);
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.
stringToElement()
export function stringToElement(string);
A specialized version of stringToElements()
for when the string
is a single element and the wrapper <div>
is unnecessary.
isModified()
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.
reset()
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.
validate()
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.
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.
ValidationError
export class ValidationError extends Error;
Custom error class for validate()
.
serialize()
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)
.
relativizeDateTimeElement()
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>
`;
relativizeDateTime()
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
.
localizeDateTime()
export function localizeDateTime(dateString);
Returns a localized datetime, for example, 2024-04-03 15:20
.
validateLocalizedDateTime()
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);
};
`}"
/>
`;
localizeDate()
export function localizeDate(dateString);
Returns a localized date, for example, 2024-04-03
.
localizeTime()
export function localizeTime(dateString);
Returns a localized time, for example, 15:20
.
formatUTCDateTime()
export function formatUTCDateTime(dateString);
Format a datetime into a representation that is user friendly, for example, 2024-04-03 15:20 UTC
.
stateAdd()
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.
stateRemove()
export function stateRemove(element, token);
See stateAdd()
.
stateToggle()
export function stateToggle(element, token);
See stateAdd()
.
stateContains()
export function stateContains(element, token);
See stateAdd()
.
popover()
export function popover({
element,
target = element.nextElementSibling,
trigger = "hover",
remainOpenWhileFocused = false,
placement = trigger === "hover"
? "top"
: trigger === "click"
? "bottom-start"
: trigger === "none"
? "top"
: (() => {
throw new Error();
})(),
});
Create a popover (tooltip, dropdown menu, and so forth).
The target
is decorated with the showPopover()
, hidePopover()
, and positionPopover()
functions. The target
is decorated with the popoverTriggerElement
attribute, which refers to element
. The element
is decorated with event handler attributes to trigger the popover.
Parameters
element
: The element that is used a 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
).trigger
: One of the following:"hover"
: Show the popover on themouseenter
orfocusin
events and hide the popover ononmouseleave
oronfocusout
events. Thetarget
must not contain elements that may have focus (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 onclick
. 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."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.
parents()
export function parents(element);
Returns an array of parents, including element
itself.
children()
export function children(element, { includeSubforms = true } = {});
Returns an array of children, including element
itself.
nextSiblings()
export function nextSiblings(element);
Returns an array of sibling elements, including element
itself.
previousSiblings()
export function previousSiblings(element);
Returns an array of sibling elements, including element
itself.
backgroundJob()
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”.
isAppleDevice
export const isAppleDevice;
isSafari
export const isSafari;
isPhysicalKeyboard
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.
shiftKey
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.
isTouch
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.