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:

Core entry points (under formatting group):


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:

  1. options.formatting.locale (non-empty string)
  2. Browser locale (navigator.language)
  3. 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:

  1. Looking up the registered bundle whose key is the longest prefix of this.locale (e.g. 'en-US' matches the 'en' bundle).
  2. Falling back to the 'en' bundle if nothing matches.
  3. 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

3.2 Column filter panel keys

The filter panel (header-menu "Filter…" item and the funnel-icon popover) reads:

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:

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:

Group bar (the strip above the header carrying one chip per applied group level):

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.

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:

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:

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 pathLabel: 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:

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:

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:

Buttons and labels:

All are driven by messages.* keys.


8. Empty/Loading/Error Text

These texts are not part of the messages dictionary; they are constructor options:

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:

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:

  1. Update your app locale state.
  2. Update grid instance properties/options (locale, messages, formatInteger, formatScrollIndicator) through your integration layer.
  3. 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:


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