LilyDesignSystem.Blazor.LocalePicker
0.2.0
See the version list below for details.
dotnet add package LilyDesignSystem.Blazor.LocalePicker --version 0.2.0
NuGet\Install-Package LilyDesignSystem.Blazor.LocalePicker -Version 0.2.0
<PackageReference Include="LilyDesignSystem.Blazor.LocalePicker" Version="0.2.0" />
<PackageVersion Include="LilyDesignSystem.Blazor.LocalePicker" Version="0.2.0" />
<PackageReference Include="LilyDesignSystem.Blazor.LocalePicker" />
paket add LilyDesignSystem.Blazor.LocalePicker --version 0.2.0
#r "nuget: LilyDesignSystem.Blazor.LocalePicker, 0.2.0"
#:package LilyDesignSystem.Blazor.LocalePicker@0.2.0
#addin nuget:?package=LilyDesignSystem.Blazor.LocalePicker&version=0.2.0
#tool nuget:?package=LilyDesignSystem.Blazor.LocalePicker&version=0.2.0
LocalePicker (Blazor helper)
A reusable, headless Blazor locale select that applies the chosen
locale to the document root via lang and dir, with optional
localStorage persistence and navigator.languages detection. It
renders an icon button (a bundled globe-outline SVG, not a Unicode
character -- reversed 2026-09-16) that opens a dropdown listbox, built to
the WAI-ARIA Authoring Practices listbox pattern.
For the full contract see spec/index.md — it is the single source of truth for the API, behaviour, and tests. For topic deep-dives see docs/ and for working code see examples/.
Table of contents
- Install
- Quick start
- BCP 47 normalisation
- RTL auto-detection
- Examples
- Keyboard
- Built-in locale data
- Parameters
- Events
- Accessibility
- SSR
- Files in this directory
- Documentation
- Examples directory
Install
Drop the folder into your Razor class library or app and add the
namespace import to your _Imports.razor:
@using LilyDesignSystem.Blazor.Helpers
The only runtime dependency is
Microsoft.AspNetCore.Components.Web 10.0. There is no extra NuGet
package; the helper is four source files (LocalePicker.razor +
LocalePicker.razor.cs + Locales.cs + locales.tsv).
Quick start
Render the select with a Label and the list of locales your app
supports. It renders an icon button that opens a dropdown listbox. The
select writes lang and dir onto <html> so your i18n library, your
CSS (html[dir="rtl"]), and assistive technology all see the change.
@using LilyDesignSystem.Blazor.Helpers
<LocalePicker
Label="Language"
Locales="@(new []{ "en", "en_US", "fr", "fr_CA", "ar", "he" })"
@bind-Value="locale"
StorageKey="lily-locale"
DetectFromNavigator="true" />
<p class="locale-picker-status" aria-live="polite">
Active language:
<span lang="@Locales.Bcp47LocaleTag(locale)">@Locales.LocaleName(locale)</span>
</p>
@code {
private string locale = "";
}
The status line is part of the pattern, not decoration. The closed
control shows only a glyph, so this line is the only place on screen
that says which locale is active. aria-live="polite" announces changes
only, staying silent on first paint. The locale's own name carries its
own lang so it is pronounced correctly (WCAG 3.1.2). Render it visible
by default; hide it with a visually-hidden class only if the design
truly cannot spare the space. Full rationale:
docs/accessibility.md.
When the user picks ar, the component:
- sets
lang="ar"on<html>, - sets
dir="rtl"on<html>(auto-detected from the locale), - writes
"ar"tolocalStorage["lily-locale"], - fires
ValueChanged("ar")(drives@bind-Value), - fires
OnChange("ar")(one-shot side effect hook).
The select does NOT translate strings — that is the consumer's i18n
library (e.g. Microsoft.Extensions.Localization,
IStringLocalizer<T>, custom CultureInfo switching). Wire the
bindable Value or OnChange to your library so it loads the
right messages.
BCP 47 normalisation
Language tags follow BCP 47 (RFC 5646). The lang attribute on
HTML elements must use hyphens, while many applications carry
locale identifiers with underscores (en_US, zh_Hant_TW). The
select accepts whichever form you prefer in the Locales list and
converts to the hyphen form when writing to the DOM. The bindable
Value preserves your original form, so round-trips are lossless.
Locales.Bcp47LocaleTag("en_US"); // "en-US"
Locales.Bcp47LocaleTag("zh_Hant_TW"); // "zh-Hant-TW"
Locales.Bcp47LocaleTag("en"); // "en"
References:
- W3C — Language tags in HTML and XML
- IETF — RFC 5646 (BCP 47), Tags for Identifying Languages
- IANA — Language Subtag Registry
RTL auto-detection
Locales.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.
Locales.IsRtlLocale("ar"); // true
Locales.IsRtlLocale("he_IL"); // true
Locales.IsRtlLocale("uz_Arab_AF"); // true (script subtag)
Locales.IsRtlLocale("en"); // false
Pass ApplyDir="false" if you want full control of dir yourself.
Examples
Default rendering
<LocalePicker
Label="Language"
Locales="@(new[] { "en", "cy" })"
@bind-Value="locale" />
Renders (ids abbreviated; the listbox is shown open):
<div class="locale-picker">
<input type="hidden" name="locale" value="en" />
<button type="button" class="locale-picker-button"
aria-label="Language" aria-haspopup="listbox"
aria-expanded="true" aria-controls="locale-picker-1-list">
<svg class="locale-picker-icon" viewBox="0 0 16 16" aria-hidden="true"
width="1.05rem" height="1.05rem">…</svg>
</button>
<ul class="locale-picker-list" id="locale-picker-1-list" role="listbox"
aria-label="Language" tabindex="-1"
aria-activedescendant="locale-picker-1-option-0">
<li class="locale-picker-option" id="locale-picker-1-option-0"
role="option" aria-selected="true" data-active lang="en">English</li>
<li class="locale-picker-option" id="locale-picker-1-option-1"
role="option" aria-selected="false" lang="cy">Cymraeg</li>
</ul>
</div>
Reading that markup:
- The root
<div>carries thelocale-pickerclass hook plusCssClass, and everything captured byAdditionalAttributesspreads onto it. - The hidden input carries
NameandValueso the control still participates in a form; the listbox itself is not a form control. - The icon is a bundled outline SVG (not a Unicode character --
reversed 2026-09-16) wrapped in
aria-hidden="true", so the accessible name comes wholly fromLabel— never from the icon. - An
<li role="option">carries its ownlangonly when its label is the derived endonym — above, "Cymraeg" really is Welsh, solang="cy"is a true claim and screen readers may switch voice (WCAG 3.1.2, Language of Parts). A consumer-supplied label or the English-table fallback makes no claim: the English word "Arabic" must never be handed to an Arabic speech engine. The button and the list carry nolang. - The listbox carries
hiddenwhile closed and drops it while open;aria-expandedon the button tracks the same state. The sample above is the open state — while closed the<ul>carrieshidden, and neitheraria-activedescendantnordata-activeis emitted at all.
Class hooks: .locale-picker on the root, .locale-picker-button on
the trigger, .locale-picker-icon on the glyph, .locale-picker-list
on the <ul>, and .locale-picker-option on every <li>. The active
option additionally carries [data-active], and the selected option
[aria-selected="true"].
Positioning the listbox
The package ships no CSS, so an open listbox participates in normal flow and shoves the rest of the page around. Positioning it is your job:
.locale-picker { position: relative; }
.locale-picker-list {
position: absolute;
inset-block-start: 100%;
inset-inline-start: 0; /* logical, not `left` */
min-inline-size: 12ch;
}
.locale-picker-option[data-active] { outline: 2px solid currentColor; }
.locale-picker-option[aria-selected="true"] { font-weight: 600; }
Use logical properties (inset-inline-start, min-inline-size)
rather than physical ones. This control changes dir on the document
root, possibly while the listbox is open, and logical properties let
the open list re-mirror around the button instead of jumping
off-screen mid-interaction.
Pretty labels for option text
By default each option shows the language's endonym — its own name
for itself, "Deutsch" not "German" — via the public
Locales.LocaleEndonym(code) helper (CultureInfo.NativeName
underneath). The English names from locales.tsv are the fallback for
cultures the runtime has no data for, and the raw code is the last
resort. Override per-code with LocaleLabels:
<LocalePicker
Label="Langue"
Locales="@(new[] { "en", "fr", "ar" })"
LocaleLabels="@(new Dictionary<string, string>
{
["en"] = "English",
["fr"] = "Français",
["ar"] = "العربية",
})"
@bind-Value="locale" />
Replacing the glyph
ChildContent is a RenderFragment<LocalePickerContext> that
replaces the glyph inside the button. It does not render options —
the component always owns the listbox. The context gives you Value,
Open, and LabelFor:
<LocalePicker
Label="Language"
Locales="@(new[] { "en", "fr", "es", "de", "ar" })"
@bind-Value="locale"
StorageKey="lily-locale">
<ChildContent Context="ctx">
@* Inline SVG is the robust choice: no font-coverage risk.
Keep it aria-hidden — the name still comes from Label. *@
<svg class="locale-picker-icon" aria-hidden="true" focusable="false"
width="20" height="20" viewBox="0 0 20 20">
<circle cx="10" cy="10" r="8" fill="none" stroke="currentColor" />
<ellipse cx="10" cy="10" rx="3.5" ry="8" fill="none" stroke="currentColor" />
<path d="M2 10h16" fill="none" stroke="currentColor" />
</svg>
<span class="locale-picker-code">@ctx.LabelFor(ctx.Value)</span>
</ChildContent>
</LocalePicker>
Set Label even when the fragment renders visible text: the
accessible name always comes from the button's aria-label.
Building a fully custom control
ChildContent no longer gives you the whole control, so if you want
different markup entirely — a button group, a filtering combobox, a
third-party picker — build it yourself and drive this helper's
lifecycle through a @ref and the static Locales.* helpers:
<LocalePicker @ref="localeSelect"
Label="Language"
Locales="@codes"
@bind-Value="locale"
StorageKey="lily-locale"
class="visually-hidden" />
<ul role="list">
@foreach (var l in codes)
{
<li>
<button type="button"
aria-pressed="@(locale == l)"
lang="@Locales.Bcp47LocaleTag(l)"
dir="@(Locales.IsRtlLocale(l) ? "rtl" : "ltr")"
@onclick="@(async () => await localeSelect!.SetLocaleAsync(l))">
@Locales.LocaleName(l)
</button>
</li>
}
</ul>
@code {
private LocalePicker? localeSelect;
private string[] codes = { "en", "fr", "ar" };
private string locale = "";
}
SetLocaleAsync(string) applies a locale exactly as a click on an
option would — lang, dir, storage, ValueChanged, OnChange — so
the helper still owns the whole apply lifecycle.
Wiring IStringLocalizer<T>
@inject IStringLocalizer<SharedResources> Localizer
<LocalePicker
Label="@Localizer["chooseLanguage"]"
Locales="@(new[] { "en", "fr", "ar" })"
@bind-Value="locale"
DetectFromNavigator="true"
StorageKey="app-locale"
OnChange="OnLocaleChange" />
@code {
private string locale = "";
private void OnLocaleChange(string code)
{
var ci = new CultureInfo(Locales.Bcp47LocaleTag(code));
CultureInfo.DefaultThreadCurrentCulture = ci;
CultureInfo.DefaultThreadCurrentUICulture = ci;
}
}
Server-resolved initial value (SSR)
For flicker-free first paint, resolve the locale on the server
(from a cookie or Accept-Language) and pass it as Value:
@inject IHttpContextAccessor HttpContextAccessor
@{
var cookieLocale = HttpContextAccessor.HttpContext?
.Request.Cookies["locale"] ?? "en";
}
<LocalePicker
Label="Language"
Locales="@(new[] { "en", "fr", "ar" })"
Value="@cookieLocale"
@bind-Value="locale" />
During SSR the component renders the button and the (closed) listbox
with the supplied value marked aria-selected, and the document
already arrives with the correct lang attribute on <html> (set in
App.razor).
Keyboard
The component implements the WAI-ARIA APG listbox keyboard contract
itself — there is no native <select> doing it for you.
On the button:
| Key | Action |
|---|---|
Tab / Shift+Tab |
Move focus to / away from the button (one stop). |
Arrow Down |
Open, active option = the selected one (else index 0). |
Enter / Space |
Open, active option = the selected one (else index 0). |
Arrow Up |
Open with the last option active. |
Opening moves focus to the <ul>.
On the listbox:
| Key | Action |
|---|---|
Arrow Down |
Move the active option down one; clamps at the last (no wrap). |
Arrow Up |
Move the active option up one; clamps at the first (no wrap). |
Home |
Jump to the first option. |
End |
Jump to the last option. |
Enter / Space |
Select the active option, apply it, close, return focus to the button. |
Escape |
Close and return focus without changing the value. |
PageUp / PageDown |
Move the active option by ten; clamps at the ends. |
Tab |
Close and move on; the browser's default Tab proceeds from the picker's position. |
| Printable chars | Typeahead over the option labels, 500 ms buffer reset. A single character advances to the next match and repeating it cycles onward; differing characters refine the match from the active option. |
Pointer and focus:
- Clicking an option selects it, applies it, and closes the listbox.
- Focus leaving the root closes the listbox without changing the value.
Focus stays on the <ul> while the listbox is open; the active option
is conveyed by aria-activedescendant, never by moving DOM focus onto
an <li>. Style [data-active] so sighted keyboard users can see
where the arrow keys have moved them. Two Blazor-specific deviations
from the canonical Svelte contract are documented in
docs/accessibility.md.
Built-in locale data
Locales.cs ships a 436-row built-in map from locale codes to
English names (derived from locales.tsv). The picker falls back to
this table when LocaleLabels has no entry for a code and the
runtime cannot supply an endonym for it. You can also use the data
directly:
using LilyDesignSystem.Blazor.Helpers;
var endonym = Locales.LocaleEndonym("de"); // "Deutsch" ("" when unknown)
var name = Locales.LocaleName("en_US"); // "English (United States)"
var rtl = Locales.RtlLanguageTags.Contains("ar"); // true
Parameters
See spec/index.md §4 for the full table.
Required parameters: Label, Locales. Because the button is
icon-only, Label is its entire accessible name.
Common optional parameters: Value (bindable via @bind-Value),
DefaultValue, StorageKey, DetectFromNavigator, LocaleLabels,
ApplyDir, CssClass, Name.
There is no Placeholder parameter. It existed only to pin a
native <select>'s closed display; there is no <select> any more.
The parameters that attach to specific parts of the markup:
| Parameter | Attaches to |
|---|---|
Label |
aria-label on both the button and the <ul role="listbox">. |
Name |
name on the hidden input that carries Value for form posts. |
CssClass |
Merged into the class list of the root <div>. |
AdditionalAttributes |
Captures unmatched attributes; spread onto the root <div>. |
ChildContent |
Replaces the glyph inside the button; receives { Value, Open, LabelFor }. |
Events
| Event | Payload | When |
|---|---|---|
ValueChanged |
string |
After selection, drives @bind-Value. |
OnChange |
string |
After the select applies a new locale (consumer-form code). |
Accessibility
- The
<button aria-label="…">is the announced trigger, carryingaria-haspopup="listbox",aria-expanded, andaria-controls. - The
<ul role="listbox">takes focus while open and conveys the active option witharia-activedescendant; exactly one<li role="option">isaria-selected="true"(WCAG 4.1.2). - The component implements the full APG listbox keyboard contract itself — see Keyboard.
- Each locale
<li role="option">carrieslang="…"so its name is pronounced in the right language (WCAG 3.1.2, Language of Parts). This is more reliable than a native<select>, whose popup is often drawn by the OS and ignores per-optionlangentirely. - The document root carries
langand (by default)dirso the page satisfies WCAG 3.1.1 (Language of Page) and bidi text/layout inverts correctly for RTL locales. - No colour-only meaning; state rides
aria-selected,data-active, and the resolvedlang/diron the document root.
Three tradeoffs come with an icon button plus a custom listbox. None is a bug; all are worth knowing before you ship:
- The accessible name rests entirely on
aria-label. The button has no visible text and the icon isaria-hidden. An empty, missing, or untranslatedLabelleaves the control unnameable. - A custom listbox has weaker assistive-technology support than a
native
<select>. Correct ARIA is necessary but not sufficient; behaviour varies more on mobile screen readers and in browse modes. (The old font-dependent-rendering tradeoff no longer applies: the icon is a bundled SVG, not a Unicode character — reversed 2026-09-16.)
Full detail, the screen-reader matrix, and the Blazor-specific deviations: docs/accessibility.md.
SSR
The select is SSR-safe — all DOM writes happen inside
OnAfterRenderAsync(firstRender:true). For flicker-free first
paint, resolve the locale on the server (cookie /
Accept-Language) and pass it as Value. See
docs/ssr.md for the recipe.
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. |
LocalePicker.razor |
Razor markup. |
LocalePicker.razor.cs |
C# code-behind (partial class). |
LocalePickerTests.cs |
bUnit + xUnit spec covering every spec §7 item. |
Locales.cs |
Built-in code → English-name map and RTL sets. |
locales.tsv |
Canonical 436-row source for Locales.cs. |
index.md |
This file. |
docs/ |
Deep-dive guides — see Documentation. |
examples/ |
Runnable .razor files — see Examples directory. |
CHANGELOG.md |
Version history. |
Documentation
| Guide | Covers |
|---|---|
| docs/concepts.md | Mental model, lifecycle diagram, why the defaults are what they are. |
| docs/parameters-reference.md | Field-by-field reference for every parameter, method, and static. |
| docs/styling.md | Class hooks, state hooks, positioning CSS, the status region. |
| docs/custom-rendering.md | Replacing the button glyph via ChildContent; driving from your own UI. |
| docs/recipes.md | Task-shaped solutions — endonyms, cookies, culture switching, sorting. |
| docs/troubleshooting.md | Symptom-first fixes for the common failure modes. |
| docs/bcp47.md | Language-tag syntax (RFC 5646), IANA registry, subtag composition. |
| docs/rtl.md | What's auto-detected, what dir="rtl" actually changes, CSS tips. |
| docs/i18n-integration.md | Wiring IStringLocalizer<T>, ResX, Microsoft.Extensions.Localization. |
| docs/ssr.md | Cookie, URL-prefix, Accept-Language, FOUC avoidance for Blazor Web App. |
| docs/accessibility.md | WCAG 2.2 AAA mapping, keyboard contract, screen-reader matrix. |
Examples directory
Each file in examples/ is a complete, runnable .razor page you
can copy into your project.
All ten examples target the current icon-button + APG-listbox API.
Examples that need an affordance other than the component's own button
(a toggle-button group, an NHS-style endonym banner, a <datalist>
combobox) drive it externally via @ref + SetLocaleAsync, because
ChildContent replaces only the glyph inside the button.
| Example | Demonstrates |
|---|---|
| Basic.razor | The default rendering, plus the aria-live status region. |
| CustomRendering.razor | Custom button glyph via ChildContent — inline SVG, state-aware caret. |
| ExternalButtons.razor | Toggle-button group driving SetLocaleAsync via @ref. |
| RtlDemo.razor | Live RTL preview — Arabic, Hebrew, Persian, Urdu, Pashto. |
| NhsStyle.razor | NHS UK-style endonym banner driving SetLocaleAsync via @ref. |
| WithIStringLocalizer.razor | Wiring IStringLocalizer<T>. |
| WithResX.razor | Per-component .resx file driving labels. |
| SsrCookie.razor | Cookie + IHttpContextAccessor for flicker-free SSR. |
| ScopedTarget.razor | Multiple per-region selects, each scoped to its own panel. |
| Combobox.razor | <datalist> type-ahead over the 436 built-in locales, driving SetLocaleAsync. |
License
MIT or Apache-2.0 or GPL-2.0 or GPL-3.0 or BSD-3-Clause. Contact joel@joelparkerhenderson.com for other terms.
Lily™ and Lily Design System™ are trademarks.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.AspNetCore.Components.Web (>= 10.0.11)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on LilyDesignSystem.Blazor.LocalePicker:
| Package | Downloads |
|---|---|
|
LilyDesignSystem.Blazor.PickerBar
Lily Design System Blazor picker bar: composes theme-picker, locale-picker, text-size-picker, and share-picker into one page-header row. Headless, SSR-safe, no CSS. |
GitHub repositories
This package is not used by any popular GitHub repositories.