apsw-gridwright
Documentation

Translation

Every string the grid renders or announces comes from a message catalog, and none of them is written into JSX, because a string literal in a component cannot be translated.

There are two kinds of catalog, because there are two kinds of code rendering. The shell, which is <Gridwright /> without any add-on, renders seven strings: its status rows and its live region. Every feature is an add-on, and each add-on carries its own strings under its own name. A locale pack translates both, so one prop still translates the whole grid.

The quick version#

tsx
import { Gridwright } from 'apsw-gridwright/react';
import { pl } from 'apsw-gridwright/locales';

<Gridwright columns={columns} data={people} locale={pl} />

That switches the text, the plural rules, the number formatting and the text direction together, for the shell and for every built-in add-on.

Bundled packs: en, de, es, fr, pl. They live behind their own entry point, so a bundler drops the ones you do not import.

The format#

Keys are flat and dot-separated. Placeholders are ICU-style {name}. Plurals use the CLDR categories that Intl.PluralRules returns.

None of that is invented here. It is what i18next, FormatJS, Lingui, Weblate and Crowdin already consume, so a catalog is a file your translators can already open.

ts
import type { LocaleCatalog } from 'apsw-gridwright';

export const it: LocaleCatalog = {
    locale: 'it',
    // The shell's strings.
    messages: {
        'status.loading': 'Caricamento delle righe',
        'status.empty': 'Nessuna riga da mostrare',
        'error.title': 'Impossibile caricare le righe',
        'error.retry': 'Riprova',
        'a11y.rowsShown': 'Da {from} a {to} di {total}',
        'a11y.rowsShownUnknown': 'Da {from} a {to} di molte',
        'a11y.rowsTotal': {
            one: '{count} riga',
            other: '{count} righe',
        },
    },
    // Each add-on's strings, under the add-on's name, by the add-on's own keys.
    addons: {
        'gridwright:search': {
            placeholder: 'Cerca',
            label: 'Cerca nelle righe',
        },
        'gridwright:selection': {
            row: 'Seleziona la riga',
            all: 'Seleziona tutte le righe di questa pagina',
            count: {
                zero: 'Nessuna riga selezionata',
                one: '{count} riga selezionata',
                other: '{count} righe selezionate',
            },
        },
        'gridwright:pagination': {
            previous: 'Pagina precedente',
            next: 'Pagina successiva',
            rowsPerPage: 'Righe per pagina',
            range: '{from}-{to} di {total}',
            rangeUnknown: '{from}-{to} di molte',
        },
        // ...and gridwright:sorting, gridwright:stale-notice, gridwright:filters,
        // gridwright:export, gridwright:row-actions, gridwright:tree,
        // gridwright:column-layout, gridwright:row-detail and gridwright:cell-navigation.
    },
};

The shell's keys#

KeyPlaceholdersWhere it appears
status.loadinginside the table, and the live region
status.emptyinside the table
error.titlethe error state
error.retrythe retry button, shown only when retrying could help
a11y.rowsShown{from} {to} {total}the live region, when a paginated result settles
a11y.rowsShownUnknown{from} {to}the same, when the source sent no total
a11y.rowsTotal{count}the live region for a windowed grid; plural

The a11y.* keys are never drawn on screen. They are read out by assistive technology through the grid's live region, which is exactly why they belong in the catalogue: a string nobody can see is the easiest one to leave in English. See Accessibility for when each is said.

The built-in add-ons' keys#

