Skip to content

Custom Integrations

Choose one analytical owner. An attached grid already has a PivotPlugin controller; its getAuthoringController() facade lets your application author that same report. A standalone renderer uses createPivotController from @revolist/pivot/headless instead.

IntegrationApplication ownsPublic surface
ViewerRendering policy and permitted interactionsCommitted runtime, exports and optional drill
Custom constructorFour-zone controls and application admissionAttached authoring commands, snapshots, history
Stock panel outside gridHost element, update wiring and cleanupdefinePivotConfigurator
Toolbar or field editorControls, download lifetime and presentationVisible export, stable field/measure commands
Async application APIAuth, tenant scope, response validation and executionClient/server adapter contract
Standalone engineRendering, source replacement and disposalHeadless controller
Interactive example

Custom integrations

Run checked attached constructor/editor/toolbar shell with one analytical owner

The example loads when visible.

Install and connect the RevoGrid element with PivotPlugin once before calling the recipe. The host owns grid destruction. This mount requires an inactive Pivot, supplies the source/config once, and returns cleanup for only the application shell.

import { PivotPlugin } from '@revolist/pivot';
import { mountPivotIntegration } from './public-integration/index';
// In your framework's mounted/effect callback, after the grid is connected:
grid.plugins = [PivotPlugin];
const integration = await mountPivotIntegration({
grid, constructorHost, toolbarHost, editorHost,
onCommitted(snapshot) {
// Ready revisions only. Session descriptors are not automatically portable.
saveAcceptedLayout(snapshot.report?.specification);
},
});
// On framework cleanup:
integration.dispose();

Copy the complete checked modules below into public-integration/, retaining their relative imports. Every library import uses a public package entry. The displayed source is imported directly from the executable recipe files, so the documentation and browser fixture exercise the same code. Run pnpm --filter @revolist/pivot check:recipes in the monorepo after building Pivot to compile against actual installed dist declarations and execute the DOM-free headless example.

The constructor starts with populated Rows, Columns, Values and Filters. Use Add, Remove, Up/Down and cross-zone controls to edit the report. The same Revenue field has distinct SUM and AVG measure IDs. The atomic action swaps axes and relabels one measure in one history entry; Undo, Redo and Reset use the same owner as the grid.

Member filters accept one typed JSON scalar, for example 2025 or "Australia"; Clear removes that selection. Field label/allowed-role controls change metadata, while the measure editor independently changes aggregation, label and numeric formatting. Field IDs and source properties are never edited as captions.

Controls render from pendingReport while loading and committed report otherwise. A pending shelf is intent; the grid retains the last committed values until success. Invalid commands can throw synchronously, and accepted operations can return prevented, superseded, cancelled, noop or failed. The recipe catches both forms and does not call every resolved promise a commit. Inputs stay mounted while typing; grouped controls restore keyboard focus after a shelf change.

Pass mode: 'viewer' to omit constructor/editor controls and report import. This recipe synchronously prevents all subsequent configuration proposals, captures the public row expansion event before Core mutates its grouping view, and disables column collapse controls (including per-level overrides). Column collapse events are post-mutation notifications and are not veto points. Export and supported drill remain available. A viewer that permits expansion needs a narrower explicit policy instead.

There is no Pivot readOnly authoring switch. Core editing restrictions do not freeze analytical layout, and a UI veto is not backend authorization. Apply tenant and field permissions on the application API too.

CSV and XLSX use plugin.exportCsv() and plugin.exportXlsx(); TSV uses plugin.getVisibleExportData(): readable grouped captions, raw numeric aggregates, nested headers and pinned totals. The remote export covers the current logical report/window, not every fact in the backend. Exporting raw grid.source loses renderer-generated group aggregates.

CSV, XLSX and TSV require a settled result. The checked toolbar disables data exports while an analytical request is loading and explains when they become available again. Retained grid pixels during loading do not promise that visible-export capture is available. Report JSON is a separate configuration operation.

Report JSON describes configuration, not data. Export can reject session-only callbacks or transient linked filters. Supply explicit reportOptions with named runtime Maps when importing bound reports, and reportVersion: 2 only when your application admits V2. The import path retains strict version/runtime admission; the authoring facade does not accept report.replace.

Drill inputs are semantic row/column paths, with a maximum 100-row page in this recipe. loadPivotDrilldown also supports positional dataIndex when selecting a measure. Requests carry an AbortSignal and are fenced to the captured committed revision. Cancelling, changing revisions or disposing the shell prevents stale results from painting the UI. Capability checks require an active runtime first; unsupported drill is disabled and explained.

Optional chart options create an unlinked standalone chart using an explicit application-supplied dataset/model via the public /charts entry. The recipe destroys only the chart it creates, including when initialization finishes after unmount. It does not recompute Pivot aggregates or claim that this unlinked chart follows report edits automatically. Use the existing Pivot Charts integration for linked chart behavior.

createHttpAdapter(baseUrl) posts load, drilldown and filter-values requests to application endpoints, forwards AbortSignal and rejects non-2xx responses. Its advertised capabilities are a contract your endpoints must implement; response validation and authentication remain application responsibilities. Set config.engine to { mode: 'server', adapter }. Only client and server are public engine modes.

