@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
Maintainers
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
- How the pieces fit
- Install
- Quick start
- How it works
- Initial locale
- Macro parameters
- Client.js API
- Pretty labels from the built-in table
- BCP 47 normalisation
- RTL auto-detection
- Custom icon
- Accessibility
- Keyboard
- Styling
- SSR and the first paint
- i18n library integration
- Recipes
- Testing
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/dirlifecycle, 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
langattribute or theonChangecallback. - Consumers own the visual style of the control via the
locale-pickerclass 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, markeddata-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 viadata-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/dirapply, change callbacks), and the label upgrade: each marked option becomes the language's endonym ("Cymraeg", not "Welsh") viaIntl.DisplayNames, falling back to the built-in English table, then the raw code — and gainslang="…"(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
- 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>- 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"tolocalStorage["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:
- Resolve target — defaults to
document.documentElement; overridable viainitLocalePicker(root, { target }). - Set
target.langto the BCP 47 hyphen form of the code (en_US→en-US). - Set
target.dirto"rtl"or"ltr"based onisRtlLocale(code)— skipped whenopts.applyDir=false. - Persist the consumer-form code to
localStorageifstorageKeyis set. - Mirror the consumer-form code into the hidden input (so an
enclosing
<form>submits it) and setaria-selected="true"on the matching<li role="option">,"false"on the rest. - 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:
- The root's
data-lily-locale-picker-value(i.e.opts.value). This attribute is the only channel by whichopts.valuereaches the client — see docs/ssr.md. localStorage.getItem(storageKey)(when set and readable).matchNavigatorLanguage(navigator.languages, locales)(whendetectFromNavigator=true).- The root's
data-lily-locale-picker-default-value(i.e.opts.defaultValue). "en"if present among the rendered option values.- 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 receivinglanganddir(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, andaria-controlspointing at the listbox id. It is icon-only, soaria-labelis its only accessible name. - The icon is wrapped in
aria-hidden="true"so assistive technology never reads it. - The
<ul role="listbox">carries the samearia-label, istabindex="-1", and receives focus while open; the active option is conveyed witharia-activedescendant, per the WAI-ARIA APG listbox pattern. Exactly one option isaria-selected="true". - A locale
<li role="option">carrieslang="…"(WCAG 3.1.2, Language of Parts) only when its text is the client-derived endonym —langis 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
langand (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-statusregion witharia-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 gettextviai18next-server
Recipes
- Setting initial locale from
Accept-Languageheader. - 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/dirto one panel withtarget.
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.
