Locale-aware text filter

Pass filterLocale and the grid normalises text (NFD + diacritic strip + locale-aware lowercase) so "cafe" matches "Café", "tokyo" matches "Tōkyō".

A live, editable Svelte 5 data grid example from the SvGrid gallery (Filtering & Search). See the SvGrid documentation for the full API.

About this example

Locale-aware text filtering in the Svelte 5 data grid. Set filterLocale to a BCP-47 tag and the grid normalises text with NFD decomposition, diacritic stripping and locale-aware lowercasing, so cafe matches Café, munch matches München and, in tr-TR, istanbul matches İstanbul. The same pipeline drives global search and every column's contains, equals and starts-with operator.

Set filterLocale on <SvGrid responsive={true}> (BCP-47 tag) and the grid filters text using NFD-decomposition + diacritic stripping + locale-aware lowercasing. The result:

  • "cafe" matches "Café", "CAFÉ", "café"
  • "munch" matches "München"
  • "tunel" matches "túnel"
  • In tr-TR: "istanbul" matches "İstanbul" (dotted-I rule)

The same pipeline drives global search AND every column's contains / equals / startsWith operator in the column menu.

Imports, features and API used

Imports: @svgrid/grid

Table features registered: rowSortingFeature, columnFilteringFeature

Columns: city (City), country (Country), region (Region), population (Population), notes (Notes)

Frequently asked questions

How do I make the filter ignore accents?

Set filterLocale on <SvGrid>, for example filterLocale='en'. Values and queries are decomposed with NFD and stripped of combining marks before comparison, so cafe and Café match.

Why does the locale matter for lowercasing?

Some languages have casing rules that plain toLowerCase gets wrong. Turkish has the dotted and dotless I, so under tr-TR the grid lowercases with that locale and istanbul matches İstanbul.

Does this apply to the column menu filters too?

Yes. Global search and the contains, equals and starts-with operators of every text column share the same normalisation.

Related documentation

Related articles

Source code (110-locale-aware-filter.svelte)

