Add <datasette-sql-editor> form-associated custom element

Light-DOM element built on createSqlEditor(): form participation via
ElementInternals (name= field, reset support), schema fetched from
{base-url}/{database}/-/editor-schema.json or schema-url= without ever
blocking editing, cancelable submit event on Mod/Shift-Enter driving
form.requestSubmit(), readOnly/value/schema/view properties, format()
via the sql-formatter global, theming through CSS custom properties
with appearance-preserving fallbacks. Auto-registers the tag on import
(guarded). Manual-QA page at demos/sql-editor-element.html.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alex Garcia 2026-07-10 11:15:45 -07:00
commit 866b2d34e1
4 changed files with 464 additions and 3 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -272,7 +272,7 @@ function columnCompletion(name, type) {
return completion;
}
function schemaFromTables(tables) {
export function schemaFromTables(tables) {
const schema = {};
for (const table of tables || []) {
const completions = (table.columns || []).map((column) =>
@ -309,6 +309,297 @@ export async function datasetteSchema(baseUrl, database) {
return schemaFromTables(data.tables);
}
// readOnlyState(ro) -> extensions that toggle editability. EditorState.readOnly
// blocks document edits; EditorView.editable additionally drops the
// contenteditable attribute so screen readers and the cursor reflect the
// read-only state.
function readOnlyState(ro) {
return [EditorState.readOnly.of(ro), EditorView.editable.of(!ro)];
}
// Theme that plumbs a small set of CSS custom properties into the editor so
// embedders can restyle without reaching into CodeMirror internals. The fallbacks
// reproduce Datasette's current editor appearance (monospace family, the
// page-inherited font size, a transparent background over the page/plugin
// background) so mounting the element on an existing Datasette page is visually a
// no-op — including in dark mode, which Datasette implements entirely through the
// surrounding page's colors, not editor-specific CSS. CodeMirror themes are static
// CSS-in-JS, but var() references pass straight through to the generated rules and
// resolve at render time, so an embedder's :root / @media (prefers-color-scheme)
// overrides just work.
const sqlEditorTheme = EditorView.theme({
"&": {
fontSize: "var(--datasette-sql-editor-font-size, inherit)",
background: "var(--datasette-sql-editor-bg, transparent)",
},
".cm-content, .cm-gutters": {
fontFamily: "var(--datasette-sql-editor-font-family, monospace)",
},
});
// <datasette-sql-editor> — a form-associated, light-DOM custom element wrapping
// createSqlEditor(). The module auto-registers the default tag on import (see the
// bottom of this file); call registerSqlEditorElement("my-tag") to also/instead
// register it under a different name.
//
// Attributes (all optional):
// name form-field name for the submitted SQL (form participation)
// database Datasette database name; when set and schema-url is absent the
// schema URL is derived as
// {base-url}/{database}/-/editor-schema.json
// base-url Datasette base_url prefix used for the derived schema URL ("")
// schema-url explicit URL returning the neutral {tables:[...]} schema payload
// default-table unqualified-column default table for autocomplete
// readonly boolean; mounts the editor read-only
// autofocus boolean; focuses the editor once mounted
// The initial document is the element's trimmed textContent (cleared on mount).
//
// Properties:
// value get/set the document (set is host-tagged: no "input" event)
// schema set -> updateSchema({schema, defaultTable})
// view get the raw EditorView escape hatch (null before mount)
// readOnly get/set via a Compartment
// extensions get/set extra CodeMirror extensions; honored ONLY before the
// element connects (createSqlEditor builds the extension set once)
// Methods: focus(), updateSchema(conf), format().
// Events (all bubble):
// input {detail:{origin:"user"}} on user edits (host edits suppressed)
// submit cancelable; default action requestSubmit()s internals.form
// ready once mounted (schema may still be fetching — see below)
// editor-escape on Escape at the editor top level
export class DatasetteSqlEditorElement extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this._handle = null;
this._internals = null;
this._readOnly = false;
this._readOnlyCompartment = new Compartment();
this._extensions = [];
this._pendingDoc = null;
this._initialDoc = "";
// attachInternals is guarded: Safari < 16.4 has no ElementInternals. Without
// it the editor still works fully, but form participation
// (setFormValue/reset) is a no-op, so this field degrades to contributing
// nothing on submit. Documented as an accepted graceful degradation.
try {
this._internals = this.attachInternals ? this.attachInternals() : null;
} catch (err) {
this._internals = null;
}
}
connectedCallback() {
if (this._handle) return; // already mounted (e.g. move within the DOM)
const initialDoc =
this._pendingDoc != null ? this._pendingDoc : this.textContent.trim();
this._initialDoc = initialDoc;
this._pendingDoc = null;
// Clear the light-DOM text so it doesn't render behind the editor.
this.textContent = "";
this._readOnly = this.hasAttribute("readonly");
const defaultTable = this.getAttribute("default-table") || undefined;
this._handle = createSqlEditor(this, {
doc: initialDoc,
defaultTable,
extensions: [
sqlEditorTheme,
this._readOnlyCompartment.of(readOnlyState(this._readOnly)),
...(this._extensions || []),
],
onChange: () => {
this._syncFormValue();
this.dispatchEvent(
new CustomEvent("input", {
bubbles: true,
detail: { origin: "user" },
}),
);
},
onSubmit: () => {
const proceed = this.dispatchEvent(
new CustomEvent("submit", { bubbles: true, cancelable: true }),
);
if (!proceed) return; // default prevented
const form = this._internals && this._internals.form;
if (!form) return;
// requestSubmit() runs constraint validation and submit handlers, exactly
// like clicking a submit button; fall back to submit() where unsupported.
if (typeof form.requestSubmit === "function") {
form.requestSubmit();
} else {
form.submit();
}
},
onEscape: () => {
this.dispatchEvent(new CustomEvent("editor-escape", { bubbles: true }));
},
});
this._syncFormValue();
// Fetch schema (if configured) without blocking the editor: a failure
// downgrades to keyword-only completion and never breaks editing.
const schemaUrl = this._resolveSchemaUrl();
if (schemaUrl) {
fetch(schemaUrl, { credentials: "same-origin" })
.then((response) => {
if (!response.ok) {
throw new Error(
`schema fetch ${schemaUrl} -> ${response.status} ${response.statusText}`,
);
}
return response.json();
})
.then((data) => {
this.updateSchema({ schema: schemaFromTables(data.tables) });
})
.catch((err) => {
console.warn(
"datasette-sql-editor: schema fetch failed; keyword-only completion",
err,
);
});
}
if (this.hasAttribute("autofocus")) {
this._handle.view.focus();
}
// "ready" fires after mount; schema may still be in flight (it applies later
// via updateSchema). Dispatched synchronously so listeners attached before the
// element is inserted observe it.
this.dispatchEvent(new CustomEvent("ready", { bubbles: true }));
}
disconnectedCallback() {
if (this._handle) {
// Preserve the document across DOM moves (disconnect + reconnect):
// connectedCallback prefers _pendingDoc over textContent, and the dead
// editor's DOM must not be left behind to be misread as initial content.
this._pendingDoc = this._handle.value;
this._handle.destroy();
this._handle = null;
this.replaceChildren();
}
}
formResetCallback() {
if (!this._handle) return;
this._handle.value = this._initialDoc; // hostChange-tagged: no "input" event
this._syncFormValue();
}
_resolveSchemaUrl() {
const explicit = this.getAttribute("schema-url");
if (explicit) return explicit;
const database = this.getAttribute("database");
if (!database) return null;
const base = (this.getAttribute("base-url") || "").replace(/\/+$/, "");
return `${base}/${encodeURIComponent(database)}/-/editor-schema.json`;
}
_syncFormValue() {
if (this._internals && this._internals.setFormValue) {
this._internals.setFormValue(this.value);
}
}
// ---- properties -------------------------------------------------------
get value() {
return this._handle ? this._handle.value : this._pendingDoc || "";
}
set value(newValue) {
const v = newValue == null ? "" : String(newValue);
if (this._handle) {
this._handle.value = v; // hostChange-tagged: suppresses the "input" event
this._syncFormValue();
} else {
this._pendingDoc = v;
}
}
set schema(ns) {
this.updateSchema({ schema: ns });
}
get view() {
return this._handle ? this._handle.view : null;
}
get readOnly() {
return this._readOnly;
}
set readOnly(value) {
this._readOnly = !!value;
if (this._handle) {
this._handle.view.dispatch({
effects: this._readOnlyCompartment.reconfigure(
readOnlyState(this._readOnly),
),
});
}
}
get extensions() {
return this._extensions;
}
set extensions(exts) {
if (this._handle) {
console.warn(
"datasette-sql-editor: .extensions must be set before the element connects; ignoring",
);
return;
}
this._extensions = exts || [];
}
// ---- methods ----------------------------------------------------------
focus() {
if (this._handle) this._handle.view.focus();
}
updateSchema(conf = {}) {
if (!this._handle) return;
// Merge in default-table so a bare {schema} update doesn't drop it (the
// compartment reconfigure replaces the whole sql() extension).
this._handle.updateSchema({
defaultTable: this.getAttribute("default-table") || undefined,
...conf,
});
}
format() {
const formatter =
typeof window !== "undefined" ? window.sqlFormatter : undefined;
if (!formatter || typeof formatter.format !== "function") {
console.warn(
"datasette-sql-editor: window.sqlFormatter is not loaded; format() is a no-op",
);
return;
}
if (!this._handle) return;
const formatted = formatter.format(this.value);
this._handle.value = formatted; // hostChange-tagged full replace
this._syncFormValue();
}
}
// registerSqlEditorElement(tagName) — defines the element under tagName, guarding
// against double registration (customElements.define throws on a duplicate). A
// no-op in non-DOM contexts. Returns the tag name.
export function registerSqlEditorElement(tagName = "datasette-sql-editor") {
if (typeof customElements === "undefined") return tagName;
if (!customElements.get(tagName)) {
customElements.define(tagName, DatasetteSqlEditorElement);
}
return tagName;
}
// Re-export the CodeMirror pieces callers need so plugin code shares this
// module's single CM instance instead of bundling its own.
export {
@ -324,3 +615,16 @@ export {
autocompletion,
completionKeymap,
};
// Auto-register the default <datasette-sql-editor> tag on import. Guarded so
// importing this module in a non-DOM context (SSR/tests) or after another copy of
// the module already claimed the tag is a harmless no-op. This gives template and
// dogfood usage a zero-config element; plugins that want to compose the primitives
// without the element simply don't touch the tag. Register a differently-named tag
// with registerSqlEditorElement("my-tag").
if (
typeof customElements !== "undefined" &&
!customElements.get("datasette-sql-editor")
) {
registerSqlEditorElement("datasette-sql-editor");
}

View file

@ -0,0 +1,157 @@
<!DOCTYPE html>
<!--
Manual-QA demo for <datasette-sql-editor> (ticket 08).
This page is NOT served by Datasette by default. Launch a Datasette instance
with this directory mounted at /demos so both the demo page and the ESM bundle
are served same-origin (the schema fetch needs same-origin):
uv run python tests/fixtures.py fixtures.db # once, to build fixtures
uv run datasette fixtures.db --static demos:demos/ -p 8001
Then open:
http://localhost:8001/demos/sql-editor-element.html
The editor imports /-/static/datasette-sql-editor.bundle.js (Datasette's built
ESM module, which auto-registers the <datasette-sql-editor> tag on import) and
fetches its schema from /fixtures/-/editor-schema.json.
QA checklist:
[ ] Editor mounts; "ready" logs in the event panel.
[ ] Typing "select st" / "facetable." offers ranked, typed column completions
(schema fetch succeeded). Ctrl-Space forces the completion popup.
[ ] Editing fires "input" events (origin: user).
[ ] Shift-Enter / Mod-Enter fires a cancelable "submit"; with the checkbox
UNchecked the form GET-submits ?sql=... (name=sql). Checked cancels it.
[ ] The plain "Run SQL" button also submits the current editor text as sql=.
[ ] Escape fires "editor-escape".
[ ] Reset button restores the initial document (form reset).
[ ] Format button reformats via window.sqlFormatter (loaded below).
[ ] Read-only toggle disables editing.
[ ] Point schema-url at a bad URL (edit source) -> editor still works,
keyword-only completion, a console.warn appears.
-->
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>&lt;datasette-sql-editor&gt; demo</title>
<!-- sql-formatter global (window.sqlFormatter) so format() works. -->
<script src="/-/static/sql-formatter-2.3.3.min.js"></script>
<style>
:root {
--datasette-sql-editor-font-size: 14px;
}
body {
font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
max-width: 820px;
margin: 2rem auto;
padding: 0 1rem;
}
datasette-sql-editor {
display: block;
}
.cm-editor {
border: 1px solid #ddd;
border-radius: 4px;
}
.toolbar {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin: 0.75rem 0;
align-items: center;
}
#log {
background: #111;
color: #b9f6ca;
font: 12px/1.5 ui-monospace, Menlo, monospace;
padding: 0.75rem;
border-radius: 4px;
height: 200px;
overflow: auto;
white-space: pre-wrap;
}
label {
font-size: 0.9rem;
}
</style>
</head>
<body>
<h1>&lt;datasette-sql-editor&gt; demo</h1>
<p>
Form-associated custom element against the live
<code>fixtures</code> database. See the HTML comment at the top of this file
for launch and QA instructions.
</p>
<form id="f" action="/fixtures/-/query" method="get">
<datasette-sql-editor
id="ed"
name="sql"
database="fixtures"
default-table="facetable"
autofocus
>
select state, county, on_earth
from facetable
where on_earth = 1
order by pk</datasette-sql-editor
>
<div class="toolbar">
<button type="submit">Run SQL</button>
<button type="button" id="format">Format</button>
<button type="reset">Reset</button>
<label
><input type="checkbox" id="cancel" /> cancel submit event</label
>
<label
><input type="checkbox" id="ro" /> read-only</label
>
</div>
</form>
<h3>Events</h3>
<div id="log"></div>
<script type="module">
// Importing the bundle auto-registers <datasette-sql-editor>.
import "/-/static/datasette-sql-editor.bundle.js";
const ed = document.getElementById("ed");
const log = document.getElementById("log");
const line = (m) => {
log.textContent += m + "\n";
log.scrollTop = log.scrollHeight;
};
["ready", "input", "submit", "editor-escape"].forEach((type) => {
ed.addEventListener(type, (e) => {
if (type === "submit" && document.getElementById("cancel").checked) {
e.preventDefault();
line("submit (CANCELED via preventDefault)");
return;
}
line(
type +
(e.detail ? " " + JSON.stringify(e.detail) : "") +
" | value=" +
JSON.stringify(ed.value.replace(/\s+/g, " ").slice(0, 60)),
);
});
});
document.getElementById("format").addEventListener("click", () => {
ed.format();
line("format() called");
});
document.getElementById("ro").addEventListener("change", (e) => {
ed.readOnly = e.target.checked;
line("readOnly = " + e.target.checked);
});
// view escape hatch smoke test
line("view is EditorView? " + (ed.view && ed.view.constructor.name));
</script>
</body>
</html>