StockSharp JS Data Grid is the browser table component behind StockSharp's
web applications: a column-driven DataGrid that owns its header, body, column
order, sort, filters, grouping, selection and exported sheet, a ColumnSettings
adapter for tables the server already rendered, and a dependency-free .xlsx
writer.
Live demo · StockSharp website · GitHub repository · Issue tracker
npm install @stocksharp/gridsimport { DataGrid } from '@stocksharp/grids';
interface Order { id: number; symbol: string; side: number; price: number; }
const grid = new DataGrid<Order>({
head: document.querySelector<HTMLElement>('#orders thead')!,
body: document.querySelector<HTMLElement>('#orders tbody')!,
columns: [
{ key: 'id', header: 'ID', exportable: true, value: (o) => o.id },
{ key: 'symbol', header: 'Symbol', exportable: true, value: (o) => o.symbol },
{
key: 'side',
header: 'Side',
exportable: true,
value: (o) => o.side,
render: (o) => (o.side === 0 ? 'Buy' : 'Sell'),
cellClass: (o) => (o.side === 0 ? 'side-buy' : 'side-sell'),
exportValue: (o) => (o.side === 0 ? 'Buy' : 'Sell'),
},
{ key: 'price', header: 'Price', exportable: true, value: (o) => o.price },
],
defaultSort: { col: 'id', dir: 'desc' },
rowKey: (o) => String(o.id),
emptyText: 'No orders',
});
grid.setRows(orders);
grid.download('orders', 'Orders'); // orders-20260807-120000.xlsxThe package also ships a ready-to-use browser bundle exposed as window.SSGrid:
<script src="https://cdn.jsdelivr.net/npm/@stocksharp/grids@1.0.0/dist/ssgrid.js"></script>
<script>
const { DataGrid } = window.SSGrid;
</script>A blotter used to declare its columns three times — the header cells in markup, the sort accessors next to the widget, and the header/row arrays in its export — with nothing keeping the three in step. Here a column states its key, caption, how to read its value, how to render it, how to class it and whether it exports, and the grid derives the header, the body, the sort and the sheet from that one declaration.
Two things a naive column pipeline cannot express are first-class:
render()may return aNode. A cell can hold a real control with its ownaddEventListenerinstead of an inlineonclickattribute reaching a global. ADocumentFragmentworks too, so a cell can mix text and an element.- Pinned rows sit outside the sort and outside the export. A balance summary
stays on top whichever column the user sorts by, and never lands in the sheet.
pinnedRows()is re-read on every render, so live figures update.
The grid renders elements, never HTML strings, so nothing in it can be an
injection site. Colour stays with the host: a column returns class names
(cellClass, rowClass) and the host stylesheet decides what they look like.
Three class names come out of the package itself and an adopting site has to
style them: grid-empty on the cell that spans the table when there is nothing
to show, visually-hidden on a header whose caption is for screen readers only,
and sort-asc / sort-desc on the sorted header. The first is ours; the other
two are spelled the way Bootstrap spells them, which is a coupling worth knowing
about before adopting.
renderLimit caps what is painted, not what is exported — which is what a
watchlist wants: a screen-sized table over a full sheet. afterRender() fires
after every repaint, including one caused by a sort click the caller never saw,
so a host can re-subscribe to the symbols now on screen.
Everything below is off unless the declaration asks for it, and the arrangement it
produces is in getState() — so a table comes back the way somebody left it.
const grid = new DataGrid<Order>({
// ...
reorderable: true, // drag a header to move its column
filtersVisible: true, // the row of filter boxes; off by default, see below
selection: 'multi', // 'none' (default) | 'single' | 'multi'
selectedClass: 'is-selected',
contextMenu: true, // the grid's own menu, see below
onStateChange: (state) => localStorage.setItem('orders', JSON.stringify(state)),
});
grid.setState(JSON.parse(localStorage.getItem('orders') || '{}'));setColumnOrder(keys) hideColumn(key) showColumn(key) |
the column view, which every render path reads |
setFilter(key, filter) clearFilters() |
per-column filters — see below |
groupBy(key) toggleGroup(value) |
single-level grouping, collapsible |
setSelection(keys) selectedKeys() selectedRows() |
selection, held by row key |
showFilters(visible) filtersVisible() |
the row of filter boxes under the header |
getState() setState(state) |
the arrangement above, as plain JSON |
Three things about the state are worth knowing before you store it. Unknown column
keys are dropped rather than rejected, so a view saved before a deploy that
removed a column still restores the rest. A partial order keeps the columns it
does not name, so a stored layout cannot hide a column added later. And
setState does not fire onStateChange, or a store would write itself back
on every page load.
Selection is held by row key, not by index or element — which is what makes it
survive a repaint that rebuilds every <tr>, and a filter that takes a row off
screen and later brings it back. It is not part of getState(): a stored view
is how a table is arranged, and which rows were highlighted when somebody closed
the tab is not that. Persist it separately if you want it back — onSelectionChange
hands you the keys, setSelection(keys) puts them back.
A column declares which control it wants, and the grid does the filtering:
{ key: 'symbol', header: 'Symbol', filter: 'text', exportable: true, value: o => o.symbol }
{ key: 'pnl', header: 'P&L', filter: 'number', exportable: true, value: o => o.pnl }
{ key: 'board', header: 'Board', filter: 'set', exportable: true, value: o => o.board }There are two ways in, and the popup is the main one: a right-click on a column
offers Filter…, which asks for an operator and its operand — contains,
starts with, >, between, is empty, and on a set column a list of the
values present, ticked. The row of boxes under the header is the quick one, and it
is off by default (filtersVisible: true turns it on, showFilters(visible)
toggles it at runtime, and the menu carries the same toggle): it costs a line of
vertical space on every table, and everything it can say the popup can say better.
A filter is plain data — { text }, { min, max }, { values } — rather than a
predicate, and deliberately so: a closure cannot be written to a store, so a table
filtered by one could never come back filtered the same way. An empty filter is
the same as none, so clearing an input brings the rows back instead of matching
nothing.
grid.groupBy('board'); // a collapsible header per distinct value, with a count
grid.groupBy(null); // flat againGroups run in label order while the rows inside each keep the sort the user
picked. A group is keyed by the value and labelled by the text, so a column
holding a code and showing a word — a side held as 0, read as Buy — heads its
groups Buy rather than 0. Declare text(row) on such a column and the header,
the set filter's list and the menu's filter-by-this-value line all read from it,
while value stays what the grid sorts, groups and compares by. renderLimit counts rows of data rather than headers, and the export
follows the on-screen order but drops the header rows — a spreadsheet has its own
grouping, and a label row in the middle of the range breaks every formula pointed
at it.
This is the one place the package draws chrome. Everywhere else the host owns it — the column picker's dialog is the host's markup and its own modal library. A menu is the exception because positioning a list at a pointer, dismissing it on the next click and on Escape and keeping it inside the window is the same hundred lines in every host, and none of them are about tables.
contextMenu: true gives you sort, group, filter by the value under the pointer,
hide the column, show all, copy cell, copy row and export. A host can restyle it,
reword it, add to it, or refuse it for a click:
contextMenu: {
classes: { menu: 'my-menu', item: 'my-menu-item' },
labels: { sortAsc: 'По возрастанию' },
items: (context, defaults) => context.row
? [{ label: `Cancel #${context.row.id}`, run: () => cancel(context.row!.id) }, {}, ...defaults]
: defaults,
}Returning an empty array suppresses the menu for that click — and lets the
browser's own appear, which is what refusing should mean. The grid names the
elements (grid-menu, grid-menu-item, grid-menu-separator, is-disabled)
and styles none of them; until your stylesheet describes them, the menu is
invisible.
TableSort is the sort state behind the grid, and is usable on its own. Clicking
a <th data-sort="key"> cycles asc → desc → default: there is no "unsorted"
state, because a table always has a deterministic order. Each table declares its
resting order (defaultSort), so a first paint is meaningful instead of exposing
the raw arrival order of a cache hydration, a snapshot and live updates. Rows
with no value for the sorted column sink to the end in both directions, and
apply() always returns a copy, so the caller's array is never reordered.
Text is compared through one Intl.Collator, built from the locale option (or
the document's own lang), and the same one orders the groups and the list a set
filter offers, and answers the > ≥ < ≤ filter rules. That is one collator
rather than four comparisons because they are all about the same column: raw >
on strings is UTF-16 order, which puts every accented letter after z and every
Cyrillic capital before every small letter — so a table shown in one order would
filter in another. It is numeric, so a text column of ids reads 1, 2, 10, and
case-folding, so ≥ "aapl" matches AAPL the way = "aapl" already did.
The grid ships English defaults and takes every word of them from the host:
new DataGrid<Order>({
// ...
locale: 'zh-CN',
emptyText: '暂无委托',
columns: [{ key: 'side', header: '方向', text: o => (o.side === 0 ? '买入' : '卖出'), /* ... */ }],
contextMenu: {
labels: { sortAsc: '升序排列', filterByValue: (text) => `按此值筛选:${text}`, /* ... */ },
filterDialog: { labels: { title: (header) => `筛选:${header}`, apply: '应用', /* ... */ } },
},
groupHeader: (label, count, collapsed) => `${collapsed ? '▸' : '▾'} ${label}(${count})`,
});The labels that join two pieces — filterByValue, copyRows, the dialog's
title — are functions of what they join, not strings the grid concatenates
onto. A Chinese UI writes a full-width colon and full-width brackets, and some
languages want the value first; neither is expressible if the package owns the
punctuation.
Columns are declared once, so translated headers mean a new grid over the same
table. Call destroy() on the old one first — it takes back the Ctrl+C
binding it put on the document and closes anything it had open — then carry the
arrangement across with getState() / setState(). That works because the state
is written in column keys and raw values, never in anything on screen: a table
grouped by side and filtered to one board comes back grouped and filtered, now
reading 卖出. Group order, though, follows the labels, so two languages show
the same table in a different order — the keys underneath do not move.
demo/i18n.js is a worked example of the split: what the package would have said
in English, versus the headers, cell words and prose that were always the host's.
The demo's language button switches between them live — try it on the
live demo.
ColumnSettings is the other half: a table the server already rendered, where
the columns exist as markup and the only thing missing is the user's say over
which are shown and in what order. It discovers the columns from the data-col
keys the header already carries, and moves and hides cells in place.
It knows nothing about the page it is on — the dialog, the store and the affordance that opens the picker are all arguments:
import { ColumnSettings } from '@stocksharp/grids';
const settings = new ColumnSettings({
table: document.querySelector<HTMLTableElement>('#users')!,
dialog: {
list: document.querySelector<HTMLElement>('#column-list')!,
moveUpTitle: t('Move up'),
moveDownTitle: t('Move down'),
classes: {
item: 'list-group-item d-flex align-items-center gap-2',
toggle: 'form-check-input',
label: 'flex-grow-1',
move: 'btn btn-sm btn-outline-secondary',
moveUpIcon: 'bi bi-arrow-up',
moveDownIcon: 'bi bi-arrow-down',
},
open: () => modal.show(),
close: () => modal.hide(),
},
store: {
read: () => new URLSearchParams(location.search).get('columns')?.split(',') ?? null,
write: (visible) => updateQueryString(visible),
},
});
// The host owns the dialog chrome — its markup, its buttons, its modal library —
// so the host wires them too. openPicker() only fills the list and shows the
// dialog; without the two below, nothing the user picks can ever be committed.
header.addEventListener('contextmenu', () => settings.openPicker());
confirmButton.addEventListener('click', () => settings.applyPicked());
resetButton.addEventListener('click', () => settings.resetToDefault());applyPicked() and resetToDefault() each apply the layout, persist it through
the store and call dialog.close() — the adapter closes the dialog it was told
how to close, and does nothing else to it.
The table needs a real <thead>: a parser inserts a missing <tbody> but never
a header, and the columns are only knowable from its cells, so a table without
one is rejected at construction rather than faulting later.
A column with no data-col key (an Actions column) is a fixed anchor: it keeps
its slot and is never hidden. A row whose cell count does not match the header (a
colspan "nothing to show" row) is left alone. write(null) means "this IS the
table default", so a store that lives in a URL carries its parameter only while
it means something.
TableExport.download(baseName, sheetName, headers, rows) builds a real OOXML
workbook — a store-only ZIP with the minimal SpreadsheetML part set, strings as
inline strings and finite numbers as native numeric cells — so Excel,
LibreOffice and Numbers open it without a conversion warning. No third-party
library is involved, and CSV is never renamed to .xlsx.
DataGrid.download() routes through it with the exportable columns, every held
row, in the order on screen. exportData() returns the same headers and rows
without touching the browser, which is how the sheet's content is asserted in a
test.
src/
index.ts complete public entry point
data-grid.ts DataGrid: columns, rendering, pinned rows, export
table-sort.ts TableSort: the column-sort state behind the grid
column-settings.ts ColumnSettings: column picker for a rendered table
table-export.ts TableExport: dependency-free .xlsx writer
tests/ unit suite plus the fake DOM it runs against
Applications can let their own esbuild/Vite build compile the TypeScript published inside the package:
{
"dependencies": {
"@stocksharp/grids": "^1.0.0"
}
}import { DataGrid } from '@stocksharp/grids/source';
import { TableExport } from '@stocksharp/grids/source/table-export';./source exists for the case where there is nothing compiled to resolve.
dist/ is a build output and is gitignored, so a checkout of this repository
that has not been built yet ships only src/ — the . and ./data-grid entry
points point at files that are not there. A consumer that depends on such a
checkout imports through ./source and lets its own bundler compile the
TypeScript, which is what StockSharp's own web bundles do.
For sibling-repository development, replace the version with a relative path to
the checkout. The depth is whatever the consumer's own location makes it: from
Broker/Broker.Web.Trader/package.json, three directories below the workspace
root that holds Grids/, it is "file:../../../Grids". The import paths stay
identical either way.
Dedicated entry points are available for consumers with narrower needs:
@stocksharp/grids/data-grid— the grid alone;@stocksharp/grids/table-sort— sort state without a grid;@stocksharp/grids/column-settings— the picker for a server-rendered table;@stocksharp/grids/table-export— the workbook writer on its own.
npm run build produces:
| File | Purpose |
|---|---|
dist/esm/** |
complete ESM module tree |
dist/types/** |
TypeScript declarations |
dist/ssgrid.js |
complete browser IIFE exposed as window.SSGrid |
npm ci
npm test
npm run build
npm run serve # the demo on http://localhost:8793/demo/
node tools/screenshots.mjs # what the README screenshots would be; --apply writes them
npm run pack:check
npm run release:patch
npm run release:minor
npm run release:major
npm run api:check
npm run api:update # only after reviewing an intentional public API change
CI verifies type checking, the reviewed declaration snapshot, the unit tests, the
bundles and the tarball contents. pages.yml builds the same bundle and publishes
demo/ to GitHub Pages on every push to main, which is where the live demo link
at the top goes; it needs Pages enabled for the repository with Source: GitHub
Actions.
Publishing is driven by the version in package.json. release.yml runs on
every push to main and publishes only when that version is not yet on npm, so
an ordinary push is a no-op. To cut a release, bump the version and push:
npm run release:patch # or release:minor / release:major
git pushThe new version triggers release.yml, which rebuilds and tests the repository,
publishes to npm with provenance, then creates the v<version> tag and GitHub
Release. A failed publication can be retried from the workflow's Run workflow
action; already-published versions are detected and skipped.
Publishing authenticates through npm trusted publishing (OIDC) — there is no
NPM_TOKEN secret. The workflow grants id-token: write, and npm exchanges the
GitHub OIDC token for a short-lived publish token. The npm Trusted Publisher is
bound to the workflow filename release.yml, so do not rename that workflow
without updating the trusted publisher configuration on npmjs first.
Copyright © 2010-present StockSharp Platform LLC and/or its affiliates. All rights reserved. Use is governed by the StockSharp EULA and LICENSE.