createSimulatedAdapter() is explicitly an in-memory simulation over the supplied sample configuration/facts. setDelay(ms), failNext() and requests support testing retained results, failure and latest-wins behavior. It advertises drill/member lookup unavailable and deliberately ignores abort to test stale-result fencing. It is not a production backend test.

OperationClientServer
Commands/historyAttached facadeSame facade; async outcomes
Member lookup/drillPublic methods, committed scopeAdapter support required
ExportCurrent visible logical resultCurrent returned window
Isolated analytical queryExact-source capability checkUnsupported
Source/runtime replacementHost/report importHost/report import

For synchronous application admission, prevent pivot-config-update before execution. Persist a newly committed authoring snapshot afterwards. Do not save proposals as successful results or build a second headless controller to mirror the grid.

Source code
index.tstypescript
import { type PivotAuthoringSnapshot, type PivotConfig, type PivotReportPluginOptions } from '@revolist/pivot';
import { GROUP_EXPAND_EVENT, type DataType } from '@revolist/revogrid';
import { findPivot, mountConstructor, sampleConfig, sampleFacts } from './attached';
import { mountFieldEditor } from './field-editor';
import { mountToolbar, type ToolbarOptions } from './toolbar';

export interface PivotIntegrationOptions {
  grid: HTMLRevoGridElement;
  constructorHost: HTMLElement;
  toolbarHost: HTMLElement;
  editorHost: HTMLElement;
  mode?: 'editor' | 'viewer';
  config?: Partial<PivotConfig>;
  source?: DataType[];
  reportOptions?: PivotReportPluginOptions;
  chart?: ToolbarOptions['chart'];
  /** Called only for a newly committed revision, never for a pending/failed proposal. */
  onCommitted?: (snapshot: PivotAuthoringSnapshot) => void;
}
/** Host installs Core/Pivot once and owns grid teardown. This mount owns only its shell. */
export async function mountPivotIntegration(options: PivotIntegrationOptions) {
  const { grid } = options;
  const plugin = await findPivot(grid);
  if (plugin.getRuntimeSnapshot()) throw new Error('Mount initialization requires an inactive Pivot; dispose/clear the old integration first.');
  grid.source = (options.source ?? sampleFacts).map(row => ({ ...row }));
  grid.columns = Object.keys(grid.source[0] ?? {}).map(prop => ({ prop, name: prop }));
  if (options.reportOptions) plugin.setPivotReportRuntime(options.reportOptions);
  const config: Partial<PivotConfig> = { ...sampleConfig, ...options.config, contextMenu: false, hasConfigurator: false };
  if (options.mode === 'viewer') {
    // Column collapse events are post-mutation notifications, not veto points.
    config.columnCollapse = false;
    config.columnLevels = Object.fromEntries(Object.entries(config.columnLevels ?? {}).map(([level, settings]) => [level, { ...settings, collapsible: false }]));
  }
  const accepted = await plugin.updatePivotConfig(config);
  if (!accepted || !plugin.getRuntimeSnapshot()) throw new Error('Initial Pivot was not committed.');
  const authoring = plugin.getAuthoringController();
  const cleanup: (() => void)[] = [];
  let disposed = false; let committedRevision = -1;
  const persist = () => {
    const snapshot = authoring.getSnapshot();
    if (!disposed && snapshot.status === 'ready' && snapshot.report && snapshot.revision !== committedRevision) {
      committedRevision = snapshot.revision;
      options.onCommitted?.(snapshot);
    }
  };
  try {
  if (options.mode === 'viewer') {
    // This example forbids every report proposal, including row/column expansion.
    // It is application UI policy, not backend authorization.
    const veto = (event: Event) => event.preventDefault();
    grid.addEventListener('pivot-config-update', veto); cleanup.push(() => grid.removeEventListener('pivot-config-update', veto));
    // Core grouping mutates its view independently of the later config proposal.
    // Capture the public event before its bubble-phase grouping handler runs.
    const fixedExpansion = (event: Event) => { event.preventDefault(); event.stopImmediatePropagation(); };
    grid.addEventListener(GROUP_EXPAND_EVENT, fixedExpansion, true);
    cleanup.push(() => grid.removeEventListener(GROUP_EXPAND_EVENT, fixedExpansion, true));
    const notice = document.createElement('p'); notice.textContent = 'Viewer: layout and expansion are fixed';
    options.constructorHost.append(notice); cleanup.push(() => notice.remove());
  } else {
    cleanup.push(mountConstructor(options.constructorHost, authoring), mountFieldEditor(options.editorHost, authoring));
  }
  cleanup.push(authoring.subscribe(persist)); persist();
  cleanup.push(mountToolbar(options.toolbarHost, plugin, { reportOptions: options.reportOptions, chart: options.chart, viewer: options.mode === 'viewer' }));
  } catch (error) { disposed = true; cleanup.reverse().forEach(dispose => dispose()); throw error; }
  return {
    plugin, authoring,
    dispose() { if (disposed) return; disposed = true; cleanup.reverse().forEach(dispose => dispose()); },
  };
}
attached.tstypescript
import { PivotPlugin, type PivotAuthoringController, type PivotAuthoringCommand, type PivotAuthoringSnapshot, type PivotControllerOutcome, type PivotConfig } from '@revolist/pivot';