Add-onKeys
gridwright:sortingascending, descending, clear (the sort button's title, naming the next action); sortedAscending, sortedDescending, sortCleared with {column} (the live region)
gridwright:selectionrow, all (the checkboxes); count with {count} (the toolbar; plural)
gridwright:paginationprevious, next, rowsPerPage; range with {from} {to} {total}; rangeUnknown with {from} {to}
gridwright:stale-noticetitle, detail (the banner when a refresh failed over rows still on screen)
gridwright:searchplaceholder, label
gridwright:filtersopen, openActive, condition, value, from, to, values, apply, clear, clearAll (plural), applied, removed, and op.<operator> per condition; see Filtering
gridwright:exportaction, csv, excel, markdown, print, rows, scopeAll, scopePage, scopeSelected (plural), allUnavailable, inProgress, complete, failed; see Exporting
gridwright:row-actionsmenu (the menu's accessible name)
gridwright:treeexpand, collapse, loadFailed, cycle, childCount with {count} (plural)
gridwright:column-layoutresize with {column} (the resize handle); picker, showAll, resetLayout, pinnedLeft, pinnedRight (the column picker); pinStart, pinEnd with {column} (its pin toggles); width with {column} {width}, hidden, shown, moved with {column} {position} {total} (the live region); see Column layout

| gridwright:row-detail | expand, collapse, panel, expanded, collapsed with {row}; column; see Expandable rows |

| gridwright:cell-navigation | copiedRows with {count} (plural), copiedCell (the live region, after a copy; moving the cursor says nothing) |

Inline editing and windowing render no strings of their own. The English text of each add-on is exported beside it (sortingMessages, selectionMessages, paginationMessages, staleNoticeMessages, searchMessages, filterMessages, exportMessages, rowActionsMessages, treeMessages, columnLayoutMessages, rowDetailMessages, cellNavigationMessages), which is what a pack of your own is checked against.

Adding a key is a minor version. Renaming or removing one is a major, because every translator's catalog is keyed on these.

Plurals#

This is why the catalog exists rather than a template string.

ts
'gridwright:selection': {
    count: {
        zero: 'Nie zaznaczono wierszy',
        one: 'zaznaczono {count} wiersz',
        few: 'zaznaczono {count} wiersze',      // 2-4, 22-24, ...
        many: 'zaznaczono {count} wierszy',     // 5-21, 25-31, ...
        other: 'zaznaczono {count} wiersza',    // fractions
    },
}

Polish needs four forms and Arabic six. English needs one, which is exactly why an English-speaking author reaches for ${count} selected and ships something no Slavic language can translate.

Intl.PluralRules picks the category, so this package carries no plural tables and no CLDR data. Only other is required; a missing category falls back to it.

zero is an exact match on the number zero rather than a category, following ICU where an exact match beats a keyword. English has no zero category at all, yet often wants a distinct sentence for an empty selection.

Numbers#

A numeric placeholder is formatted with Intl.NumberFormat for the resolved locale, so a total of 12345 renders as 12,345 in English and 12.345 in German without the catalog saying anything.

Column values are yours to format, through formatValue:

ts
{ id: 'salary', formatValue: (value) => new Intl.NumberFormat(locale, {
    style: 'currency', currency: 'EUR',
}).format(value) }

That is also what global search matches against, so a reader searching for what they can see finds it.

Word order#

The pagination add-on's range is one message with three placeholders, not a label plus a separator plus a count. A sentence assembled from fragments cannot be translated correctly, because languages do not agree on where the numbers go relative to the words. Keep any message you add whole for the same reason.

Ways in#

A shell string resolves in this order, highest first:

labels  →  translate  →  messages  →  locale catalog  →  English

An add-on string, asked for as <add-on>.<key>, resolves in this one:

translate  →  messages  →  pack's addons[<add-on>]  →  add-on's own catalog (locale, then base language)  →  English  →  the key

The pack outranks the add-on's own catalog for the same language, because the pack is the one the application chose.

1. A catalog#

tsx
<Gridwright locale={pl} />

The normal case.

2. A bare tag#

tsx
<Gridwright locale="de-DE" />

Sets plural rules, number formatting and direction. The shell's text stays English, and an add-on whose own catalog has de speaks German. That is right for en-GB and wrong for pl, which is why the pack is a separate thing to pass.

3. Individual overrides#

tsx
<Gridwright
    locale={pl}
    messages={{
        'status.empty': 'Nie znaleziono pracowników',
        'gridwright:pagination.range': '{from}-{to} z {total} pracowników',
        'gridwright:filters.apply': 'Filtruj',
    }}
/>

Applied over the catalog. The shell's keys are written as they are; an add-on's are namespaced with the add-on's name. The type is MessageOverrides. Use it for wording specific to one screen.

4. An existing i18n library#

tsx
import { useTranslation } from 'react-i18next';

function People() {
    const { t } = useTranslation('grid');
    return <Gridwright columns={columns} data={people} addons={[search()]} translate={t} />;
}

react-i18next, FormatJS and Lingui all expose a function of the shape (key: string, values) => string, which is TranslateFn, so this is one prop rather than an adapter package. The grid asks it for the shell's keys (status.empty) and for every add-on's keys under the add-on's name (gridwright:search.placeholder, acme:pay-band.legend), so one function covers the whole grid. Put the keys in your own resource files under whichever namespace you like. i18next reads : as its namespace separator by default, so it looks gridwright:search.placeholder up as the key search.placeholder in a gridwright namespace; keep them there, or change nsSeparator.

Every major library returns the key itself when it has no entry for it. That is treated as "not translated" and falls through to the catalog, so a missing key never renders as gridwright:pagination.rowsPerPage on screen.

And labels#

tsx
<Gridwright locale={pl} labels={{ empty: 'Brak pracowników' }} />

The escape hatch above the shell's strings: loading, empty, errorTitle, retry, and the two functions rowsShown and rowsTotal. Use it to change one of those without shipping a catalog, or where a label needs logic a message cannot express. It does not reach add-on strings; those go through messages. The catalog is the translation path; this is not.

Strings of an add-on of your own#

An add-on declares its English, and any languages it ships, in its contribution's messages, and reads them in a component with useAddonMessages:

tsx
const messages = {
    en: { legend: 'Highlighted: salaries above {threshold}' },
    pl: { legend: 'Wyróżnione: pensje powyżej {threshold}' },
};

function Legend({ threshold }: { threshold: number }) {
    const t = useAddonMessages('acme:pay-band', messages);
    return <p>{t('legend', { threshold })}</p>;
}

Outside a component, in an announcement or a serializer, addonMessages(translator, contributions, name) gives the same function. Either way the grid's translate, messages and pack get the first word, so an application translates a third-party add-on exactly as it translates a built-in one:

ts
const myPl: LocaleCatalog = { ...pl, addons: { ...pl.addons, 'acme:pay-band': { legend: '...' } } };

The full contract is in Add-ons.

Fallback#

A key missing from a catalog resolves to English, never to the key. One English word in a translated page is a cosmetic defect; gridwright:pagination.rowsPerPage rendered in the footer is a broken product. The only way to see a key on screen is an add-on that has no English for it, which the audit below exists to catch.

Catch the drift in a test instead:

ts
import { auditAddonMessages, auditCatalog } from 'apsw-gridwright';
import { filterMessages } from 'apsw-gridwright/react';
import { it as italian } from './locales/it';

test('the Italian catalog is complete', () => {
    const audit = auditCatalog(italian.messages);
    expect(audit.missing).toEqual([]);   // shell keys the catalog has not translated
    expect(audit.unknown).toEqual([]);   // keys that are no longer in the contract
});

test('the Italian filters are complete', () => {
    // Per language, the keys missing and the keys invented. An empty object is a complete catalog.
    expect(auditAddonMessages({ en: filterMessages.en, it: italian.addons!['gridwright:filters']! })).toEqual({});
});

Right to left#

Direction is resolved from the locale, through Intl.Locale where the runtime supports it and a language list otherwise. The grid root then carries dir="rtl" and lang, and the stylesheet uses logical properties (margin-inline-end, border-inline) so the layout mirrors rather than needing a second stylesheet.

dir is set only for right-to-left, so a grid inside an already-RTL page does not reset itself to the document direction it is nested in.

Override it if a catalog needs to disagree with its tag:

ts
export const custom: LocaleCatalog = { locale: 'en', direction: 'rtl', messages };

No RTL catalog ships yet. The mechanism is tested; the translations are not written. A pull request adding one is welcome.

Outside React#

The translator is part of the headless core and has no React dependency:

ts
import { createTranslator } from 'apsw-gridwright';
import { pl } from 'apsw-gridwright/locales';

const translator = createTranslator({ catalog: pl });

translator.t('a11y.rowsTotal', { count: 3 });                           // '3 wiersze'
translator.translateAddon('gridwright:selection', 'count', { count: 3 }); // 'zaznaczono 3 wiersze'
translator.direction;                                                   // 'ltr'
translator.formatNumber(12345);                                         // '12 345'

t takes the shell's keys. translateAddon(name, key, values, own) takes an add-on's, with the add-on's own catalog as the optional last argument for the languages the pack does not cover.

Inside React, useGridwrightContext().translator is the same object the grid is rendering through, so a part you wrote yourself translates from the same catalog.

Adding a locale to this package#

  1. src/locales/<tag>.ts, following an existing pack: the shell's messages, and an addons section for every built-in add-on.
  2. Export it from src/locales/index.ts.
  3. Add the tag to the parameterised completeness tests in tests/unit/i18n.test.ts, which audit the shell's keys and every built-in add-on's.
  4. Add it to the list in scripts/check-exports.mjs.
  5. npm run verify.

Plural categories are the part worth care. Check which ones your language actually uses before writing the forms:

js
new Intl.PluralRules('pl').resolvedOptions().pluralCategories
// [ 'one', 'few', 'many', 'other' ]

Edit this page on GitHub