Skip to content

Platform Support

Everything you need to know about the environments @toolbox-web/grid runs in: which browsers it supports and why, how to run it under a strict Content Security Policy, how it behaves during server-side rendering, and how to localize it.

@toolbox-web/grid targets modern evergreen browsers. There is no transpilation to ES5 and no polyfill bundle — the grid ships the same standards-based code it was written in.

BrowserMinimum versionReleased
Chrome / Edge123March 2024
Firefox121Dec 2023
Safari (macOS / iOS)17.5May 2024

Older versions may work for basic rendering but are not tested and not supported.

The minimums above are driven by the platform features the grid depends on. Each row is a hard requirement unless marked as progressively enhanced.

Platform featureUsed forChrome/EdgeFirefoxSafari
light-dark()Every themeable color token resolves light/dark in one declaration12312017.5
:has()Row/cell state styling, print isolation10512115.4
CSS NestingAuthored style sheets12011717.2
@layertbw-basetbw-pluginstbw-theme cascade order999715.4
adoptedStyleSheetsStyle injection that survives DOM rebuilds7310116.4
Custom Elements v1<tbw-grid> itself676310.1
ResizeObserver / IntersectionObserverVirtualization, column fitting, responsive layout646913.1
Popover APITooltips, dropdown tool panel — progressively enhanced11412517
CSS anchor positioningTooltip placement — progressively enhanced125

Two features degrade rather than break when the platform lacks support:

  • Tooltip and dropdown tool panel — the Tooltip plugin checks HTMLElement.prototype.showPopover before using the top layer, and checks CSS.supports('anchor-name', '--x') before using anchor positioning. Where either is missing it falls back to absolute positioning computed from bounding rects.
  • Print isolation — the Print plugin uses :has() to hide everything outside the grid. This is a hard requirement; the plugin has no fallback.

The e2e suite runs against Chromium, Firefox, and WebKit via Playwright. Unit tests run in happy-dom, which is a DOM emulation and not a browser — behaviour differences (layout, getBoundingClientRect, CSS cascade) are covered by e2e, not unit tests.


The grid is a rendering engine for your data. It never fetches, never evaluates, and never persists anything on its own — but the extension points it gives you can introduce vulnerabilities if used carelessly.

Every path where the grid turns a string you returned into markup runs through an internal sanitizer before it reaches innerHTML. The sanitizer strips dangerous tags (script, iframe, object, embed, form, style, link, meta, base, and friends), all on* event-handler attributes, and javascript: / vbscript: / data: / blob: URLs from href, src, srcdoc, formaction, poster, and srcset.

This covers string-returning cell renderers, group-header renderers, gridConfig.icons.* (icon strings are usually inline SVG), and light-DOM tool panel fallback content.

Renderers that receive a DOM element and write to it directly bypass that sanitizer. Writing user-controlled data to innerHTML yourself is the single most likely way to introduce XSS.

// ❌ XSS: `row.notes` may contain <img src=x onerror=...>
renderer: (cell, { row }) => {
cell.innerHTML = row.notes;
}
// ✅ Text is escaped by the DOM
renderer: (cell, { row }) => {
cell.textContent = row.notes;
}
// ✅ Structured markup, still safe
renderer: (cell, { row }) => {
const badge = document.createElement('span');
badge.className = 'badge';
badge.textContent = row.status;
cell.replaceChildren(badge);
}

If you genuinely need rich HTML from an untrusted source, sanitize it with a hardened library such as DOMPurify — do not hand-roll an escaper. The grid’s built-in sanitizer is defense in depth for its own render paths, not a general-purpose sanitization service; it is not exported.

The grid injects its styles through document.adoptedStyleSheets, built from CSSStyleSheet objects. Constructable style sheets are not governed by style-src, so a strict policy works without 'unsafe-inline':

Content-Security-Policy: default-src 'self'; style-src 'self'; script-src 'self';