<script lang="ts">
  /**
   * 110. Locale-aware text filtering (accent + case insensitive)
   * ------------------------------------------------------------
   * Set `filterLocale` on `<SvGrid responsive={true}>` (BCP-47 tag) and the grid filters
   * text using NFD-decomposition + diacritic stripping + locale-aware
   * lowercasing. The result:
   *
   *   - "cafe" matches "Café", "CAFÉ", "café"
   *   - "munch" matches "München"
   *   - "tunel" matches "túnel"
   *   - In `tr-TR`: "istanbul" matches "İstanbul" (dotted-I rule)
   *
   * The same pipeline drives global search AND every column's contains /
   * equals / startsWith operator in the column menu.
   */
  import {
    SvGrid,
    tableFeatures,
    rowSortingFeature,
    columnFilteringFeature,
    type GridColumns,
  } from '@svgrid/grid'

  type City = {
    id: number
    city: string
    country: string
    region: 'Europe' | 'Americas' | 'Asia' | 'Africa' | 'Oceania'
    population: number
    notes: string
  }

  let rows = $state<City[]>([
    { id: 1,  city: 'München',       country: 'Deutschland',     region: 'Europe',   population:  1_510_000, notes: 'Bayerische Hauptstadt' },
    { id: 2,  city: 'Köln',          country: 'Deutschland',     region: 'Europe',   population:  1_080_000, notes: 'Dom-Stadt am Rhein' },
    { id: 3,  city: 'Zürich',        country: 'Schweiz',         region: 'Europe',   population:    430_000, notes: 'Finanzplatz' },
    { id: 4,  city: 'Genève',        country: 'Suisse',          region: 'Europe',   population:    203_000, notes: 'Siège des Nations Unies' },
    { id: 5,  city: 'Montréal',      country: 'Canada',          region: 'Americas', population:  1_780_000, notes: 'Ville francophone' },
    { id: 6,  city: 'Québec',        country: 'Canada',          region: 'Americas', population:    540_000, notes: 'Capitale provinciale' },
    { id: 7,  city: 'México',        country: 'México',          region: 'Americas', population:  9_210_000, notes: 'Ciudad de México' },
    { id: 8,  city: 'São Paulo',     country: 'Brasil',          region: 'Americas', population: 12_330_000, notes: 'Maior cidade do país' },
    { id: 9,  city: 'Brasília',      country: 'Brasil',          region: 'Americas', population:  3_050_000, notes: 'Capital federal' },
    { id: 10, city: 'Bogotá',        country: 'Colombia',        region: 'Americas', population:  7_410_000, notes: 'Capital andina' },
    { id: 11, city: 'Tōkyō',         country: '日本',              region: 'Asia',     population: 13_960_000, notes: 'Cherry blossoms in spring' },
    { id: 12, city: 'Ōsaka',         country: '日本',              region: 'Asia',     population:  2_750_000, notes: 'Famous for street food' },
    { id: 13, city: 'Sapporo',       country: '日本',              region: 'Asia',     population:  1_970_000, notes: 'Snow festival hub' },
    { id: 14, city: 'İstanbul',      country: 'Türkiye',         region: 'Asia',     population: 15_840_000, notes: 'Köprü Asya ile Avrupa arasında' },
    { id: 15, city: 'İzmir',         country: 'Türkiye',         region: 'Asia',     population:  4_390_000, notes: 'Ege Denizi kıyısında' },
    { id: 16, city: 'Tunis',         country: 'Tunisie',         region: 'Africa',   population:    640_000, notes: 'Médina inscrite UNESCO' },
    { id: 17, city: "N'Djamena",     country: 'Tchad',           region: 'Africa',   population:  1_440_000, notes: 'Capitale sur le Chari' },
    { id: 18, city: 'Reykjavík',     country: 'Ísland',          region: 'Europe',   population:    140_000, notes: 'Nyrsta höfuðborg heims' },
    { id: 19, city: 'København',     country: 'Danmark',         region: 'Europe',   population:    660_000, notes: 'Hovedstad og kongesæde' },
    { id: 20, city: 'Göteborg',      country: 'Sverige',         region: 'Europe',   population:    580_000, notes: 'Hamnstad på västkusten' },
    { id: 21, city: 'Málaga',        country: 'España',          region: 'Europe',   population:    580_000, notes: 'Ciudad natal de Picasso' },
    { id: 22, city: 'A Coruña',      country: 'España',          region: 'Europe',   population:    245_000, notes: 'Torre de Hércules' },
    { id: 23, city: 'Wrocław',       country: 'Polska',          region: 'Europe',   population:    640_000, notes: 'Miasto stu mostów' },
    { id: 24, city: 'Łódź',          country: 'Polska',          region: 'Europe',   population:    670_000, notes: 'Dawna stolica przemysłu' },
    { id: 25, city: 'Reykjanesbær',  country: 'Ísland',          region: 'Europe',   population:     22_000, notes: 'Bær á Suðurnesjum' },
  ])

  const features = tableFeatures({ rowSortingFeature, columnFilteringFeature })

  // ---- Locale picker ---------------------------------------------------
  type LocalePreset = { id: string; label: string; locale: string | undefined; hint: string }
  const LOCALES: LocalePreset[] = [
    { id: 'browser', label: 'Browser default',  locale: undefined,  hint: 'Whatever navigator.language reports' },
    { id: 'en-US',   label: 'English (US)',     locale: 'en-US',    hint: 'Standard Western lowercasing' },
    { id: 'de-DE',   label: 'German',           locale: 'de-DE',    hint: 'ÄÖÜ → äöü → aou; ß stays as ss when normalized' },
    { id: 'fr-FR',   label: 'French',           locale: 'fr-FR',    hint: 'É È Ê Ë → eee; Ç → c' },
    { id: 'es-ES',   label: 'Spanish',          locale: 'es-ES',    hint: 'Á É Í Ó Ú Ü Ñ all fold to a e i o u u n' },
    { id: 'pt-BR',   label: 'Portuguese (BR)',  locale: 'pt-BR',    hint: 'Ã Õ Ç → a o c; São → sao' },
    { id: 'tr-TR',   label: 'Turkish',          locale: 'tr-TR',    hint: 'İ → i (dotted), I → ı (dotless). "istanbul" matches "İstanbul"' },
    { id: 'is',      label: 'Icelandic',        locale: 'is',       hint: 'Þ Ð Æ Ö - non-ASCII letters stay distinct' },
  ]
  let selectedLocaleId = $state<string>('browser')
  const currentLocale = $derived(LOCALES.find((l) => l.id === selectedLocaleId)?.locale)
  const currentHint = $derived(LOCALES.find((l) => l.id === selectedLocaleId)?.hint ?? '')

  // ---- Sample queries that prove locale-aware matching ------------------
  const SAMPLES = [
    { q: 'munchen',     why: '→ matches München (German umlaut stripped)' },
    { q: 'cafe',        why: '→ matches Genève? No - try "geneve" or "geneve" instead' },
    { q: 'geneve',      why: '→ matches Genève (è → e)' },
    { q: 'sao paulo',   why: '→ matches São Paulo (ã → a)' },
    { q: 'tokyo',       why: '→ matches Tōkyō (macron stripped)' },
    { q: 'istanbul',    why: '→ matches İstanbul under any locale (NFD strips the dot)' },
    { q: 'mexico',      why: '→ matches México (é → e)' },
    { q: 'wroclaw',     why: '→ matches Wrocław (Polish stroke-l would not strip with NFD; this only matches the rest)' },
    { q: 'reykjavik',   why: '→ matches Reykjavík (í → i)' },
    { q: 'rhein',       why: '→ matches "Dom-Stadt am Rhein"' },
  ]

  const columns: GridColumns<City> = [
    { field: 'city',       header: 'City',       width: 180, editable: false },
    { field: 'country',    header: 'Country',    width: 160, editable: false },
    { field: 'region',     header: 'Region',     width: 110, editable: false },
    { field: 'population', header: 'Population', width: 130, align: 'right', editable: false,
      format: { type: 'number', options: { maximumFractionDigits: 0 } } },
    { field: 'notes',      header: 'Notes',      width: 360, editable: false },
  ]
