- ClayJS
- Guide
- A document, line by line
A document, line by line
The smallest correct ClayJS app, a reading list, with a note beside every attribute and line that matters.
Start every app from examples/skeleton.html. It is a reading list with sortable rows, a checkbox and a title per row, an Add button, a summary line and a filter.
The html element and autosave#
<!DOCTYPE html>
<html lang="en" autosave>
autosave turns on autosave for edit mode. Autosave is decided at boot. (Autosave)Hiding edit controls before ClayJS runs#
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Reading list</title>
<style>
html:not([editmode="true"]) [show-when\:editmode="true"] { display: none }
/* app styles */
</style>
The fallback CSS rule hides
show-when:editmode="true" controls before ClayJS runs. Without it, a viewer of a never-saved file sees the edit buttons for a moment, or for good with JavaScript off. (Showing and hiding edit controls: clay-options)</head>
<body>
The record#
<main id="app">
<main id="app"> is the record, the part of the page that ClayJS saves. (Element identity: data-id and id) <h1 editmode:contenteditable inert-contenteditable="plaintext-only">Reading list</h1>
editmode:contenteditable makes the text editable in edit mode. The file is written in its saved form with inert-contenteditable, so a viewer gets inert text from the very first open. (Mode attributes: one file for editors and viewers) <ul id="items" sortable="items">
<li data-id="r-mmm"><span class="grip" sortable-handle show-when:editmode="true" aria-label="Drag">⠿</span><input type="checkbox" persist viewmode:disabled disabled checked> <span class="title" editmode:contenteditable inert-contenteditable="plaintext-only">The Mythical Man-Month</span><button type="button" data-action="remove" show-when:editmode="true" aria-label="Remove">×</button></li>
Each row has a
data-id, its text is editmode:contenteditable, and its checkbox is persist viewmode:disabled. The saved file includes disabled so a viewer gets disabled checkboxes from the first open. (Mode attributes: one file for editors and viewers) <li data-id="r-sicp"><span class="grip" sortable-handle show-when:editmode="true" aria-label="Drag">⠿</span><input type="checkbox" persist viewmode:disabled disabled> <span class="title" editmode:contenteditable inert-contenteditable="plaintext-only">Structure and Interpretation of Computer Programs</span><button type="button" data-action="remove" show-when:editmode="true" aria-label="Remove">×</button></li>
</ul>
<button type="button" data-action="add" show-when:editmode="true">+ Add book</button>
</main>
Templates for new rows#
<template id="item-tpl">
<li><span class="grip" sortable-handle show-when:editmode="true" aria-label="Drag">⠿</span><input type="checkbox" persist viewmode:disabled> <span class="title" editmode:contenteditable contenteditable="plaintext-only"></span><button type="button" data-action="remove" show-when:editmode="true" aria-label="Remove">×</button></li>
Templates are blueprints.
item-tpl holds a new row in its active form (contenteditable="plaintext-only", no disabled), because a clone is inserted while the owner is editing and the mode transform never touches template content. (Mode attributes: one file for editors and viewers)</template>
Editor-only controls#
<template id="chrome-tpl">
<p id="summary"></p>
<input type="search" id="filter" placeholder="Filter" aria-label="Filter">
<style id="view-state"></style>
chrome-tpl holds the summary, the filter and the #view-state style outside the record, so the filter stays out of the saved file. (Per-tab state)</template>
Loading ClayJS#
<script src="https://clayjs.com/v1/clay.js?plugins=sync,sortable,undo,indicator&exclude=richclay"></script>
<script src="https://clayjs.com/v1/clay-options.js"></script>
<script src="https://clayjs.com/v1/clay-ui.js"></script>
The script tags load core with
sync,sortable,undo,indicator, exclude=richclay because this app has no rich text, and then the clay-options and clay-ui satellites. (Add ClayJS to a page)Starting the app#
<script>
clay.start(() => {
const items = document.getElementById("items");
clay.start runs the app code once, after boot. A live-sync merge can re-insert the script, and the repeat call does nothing. (Starting the app once) const newId = (prefix) => prefix + "-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
newId makes ids from a timestamp and random suffix. crypto.randomUUID exists only in secure contexts, so it fails on a file served over plain http from another machine. (Element identity: data-id and id) const command = (label, fn) => (clay.undo ? clay.undo.commit(label, fn) : fn());
command wraps every record write in an undo step when the undo plugin is loaded. (undo: whole-page undo) clay.onSnapshot?.((root) => {
for (const el of root.querySelectorAll(".sortable-chosen, .sortable-ghost, .sortable-drag")) {
el.classList.remove("sortable-chosen", "sortable-ghost", "sortable-drag");
if (!el.classList.length) el.removeAttribute("class");
}
for (const el of root.querySelectorAll('[draggable="false"]')) el.removeAttribute("draggable");
for (const el of root.querySelectorAll('[style=""]')) el.removeAttribute("style");
});
The
onSnapshot hook strips Sortable’s drag residue (draggable="false", empty style, drag classes) from every snapshot, so a drag never writes it into the file. (Hooks) const setText = (el, text) => { if (el.textContent !== text) el.textContent = text; };
const chrome = document.createElement("div");
chrome.setAttribute("clay", "editor-ui");
chrome.append(document.getElementById("chrome-tpl").content.cloneNode(true));
items.before(chrome);
const filter = chrome.querySelector("#filter");
filter.value = new URLSearchParams(location.hash.slice(1)).get("q") || "";
filter.addEventListener("input", () => {
history.replaceState(null, "", filter.value ? "#q=" + encodeURIComponent(filter.value) : location.pathname + location.search);
render();
});
chrome-tpl is cloned once into a clay="editor-ui" wrapper. Because the summary, the filter and #view-state live there, none of them is saved, synced or undone. (Regions: the clay attribute)Keeping derived text in step#
function render() {
const rows = [...items.querySelectorAll(":scope > li")];
const done = rows.filter((li) => li.querySelector("input").hasAttribute("checked")).length;
setText(chrome.querySelector("#summary"), `${done} of ${rows.length} read`);
const q = filter.value.trim().toLowerCase();
const hidden = q ? rows.filter((li) => !li.textContent.toLowerCase().includes(q)) : [];
setText(chrome.querySelector("#view-state"), hidden.map((li) => `#items > [data-id="${CSS.escape(li.dataset.id)}"]{display:none}`).join("\n"));
}
render derives the summary and the filter style from the record. It writes only when text changes, because writing the same text is still a mutation. (Derived values) document.addEventListener("click", (event) => {
const button = event.target.closest("[data-action]");
if (!button || !clay.isEditMode) return;
if (button.dataset.action === "add") {
const li = document.getElementById("item-tpl").content.firstElementChild.cloneNode(true);
li.dataset.id = newId("r");
command("Add book", () => items.append(li));
li.querySelector(".title").focus();
}
if (button.dataset.action === "remove") {
const li = button.closest("li");
clay.confirm("Remove this book?").then(() => command("Remove book", () => li.remove()), () => {});
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Enter" && event.target.matches?.('[contenteditable="plaintext-only"]')) {
event.preventDefault();
event.target.blur();
}
});
window.addEventListener("hashchange", () => { filter.value = new URLSearchParams(location.hash.slice(1)).get("q") || ""; render(); });
clay.effect(render);
clay.effect(render) runs render now and again after any change to the document: local typing, a drag, a merge, undo or redo. render writes only into the chrome, and an effect’s own writes never re-trigger it. (Reconciling in app code)});
</script>
</body>
</html>
The test suite checks that this file opens clean, saves only the record and never the filter or chrome, keeps undo and the screen in step, mints unique ids, merges peer edits beside unsaved local edits, and gives viewers inert text and disabled checkboxes.
Part of the ClayJS complete guideDownload the whole guide as Markdown