JavaScript plugins¶
Datasette can run custom JavaScript in several different ways:
Datasette plugins written in Python can use the extra_js_urls() or extra_body_script() plugin hooks to inject JavaScript into a page
Datasette instances with custom templates can include additional JavaScript in those templates
The
extra_js_urlskey indatasette.yamlcan be used to include extra JavaScript
There are no limitations on what this JavaScript can do. It is executed directly by the browser, so it can manipulate the DOM, fetch additional data and do anything else that JavaScript is capable of.
Warning
Custom JavaScript has security implications, especially for authenticated Datasette instances where the JavaScript might run in the context of the authenticated user. It's important to carefully review any JavaScript you run in your Datasette instance.
The datasette_init event¶
Datasette emits a custom event called datasette_init when the page is loaded. This event is dispatched on the document object, and includes a detail object with a reference to the datasetteManager object.
Your JavaScript code can listen out for this event using document.addEventListener() like this:
document.addEventListener("datasette_init", function (evt) {
const manager = evt.detail;
console.log("Datasette version:", manager.VERSION);
});
datasetteManager¶
The datasetteManager object
VERSION- stringThe version of Datasette
plugins-Map()A Map of currently loaded plugin names to plugin implementations
registerPlugin(name, implementation)Call this to register a plugin, passing its name and implementation
makeColumnField(context)Calls the
makeColumnField()hook on registered plugins, returning the first custom insert/edit field control that matches the provided field context. This is used internally by Datasette's row insert and edit dialogs.createModal(options)Creates a <datasette-modal> element, appends it to the page and returns it. Returns
nullin browsers without<dialog>support.selectors- objectAn object providing named aliases to useful CSS selectors, listed below
Modal dialogs: the datasette-modal element¶
Datasette provides a <datasette-modal> Web Component that renders a modal dialog in the same visual style as Datasette's own dialogs - the create/alter table dialogs, the insert/edit/delete row dialogs, the column chooser, the mobile column actions sheet and the / jump menu are all built on it.
The element, and the DatasetteModal class exposed as window.DatasetteModal, are a stable public API for plugins. Plugins that need a modal dialog should use this component rather than building their own, so their dialogs automatically match Datasette's styling, keyboard handling and accessibility behavior.
The component wraps a native <dialog> element and provides:
Datasette's standard modal frame: sizing, rounded corners, backdrop, open/close animations and an optional header with a title and a "meta" chip
Close on backdrop click and on the
EscapekeyA
busyproperty that blocks the user from dismissing the dialog while a save or delete is in flightA
closeGuardhook for "Discard unsaved changes?" style confirmation promptsFocus restoration to the triggering element when the dialog closes
datasette-modal-openanddatasette-modal-closeevents
Creating a modal¶
The simplest way to create a modal from a plugin is datasetteManager.createModal() (or the equivalent window.DatasetteModal.create()), which creates the element, appends it to document.body and returns it:
document.addEventListener("datasette_init", function (event) {
const manager = event.detail;
const modal = manager.createModal({
id: "my-plugin-dialog",
className: "my-plugin-dialog",
title: "My plugin",
content: `
<p style="padding: 16px 24px">Hello from a plugin!</p>
<div class="modal-footer">
<span class="footer-info"></span>
<button type="button" class="btn btn-ghost" data-modal-cancel>Cancel</button>
<button type="button" class="btn btn-primary my-plugin-save">Save</button>
</div>
`,
});
if (!modal) {
return; // Browser does not support <dialog>
}
// Open it later, for example from a button click:
// modal.showModal({trigger: button});
});
The data-modal-cancel attribute on the Cancel button is a declarative shortcut: clicking any element inside the modal that carries this attribute calls modal.requestClose("cancel"), so Cancel buttons need no JavaScript wiring. Like other dismissals it is blocked while the modal is busy and consults closeGuard, both described below.
Calling modal.showModal() then displays the dialog:
createModal(options) / DatasetteModal.create(options) accepts:
id- string, optionalidattribute for the inner<dialog>element.className- string, optionalclassattribute for the inner<dialog>, useful for scoping custom CSS.title- string, optionalText for the standard header title. If omitted no header is created and the modal content fills the whole dialog.
meta- string, optionalText for the small "meta" chip shown on the right of the header, for example
"3 of 12 selected".titleId- string, optionalidfor the generated title element. Defaults to"<id>-title".labelledBy/describedBy- strings, optionalExplicit
aria-labelledby/aria-describedbyvalues for the dialog. By default the dialog is labelled by the generated title element.content- string or DOM node, optionalContent placed inside the dialog, after the header. By convention this ends with a
<div class="modal-footer">containing an optional<span class="footer-info">and buttons using thebtnclasses shown above.parent- element, optionalElement to append the modal to. Defaults to
document.body.
The element can also be used declaratively in a template - light DOM children become the dialog content:
<datasette-modal dialog-id="my-dialog" modal-title="My dialog">
<p>Dialog content</p>
<div class="modal-footer">
<button type="button" class="btn btn-ghost" data-modal-cancel>Cancel</button>
<button type="button" class="btn btn-primary">OK</button>
</div>
</datasette-modal>
The dialog-id, dialog-class, modal-title, modal-meta, title-id, labelled-by and described-by attributes correspond to the options above. modal-title and modal-meta can be updated at any time and the header will update to match.
Properties, methods and events¶
DatasetteModal.supported- boolean, staticTrue if the browser supports everything the component needs.
createModal()returnsnullwhen this is false.modal.dialog-HTMLDialogElementThe underlying native dialog element. Query this for elements inside the modal.
modal.open- booleanTrue while the modal is open.
modal.showModal(options)Opens the modal.
options.triggeris the element that focus should return to when the modal closes - it defaults to the element that was focused whenshowModal()was called.modal.close(options)Closes the modal unconditionally, skipping
busyandcloseGuard. Pass{restoreFocus: false}to leave focus where it is, for example when the page is about to navigate.modal.requestClose(reason)Asks the modal to close on the user's behalf, respecting
busyandcloseGuard. Returns true if the modal closed. Backdrop clicks and theEscapekey call this internally with reasons"backdrop"and"escape", and clicking an element with adata-modal-cancelattribute calls it with"cancel". Cancel buttons can either carry that attribute or call this method directly.modal.busy- booleanWhile true,
Escape, backdrop clicks andrequestClose()will not close the modal. Set this while an operation is in flight. Abusyattribute is reflected on the element for CSS.modal.closeGuard- function or nullIf set, called with the reason string whenever the user tries to dismiss the modal. Return false to keep the modal open - for example after a
confirm("Discard unsaved changes?")returns false. Not consulted by directclose()calls.modal.setTitle(text)/modal.setMeta(text)Update the header title and meta chip. Setting the meta text to
""hides the chip.modal.titleElementandmodal.metaElementexpose the underlying elements for richer markup.
The element dispatches two bubbling events:
datasette-modal-openFired when the modal opens.
datasette-modal-closeFired when the modal closes, however that happened. Use this to reset dialog state.
Styling¶
Modal content uses light DOM, so page-level CSS and plugin CSS can style it directly. The component provides the frame plus styles for these conventional class names inside it: .modal-header, .modal-title, .modal-meta, .modal-footer, .footer-info and the button classes .btn, .btn-primary, .btn-ghost and .btn-danger.
Sizing can be customized with CSS custom properties on the dialog:
dialog.my-plugin-dialog {
--datasette-modal-width: min(700px, calc(100vw - 32px));
--datasette-modal-max-height: min(600px, calc(100vh - 32px));
}
The dialog frame also respects the page-wide theme properties --modal-border-radius, --modal-shadow, --modal-backdrop-bg, --modal-backdrop-blur and --modal-animation-duration.
<datasette-modal> also works inside the shadow DOM of other Web Components - Datasette's own <column-chooser> and <navigation-search> components use it this way. The shared frame styles are automatically adopted into whichever document or shadow root the element is connected to.
JavaScript plugin objects¶
JavaScript plugins are blocks of code that can be registered with Datasette using the registerPlugin() method on the datasetteManager object.
The implementation object passed to this method should include a version key defining the plugin version, and one or more of the following named functions providing the implementation of the plugin:
makeJumpSections(context)¶
This method should return a JavaScript array of objects defining additional sections to be added to the blank state of the / jump menu, before the user starts typing a search.
It should return an array of objects, each with the following:
id- stringA unique string ID for the section, for example
agent-chatrender(node, context)- functionA function that will be called with a DOM node to render the section into
Datasette passes a context object to both makeJumpSections(context) and render(node, context). It has the following keys:
navigationSearchThe
<navigation-search>custom element instance.container- only forrender()The
.results-containerelement used by the jump menu.input- only forrender()The
.search-inputelement used by the jump menu.
This example shows how a plugin might add a button for starting a new chat:
document.addEventListener('datasette_init', function(ev) {
ev.detail.registerPlugin('agent-plugin', {
version: 0.1,
makeJumpSections: (context) => {
return [
{
id: 'agent-chat',
render: (node, context) => {
node.innerHTML = '<button type="button">Start a new chat</button>';
node.querySelector('button').addEventListener('click', () => {
location.href = '/-/agent/new';
});
}
}
];
}
});
});
makeAboveTablePanelConfigs()¶
This method should return a JavaScript array of objects defining additional panels to be added to the top of the table page. Each object should have the following:
id- stringA unique string ID for the panel, for example
map-panellabel- stringA human-readable label for the panel
render(node)- functionA function that will be called with a DOM node to render the panel into
This example shows how a plugin might define a single panel:
document.addEventListener('datasette_init', function(ev) {
ev.detail.registerPlugin('panel-plugin', {
version: 0.1,
makeAboveTablePanelConfigs: () => {
return [
{
id: 'first-panel',
label: 'First panel',
render: node => {
node.innerHTML = '<h2>My custom panel</h2><p>This is a custom panel that I added using a JavaScript plugin</p>';
}
}
]
}
});
});
When a page with a table loads, all registered plugins that implement makeAboveTablePanelConfigs() will be called and panels they return will be added to the top of the table page.
makeColumnActions(columnDetails)¶
This method, if present, will be called when Datasette is rendering the cog action menu icons that appear at the top of the table view. By default these include options like "Sort ascending/descending" and "Facet by this", but plugins can return additional actions to be included in this menu.
The method will be called with a columnDetails object with the following keys:
columnName- stringThe name of the column
columnNotNull- booleanTrue if the column is defined as NOT NULL
columnType- stringThe SQLite data type of the column
isPk- booleanTrue if the column is part of the primary key
It should return a JavaScript array of objects each with a label and onClick property:
label- stringThe human-readable label for the action
onClick(evt)- functionA function that will be called when the action is clicked
The evt object passed to the onClick is the standard browser event object that triggered the click.
This example plugin adds two menu items - one to copy the column name to the clipboard and another that displays the column metadata in an alert() window:
document.addEventListener('datasette_init', function(ev) {
ev.detail.registerPlugin('column-name-plugin', {
version: 0.1,
makeColumnActions: (columnDetails) => {
return [
{
label: 'Copy column to clipboard',
onClick: async (evt) => {
await navigator.clipboard.writeText(columnDetails.columnName)
}
},
{
label: 'Alert column metadata',
onClick: () => alert(JSON.stringify(columnDetails, null, 2))
}
];
}
});
});
makeColumnField(context)¶
This method, if present, can provide a custom form field for a column in Datasette's row insert and edit dialogs.
It is designed for plugins that register custom column types using the Python register_column_types() plugin hook. For example, a plugin that defines a file column type can use makeColumnField() to replace a plain text input with a file picker, and a plugin that defines a rich text column type can use it to enhance the field with an editor.
Datasette calls makeColumnField(context) on each registered JavaScript plugin when it renders an editable insert/edit field. Plugins should inspect the context object and only return a control object if they can handle that field. Otherwise, use a bare return;.
The first plugin to return a truthy control object is used for that field. Plugins are called in registration order. If a plugin raises an exception, Datasette logs the error to the browser console and continues to the next plugin.
The row dialog tracks the value that will be sent to the insert/update API. The context object describes the column and form environment; custom controls should read and write field values using the field helper object passed to render(field).
Context object¶
makeColumnField(context) is called with a context object describing the field. The current context object has these keys:
mode- string"insert"or"edit".database- string or nullThe database name.
table- string or nullThe table name.
tableUrl- string or nullThe path to the table page, including any configured base URL prefix.
column- stringThe column name.
columnType- object or nullThe configured Datasette column type for this column, if one exists. This is
nullif no column type has been configured.If present, this object has exactly these keys:
type- stringThe registered column type name, matching the
nameattribute of the PythonColumnTypesubclass.config- objectConfiguration for this specific column type assignment. This is
{}if no configuration has been set.
sqliteType- string or nullThe SQLite affinity for this column, if known. This is one of
"TEXT","INTEGER","REAL","BLOB","NUMERIC"ornullif Datasette could not determine the affinity.notNull- booleanTrue if the column is defined as
NOT NULL.isPk- booleanTrue if this column is part of the table's primary key.
defaultExpression- string or nullThe SQLite default expression for the column, if available. This is
nullif the column has no SQLite default. For example, a column defined withDEFAULT (datetime('now'))will have"datetime('now')"here. This is the expression from the table schema, not the actual value SQLite will insert.form-HTMLFormElementor nullThe row insert/edit form element.
dialog-HTMLDialogElementor nullThe modal dialog element.
Returned control object¶
A plugin that wants to handle a field should return an object. Datasette currently recognizes these properties:
useTextarea- boolean, optionalIf true, Datasette creates a
<textarea>as the underlyingfield.inputbefore callingrender(). If omitted, Datasette chooses either an<input>or<textarea>based on the column type and current value.render(field)- functionCalled once to render the custom field UI.
fieldis a helper object described below.The recommended pattern is to return a DOM node from
render(). Datasette appends that node tofield.root, a<div>inside the control area for that field in the row insert/edit dialog. A plugin can alternatively manipulatefield.rootdirectly and return nothing.focus(field)- function, optionalCalled when Datasette wants to focus this field, for example when focusing the first editable field in the dialog. Use this to focus the most useful interactive element inside the custom UI.
destroy(field)- function, optionalCalled when Datasette tears down the insert/edit form. Use this to remove event listeners, close nested pickers, revoke object URLs, clear timers, or release other resources.
The field helper object¶
The field object passed to render(field), focus(field) and destroy(field) provides stable IDs, DOM elements and value helpers for integrating with the row insert/edit dialog:
context- objectThe original context object passed to
makeColumnField().id- stringThe ID Datasette assigned to
field.input, the backing<input>or<textarea>element.labelId- stringThe ID of the visible field label.
descriptionId- stringThe ID of the field metadata/help text. This metadata can include details such as
Primary key,Required,Current value: NULLorCustom type: file.root-HTMLElementThe empty
<div>container created by Datasette for this custom field. It is inside the control area for the field in the row insert/edit dialog, next to the field label and above the field metadata. Datasette appends the DOM node returned byrender(field)to this element. Plugins can alternatively manipulate this element directly and return nothing fromrender(field).input-HTMLInputElementorHTMLTextAreaElementThe core-owned backing form control. Plugins can keep this visible, wrap it or hide it, but should use the value helper methods below rather than mutating
input.valuedirectly.controlAn alias for
input.meta-HTMLElementor nullThe field metadata/help text element.
form-HTMLFormElementor nullThe containing row insert/edit form.
dialog-HTMLDialogElementor nullThe containing modal dialog.
getValue()- functionReturns the current value for this field.
Datasette uses string values by default. Insert fields for
"INTEGER"and"REAL"SQLite columns return numbers, ornullif left blank. Plugins can use strings, numbers, booleans ornull. If a plugin is editing structured data stored in a SQLiteTEXTcolumn, such as JSON, it should serialize that data to a string before callingsetValue().setValue(value)- functionSets the current value for this field.
valueshould be a string, number, boolean ornull.Calling
setValue()also stops using the SQLite default for the field, if it was previously selected.getInitialValue()- functionReturns the submitted-value representation the field had when the form was rendered. For edit forms this is the raw row value from the database. For insert forms this is the blank starting value.
hasChanged()- functionReturns true if the field has changed since Datasette last considered it unmodified. By default that means the field state when the insert/edit form was rendered.
clearValue()- functionSets the value to
null.markClean()- functionTells Datasette to treat the field's current state as unmodified. After calling this method,
hasChanged()returns false until the field value changes again or its SQLite-default state changes.This is useful when a plugin rewrites the starting value into an equivalent representation while initializing its editor. For example, a rich text editor might normalize empty HTML or reserialize its initial document before the user has made any edits.
isUsingSqliteDefault()- functionReturns true if the insert dialog is currently set to omit this column and use the SQLite default.
setValidity(message)- functionSets a custom validation message for this field, marks the backing input with
aria-invalid="true"and shows the message in the field metadata area. Pass an empty string to clear the error.clearValidity()- functionClears any custom validation message previously set by
setValidity().
Submitted value contract¶
The field.setValue() method accepts the following value types:
string
number
boolean
null
These values are used as column values in requests to the insert rows and update row JSON APIs.
Plugins should not pass objects or arrays to field.setValue(). If a column stores structured data in SQLite, such as JSON in a TEXT column, the plugin should serialize that data first and submit the serialized string. Client-side parsing can still be useful for validation or editor state, but the submitted value should match the SQLite value Datasette should write.
Value helpers¶
Custom fields should use field.getValue() and field.setValue(value) for value handling:
const currentValue = field.getValue();
field.setValue("new value");
field.setValue(null);
Plugins can keep the core input visible, wrap it in a custom element, or hide it and provide a richer interface. If the input is hidden, the custom UI must still expose an accessible name, state and keyboard interaction.
field.setValue() updates both field.input and the value used in the insert/update request.
For example, a file picker that stores a selected file ID can hide the backing input and call field.setValue() when the selection changes:
field.input.type = "hidden";
field.setValue(fileId);
For insert forms with a SQLite default, field.isUsingSqliteDefault() indicates whether Datasette will omit that column from the insert payload. Calling field.setValue(value) automatically stops using the SQLite default.
Lazy loading large controls¶
The JavaScript file that registers makeColumnField() should be small. If the actual control is large, load it from inside render() using dynamic import(). That way the heavier code is only downloaded after a user opens an insert/edit dialog containing a matching column type.
const editorUrl = new URL("./editor.js", import.meta.url).href;
document.addEventListener("datasette_init", function (event) {
event.detail.registerPlugin("my-editor", {
version: "0.1",
makeColumnField(context) {
if (!context.columnType || context.columnType.type !== "my-editor") {
return;
}
return {
useTextarea: true,
render(field) {
import(editorUrl).then(function () {
// Enhance field.input here.
});
return field.input;
}
};
}
});
});
Example: textarea-backed custom element¶
This example handles a markdown-editor column type by asking Datasette for a textarea and wrapping that textarea in a custom <my-markdown-editor> Web Component element:
document.addEventListener("datasette_init", function (event) {
event.detail.registerPlugin("markdown-editor", {
version: "0.1",
makeColumnField(context) {
if (!context.columnType || context.columnType.type !== "markdown-editor") {
return;
}
return {
useTextarea: true,
render(field) {
const editor = document.createElement("my-markdown-editor");
editor.appendChild(field.input);
if (field.labelId) {
field.input.setAttribute("aria-labelledby", field.labelId);
}
if (field.descriptionId) {
field.input.setAttribute("aria-describedby", field.descriptionId);
}
return editor;
},
focus(field) {
const editor = field.root.querySelector("my-markdown-editor");
if (editor && editor.focus) {
editor.focus();
} else {
field.input.focus();
}
}
};
}
});
});
Accessibility¶
Custom fields are responsible for preserving the accessibility of the form:
The visible field label should name the control. Use
field.labelIdwitharia-labelledbywhen wrapping or replacing the visible input.Field metadata should remain available to assistive technology. Use
field.descriptionIdwitharia-describedby.Keyboard users must be able to operate every part of the custom field.
If the field opens an inline picker or other nested UI,
Escapeshould close that nested UI first and return focus to a sensible element.If a control performs asynchronous loading, expose loading and error states in the UI. Use appropriate ARIA live regions where the state change is important to understand the field.
If a plugin hides
field.input, the replacement UI must still make the current value and available actions clear.
Plugins should not submit the row themselves from inside makeColumnField() controls. Datasette owns the insert/edit dialog lifecycle, form submission, API call, error handling and row refresh.
Selectors¶
These are available on the selectors property of the datasetteManager object.
const DOM_SELECTORS = {
/** Should have one match */
jsonExportLink: ".export-links a[href*=json]",
/** Event listeners that go outside of the main table, e.g. existing scroll listener */
tableWrapper: ".table-wrapper",
table: "table.rows-and-columns",
aboveTablePanel: ".above-table-panel",
// These could have multiple matches
/** Used for selecting table headers. Use makeColumnActions if you want to add menu items. */
tableHeaders: `table.rows-and-columns th`,
/** Used to add "where" clauses to query using direct manipulation */
filterRows: ".filter-row",
/** Used to show top available enum values for a column ("facets") */
facetResults: ".facet-results [data-column]",
};