export const sampleFacts = [
  { id: 1, country: 'Australia', category: 'Audio', quarter: 'Q1', year: 2025, revenue: 10.125 },
  { id: 2, country: 'Australia', category: 'Audio', quarter: 'Q1', year: 2025, revenue: 20.25 },
  { id: 3, country: 'Australia', category: 'Video', quarter: 'Q2', year: 2025, revenue: 30.375 },
  { id: 4, country: 'Canada', category: 'Audio', quarter: 'Q1', year: 2025, revenue: 40.5 },
  { id: 5, country: 'Canada', category: 'Video', quarter: 'Q2', year: 2025, revenue: 50.625 },
  { id: 6, country: 'Canada', category: 'Video', quarter: 'Q2', year: 2024, revenue: 60.75 },
];
export const sampleConfig: Partial<PivotConfig> = {
  rows: ['country', 'category'], columns: ['quarter'], filters: ['year'],
  dimensions: ['country', 'category', 'quarter', 'year', 'revenue'].map(prop => ({ prop, name: prop, allowedRoles: prop === 'revenue' ? ['values'] : ['rows', 'columns', 'filters'] })),
  values: [{ id: 'revenue-sum', prop: 'revenue', aggregator: 'sum', label: 'Revenue sum' }, { id: 'revenue-avg', prop: 'revenue', aggregator: 'avg', label: 'Revenue average' }],
  totals: { grandTotal: true }, hasConfigurator: false, contextMenu: false,
};
export async function findPivot(grid: HTMLRevoGridElement): Promise<PivotPlugin> {
  const plugin = (await grid.getPlugins()).find((candidate): candidate is PivotPlugin => candidate instanceof PivotPlugin);
  if (!plugin) throw new Error('Install PivotPlugin in grid.plugins before mounting this integration.');
  return plugin;
}
export function currentReport(authoring: PivotAuthoringController) {
  const state = authoring.getSnapshot();
  return (state.pendingReport ?? state.report)?.specification;
}
export function button(label: string, action: () => void) {
  const element = document.createElement('button');
  element.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;gap:6px;margin:4px;padding:6px 10px;border:1px solid #9ca3af;border-radius:4px;background:Canvas;color:CanvasText;font:inherit;line-height:1.4;max-width:100%;white-space:normal;cursor:pointer';
  element.type = 'button'; element.textContent = label; element.setAttribute('aria-label', label);
  element.addEventListener('click', action); return element;
}
export function labelledInput(host: HTMLElement, label: string, value = '') {
  const wrapper = document.createElement('label'); wrapper.textContent = label;
  wrapper.style.cssText = 'display:inline-flex;flex-direction:column;gap:4px;margin:6px;max-width:100%;vertical-align:top';
  const input = document.createElement('input'); input.style.cssText = 'display:block;padding:6px 8px;border:1px solid #9ca3af;border-radius:4px;background:Canvas;color:CanvasText;font:inherit;line-height:1.4;width:12rem;max-width:100%;min-width:0;box-sizing:border-box'; input.value = value; input.setAttribute('aria-label', label);
  wrapper.append(input); host.append(wrapper); return input;
}
export function labelledSelect(host: HTMLElement, label: string) {
  const wrapper = document.createElement('label'); wrapper.textContent = label;
  wrapper.style.cssText = 'display:inline-flex;flex-direction:column;gap:4px;margin:6px;max-width:100%;vertical-align:top';
  const select = document.createElement('select'); select.style.cssText = 'display:block;padding:6px 8px;border:1px solid #9ca3af;border-radius:4px;background:Canvas;color:CanvasText;font:inherit;line-height:1.4;width:12rem;max-width:100%;min-width:0;box-sizing:border-box'; select.setAttribute('aria-label', label);
  wrapper.append(select); host.append(wrapper); return select;
}
export function options(select: HTMLSelectElement, values: readonly { id: string; label: string }[]) {
  const selected = select.value;
  const signature = JSON.stringify(values);
  if (select.dataset.options === signature) return;
  select.dataset.options = signature;
  select.replaceChildren(...values.map(value => new Option(value.label, value.id)));
  if (values.some(value => value.id === selected)) select.value = selected;
}
export function createActions(authoring: PivotAuthoringController, status: HTMLElement) {
  let disposed = false; let sequence = 0;
  return {
    async run(action: () => Promise<PivotControllerOutcome>) {
      if (disposed) return;
      const call = ++sequence;
      try {
        const outcome = await action();
        if (!disposed && call === sequence) status.textContent = outcome.status === 'failed' ? `Failed: ${outcome.error.message}` : `Outcome: ${outcome.status}`;
        return outcome;
      } catch (error) {
        if (!disposed && call === sequence) status.textContent = `Invalid: ${error instanceof Error ? error.message : String(error)}`;
      }
    },
    dispatch(command: PivotAuthoringCommand) { return this.run(() => authoring.dispatch(command)); },
    dispose() { disposed = true; sequence++; },
  };
}

