Dojo NG is being built in the open, one section at a time — follow along on Heptapod.

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).

HookWhat it does
columnsTransform the column list: add, remove, or annotate columns.
tableOptionsTanStack options merged at table creation, such as row models. Never return state or onStateChange; the core owns those.
setupWire listeners after the table exists. Return a function that removes them.
renderCellReplace a cell's content. The first plugin that returns something other than undefined wins.
renderHeaderReplace a column header's content, for example a select-all checkbox.
decorateCellWrap a cell's content after rendering: an indent, an expander, a badge.
rowAttributesAdd attributes to each row element, such as aria-level.
subheaderCellsA second header row, for example per-column filters.
chromeTop, chromeBottomFull-width areas above the header and below the rows: a quick filter, pagination, totals.
renderDetailContent 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.

MemberWhat it does
nodesCustom Lexical node classes. Lexical needs them when the editor is created, which is one reason a plugins change rebuilds the editor.
setupRegister Lexical commands, transforms, and listeners. Return a function that removes them.
toolbarToolbar controls: a simple button (run, icon, isActive, isDisabled) or a custom control (render). label is required, so every control has an accessible name.
insertsActions offered in the slash menu from @dojo-ng/rich-text-slash.
formatsExtra output formats, such as Markdown, selected with the editor's format property.
htmlHTML 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.

HookWhat it does
domainWiden the value axis to fit what the plugin draws.
panesReserve strips below the plot, each with its own value scale, for example a volume pane.
renderUnder, renderOverSVG marks drawn beneath or above the chart's own series.
renderPaneMarks inside a pane the plugin declared.
renderTooltipReplace the tooltip body for a category. The first plugin that returns something wins.
legendItemsLegend entries for the marks the plugin draws.
tableRowsExtra columns in the chart's accessible data table, one cell per row drawn.
setupListeners 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.

Next: Browse the components 82 custom elements and 25 plugins, each its own package.