Localization Implementation in Vanilla-Grid
This document describes how localization is implemented in Vanilla-Grid, including locale resolution, message dictionaries, number formatting, and custom scroll-indicator formatting.
1. Localization Model
Vanilla-Grid uses a host-driven localization model:
- The grid does not ship translation files or a language loader.
- The host application provides localized strings and formatting callbacks via constructor options.
- Localization is applied at render-time where labels are generated (context menu and scroll indicator).
Core entry points (under formatting group):
formatting.localeformatting.messagesformatting.formatInteger(value)formatting.formatScrollIndicator(context)
2. Locale Resolution
In VanillaGrid constructor:
const fmt = options.formatting || {};
this.locale = typeof fmt.locale === 'string' && fmt.locale.trim()
? fmt.locale.trim()
: ((typeof navigator !== 'undefined' && navigator.language) ? navigator.language : VanillaGrid.defaultLocale);
Resolution order:
options.formatting.locale(non-empty string)- Browser locale (
navigator.language) VanillaGrid.defaultLocale(defaults to'en-US', configurable globally)
The resolved locale is used by the default integer formatter.
2.1 Configuring the global default locale
Hosts can override the global fallback locale once at startup, before any grid is constructed:
VanillaGrid.defaultLocale = 'it-IT';
This avoids monkey-patching when the host knows that the deployment ships in a specific locale.
3. Message Dictionary (messages)
The grid ships a built-in English locale registered under the key 'en'. When a grid is constructed, messages are resolved by:
- Looking up the registered bundle whose key is the longest prefix of
this.locale(e.g.'en-US'matches the'en'bundle). - Falling back to the
'en'bundle if nothing matches. - Merging
formatting.messages(per-instance overrides) on top.
this.messages = Object.assign({}, VanillaGrid._resolveLocaleMessages(this.locale), fmt.messages || {});
3.0 Locale registry
Hosts can ship and register additional locales (or replace the built-in English bundle):
VanillaGrid.registerLocale('it', {
of: 'di',
hideColumn: 'Nascondi colonna',
showAllColumns: 'Mostra tutte le colonne',
freezeColumn: 'Blocca colonna',
unfreezeColumn: 'Sblocca colonna',
unfreezeAll: 'Sblocca tutto',
sortAscending: 'Ordina crescente',
sortDescending: 'Ordina decrescente',
clearColumnSort: 'Rimuovi ordinamento',
autoFitColumn: 'Adatta colonna',
emptyMessage: 'Nessuna riga da visualizzare.',
exportingToExcelProgress: 'Esportazione in Excel… {percent}%',
exportCancel: 'Annulla'
});
// Inspect a registered bundle:
const bundle = VanillaGrid.getLocaleMessages('it');
registerLocale(name, messages) is additive — passing a known name replaces the previous bundle.
3.1 Where each key is used
messages.of- default scroll indicator text (
"1 - 50 of 1000")
- default scroll indicator text (
messages.hideColumn- header context menu action label
messages.showAllColumns- header context menu action label
messages.freezeColumn- header context menu action label
messages.unfreezeColumn- header context menu action label
messages.unfreezeAll- header context menu action label
messages.sortAscending- header context menu action label (shown only when the column is sortable; disabled when the column is already sorted ascending)
messages.sortDescending- header context menu action label (shown only when the column is sortable; disabled when the column is already sorted descending)
messages.clearColumnSort- header context menu action label; clears sorting for this column only (disabled when the column has no active sort) — leaves any other column's sort untouched, unlike the grid-wide
clearSort()API
- header context menu action label; clears sorting for this column only (disabled when the column has no active sort) — leaves any other column's sort untouched, unlike the grid-wide
3.2 Column filter panel keys
The filter panel (header-menu "Filter…" item and the funnel-icon popover) reads:
messages.filterColumn— the header-menu "Filter…" item labelmessages.filterApply/messages.filterClear— the panel's Apply / Clear buttonsmessages.filterListAdd/messages.filterListRemove— the "add value" button and the per-value remove (−) control of thein("is any of") value-list editormessages.filterOperatorAria/messages.filterValueAria— accessibility labels for the operator dropdown and value input(s)messages.filterOperatorLabels— a nested object mapping each operator key to its display label. Provide the whole map per locale; any operator omitted falls back to the built-in English label (so partial maps are safe). Operator keys:equals,notEquals,contains,notContains,startsWith,endsWith,greaterThan,greaterThanOrEqual,lessThan,lessThanOrEqual,between,notBetween,on,notOn,before,after,onOrBefore,onOrAfter,isTrue,isFalse,isEmpty,isNotEmpty,in.
VanillaGrid.registerLocale('it', {
filterColumn: 'Filtra…',
filterApply: 'Applica',
filterClear: 'Cancella',
filterListAdd: '+ Aggiungi valore',
filterListRemove: 'Rimuovi valore',
filterOperatorAria: 'Operatore filtro',
filterValueAria: 'Valore filtro',
filterOperatorLabels: {
equals: 'Uguale a', notEquals: 'Diverso da',
contains: 'Contiene', notContains: 'Non contiene',
startsWith: 'Inizia con', endsWith: 'Finisce con',
greaterThan: 'Maggiore di', greaterThanOrEqual: 'Maggiore o uguale',
lessThan: 'Minore di', lessThanOrEqual: 'Minore o uguale',
between: 'Compreso tra', notBetween: 'Non compreso tra',
on: 'Il', notOn: 'Non il', before: 'Prima del', after: 'Dopo il',
onOrBefore: 'Il o prima del', onOrAfter: 'Il o dopo il',
isTrue: 'È vero', isFalse: 'È falso',
isEmpty: 'È vuoto', isNotEmpty: 'Non è vuoto', in: 'È uno tra',
},
});
3.3 Row grouping keys
Header-menu actions and caption labels:
messages.groupByColumn— "Group by this column" (replaces the whole group state)messages.addToGrouping— "Add to grouping" (appends a trailing level)messages.ungroupColumn— "Remove from grouping"messages.ungroupAll— "Ungroup all" (removes every group level; the item is only rendered while grouped). The group bar's trailing command reuses this same key — one command, one stringmessages.groupItemsSuffix— the caption label's count suffix,"… — 42 items"messages.groupBlankValue— the caption label for anull/undefinedgroup valuemessages.groupCaptionToggleHint—"Shift+Click to fold or unfold the groups below", the hover hint on a caption toggle that has child groups (its Shift-click children toggle, 22 § 5.1.1)messages.groupPathAriaSeparator—', ', joining a nested group's path in the accessible names of its caption and footer ("Country: Italy, City: Rome"). A list separator rather than a glyph, which screen readers skip or read out by name; list punctuation differs by language ('، ',',')
Reason-coded titles on a disabled grouping menu item. The key is derived
mechanically from the capability result's reason code
('partial-dataset' → groupReasonPartialDataset), so a host adding a locale
must provide all of them:
messages.groupReasonDisabledmessages.groupReasonPartialDatasetmessages.groupReasonMultiLevelNotYetSupportedmessages.groupReasonNoVisibleColumnsLeft
Group bar (the strip above the header carrying one chip per applied group level):
messages.groupBarLabel— the leading static label, and the strip'saria-labelmessages.groupBarRemove— a chip's remove-button label (prefixed to the column label in itsaria-label)messages.groupBarSortAscending/messages.groupBarSortDescending— a chip's direction controlmessages.groupBarSuspended— tooltip prefix shown while a requested grouping cannot currently be applied; the reason-coded message above is appended to itmessages.groupBarExpandAll/messages.groupBarCollapseAll— the bar's trailing commands. They are rendered as text, so their translated length is what the strip lays out; the bar wraps rather than clipping- The bar's third trailing command, "Ungroup all", takes
messages.ungroupAll— the header menu's key, since it is the same command
VanillaGrid.registerLocale('it', {
groupByColumn: 'Raggruppa per questa colonna',
addToGrouping: 'Aggiungi al raggruppamento',
ungroupColumn: 'Rimuovi dal raggruppamento',
ungroupAll: 'Rimuovi tutti i raggruppamenti',
groupItemsSuffix: 'elementi',
groupBlankValue: '(Vuoto)',
groupCaptionToggleHint: 'Maiusc+Clic per chiudere o aprire i gruppi sottostanti',
groupBarLabel: 'Raggruppato per',
groupBarRemove: 'Rimuovi dal raggruppamento',
groupBarSortAscending: 'Crescente',
groupBarSortDescending: 'Decrescente',
groupBarSuspended: 'Raggruppamento sospeso:',
groupBarExpandAll: 'Espandi tutto',
groupBarCollapseAll: 'Comprimi tutto',
groupReasonPartialDataset: 'Il raggruppamento richiede il set di risultati completo.',
groupReasonNoVisibleColumnsLeft: 'Il raggruppamento lascerebbe la griglia senza colonne da mostrare.',
});
3.4 Group aggregate keys
The footer row a grouped grid renders per group per nesting level once a column
carries an aggregate, and the header-menu entries that attach one. Two maps
and the scalars below, following filterOperatorLabels' contract (§ 3.2): a
host may override a whole map or a single entry, and anything absent falls back
to the built-in English value.
messages.aggregateFunctionLabels— the full name of each function:sum: 'Sum',avg: 'Average',median: 'Median',min: 'Minimum',max: 'Maximum',countTrue: 'True count',countDistinct: 'Distinct count'. Used in the row's accessible name, in the header menu's flyout, and in each aggregated cell's tooltipmessages.aggregateFunctionMarkers— empty by default. The visible marker before each aggregated value is, for a built-in function, an icon (a masked SVG the stylesheet draws fromdata-fn, see Themes § 4.8), not text: font glyphs such asΣ μ ↓ ✓resolve to different fonts in different themes, and came out too heavy or off-centre. An icon carries no language, so there is nothing here to translate. An entry in this map replaces the icon with that text ({ avg: 'Ø' }), and it is the only way a function registered withVanillaGrid.registerAggregate()gets a marker at all, since it has no icon ({ stdev: 'σ' }). A text marker renders in the theme font, so it is subject to that font's coverage: prefer a single code point over a letter plus a combining mark (x̄), whose mark is positioned by the font and drifts off the letter in fonts that lack it. The empty string is a legitimate override and suppresses the marker ({ sum: '' }renders the bare value), which is why label and marker are two flat maps rather than one map of objects: suppressing the marker must not also cost the word a screen reader announces. The header's aggregate badge shows the same marker, but there an empty override falls back to the built-in icon (thesumicon for a registered function with none), since an empty badge would say nothingmessages.aggregateFooterAria—'Group summary', the row's leading announcementmessages.aggregateOfLabel—'of', joining the function to the column namemessages.aggregateForLabel—'for', joining the column name to the group path in the cell tooltipmessages.aggregatePathSeparator—' › ', joining the group path's values in the cell tooltip. Plain text in a native tooltip, so no theme styles it and it does not follow the group bar's separator (which some themes hide); it is a key only so a right-to-left locale can point it the other way (' ‹ ')
The footer cell tooltip names its group. Hovering an aggregated cell shows
<label> of <column> for <path>: <value> — "Average of Projects for Italy ›
Rome: 14.165". The path is the group's values from level 0, each formatted as
its caption shows it, with every ancestor cut at the end past a fixed length
(…); the leaf is never cut. The function, column and value are the same
pieces the row's accessible name reads for that cell, so a locale that
translates the labels translates the tooltip. The word order is fixed by
concatenation, as everywhere in the bundle.
Header-menu entries:
messages.aggregateMenuLabel—'Aggregate', the submenu's parent entry. Its disclosure triangle is drawn in CSS rather than written into the label, so it never reaches a translator and never lands in the accessible namemessages.aggregateRemove—'Remove aggregate', rendered only while the column carries one- The functions inside the flyout take
aggregateFunctionLabelsabove — the same key the accessible name uses, so the word a user picks is the word a screen reader reads back
Reason-coded titles on a disabled aggregate entry. The key is derived
mechanically from canAggregateColumn()'s reason code
('partial-dataset' → aggregateReasonPartialDataset), exactly as § 3.3's
groupReason* keys are, so a host adding a locale knows the full set without
being told it:
messages.aggregateReasonColumnUnsupported— the column's declared type admits no functionmessages.aggregateReasonColumnNotFoundmessages.aggregateReasonColumnGrouped— the grid is grouped by this column, so its total would have no cell to render intomessages.aggregateReasonNoGrouping— nothing is grouped, so there is no group to reducemessages.aggregateReasonPartialDataset— a requested grouping is suspended over an incomplete result setmessages.aggregateReasonDisabled— the fallback for a code with no key of its own
These are a separate vocabulary from groupReason* rather than a reuse of it:
a column can be ineligible for an aggregate while grouping by it is perfectly
available, and the reverse, so one string cannot serve both.
Nothing visible on a footer row names the group it closes — a row-level label
would be false the moment two columns carried different functions — so the row
carries one focusable element whose aria-label names it. That string is
composed by concatenation, the way every composed string in the grid is;
there is no substitution grammar and no template parser, so a locale that needs
different wording overrides the scalars rather than moving a brace:
Group summary. Country: Italy. Sum of Projects: 1,204. Sum of Salary: 4,120,500.
Group summary. Country: Italy, City: Rome. Sum of Projects: 412.
The group is named by its full path — Label: value for each level, joined
by groupPathAriaSeparator, never cut — so Rome in Italy and a Rome elsewhere
are told apart; at level 0 it is exactly the caption's label, without its
— 85 items suffix (the count is the caption's announcement, not the footer's).
A nested caption's toggle announces the same path followed by its visible text
("Country: Italy, City: Rome — 12 items"); the values are the
formatted ones, so what is heard is what is seen; and the function is always the
word rather than the marker, since Σ announced literally is noise.
VanillaGrid.registerLocale('it', {
aggregateFunctionLabels: { sum: 'Somma' },
aggregateFooterAria: 'Riepilogo del gruppo',
aggregateOfLabel: 'di',
aggregateForLabel: 'per',
aggregateMenuLabel: 'Aggrega',
aggregateRemove: 'Rimuovi aggregazione',
aggregateReasonNoGrouping: 'L’aggregazione richiede un raggruppamento attivo.',
// aggregateFunctionMarkers is left alone — the built-in markers are icons.
});
The context menu uses fallback expressions per button:
String(this.messages.hideColumn || 'Hide column')
So missing keys fail safely to English defaults.
3.5 Export keys
The overlay exportToExcel() shows while it runs, and the dialog it switches to
when an export fails, read these keys. {name} placeholders are filled in by
the export feature; a translation may drop a placeholder it does not need.
| Key | Default | Where |
|---|---|---|
exportingToExcel |
Exporting to Excel… |
overlay label before the first progress update |
exportingToExcelProgress |
Exporting to Excel… {percent}% |
overlay label while the export runs |
exportCancel |
Cancel |
overlay button that stops the export |
exportClose |
Close |
dialog button that dismisses it |
exportFailed |
The export failed. |
dialog message for a failed export; the technical reason (a browser or host-callback error, which no bundle can translate) is shown untranslated on a smaller line under it |
exportTooLarge |
{rows} rows × {columns} columns is more than the export can produce in the browser. Filter the rows or select fewer, then export again. |
dialog message when a worksheet would exceed the file format's limit; {rows} and {columns} go through formatInteger (§ 5) |
exportReady |
Your file is ready. |
iOS dialog when the share sheet needs a new tap |
exportSaveFile |
Save file |
iOS dialog button that opens the share sheet |
See Export to Excel § Progress, Cancel and failures.
4. Cell Value Formatting by Type (_formatByType)
The built-in _formatByType(column, value) method provides locale-aware formatting for cells based on column.type:
'number': Formatted withnew Intl.NumberFormat(this.locale, column.formatOptions || {}).format(value). Numbers are also right-aligned via thevn-grid-numericCSS class.'date': Formatted withIntl.DateTimeFormatusingcolumn.formatOptionsif provided, otherwise{ year: 'numeric', month: '2-digit', day: '2-digit' }.'datetime': Formatted withIntl.DateTimeFormatusingcolumn.formatOptionsif provided, otherwise{ year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }.- All other types:
String(value).
Column-Level formatOptions
Each column can carry a formatOptions object that is passed directly to Intl.NumberFormat (for numbers) or Intl.DateTimeFormat (for dates). This allows per-column formatting control without a grid-wide callback:
// Fixed 2-decimal number formatting
{ key: 'Height', type: 'number', formatOptions: { minimumFractionDigits: 2, maximumFractionDigits: 2 } }
// Default number formatting (uses locale defaults)
{ key: 'Hometown.Area', type: 'number' }
formatOptions can be set at column definition time or enriched later (e.g., from server metadata) by modifying the column object directly.
Column-Level renderCell
For columns that need full DOM control beyond text formatting, assign a renderCell function to the column:
column.renderCell = (cell, value, rowData, dataIndex) => {
cell.textContent = '';
// Build custom DOM (e.g., thermometer bar, progress bar, sparkline)
};
When renderCell is present, the grid calls it instead of setting cell.textContent via _formatByType. CSS type classes (vn-grid-numeric, vn-grid-temporal, vn-grid-uuid) are still applied based on column.type.
5. Number Formatting (formatInteger)
Default implementation:
this.formatInteger = this._resolveCallback(options.formatInteger, (value) => {
try {
return new Intl.NumberFormat(this.locale).format(value);
} catch (err) {
return String(value);
}
});
Behavior:
- Uses
Intl.NumberFormatwith resolved locale. - If
Intlformatting fails, returnsString(value). - Host can override with a custom function to match product-wide conventions.
formatInteger is consumed by the scroll indicator renderer, count aggregates in group footers, and the row/column counts in the export's exportTooLarge message, and can also be used by custom indicator logic.
6. Scroll Indicator Localization (formatScrollIndicator)
_updateScrollIndicator() supports two modes:
6.1 Custom formatter mode
If formatScrollIndicator is provided, it fully controls text:
this._scrollIndicator.textContent = String(this.formatScrollIndicator({
firstRow,
lastRow,
displayedRowCount,
totalRowCount,
loadedRowCount,
visibleCount,
locale: this.locale,
messages: this.messages,
formatInteger: this.formatInteger
}));
The callback receives all values needed for language-specific word order, pluralization, and punctuation.
6.2 Default formatter mode
If no callback is provided:
`${formatInteger(firstRow)} - ${formatInteger(lastRow)} ${messages.of} ${formatInteger(displayTotalRows)}`
This gives a basic localized numeric format with customizable connector token (messages.of).
7. Header Context Menu Localization
The context menu is built on-demand in _onHeaderContextMenu(...). Labels are localized by reading current this.messages values at creation time.
This means:
- Locale/message updates are reflected the next time the menu opens.
- There is no precompiled static menu text to refresh.
Buttons and labels:
- Hide column
- Show all columns
- Freeze column / Unfreeze column
- Unfreeze all
All are driven by messages.* keys.
8. Empty/Loading/Error Text
These texts are not part of the messages dictionary; they are constructor options:
emptyMessage
Error text is passed directly to showError(errorMessage).
Loading state is indicated visually by shimmer skeleton rows (see showLoadingSkeletons()), not by a text message.
9. Web Component (<vn-grid>) Localization Path
VanillaGridElement exposes locale as an observed attribute and passes it into DataManager context via _createContext():
locale: this.getAttribute('locale') || ''
During initializeGrid(), element options are merged into VanillaGrid options. Localization options are therefore host-controlled:
- set through
initializeGrid({ locale, messages, formatInteger, formatScrollIndicator }) - or derived from app code that reads the element locale and injects localized options
The web component emits vn-grid-attribute-changed when locale changes, allowing host code to react and reconfigure if needed.
10. Runtime Locale Switching Strategy
Vanilla-Grid does not include a dedicated setLocale() API. Recommended runtime strategy:
- Update your app locale state.
- Update grid instance properties/options (
locale,messages,formatInteger,formatScrollIndicator) through your integration layer. - Trigger the relevant UI refresh path:
- Scroll once (or force indicator update path) for scroll indicator text refresh.
- Reopen context menu to get new labels.
- Re-render data/empty/loading views as needed if localized strings changed.
For <vn-grid>, a common pattern is:
- update
localeattribute - re-run
initializeGrid(...)with locale-specific options - call
reload()
11. Example: Full Localization Setup
const grid = new VanillaGrid({
formatting: {
locale: 'it-IT',
messages: {
of: 'di',
hideColumn: 'Nascondi colonna',
showAllColumns: 'Mostra tutte le colonne',
freezeColumn: 'Blocca colonna',
unfreezeColumn: 'Sblocca colonna',
unfreezeAll: 'Sblocca tutto',
sortAscending: 'Ordina crescente',
sortDescending: 'Ordina decrescente',
clearColumnSort: 'Rimuovi ordinamento'
},
emptyMessage: 'Nessuna riga da visualizzare.',
formatInteger: (value) => new Intl.NumberFormat('it-IT').format(value),
formatScrollIndicator: ({ firstRow, lastRow, displayedRowCount, totalRowCount, formatInteger, messages }) => {
const denominator = Number.isFinite(totalRowCount) ? totalRowCount : displayedRowCount;
return `${formatInteger(firstRow)} - ${formatInteger(lastRow)} ${messages.of} ${formatInteger(denominator)}`;
}
}
});
12. Design Constraints and Notes
- Localization is intentionally lightweight and callback-based.
- There is no internal pluralization engine.
- Locale-sensitive text outside
messages(emptyMessage, errors) must be supplied by host code. messagesis focused on grid action labels and indicator glue text, not full UI translation.