/** A deliberately plain constructor: application DOM, one plugin-owned state owner. */
export function mountConstructor(host: HTMLElement, authoring: PivotAuthoringController) {
  const root = document.createElement('section'); root.setAttribute('aria-label', 'Pivot constructor');
  root.style.cssText = 'display:flex;flex-wrap:wrap;gap:12px;align-items:start';
  const stateText = document.createElement('p'); stateText.setAttribute('role', 'status');
  const outcomeText = document.createElement('p'); outcomeText.setAttribute('aria-label', 'Authoring outcome');
  const actions = createActions(authoring, outcomeText);
  let serial = 0; let disposed = false;
  const zones = (['rows', 'columns', 'values', 'filters'] as const).map(axis => {
    const zone = document.createElement('fieldset'); zone.style.cssText = 'min-width:180px;max-width:100%;flex:1;padding:10px;border:1px solid #9ca3af;border-radius:4px;box-sizing:border-box';
    const legend = document.createElement('legend'); legend.textContent = axis[0].toUpperCase() + axis.slice(1); zone.append(legend);
    const available = labelledSelect(zone, `Add ${axis} field`);
    const add = button(`Add to ${axis}`, () => {
      const report = currentReport(authoring); if (!report || !available.value) return;
      let measureId: string; do { measureId = `custom-measure-${++serial}`; } while (report.measures.some(measure => measure.id === measureId));
      void actions.dispatch(axis === 'values' ? { type: 'measure.add', fieldId: available.value, measureId, aggregator: 'sum' } : { type: 'axis.add', axis, fieldId: available.value });
    });
    const list = document.createElement('div'); zone.append(add, list); root.append(zone);
    return { axis, zone, available, add, list, signature: '' };
  });
  const filterField = labelledSelect(root, 'Filter field');
  const filterValue = labelledInput(root, 'Include member JSON', '2025');
  const filterApply = button('Include member', () => { void actions.run(() => authoring.dispatch({ type: 'memberFilter.set', fieldId: filterField.value, selection: [JSON.parse(filterValue.value)] })); });
  const filterClear = button('Clear member filter', () => { void actions.dispatch({ type: 'memberFilter.remove', fieldId: filterField.value }); });
  const swap = button('Swap Rows and Columns', () => { void actions.dispatch({ type: 'axes.swap' }); });
  const atomic = button('Swap axes and relabel measure atomically', () => {
    const report = currentReport(authoring); if (!report?.measures.length) return;
    void actions.run(() => authoring.transaction([{ type: 'axes.swap' }, { type: 'measure.configure', measureId: report.measures[0].id, patch: { label: 'Atomic revenue' } }]));
  });
  const undo = button('Undo', () => { void actions.run(() => authoring.undo()); });
  const redo = button('Redo', () => { void actions.run(() => authoring.redo()); });
  const reset = button('Reset layout', () => { void actions.run(() => authoring.reset()); });
  const cancel = button('Cancel pending changes', () => { if (!disposed) authoring.cancel(); });
  root.append(filterApply, filterClear, swap, atomic, undo, redo, reset, cancel, stateText, outcomeText); host.append(root);
  function render() {
    const snapshot = authoring.getSnapshot(); const report = currentReport(authoring);
    stateText.textContent = snapshot.status === 'loading' ? 'Loading pending layout; grid retains last committed result' : snapshot.status === 'error' ? `Error: ${snapshot.error?.message}; grid retains last committed result` : `State: ${snapshot.status} · revision ${snapshot.revision}`;
    undo.disabled = !snapshot.canUndo; redo.disabled = !snapshot.canRedo;
    const active = !!report && snapshot.status !== 'destroyed';
    for (const control of [reset, cancel, swap, atomic, filterApply, filterClear]) control.disabled = !active;
    for (const zone of zones) {
      const fields = report?.fields ?? [];
      const ids = report ? zone.axis === 'values' ? report.measures.map(measure => measure.id) : report[zone.axis] : [];
      options(zone.available, fields.filter(field => (!field.allowedRoles || field.allowedRoles.includes(zone.axis)) && (zone.axis === 'values' || !ids.includes(field.id))).map(field => ({ id: field.id, label: String(field.column?.name ?? field.id) })));
      zone.add.disabled = !active || !zone.available.options.length;
      const signature = JSON.stringify([ids, report?.fields, report?.measures]);
      if (signature === zone.signature) continue;
      zone.signature = signature;
      const focused = document.activeElement instanceof HTMLElement && zone.list.contains(document.activeElement) ? document.activeElement.getAttribute('aria-label') : null;
      zone.list.replaceChildren(...ids.map((id, index) => {
        const item = document.createElement('div'); item.style.cssText = 'padding:6px;border:1px solid #ddd;margin-top:6px';
        const measure = report?.measures.find(value => value.id === id);
        const label = zone.axis === 'values' ? measure?.label ?? id : String(fields.find(field => field.id === id)?.column?.name ?? id);
        const title = document.createElement('strong'); title.textContent = label; item.append(title);
        const move = (target: number) => { void actions.dispatch(zone.axis === 'values' ? { type: 'measure.move', measureId: id, index: target } : { type: 'axis.move', from: zone.axis, to: zone.axis, fieldId: id, index: target }); };
        const up = button(`Move ${zone.axis} ${id} up`, () => move(index - 1)); up.disabled = index === 0;
        const down = button(`Move ${zone.axis} ${id} down`, () => move(index + 1)); down.disabled = index === ids.length - 1;
        const remove = button(`Remove ${zone.axis} ${id}`, () => { void actions.dispatch(zone.axis === 'values' ? { type: 'measure.remove', measureId: id } : { type: 'axis.remove', axis: zone.axis, fieldId: id }); });
        item.append(up, down, remove);
        if (zone.axis !== 'values') {
          const destination = labelledSelect(item, `Move ${zone.axis} ${id} to`);
          options(destination, ['rows', 'columns', 'filters'].filter(axis => axis !== zone.axis).map(axis => ({ id: axis, label: axis })));
          item.append(button(`Move ${zone.axis} ${id} across zones`, () => { void actions.dispatch({ type: 'axis.move', from: zone.axis as 'rows' | 'columns' | 'filters', to: destination.value as 'rows' | 'columns' | 'filters', fieldId: id }); }));
        }
        return item;
      }));
      if (focused) (Array.from(zone.list.querySelectorAll<HTMLElement>('[aria-label]')).find(element => element.getAttribute('aria-label') === focused) ?? zone.available).focus();
    }
    options(filterField, (report?.filters ?? []).map(id => ({ id, label: String(report?.fields.find(field => field.id === id)?.column?.name ?? id) })));
    filterApply.disabled = !active || !filterField.value; filterClear.disabled = !active || !filterField.value;
  }
  const unsubscribe = authoring.subscribe(render); render();
  return () => { disposed = true; unsubscribe(); actions.dispose(); root.remove(); };
}
export type { PivotAuthoringSnapshot };
field-editor.tstypescript
import type { PivotAuthoringController, PivotFieldRole } from '@revolist/pivot';
import { button, createActions, currentReport, labelledInput, labelledSelect, options } from './attached';