</script>

<section class="flex flex-col flex-1 min-h-0 gap-3">
  <div class="info shrink-0">
    <p>
      <strong>Type any unaccented query in the global filter</strong> - the grid normalises both the
      query and every cell value (NFD-decompose + strip combining marks + locale-aware lowercase)
      before matching. The chosen locale only affects edge cases like Turkish dotted-I.
    </p>
    <div class="controls">
      <label class="ctrl">
        <span>Locale</span>
        <select bind:value={selectedLocaleId}>
          {#each LOCALES as l (l.id)}<option value={l.id}>{l.label}</option>{/each}
        </select>
      </label>
      <span class="hint-inline"><strong>Locale hint:</strong> {currentHint}</span>
    </div>
    <p class="hint">
      Use the global filter input in the grid header (top-left), or click one of these sample queries
      to test (it copies to clipboard - then paste into the filter input):
    </p>
    <div class="samples">
      {#each SAMPLES as s (s.q)}
        <button class="sample" onclick={() => navigator.clipboard?.writeText(s.q)} title={s.why}>"{s.q}"</button>
      {/each}
    </div>
  </div>

  <div class="flex-1 min-h-0">
    <SvGrid responsive={true}
      columnResize
      data={rows}
      columns={columns}
      features={features}
      filterMode="menu"
      selectionMode="cell"
      showGlobalFilter={true}
      enableInlineEditing={false}
      enableCellSelection={true}
      rowHeight={32}
      containerHeight="100%"
      fitColumns={true}
      filterLocale={currentLocale}
    />
  </div>
</section>

<style>
  .info {
    border: 1px solid var(--sg-border, #e2e8f0);
    background: color-mix(in oklab, var(--sg-accent, #6366f1) 5%, transparent);
    border-radius: 8px; padding: 10px 14px;
    font-size: 13px; color: var(--sg-fg, #0f172a);
  }
  .info p { margin: 0 0 6px; }
  .controls { display: flex; gap: 10px; align-items: stretch; margin-top: 4px; }
  .ctrl { display: flex; flex-direction: column; gap: 3px; }
  .ctrl.flex-1 { flex: 1; }
  .ctrl > span {
    font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em;
    font-weight: 700; color: var(--sg-muted, #64748b);
  }
  .hint-inline { font-size: 12px; align-self: flex-end; color: var(--sg-muted, #64748b); }
  .hint-inline strong { color: var(--sg-accent, #6366f1); }
  .ctrl input, .ctrl select {
    border: 1px solid var(--sg-input-border, #cbd5e1);
    background: var(--sg-input-bg, #fff);
    color: var(--sg-fg, #0f172a);
    border-radius: 6px; padding: 6px 10px; font-size: 13px;
  }
  .hint {
    color: var(--sg-muted, #64748b); font-size: 12px; font-style: italic;
    margin-top: 4px !important;
  }
  .hint strong { font-style: normal; color: var(--sg-accent, #6366f1); font-weight: 700; }

  .samples { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 4px; }
  .samples-label {
    font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em;
    color: var(--sg-muted, #94a3b8); font-weight: 700;
    align-self: center;
  }
  .sample {
    background: var(--sg-bg, #fff);
    border: 1px solid var(--sg-border, #cbd5e1);
    color: var(--sg-fg, #0f172a);
    border-radius: 999px; padding: 2px 10px;
    font-size: 11px; font-family: ui-monospace, monospace;
    cursor: pointer;
  }
  .sample:hover { background: color-mix(in oklab, var(--sg-accent, #6366f1) 8%, transparent);
                  border-color: var(--sg-accent, #6366f1); color: var(--sg-accent, #4338ca); }
</style>

View this example on GitHub

More Filtering & Search examples

  • Sort, filter, paginate - Three most-asked-for features wired together against ~5k rows.
  • Excel-style filters - Per-column operator dropdown with active-filter chips and clear.
  • Filter - between operator - Number + date columns expose a "Between" operator with From/To inputs. Drive it via the menu or imperatively with api.setFilter(id, { operator: "between", value, valueTo }).
  • Highlighted search matches - External search input + a custom cell snippet that wraps matched substrings in <mark>. Filters the dataset AND visually flags hits.
  • Find in grid (Ctrl+F) - Built-in find overlay with next / previous navigation. Scans every visible cell value; matches activate + scroll into view.