npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@lilydesignsystem/nunjucks-locale-picker

v0.1.1

Published

Lily Design System Nunjucks locale picker: an icon button opening an APG listbox that sets lang and dir on the document. Headless, SSR-safe, no CSS.

Downloads

239

Readme

LocalePicker (Nunjucks helper)

A reusable, headless Nunjucks 3 + vanilla-JS locale picker — an icon button that opens a listbox of locales — that applies the chosen locale to the document root via lang and dir, with optional localStorage persistence and navigator.languages detection.

The single source of truth is spec/index.md. This file is the comprehensive user guide. For topic deep-dives see docs/ and for working code see examples/.

Table of contents

Why this exists

Most locale pickers couple selection, persistence, and string translation into one opinionated widget. This one splits the contract cleanly:

  • This helper owns the lang / dir lifecycle, the listbox interaction, accessibility, and persistence — via a Nunjucks macro for the markup and a small ES module for the runtime.
  • Your i18n library (i18next, gettext, eleventy-plugin-i18n, etc.) owns the actual string translation. It picks up the lang attribute or the onChange callback.
  • Consumers own the visual style of the control via the locale-picker class hook.

The result is a small reusable widget that works in any Nunjucks host (Eleventy, Express, Cloudflare Workers, plain nunjucks.render) and against any locale catalog — your supported set or the built-in 436-row BCP 47 table.

The helper is a direct port of the Svelte canonical @lilydesignsystem/svelte-locale-picker. The DOM contract and behaviour match clause-for-clause; only the framework idioms differ.

How the pieces fit

The helper is a macro + client.js pair:

  • The macro (locale-picker.njk) renders the button + listbox markup server-side or at static-site build time, including a hidden input pre-filled with the server-resolved locale. Options without a consumer label render the raw code as a pre-hydration fallback, marked data-lily-locale-picker-derive.
  • The client (locale-picker.client.js) is an ES module the consumer loads once per page. It picks up the markup via data-lily-locale-picker-* hooks and owns the listbox interaction (open / close, focus, the APG keyboard contract, typeahead), the browser-side lifecycle (storage, navigator detection, lang / dir apply, change callbacks), and the label upgrade: each marked option becomes the language's endonym ("Cymraeg", not "Welsh") via Intl.DisplayNames, falling back to the built-in English table, then the raw code — and gains lang="…" (BCP 47 hyphen form) only when the text really is the endonym.
Nunjucks render time                 │  Browser runtime
                                      │
{{ localePicker({…}) }}               │  import { autoInit } from
   │                                  │    "./locale-picker.client.js";
   ▼                                  │  autoInit();
<div class="locale-picker"            │     │
  data-lily-locale-picker-root        │     ▼
  data-lily-locale-picker-*>          │  finds [data-lily-locale-picker-root]
  <input type="hidden" name="locale"  │     │
    value="en">                       │     ▼
  <button class="locale-picker-button"│  wires button + listbox events
    aria-haspopup="listbox"           │     │
    aria-expanded="false">[svg]</button> │     ▼
  <ul class="locale-picker-list"      │  resolves initial code
    role="listbox" hidden>            │     │
    <li role="option" data-value="en" │     ▼
      data-lily-locale-picker-derive  │  label upgrade: "en" → "English",
      >en</li>                        │    lang="en" (endonym only)
    …                                 │  then applyLocale(code):
                                      │    - target.lang = bcp47(code)
  </ul>                               │    - target.dir = rtl|ltr
</div>                                │    - localStorage.setItem(...)
                                      │    - hidden input .value = code
                                      │    - aria-selected sync
                                      │    - opts.onChange(code)

The button does nothing until the client module runs. See SSR and the first paint.

Install

The directory ships as a folder-style import. Copy the four core files into your project or wire as a workspace dependency:

| File | Purpose | | --------------------------- | ---------------------------------------- | | locale-picker.njk | The Nunjucks macro. | | locale-picker.client.js | The ES-module runtime. | | locales.ts / locales.js | Built-in 436-row label table + RTL sets. | | locales.tsv | Canonical source for locales.ts. |

Runtime dependencies: nunjucks ≥ 3 server-side and standard DOM APIs client-side. The locales.ts file is TypeScript; bundlers resolve it at test time, browsers consume the compiled locales.js.