let nextDecimalDescription = 0;

export function mountFieldEditor(host: HTMLElement, authoring: PivotAuthoringController) {
  const root = document.createElement('fieldset'); root.style.cssText = 'max-width:100%;min-width:0;padding:10px;border:1px solid #9ca3af;border-radius:4px;box-sizing:border-box';
  const legend = document.createElement('legend'); legend.textContent = 'Field and measure editor'; root.append(legend);
  const field = labelledSelect(root, 'Edit field');
  const name = labelledInput(root, 'Field label');
  const roles = labelledInput(root, 'Allowed roles', 'rows,columns,filters');
  const measure = labelledSelect(root, 'Edit measure');
  const label = labelledInput(root, 'Measure label');
  const aggregator = labelledSelect(root, 'Measure aggregation'); options(aggregator, ['sum', 'avg', 'min', 'max', 'count'].map(id => ({ id, label: id })));
  const decimals = labelledInput(root, 'Measure decimals', '2'); decimals.inputMode = 'numeric'; decimals.required = true;
  const decimalError = document.createElement('p'); decimalError.id = `pivot-recipe-decimals-${++nextDecimalDescription}`;
  decimalError.setAttribute('role', 'alert'); decimalError.textContent = 'Enter a whole number from 0 to 20.'; decimalError.hidden = true;
  decimals.setAttribute('aria-describedby', decimalError.id); root.append(decimalError);
  const status = document.createElement('p'); status.setAttribute('aria-label', 'Editor outcome');
  const actions = createActions(authoring, status);
  let disposed = false;
  let dirtyField: string | undefined; let dirtyMeasure: string | undefined; let edits = 0;
  const markField = () => { dirtyField = field.value; edits++; };
  const markMeasure = () => { dirtyMeasure = measure.value; edits++; };
  name.addEventListener('input', markField); roles.addEventListener('input', markField);
  label.addEventListener('input', markMeasure); aggregator.addEventListener('change', markMeasure); decimals.addEventListener('input', markMeasure);
  const applyField = button('Apply field settings', () => {
    const edit = edits;
    void actions.dispatch({ type: 'field.configure', fieldId: field.value, patch: { column: { name: name.value }, allowedRoles: roles.value.split(',').map(role => role.trim()) as PivotFieldRole[] } }).then(outcome => { if (!disposed && edit === edits && (outcome?.status === 'committed' || outcome?.status === 'noop')) { dirtyField = undefined; render(); } });
  });
  const applyMeasure = button('Apply measure settings', () => {
    const fractionDigits = validateDecimals();
    if (disposed || fractionDigits === undefined) return;
    const edit = edits;
    void actions.dispatch({ type: 'measure.configure', measureId: measure.value, patch: { label: label.value, aggregator: aggregator.value, format: { preset: 'number', options: { minimumFractionDigits: fractionDigits, maximumFractionDigits: fractionDigits } } } }).then(outcome => { if (!disposed && edit === edits && (outcome?.status === 'committed' || outcome?.status === 'noop')) { dirtyMeasure = undefined; render(); } });
  });
  root.append(applyField, applyMeasure, status);
  function validateDecimals() {
    if (disposed) return;
    const value = /^[0-9]+$/.test(decimals.value) ? Number(decimals.value) : NaN;
    const valid = Number.isInteger(value) && value >= 0 && value <= 20;
    decimals.setAttribute('aria-invalid', String(!valid)); decimalError.hidden = valid; applyMeasure.disabled = !valid;
    return valid ? value : undefined;
  }
  decimals.addEventListener('input', validateDecimals);
  host.append(root);
  function render() {
    if (disposed) return;
    const report = currentReport(authoring);
    options(field, (report?.fields ?? []).map(value => ({ id: value.id, label: String(value.column?.name ?? value.id) })));
    options(measure, (report?.measures ?? []).map(value => ({ id: value.id, label: value.label ?? value.id })));
    const selectedField = report?.fields.find(value => value.id === field.value);
    const selectedMeasure = report?.measures.find(value => value.id === measure.value);
    // Controls stay mounted: subscriptions never steal focus or replace a typing draft.
    if (dirtyField !== field.value && document.activeElement !== name) name.value = String(selectedField?.column?.name ?? selectedField?.id ?? '');
    if (dirtyField !== field.value && document.activeElement !== roles) roles.value = (selectedField?.allowedRoles ?? ['rows', 'columns', 'filters', 'values']).join(',');
    if (dirtyMeasure !== measure.value && document.activeElement !== label) label.value = selectedMeasure?.label ?? selectedMeasure?.id ?? '';
    if (dirtyMeasure !== measure.value && document.activeElement !== aggregator) aggregator.value = selectedMeasure?.aggregator ?? 'sum';
    if (dirtyMeasure !== measure.value && document.activeElement !== decimals) decimals.value = String(selectedMeasure?.format?.preset === 'number' ? selectedMeasure.format.options?.maximumFractionDigits ?? 2 : 2);
    validateDecimals();
    root.disabled = !report;
  }
  const changeField = () => { dirtyField = undefined; render(); };
  const changeMeasure = () => { dirtyMeasure = undefined; render(); };
  field.addEventListener('change', changeField); measure.addEventListener('change', changeMeasure);
  const unsubscribe = authoring.subscribe(render); render();
  return () => { disposed = true; unsubscribe(); actions.dispose(); decimals.removeEventListener('input', validateDecimals); field.removeEventListener('change', changeField); measure.removeEventListener('change', changeMeasure); root.remove(); };
}
toolbar.tstypescript
import { buildPivotCopyTsv, parsePivotReport, type PivotPlugin, type PivotReportPluginOptions } from '@revolist/pivot';
import { createStandalonePivotChart, type StandalonePivotChartOptions, type StandalonePivotChartRef } from '@revolist/pivot/charts';
import { button, labelledInput } from './attached';

