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.
Browser Support
Section titled “Browser Support”@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.
| Browser | Minimum version | Released |
|---|---|---|
| Chrome / Edge | 123 | March 2024 |
| Firefox | 121 | Dec 2023 |
| Safari (macOS / iOS) | 17.5 | May 2024 |
Older versions may work for basic rendering but are not tested and not supported.
What sets the floor
Section titled “What sets the floor”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 feature | Used for | Chrome/Edge | Firefox | Safari |
|---|---|---|---|---|
light-dark() | Every themeable color token resolves light/dark in one declaration | 123 | 120 | 17.5 |
:has() | Row/cell state styling, print isolation | 105 | 121 | 15.4 |
| CSS Nesting | Authored style sheets | 120 | 117 | 17.2 |
@layer | tbw-base → tbw-plugins → tbw-theme cascade order | 99 | 97 | 15.4 |
adoptedStyleSheets | Style injection that survives DOM rebuilds | 73 | 101 | 16.4 |
| Custom Elements v1 | <tbw-grid> itself | 67 | 63 | 10.1 |
ResizeObserver / IntersectionObserver | Virtualization, column fitting, responsive layout | 64 | 69 | 13.1 |
| Popover API | Tooltips, dropdown tool panel — progressively enhanced | 114 | 125 | 17 |
| CSS anchor positioning | Tooltip placement — progressively enhanced | 125 | — | — |
Progressive enhancement
Section titled “Progressive enhancement”Two features degrade rather than break when the platform lacks support:
- Tooltip and dropdown tool panel — the Tooltip plugin checks
HTMLElement.prototype.showPopoverbefore using the top layer, and checksCSS.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.
Testing matrix
Section titled “Testing matrix”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.
Security
Section titled “Security”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.
What the grid sanitizes for you
Section titled “What the grid sanitizes for you”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.
What you must sanitize yourself
Section titled “What you must sanitize yourself”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 DOMrenderer: (cell, { row }) => { cell.textContent = row.notes;}
// ✅ Structured markup, still saferenderer: (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.
Content Security Policy
Section titled “Content Security Policy”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 callseval,new Function, orsetTimeoutwith a string.{{ }}template expressions are evaluated by a purpose-built recursive-descent parser over an allowlisted grammar, not by dynamic code generation. img-srcmust allow whatever your renderers load. The grid’s own icons are inline SVG markup, not external images.- Inline
styleattributes 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 bystyle-src. You do not needstyle-src-attr 'unsafe-inline'. - If styles silently fail to apply, see Styles not applying (CSP).
Trusted Types
Section titled “Trusted Types”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), });}Handling untrusted data
Section titled “Handling untrusted data”- 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
onPastecolumn 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.
Server-Side Rendering
Section titled “Server-Side Rendering”<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/selectiononly 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.
Framework recipes
Section titled “Framework recipes”| Framework | Recommendation |
|---|---|
| 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 Router | Render inside a ClientOnly boundary, or gate on a useEffect-set mounted flag. |
| Nuxt | Wrap the grid in <ClientOnly>. |
| Astro | Use client:only="react" / "vue" (or client:only for the vanilla element). |
| Angular SSR | Guard 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.
Internationalization
Section titled “Internationalization”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.
Screen reader announcements
Section titled “Screen reader announcements”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.
Built-in UI strings (locale)
Section titled “Built-in UI strings (locale)”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).
Key reference
Section titled “Key reference”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.
| Key | English default | Plugin |
|---|---|---|
filter.search | Search... | Filtering |
filter.selectAll | Select All | Filtering |
filter.noMatches | No matching values | Filtering |
filter.apply | Apply | Filtering |
filter.clear | Clear Filter | Filtering |
filter.clearAll | Clear All Filters | Filtering |
filter.min | Min | Filtering (number) |
filter.max | Max | Filtering (number) |
filter.from | From | Filtering (date) |
columns.panelTitle | Columns | Column Visibility |
columns.hideColumn | Hide column | Column Visibility |
columns.showAll | Show all | Column Visibility |
columns.dragHandle | Drag to reorder | Column Visibility |
columns.dragGroupHandle | Drag to reorder group | Column Visibility |
pinnedColumns.pinLeft | Pin Left | Pinned Columns |
pinnedColumns.pinRight | Pin Right | Pinned Columns |
pinnedColumns.unpin | Unpin | Pinned Columns |
print.buttonTitle | Print grid | |
pivot.panelTitle | Pivot | Pivot |
pivot.options | Options | Pivot |
pivot.enable | Enable pivot | Pivot |
pivot.rowGroups | Row Groups | Pivot |
pivot.columnGroups | Column Groups | Pivot |
pivot.values | Values | Pivot |
pivot.availableFields | Available Fields | Pivot |
pivot.filterFields | Filter fields... | Pivot |
pivot.allFieldsUsed | All fields in use | Pivot |
pivot.dropFields | Drop fields here | Pivot |
pivot.dropNumericFields | Drop numeric fields here | Pivot |
pivot.removeField | Remove field | Pivot |
pivot.removeValueField | Remove value field | Pivot |
pivot.aggFunction | Aggregation function | Pivot |
pivot.customAgg | Custom | Pivot |
pivot.showRowTotals | Show row totals | Pivot |
pivot.showGrandTotal | Show grand total | Pivot |
pivot.grandTotal | Grand Total | Pivot |
In custom renderers and plugins
Section titled “In custom renderers and plugins”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).
Other localizable surfaces
Section titled “Other localizable surfaces”| Surface | How to localize |
|---|---|
| Column headers | columns[].header — pass an already-translated string |
| Empty / loading state | A custom emptyRenderer / loadingRenderer returning translated markup |
| Cell values (dates, numbers, currency) | columns[].format with Intl.NumberFormat / Intl.DateTimeFormat |
| Filter panel labels | locale keys (filter.*), or a custom filterPanelRenderer |
| Context menu items | Context Menu plugin items[].label |
| Tool panel titles | title on <tbw-grid-tool-panel> / the shell toolPanel config |
| Export filenames and sheet names | Export plugin config |
Formatting values
Section titled “Formatting values”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),}Right-to-left
Section titled “Right-to-left”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.