GUIDE
Writing plugins
dj-data-grid, dj-rich-text, and dj-chart grow through plugins. A plugin is a plain object, usually returned by a small function that takes options. The published plugins use the same public hooks you do, and the core never reads a plugin's configuration.
How plugins load
Pass an array of plugin objects to the component's plugins property from JavaScript. Order matters: for hooks where the first answer wins, the earlier plugin decides, and for hooks that merge, the later plugin wins. Changing plugins rebuilds the grid's table or the editor, so set it once, early.
defineDataGridPlugin, defineRichTextPlugin, and defineChartPlugin return the object you give them. They exist so TypeScript can check the shape; in plain JavaScript they change nothing.
Data grid plugins
A grid plugin has a name and any of these hooks. Each hook receives a context with three members: host (the dj-data-grid element), table (the live TanStack table), and refresh() (render again).
| Hook | What it does |
|---|---|
columns | Transform the column list: add, remove, or annotate columns. |
tableOptions | TanStack options merged at table creation, such as row models. Never return state or onStateChange; the core owns those. |
setup | Wire listeners after the table exists. Return a function that removes them. |
renderCell | Replace a cell's content. The first plugin that returns something other than undefined wins. |
renderHeader | Replace a column header's content, for example a select-all checkbox. |
decorateCell | Wrap a cell's content after rendering: an indent, an expander, a badge. |
rowAttributes | Add attributes to each row element, such as aria-level. |
subheaderCells | A second header row, for example per-column filters. |
chromeTop, chromeBottom | Full-width areas above the header and below the rows: a quick filter, pagination, totals. |
renderDetail | Content under an expanded row, for master-detail layouts. |
ctx.table is a getter, because the context exists before the first table and outlives each rebuild. Read it each time and do not keep a copy of it.
This plugin draws one column as dj-badge elements. It returns undefined for every other cell, so other plugins and the core can still render those.
// badge-plugin.js: draw one column's values as dj-badge elements
import { html } from "lit";
import "@dojo-ng/badge";
import { defineDataGridPlugin } from "@dojo-ng/data-grid";
export function badgePlugin({ column, variants = {} }) {
return defineDataGridPlugin({
name: "badge",
renderCell(cell) {
if (cell.column.id !== column) return undefined; // not our column: let others decide
const value = String(cell.getValue() ?? "");
return html`<dj-badge variant=${variants[value] ?? "info"}>${value}</dj-badge>`;
},
});
}
This one adds a footer that sums a column. The grid calls chromeBottom on every render, so the total follows sorting, filtering, and new data with no extra code.
// totals-plugin.js: a footer line that sums one column
import { html } from "lit";
import { defineDataGridPlugin } from "@dojo-ng/data-grid";
export function totalsPlugin({ column, label = "Total" }) {
return defineDataGridPlugin({
name: "totals",
chromeBottom(ctx) {
const rows = ctx.table.getRowModel().rows;
const sum = rows.reduce((n, row) => n + Number(row.getValue(column) ?? 0), 0);
return html`<p>${label}: ${sum} across ${rows.length} rows</p>`;
},
});
}
Using both:
import "@dojo-ng/data-grid";
import { badgePlugin } from "./badge-plugin.js";
import { totalsPlugin } from "./totals-plugin.js";
const grid = document.querySelector("dj-data-grid");
grid.columns = [
{ id: "item", header: "Item", accessorKey: "item" },
{ id: "status", header: "Status", accessorKey: "status" },
{ id: "amount", header: "Amount", accessorKey: "amount" },
];
grid.data = [
{ item: "Paper", status: "paid", amount: 12 },
{ item: "Ink", status: "late", amount: 30 },
];
grid.plugins = [
badgePlugin({ column: "status", variants: { paid: "success", late: "danger" } }),
totalsPlugin({ column: "amount" }),
];
Rich text plugins
A rich text plugin has a name and any of these members. Even bold, italic, underline, undo, and redo ship as plugins, exported together as defaultPlugins.
| Member | What it does |
|---|---|
nodes | Custom Lexical node classes. Lexical needs them when the editor is created, which is one reason a plugins change rebuilds the editor. |
setup | Register Lexical commands, transforms, and listeners. Return a function that removes them. |
toolbar | Toolbar controls: a simple button (run, icon, isActive, isDisabled) or a custom control (render). label is required, so every control has an accessible name. |
inserts | Actions offered in the slash menu from @dojo-ng/rich-text-slash. |
formats | Extra output formats, such as Markdown, selected with the editor's format property. |
html | HTML import and export overrides, to keep markup the default conversion would drop. |
The context passed to setup and to toolbar callbacks has the live Lexical editor, the host element, command(type, payload) (dispatch a Lexical command and return focus to the editor), onSelectionChange(callback), activeFormats(), and the loaded plugins.
This plugin inserts today's date at the caret, from a toolbar button and from the slash menu:
// insert-date-plugin.js: a toolbar button and a slash-menu entry
import { $getSelection, $isRangeSelection } from "lexical";
import { defineRichTextPlugin } from "@dojo-ng/rich-text";
export function insertDatePlugin({ format = (d) => d.toISOString().slice(0, 10) } = {}) {
const insert = (ctx) =>
ctx.editor.update(() => {
const selection = $getSelection();
if ($isRangeSelection(selection)) selection.insertText(format(new Date()));
});
return defineRichTextPlugin({
name: "insert-date",
toolbar: [
{ id: "insert-date", group: "insert", order: 1, label: "Insert today's date", icon: "Date", run: insert },
],
inserts: [
{ id: "date", label: "Today's date", keywords: ["date", "today"], run: insert },
],
});
}
import "@dojo-ng/rich-text";
import { defaultPlugins } from "@dojo-ng/rich-text";
import { insertDatePlugin } from "./insert-date-plugin.js";
const editor = document.querySelector("dj-rich-text");
// Setting plugins replaces the defaults, so keep them. Set plugins before value:
// a plugins change rebuilds the editor.
editor.plugins = [...defaultPlugins, insertDatePlugin()];
editor.value = "<p>Due: </p>";
Chart plugins
A chart plugin has a name and any of these hooks. Plugin marks share the scales and coordinate space the chart's own series use, so a mark drawn at ctx.scales.y(80) lines up with the value 80 on the axis.
| Hook | What it does |
|---|---|
domain | Widen the value axis to fit what the plugin draws. |
panes | Reserve strips below the plot, each with its own value scale, for example a volume pane. |
renderUnder, renderOver | SVG marks drawn beneath or above the chart's own series. |
renderPane | Marks inside a pane the plugin declared. |
renderTooltip | Replace the tooltip body for a category. The first plugin that returns something wins. |
legendItems | Legend entries for the marks the plugin draws. |
tableRows | Extra columns in the chart's accessible data table, one cell per row drawn. |
setup | Listeners and controllers. Return a function that removes them. |
The context gives you the host, the rows being drawn (data), the series, the scales, the plot size (inner), the chart's own number format, xCenter(category), paneScale(id), and refresh(). The chart rebuilds it on every render, so use it inside the call and do not keep it.
Give every mark a legend entry and a table column. Then a reader who cannot see the chart gets the same information from the accessible table that a sighted reader gets from the lines. Plugin marks are SVG, so a chart with plugins ignores renderer="canvas" and draws with SVG.
This plugin draws a dashed threshold line, widens the axis so the line always fits, and reports itself in the legend and the data table:
// threshold-plugin.js: a dashed line at a fixed value
import { svg } from "lit";
import { defineChartPlugin } from "@dojo-ng/chart";
export function thresholdPlugin({ value, label, color = "var(--dj-color-danger-600, #dc2626)" }) {
return defineChartPlugin({
name: "threshold",
domain: () => [value, value],
renderOver(ctx) {
const y = ctx.scales.y(value);
return svg`<line x1="0" y1=${y} x2=${ctx.inner.width} y2=${y}
stroke=${color} stroke-dasharray="4 2"></line>`;
},
legendItems: () => [{ label, color }],
tableRows: (ctx) => [{ header: label, cells: ctx.data.map(() => ctx.format(value)) }],
});
}
// chart.plugins = [thresholdPlugin({ value: 80, label: "Alert threshold" })];
A chart can draw everything through plugins and have no series of its own. The candlestick, volume, indicator, and crosshair plugins in @dojo-ng/chart-financial work that way.
Use the same copy of Lit and Lexical
Lexical's $ functions, such as $getSelection, only work inside the copy of lexical that created the editor. Lit templates (html and svg) are safest with the same copy of lit the components use. With npm and a bundler you get one copy of each by default. With an import map, map lit and lexical yourself and load the Dojo NG packages from esm.sh with ?external=, so they import your copies:
<script type="importmap">
{
"imports": {
"lit": "https://esm.sh/lit@3.3.0",
"lit/": "https://esm.sh/lit@3.3.0/",
"lexical": "https://esm.sh/lexical@0.21.0",
"@dojo-ng/data-grid": "https://esm.sh/@dojo-ng/data-grid?external=lit",
"@dojo-ng/rich-text": "https://esm.sh/@dojo-ng/rich-text?external=lit,lexical",
"@dojo-ng/chart": "https://esm.sh/@dojo-ng/chart?external=lit"
}
}
</script>
Learn from the published plugins
The data grid plugins, rich text plugins, and chart plugins in the catalog are ordinary packages built on these same hooks. Pagination, filtering, grouping, tree rows, tables, mentions, the slash menu, and the financial chart plugins are all good references. Each one depends on its host package and exports a function that returns the plugin.