function download(contents: string | Uint8Array, filename: string, type: string) {
  const blob = new Blob([typeof contents === 'string' ? contents : new Uint8Array(contents)], { type });
  const url = URL.createObjectURL(blob); const link = document.createElement('a');
  link.href = url; link.download = filename; link.click();
  setTimeout(() => URL.revokeObjectURL(url), 0);
}
export interface ToolbarOptions {
  /** Explicit named runtime bindings for portable imports; never serialize callback handles. */
  reportOptions?: PivotReportPluginOptions;
  viewer?: boolean;
  chart?: Omit<StandalonePivotChartOptions, 'container'> & { container: HTMLElement };
}
export function mountToolbar(host: HTMLElement, plugin: PivotPlugin, options: ToolbarOptions = {}) {
  const root = document.createElement('section'); root.setAttribute('aria-label', 'Pivot toolbar');
  const status = document.createElement('p'); status.setAttribute('aria-label', 'Toolbar outcome');
  const exportAvailability = document.createElement('p'); exportAvailability.setAttribute('aria-label', 'Export availability');
  const output = document.createElement('textarea'); output.style.cssText = 'display:block;width:100%;max-width:100%;min-height:6rem;padding:8px;border:1px solid #9ca3af;border-radius:4px;box-sizing:border-box;font:inherit;background:Canvas;color:CanvasText'; output.setAttribute('aria-label', 'Report JSON');
  const drillRows = document.createElement('pre'); drillRows.style.cssText = 'max-width:100%;overflow:auto;white-space:pre-wrap;word-break:break-word'; drillRows.setAttribute('aria-label', 'Drill rows');
  const rowPath = labelledInput(root, 'Drill row path JSON', '["Australia"]');
  const columnPath = labelledInput(root, 'Drill column path JSON', '[]');
  let disposed = false; let drillAbort: AbortController | undefined; let operation = 0;
  let chart: StandalonePivotChartRef | undefined;
  async function run(action: () => unknown | Promise<unknown>) {
    if (disposed) return;
    try { await action(); } catch (error) {
      if (!disposed) status.textContent = error instanceof Error ? error.message : String(error);
    }
  }
  const pendingExportMessage = 'Data exports are available when the pending report settles.';
  function runDataExport(action: () => unknown | Promise<unknown>) {
    if (disposed) return;
    if (!plugin.getRuntimeSnapshot() || plugin.getAuthoringController().getSnapshot().status === 'loading') {
      status.textContent = pendingExportMessage;
      return;
    }
    void run(action);
  }
  const csv = button('Download CSV', () => { runDataExport(() => download(plugin.exportCsv({ bom: true }), 'pivot-report.csv', 'text/csv;charset=utf-8')); });
  const xlsx = button('Download XLSX', () => { runDataExport(() => download(plugin.exportXlsx(), 'pivot-report.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')); });
  const copy = button('Copy TSV', () => { runDataExport(() => navigator.clipboard.writeText(buildPivotCopyTsv(plugin.getVisibleExportData()))); });
  const exportReport = button('Export report JSON', () => { void run(() => {
    output.value = plugin.exportPivotReportJson(options.reportOptions);
    download(output.value, 'pivot-report.json', 'application/json');
  }); });
  const importReport = button('Import report JSON', () => { void run(async () => {
    // Explicitly admit V2 only when the application supplied reportVersion:2.
    if (options.reportOptions?.reportVersion !== 2) parsePivotReport(output.value);
    const accepted = await plugin.importPivotReport(output.value, options.reportOptions);
    if (!disposed) status.textContent = accepted ? 'Report committed' : 'Report not committed';
  }); });
  if (options.viewer) { importReport.hidden = true; output.readOnly = true; }
  const drill = button('Drill rows', () => {
    if (disposed) return;
    drillAbort?.abort(); const abort = new AbortController(); drillAbort = abort; const call = ++operation;
    const revision = plugin.getRuntimeSnapshot()?.revision;
    const current = () => !disposed && !abort.signal.aborted && call === operation && plugin.getRuntimeSnapshot()?.revision === revision;
    void (async () => {
      try {
        const result = await plugin.loadPivotDrilldown({ cell: { rowPath: JSON.parse(rowPath.value), columnPath: JSON.parse(columnPath.value) }, offset: 0, limit: 100, signal: abort.signal });
        if (current()) { drillRows.textContent = JSON.stringify(result, null, 2); status.textContent = 'Drill page loaded'; }
      } catch (error) {
        if (current()) status.textContent = error instanceof Error ? error.message : String(error);
      }
    })();
  });
  const cancel = button('Cancel drill', () => { operation++; drillAbort?.abort(); });
  root.append(csv, xlsx, copy, exportReport, output, importReport, drill, cancel, exportAvailability, status, drillRows); host.append(root);
  function sync() {
    const active = !!plugin.getRuntimeSnapshot();
    const loading = plugin.getAuthoringController().getSnapshot().status === 'loading';
    for (const control of [csv, xlsx, copy]) {
      control.disabled = !active || loading;
      control.title = loading ? pendingExportMessage : active ? 'Export the settled visible result' : 'Activate Pivot to export data';
    }
    exportAvailability.textContent = loading ? pendingExportMessage : active ? 'Data export uses the settled visible result.' : 'Activate Pivot to export data.';
    for (const control of [exportReport, importReport]) control.disabled = !active;
    const supported = active && plugin.getPivotEngineCapabilities().drilldown !== false;
    drill.disabled = !supported; drill.title = supported ? '' : 'Drillthrough requires an active result and engine support';
    if (plugin.getRuntimeSnapshot()?.revision !== drillRevision) { operation++; drillAbort?.abort(); drillRows.textContent = ''; status.textContent = 'Report changed; drill scope cleared'; }
    drillRevision = plugin.getRuntimeSnapshot()?.revision;
  }
  let drillRevision = plugin.getRuntimeSnapshot()?.revision;
  const unsubscribe = plugin.getAuthoringController().subscribe(sync); sync();
  if (options.chart) void run(async () => {
    const created = await createStandalonePivotChart(options.chart!);
    if (disposed) created.destroy(); else chart = created;
  });
  return () => { disposed = true; operation++; drillAbort?.abort(); unsubscribe(); chart?.destroy(); root.remove(); };
}
remote.tstypescript
import { ClientPivotEngineAdapter, type PivotEngineAdapter, type PivotLoadRequest, type PivotLoadResponse, type PivotDrilldownResponse, type PivotFilterValuesResponse } from '@revolist/pivot';
import { sampleConfig, sampleFacts } from './attached';