Quick start

  1. Render the macro in your Nunjucks template:
{% from "./lily-design-system-nunjucks-locale-picker/locale-picker.njk" import localePicker %}

{{ localePicker({
    label: "Language",
    locales: ["en", "en_US", "fr", "fr_CA", "ar", "he"],
    storageKey: "lily-locale",
    detectFromNavigator: true
}) }}

{# Status region: the closed control is an icon-only button showing
   a globe icon, never the active locale, so the active locale is
   surfaced here instead — visibly, for sighted and screen-reader
   users alike. See docs/accessibility.md. #}
<p class="locale-picker-status" aria-live="polite"></p>
  1. Load the client.js once per page and keep the status region in sync from onChange:
<script type="module">
  import { autoInit, localeName } from "/path/to/locale-picker.client.js";

  const status = document.querySelector(".locale-picker-status");

  autoInit({
    onChange(code) {
      // aria-live="polite" announces mutations only, so this is
      // silent on first paint and speaks on each later change.
      status.textContent = `Active language: ${localeName(code)}`;
    },
  });
</script>

localeName(code) resolves the human name from the built-in 436-row table (en_US → "English (United States)"), so the region shows a real language name rather than a raw code.

Keep the status region visible by default: it helps sighted and cognitive-accessibility users too, and WCAG 2.2 AAA favours it. If your design genuinely can't spare the space, make it visually hidden rather than dropping it — recipe in docs/styling.md.

When the user picks ar, the client:

  • sets lang="ar" on <html>,
  • sets dir="rtl" on <html> (auto-detected from the locale),
  • writes "ar" to localStorage["lily-locale"],
  • fires onChange("ar") if provided.

The select does NOT translate strings — that is the consumer's i18n library's job. Wire onChange (or MutationObserver on <html lang>) to your library so it loads the right messages.

How it works

On every locale change the client.js performs six steps:

  1. Resolve target — defaults to document.documentElement; overridable via initLocalePicker(root, { target }).
  2. Set target.lang to the BCP 47 hyphen form of the code (en_USen-US).
  3. Set target.dir to "rtl" or "ltr" based on isRtlLocale(code) — skipped when opts.applyDir=false.
  4. Persist the consumer-form code to localStorage if storageKey is set.
  5. Mirror the consumer-form code into the hidden input (so an enclosing <form> submits it) and set aria-selected="true" on the matching <li role="option">, "false" on the rest.
  6. Notify — call opts.onChange(code) if supplied.

The icon button and the listbox

The macro renders a <div> root holding three things: a hidden input, an icon-only trigger button, and a listbox of options.

<div class="locale-picker" data-lily-locale-picker-root …>
  <input type="hidden" name="locale" value="en" data-lily-locale-picker-input />
  <button
    type="button"
    class="locale-picker-button"
    aria-label="Locale"
    aria-haspopup="listbox"
    aria-expanded="false"
    aria-controls="locale-picker-locale-list"
    data-lily-locale-picker-button
  >
    <svg class="locale-picker-icon" viewBox="0 0 16 16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" width="1.05rem" height="1.05rem"><circle cx="8" cy="8" r="6"/><path d="M2 8h12"/><path d="M8 2c2.2 0 4 2.7 4 6s-1.8 6-4 6-4-2.7-4-6 1.8-6 4-6z"/></svg>
  </button>
  <ul
    class="locale-picker-list"
    id="locale-picker-locale-list"
    role="listbox"
    aria-label="Locale"
    tabindex="-1"
    hidden
    data-lily-locale-picker-list
  >
    <li
      class="locale-picker-option"
      id="locale-picker-locale-option-0"
      role="option"
      aria-selected="true"
      data-value="en"
      data-lily-locale-picker-derive
    >
      en
    </li>
    <li
      class="locale-picker-option"
      id="locale-picker-locale-option-1"
      role="option"
      aria-selected="false"
      data-value="ar"
      data-lily-locale-picker-derive
    >
      ar
    </li>
  </ul>
</div>

Options without a localeLabels entry render the raw code and are marked data-lily-locale-picker-derive — a template cannot ask ICU for anything. When the client runs, each marked option is upgraded to the language's endonym ("English", "العربية") via Intl.DisplayNames asked in that language, falling back to the built-in English table, then the raw code; and it gains lang="en" / lang="ar" only when the text really is the endonym, so a screen reader may switch voice for text that genuinely is in that language. Consumer-labelled options are rendered verbatim and carry no lang — their language is unknown, and the English word "Arabic" must never be handed to an Arabic speech engine.

The button's icon is a bundled globe-outline SVG (viewBox="0 0 16 16", reversed 2026-09-16 from a Unicode glyph), wrapped in aria-hidden="true", so the control's width stays constant no matter how long your locale names are. The accessible name comes solely from the button's aria-label.

This means the closed control never displays the active locale. The active locale lives in lang / dir on the target, in the hidden input's value, in localStorage when storageKey is set, in the option that carries aria-selected="true", and in the onChange(code) argument.

The macro resolves a selected option server-side — value or defaultValue or ("en" if listed else the first locale) — marks exactly one <li> aria-selected="true", and pre-fills the hidden input with it. The client may correct that after hydration, because storage and navigator are client-only.

The code passed to onChange is the consumer form (en_US if you passed en_US in locales). The DOM lang attribute is in the BCP 47 hyphen form (en-US). See BCP 47 normalisation.

Initial locale

The initial code on initLocalePicker(root) resolves to the first non-empty value of:

  1. The root's data-lily-locale-picker-value (i.e. opts.value). This attribute is the only channel by which opts.value reaches the client — see docs/ssr.md.
  2. localStorage.getItem(storageKey) (when set and readable).
  3. matchNavigatorLanguage(navigator.languages, locales) (when detectFromNavigator=true).
  4. The root's data-lily-locale-picker-default-value (i.e. opts.defaultValue).
  5. "en" if present among the rendered option values.
  6. The first option value, or "" if none.

Macro parameters

Full table in spec/index.md §4.1.

| Key | Type | Required | Default | | --------------------- | ----------------------- | -------- | ------------------------- | | label | string | yes | — | | locales | array<string> | yes | — | | value | string | no | "" | | defaultValue | string | no | "" | | storageKey | string | no | "" | | detectFromNavigator | boolean | no | false | | name | string | no | "locale" | | applyDir | boolean | no | true | | localeLabels | object<string,string> | no | {} | | id | string | no | "locale-picker-{name}" | | classes | string | no | "" | | attributes | object | no | {} |

label is the aria-label on both the button and the listbox; the button is icon-only, so this is its only accessible name. name is the hidden input's name.

id is the prefix for the listbox id ({id}-list) and each option id ({id}-option-{index}). A Nunjucks macro cannot hold a module counter, so this is the framework's stable-id mechanism: if you render two instances that share a name, pass a distinct id to at least one of them or their ids will collide.

See docs/concepts.md for the mental model and the catalog-wide reference.

Client.js API

import {
  initLocalePicker,
  autoInit,
  bcp47LocaleTag,
  isRtlLocale,
  localeName,
  matchNavigatorLanguage,
  defaultLocaleLabels,
  RTL_LANGUAGE_TAGS,
  RTL_SCRIPT_SUBTAGS,
} from "./locale-picker.client.js";
  • autoInit(opts?) — find every [data-lily-locale-picker-root] and wire it.
  • initLocalePicker(root, opts?) — wire a single root <div>; returns {setLocale, destroy}.
  • Pure helpers: bcp47LocaleTag, isRtlLocale, localeName, matchNavigatorLanguage.
  • Built-in data: defaultLocaleLabels (436 rows), RTL_LANGUAGE_TAGS, RTL_SCRIPT_SUBTAGS,

Optional opts:

  • onChange(code) — fired once per applied change; receives the consumer-form code.
  • target — element receiving lang and dir (defaults to <html>).

Pretty labels from the built-in table

locales.ts ships a 436-row code → English-name map. To populate option labels at macro time, pass defaultLocaleLabels (or any subset) as localeLabels. In Eleventy, expose it as a global data file:

// _data/localeLabels.js
import { defaultLocaleLabels } from "../lily-design-system-nunjucks-locale-picker/locale-picker.client.js";
export default defaultLocaleLabels;
{{ localePicker({
    label: "Language",
    locales: ["en", "fr", "ar"],
    localeLabels: localeLabels
}) }}

BCP 47 normalisation

The lang attribute on HTML elements must use hyphens (en-US), while many applications carry locale identifiers with underscores (en_US). The select accepts either form in locales and converts to the hyphen form when writing to the DOM. onChange receives the consumer-form code.

bcp47LocaleTag("en_US"); // "en-US"
bcp47LocaleTag("zh_Hant_TW"); // "zh-Hant-TW"
bcp47LocaleTag("en"); // "en"

See docs/bcp47.md for the full primer.

RTL auto-detection

isRtlLocale(locale) returns true for any locale whose base language is one of ar, arc, ckb, dv, fa, he, iw, ji, ks, ku, mzn, ps, sd, ug, ur, yi, OR whose script subtag is one of Arab, Hebr, Thaa, Syrc, Nkoo, Mong, Adlm.

Pass applyDir: false in the macro opts if you want full control of dir yourself.

See docs/rtl.md for the full table and the CSS authoring guide.

Custom icon

The button renders a globe icon (a bundled SVG) by default. Nunjucks's equivalent of "children" is a {% call %} block, and its body replaces that icon inside the button:

{% call localePicker({
    label: "Language",
    locales: ["en", "fr", "ar"]
}) %}
    <svg class="my-globe" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
        <circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" />
    </svg>
{% endcall %}

The call block does not render options — the listbox still comes from opts.locales. Keep your replacement aria-hidden="true": the button's accessible name is its aria-label, and visible icon text would compete with it.

If you need a control the macro cannot render at all, write the DOM by hand with the same data-lily-locale-picker-* hooks (-root, -button, -list, -input) plus role="option" + data-value on each choice; initLocalePicker(root) works against any conforming DOM.

See docs/concepts.md and the examples/ directory.

Accessibility

  • The button carries aria-label="{label}", aria-haspopup="listbox", aria-expanded, and aria-controls pointing at the listbox id. It is icon-only, so aria-label is its only accessible name.
  • The icon is wrapped in aria-hidden="true" so assistive technology never reads it.
  • The <ul role="listbox"> carries the same aria-label, is tabindex="-1", and receives focus while open; the active option is conveyed with aria-activedescendant, per the WAI-ARIA APG listbox pattern. Exactly one option is aria-selected="true".
  • A locale <li role="option"> carries lang="…" (WCAG 3.1.2, Language of Parts) only when its text is the client-derived endonymlang is a claim about the language of the text, and consumer labels or raw-code fallbacks make no such claim. The button and the <ul> never do — they are chrome, not content.
  • The document root carries lang and (by default) dir (WCAG 3.1.1, Language of Page, and 1.4.10, Reflow / bidi).
  • Tradeoff, and the default answer to it: because the closed control shows only an icon, a screen-reader user hears the label but not the active locale. The examples and the quick start therefore ship a visible .locale-picker-status region with aria-live="polite" next to the control. Treat that region as part of the pattern; omitting it is the deliberate choice.

Topic guide: docs/accessibility.md.

Keyboard

Every key below is implemented in locale-picker.client.js; none of it works before that module loads.

| Key | Where | Action | | ------------------------- | ------- | -------------------------------------------------------------- | | Enter / Space / ArrowDown | Button | Open, with the selected option active (or the first). | | ArrowUp | Button | Open, with the last option active. | | ArrowDown / ArrowUp | Listbox | Move the active option. Clamps at the ends; does not wrap. | | Home / End | Listbox | Jump to the first / last option. | | Enter / Space | Listbox | Select the active option, apply it, close, refocus the button. | | Escape | Listbox | Close and refocus the button without changing the locale. | | PageUp / PageDown | Listbox | Move the active option by ten, clamped. | | Tab | Listbox | Close and move on — focus goes to the button first, so the default Tab proceeds from the picker's position. | | Printable characters | Listbox | APG typeahead: one character advances to the next match and repeats cycle; differing characters refine. Buffer resets after 500 ms. |

Opening moves focus to the <ul>. Clicking an option selects it; clicking outside, or focus leaving the root, closes the listbox.

Styling

The control ships no CSS. Class hooks: .locale-picker on the root <div>, .locale-picker-button on the trigger, .locale-picker-icon on the default icon svg, .locale-picker-list on the <ul role="listbox">, and .locale-picker-option on each <li>. Style open / closed state from [aria-expanded] on the button and [hidden] on the list; the client marks the active option with data-active and the applied one with aria-selected="true".

.locale-picker {
  position: relative;
  display: inline-block;
}
.locale-picker-list[hidden] {
  display: none;
}
.locale-picker-option[data-active] {
  outline: 2px solid currentColor;
}
.locale-picker-option[aria-selected="true"] {
  font-weight: 700;
}

Topic guide: docs/styling.md.

SSR and the first paint

Nunjucks is the server side. The macro is pure: same opts in, same HTML out. For zero-flicker first paint, read the cookie or session value in your Nunjucks host (Eleventy edge function, Express middleware, Cloudflare Workers handler) and pass it as opts.value. See docs/ssr.md.

The server-rendered markup is not fully usable before the client JS loads. The button will not open the listbox without JS, because every open / close / keyboard / typeahead behaviour lives in locale-picker.client.js. This is a real regression from the earlier native <select>, which worked unenhanced. The one no-JS affordance that survives is the hidden input: it is pre-filled server-side with the resolved locale, so a form submitted without JS still carries a locale. If unenhanced locale switching is a hard requirement for your audience, render a plain <form> of links or submit buttons alongside (or instead of) this helper.

i18n library integration

The select doesn't translate strings — your i18n library does. See docs/i18n-integration.md for recipes with:

  • @11ty/eleventy-plugin-i18n
  • Raw Intl.* formatters
  • gettext via i18next-server

Recipes

  • Setting initial locale from Accept-Language header.
  • Cookie-based persistence so the next request paints in the right language.
  • URL-prefix locales (/en/about, /fr/about) with the select driving navigation.
  • Replacing the globe icon with your own icon via {% call %}.
  • Scoping the applied lang / dir to one panel with target.

Testing

pnpm test under a vitest + jsdom setup exercises every numbered acceptance criterion in spec/index.md §7.

Topic guides

| Guide | Covers | | ---------------------------------------------------------------- | ---------------------------------------------------------------------- | | docs/macro-opts-reference.md | Every localePicker(opts) key, field by field. | | docs/concepts.md | The macro / client.js split, lifecycle, persistence. | | docs/bcp47.md | Tag composition, normalisation, Intl.* compatibility. | | docs/rtl.md | RTL detection, dir, logical properties. | | docs/i18n-integration.md | Eleventy i18n, Intl.*, i18next, ICU MessageFormat. | | docs/ssr.md | Render-time vs runtime; cookie-resolved value. | | docs/accessibility.md | WCAG 2.2 AAA, the APG listbox contract, the tradeoffs. | | docs/styling.md | Class hooks and state selectors. | | docs/custom-rendering.md | Icon override, CSS-only styling, hand-written DOM. | | docs/recipes.md | Task-shaped solutions: status region, cookies, Intl, scoped targets. | | docs/troubleshooting.md | Symptoms, causes, fixes. |

Files in this directory

| File | Purpose | | -------------------------- | ----------------------------------------------- | | spec/index.md | Single source of truth — API, behaviour, tests. | | AGENTS.md | Fast-index pointer; loads the AGENTS bundle. | | AGENTS/ | Topic-by-topic agent files. | | CLAUDE.md | @AGENTS.md. | | locale-picker.njk | The macro. | | locale-picker.client.js | The ES-module runtime. | | locale-picker.test.ts | vitest suite covering every spec §7 item. | | locales.ts | Built-in code → English-name map + RTL sets. | | locales.tsv | Canonical 436-row source. | | index.md | This file. | | docs/ | Deep-dive topic guides. | | examples/ | Runnable Nunjucks templates. | | CHANGELOG.md | Version history. |

License

MIT or Apache-2.0 or GPL-2.0 or GPL-3.0 or BSD-3-Clause. Contact [email protected] for other terms.


Lily™ and Lily Design System™ are trademarks.