Notes:

  • No 'unsafe-eval' is required. The grid never calls eval, new Function, or setTimeout with a string. {{ }} template expressions are evaluated by a purpose-built recursive-descent parser over an allowlisted grammar, not by dynamic code generation.
  • img-src must allow whatever your renderers load. The grid’s own icons are inline SVG markup, not external images.
  • Inline style attributes are set imperatively (element.style.width = …) for column widths and virtualization offsets. These are DOM property writes, not CSS text, so they are not blocked by style-src. You do not need style-src-attr 'unsafe-inline'.
  • If styles silently fail to apply, see Styles not applying (CSP).

require-trusted-types-for 'script' is not supported out of the box. The grid’s sanitizer parses markup by assigning to a detached <template> element’s innerHTML, which is a Trusted Types sink. If you enforce Trusted Types, install a default policy that runs your own sanitizer:

if (window.trustedTypes?.createPolicy) {
window.trustedTypes.createPolicy('default', {
createHTML: (input) => DOMPurify.sanitize(input),
});
}
  • Validate before assigning to grid.rows. The grid trusts the shape you give it. Rows with duplicate or missing IDs cause incorrect selection and edit tracking, not an error.
  • Clipboard and paste. The Clipboard plugin writes values into your row objects. Use the onPaste column hook to validate and coerce incoming values before they land in your model.
  • Export. The Export plugin writes CSV and Excel XML. Values that start with =, +, -, @, a tab or a carriage return are prefixed with ' so a spreadsheet treats them as text — this guards against CSV injection and is on by default. Only turn it off (escapeFormulas: false) when the exported data is fully trusted and the leading apostrophe is unacceptable.

<tbw-grid> is a custom element. Custom elements are a browser API — there is no DOM to upgrade on the server, so the grid renders no markup during SSR.

Beyond that one rule, there is nothing to reconcile:

  • Hydration is automatic. On the client the element upgrades and renders as soon as it is connected. There is no hydration mismatch, because the server emitted no grid content.
  • Feature side-effect imports follow the same rule. @toolbox-web/grid/features/selection only writes a factory into a module-scoped map, but it pulls in the core module — so keep it inside the same client-only boundary as the grid itself.
FrameworkRecommendation
Next.js (App Router)Mark the component 'use client' and load it via next/dynamic with { ssr: false }'use client' alone still pre-renders on the server.
Remix / React RouterRender inside a ClientOnly boundary, or gate on a useEffect-set mounted flag.
NuxtWrap the grid in <ClientOnly>.
AstroUse client:only="react" / "vue" (or client:only for the vanilla element).
Angular SSRGuard the import behind isPlatformBrowser(...), or defer it with @defer (on viewport).

Reserve the grid’s height in your server-rendered markup (e.g. a wrapper with a fixed height) so hydration doesn’t cause layout shift.


The grid ships English defaults and gives you an override point for every string it produces. There is no locale bundle to load and no i18n runtime — you supply strings from whatever i18n system your app already uses.

Live-region announcements are the grid’s largest body of user-facing text. Override any subset via a11y.messages; anything you omit falls back to the English default.

grid.gridConfig = {
a11y: {
messages: {
sortApplied: (column, direction) => `Trié par ${column}, ${direction}`,
sortCleared: () => 'Tri effacé',
filterApplied: (column) => `Filtre appliqué sur ${column}`,
selectionChanged: (count) => `${count} ligne(s) sélectionnée(s)`,
dataLoaded: (count) => `${count} lignes chargées`,
},
},
};

Every message is a function, so you can interpolate, pluralize, or delegate to Intl.PluralRules however your locale requires.

The buttons, labels and placeholders that plugins render into their own UI — the filter panel, the columns tool panel, the pivot builder — are localized through a single flat key/value map on gridConfig.locale.