/** Application API owns authentication, tenant scope, selector validation and analytical execution. */
export function createHttpAdapter(baseUrl: string): PivotEngineAdapter {
  async function post<T>(route: string, request: unknown, signal?: AbortSignal): Promise<T> {
    const response = await fetch(`${baseUrl.replace(/\/$/, '')}/${route}`, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(request), signal });
    if (!response.ok) throw new Error(`Pivot ${route} failed: HTTP ${response.status}`);
    return await response.json() as T; // Validate response schema at your application's API boundary.
  }
  return {
    capabilities: { aggregation: true, calculations: false, filter: true, filterValues: true, sort: true, drilldown: true },
    load: (request, signal) => post<PivotLoadResponse>('load', request, signal),
    drilldown: (request, signal) => post<PivotDrilldownResponse>('drilldown', request, signal),
    filterValues: (request, signal) => post<PivotFilterValuesResponse>('filter-values', request, signal),
  };
}

/** In-memory async simulation, not a backend acceptance test. Deliberately ignores abort to exercise fencing. */
export function createSimulatedAdapter(options: { delayMs?: number } = {}) {
  const client = new ClientPivotEngineAdapter(sampleConfig, sampleFacts.map(row => ({ ...row })));
  let delay = options.delayMs ?? 80; let failure = false; let requests = 0;
  const adapter: PivotEngineAdapter = {
    capabilities: { ...client.capabilities, drilldown: false, filterValues: false },
    async load(request: PivotLoadRequest) {
      requests++; const fail = failure; failure = false; const wait = delay;
      await new Promise(resolve => setTimeout(resolve, wait));
      if (fail) throw new Error('Simulated analytical request failed');
      return client.load(request);
    },
  };
  return { adapter, failNext() { failure = true; }, setDelay(ms: number) { delay = ms; }, get requests() { return requests; } };
}
headless.tstypescript
import { createPivotController, type PivotReport } from '@revolist/pivot/headless';