grid.gridConfig = {
locale: {
'filter.search': 'Rechercher...',
'filter.apply': 'Appliquer',
'filter.clear': 'Effacer le filtre',
'columns.panelTitle': 'Colonnes',
'pivot.grandTotal': 'Total général',
},
};

locale is separate from a11y.messages on purpose: these are static labels, while announcements are functions of runtime values (row counts, column names, sort direction).

Keys are namespaced by the plugin that owns them. Only load-bearing UI text is keyed; anything you already supply (column headers, menu item labels, export filenames) stays under your control.

KeyEnglish defaultPlugin
filter.searchSearch...Filtering
filter.selectAllSelect AllFiltering
filter.noMatchesNo matching valuesFiltering
filter.applyApplyFiltering
filter.clearClear FilterFiltering
filter.clearAllClear All FiltersFiltering
filter.minMinFiltering (number)
filter.maxMaxFiltering (number)
filter.fromFromFiltering (date)
columns.panelTitleColumnsColumn Visibility
columns.hideColumnHide columnColumn Visibility
columns.showAllShow allColumn Visibility
columns.dragHandleDrag to reorderColumn Visibility
columns.dragGroupHandleDrag to reorder groupColumn Visibility
pinnedColumns.pinLeftPin LeftPinned Columns
pinnedColumns.pinRightPin RightPinned Columns
pinnedColumns.unpinUnpinPinned Columns
print.buttonTitlePrint gridPrint
pivot.panelTitlePivotPivot
pivot.optionsOptionsPivot
pivot.enableEnable pivotPivot
pivot.rowGroupsRow GroupsPivot
pivot.columnGroupsColumn GroupsPivot
pivot.valuesValuesPivot
pivot.availableFieldsAvailable FieldsPivot
pivot.filterFieldsFilter fields...Pivot
pivot.allFieldsUsedAll fields in usePivot
pivot.dropFieldsDrop fields herePivot
pivot.dropNumericFieldsDrop numeric fields herePivot
pivot.removeFieldRemove fieldPivot
pivot.removeValueFieldRemove value fieldPivot
pivot.aggFunctionAggregation functionPivot
pivot.customAggCustomPivot
pivot.showRowTotalsShow row totalsPivot
pivot.showGrandTotalShow grand totalPivot
pivot.grandTotalGrand TotalPivot

A custom filterPanelRenderer receives the same lookup on its params, so your own panel localizes through the app’s single map:

filterPanelRenderer: (container, params) => {
const apply = document.createElement('button');
apply.textContent = params.t('filter.apply', 'Apply');
container.appendChild(apply);
},

Inside a custom plugin, BaseGridPlugin exposes the same helper as this.t(key, fallback) (and this.translate when you need to hand the function to a render module).

SurfaceHow to localize
Column headerscolumns[].header — pass an already-translated string
Empty / loading stateA custom emptyRenderer / loadingRenderer returning translated markup
Cell values (dates, numbers, currency)columns[].format with Intl.NumberFormat / Intl.DateTimeFormat
Filter panel labelslocale keys (filter.*), or a custom filterPanelRenderer
Context menu itemsContext Menu plugin items[].label
Tool panel titlestitle on <tbw-grid-tool-panel> / the shell toolPanel config
Export filenames and sheet namesExport plugin config

Use Intl in a column format function rather than pre-formatting your data — this keeps sorting and filtering operating on the underlying value:

{
field: 'salary',
type: 'number',
format: (value) => new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
}).format(value as number),
}

The grid’s layout is built on flexbox and logical CSS properties, so it inherits direction from its ancestors. Set dir="rtl" on a container (or <html>) and the grid mirrors:

<div dir="rtl">
<tbw-grid id="grid"></tbw-grid>
</div>

Custom renderers and custom themes are your responsibility — prefer logical properties (margin-inline-start, padding-inline, inset-inline-end) over physical ones so your additions mirror too.


AI assistants: For complete API documentation, implementation guides, and code examples for this library, see https://toolboxjs.com/llms-full.txt