/** Separate DOM-free engine owner for applications without an attached grid. */
export async function executeHeadlessRecipe() {
  const report: PivotReport = {
    kind: 'revogrid:pivot.report', version: 1,
    fields: [{ id: 'country', sourceProp: 'country' }, { id: 'revenue', sourceProp: 'revenue' }],
    rows: ['country'], columns: [], filters: [], measures: [{ id: 'revenue-sum', fieldId: 'revenue', aggregator: 'sum' }],
    totals: { grandTotal: true },
  };
  const controller = createPivotController({ report, source: [{ country: 'Australia', revenue: 10.125 }, { country: 'Australia', revenue: 20.25 }] });
  const statuses: string[] = [];
  const unsubscribe = controller.subscribe(() => statuses.push(controller.getSnapshot().status));
  try {
    const outcome = await controller.refresh();
    if (outcome.status === 'failed') throw new Error(outcome.error.message);
    return { snapshot: controller.getSnapshot(), statuses, outcome };
  } finally { unsubscribe(); controller.dispose(); }
}
stock-panel.tstypescript
import { definePivotConfigurator, disposePivotConfigurator, type PivotPlugin, type PivotConfig, type PivotConfigurationActions, type PivotConfigurationPatch } from '@revolist/pivot';

/** Optional stock UI. Custom constructors use the attached command facade instead. */
export function mountStockConfigurator(host: HTMLElement, plugin: PivotPlugin, applicationSorting: Pick<PivotConfigurationActions, 'sorting' | 'onToggleSort'> = {}) {
  let disposed = false; let generation = 0;
  let draft: Partial<PivotConfig> = { ...plugin.getRuntimeSnapshot()?.config };
  const authoring = plugin.getAuthoringController();
  const error = document.createElement('p'); error.setAttribute('role', 'alert'); host.after(error);
  async function apply(patch: Partial<PivotConfig>) {
    const call = ++generation;
    draft = { ...draft, ...patch };
    try {
      const accepted = await plugin.updatePivotConfig(draft);
      if (!disposed && call === generation) {
        if (!accepted) error.textContent = 'Configuration was not committed';
        draft = { ...plugin.getRuntimeSnapshot()?.config };
        render();
      }
    } catch (failure) {
      if (!disposed && call === generation) { error.textContent = String(failure); draft = { ...plugin.getRuntimeSnapshot()?.config }; render(); }
    }
  }
  function render() {
    if (disposed) return;
    definePivotConfigurator(host, draft, {
      onUpdateConfiguration: (patch: PivotConfigurationPatch) => { void apply(patch); },
      onUpdateFilterSelections: filterSelections => { void apply({ filterSelections }); },
      ...applicationSorting,
      onResetLayout: () => { void (async () => {
        try {
          const outcome = await authoring.reset();
          if (!disposed && outcome.status === 'failed') error.textContent = outcome.error.message;
        } catch (failure) { if (!disposed) error.textContent = String(failure); }
      })(); },
    });
  }
  const unsubscribe = authoring.subscribe(() => {
    if (authoring.getSnapshot().status !== 'loading') { draft = { ...plugin.getRuntimeSnapshot()?.config }; render(); }
  });
  render();
  return () => { disposed = true; generation++; unsubscribe(); error.remove(); disposePivotConfigurator(host); };
}

Dispose the returned shell before removing its hosts. This unsubscribes constructors/editors/persistence, aborts drill, removes viewer veto and releases its chart. It does not destroy the grid or erase its report. Clear the existing Pivot explicitly before mounting a new integration that initializes source/config. Captured authoring facades survive clear/re-enable; after plugin destroy they remain terminal. Pending adapter work belongs to the plugin and can be cancelled explicitly when the grid owner tears down.

The compile-only authority.type-test.ts additionally rejects private controller access, attached disposal/source replacement, report.replace, invented hybrid mode and cross-entry authority imports. These negative assertions are never executed in the application.