From c3381a6e97cd67b260ba17915199547d0f0461e8 Mon Sep 17 00:00:00 2001 From: Ujwal Bajaj Date: Thu, 15 Oct 2026 17:20:13 +0530 Subject: [PATCH 1/7] feat: add LocalePicker component (#33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Region picker for settings pages: a searchable, keyboard-accessible dropdown over ISO 3166-1 regions plus a summary card of the locale defaults that follow from the selection — currency, date format, number format, and first day of week — all derived through Intl. The value is a full BCP 47 tag whose region subtag is the picked region; picking a region swaps only that subtag, so the language survives (en-IN -> en-US, fr-JP -> fr-DE). Week info comes from Intl.Locale#getWeekInfo() where available, with a regional fallback table; the currency row uses an ISO 4217 table keyed by region and shows an em dash rather than a guess where none exists. Follows the component conventions: useControllableState, forwarded ref with rest passthrough, shared Locale type from ./types, and useListboxNavigation for the popup keyboard semantics. --- content/docs/components/index.mdx | 8 + .../locale-pickers/locale-picker.mdx | 160 +++++ .../docs/components/locale-pickers/meta.json | 6 + content/docs/components/meta.json | 9 +- src/components/docs/locale-picker-demo.tsx | 21 + src/registry/components/index.ts | 3 + src/registry/components/locale-picker.tsx | 566 ++++++++++++++++++ src/registry/registry.ts | 16 + test/locale-picker.test.tsx | 395 ++++++++++++ 9 files changed, 1183 insertions(+), 1 deletion(-) create mode 100644 content/docs/components/locale-pickers/locale-picker.mdx create mode 100644 content/docs/components/locale-pickers/meta.json create mode 100644 src/components/docs/locale-picker-demo.tsx create mode 100644 src/registry/components/locale-picker.tsx create mode 100644 test/locale-picker.test.tsx diff --git a/content/docs/components/index.mdx b/content/docs/components/index.mdx index cbcb5aa..3eaea3e 100644 --- a/content/docs/components/index.mdx +++ b/content/docs/components/index.mdx @@ -12,6 +12,14 @@ Professional language switcher components designed for seamless locale selection | **[Language Switcher](/docs/components/language-switchers/language-switcher)** | Full-featured dropdown with search, flags, and RTL support | | **[Language Switcher Pill](/docs/components/language-switchers/language-switcher-pill)** | Animated pill-style selector with keyboard navigation and RTL support | +### Locale Pickers + +Choose a region and read back the locale defaults that come with it — for settings pages and onboarding flows. + +| Component | Description | +| ------------------------------------------------------------------ | --------------------------------------------------------------------------------- | +| **[Locale Picker](/docs/components/locale-pickers/locale-picker)** | Region picker with derived currency, date, number, and week defaults, plus search | + ### Phone Input International phone number input with country selector and dial code search. diff --git a/content/docs/components/locale-pickers/locale-picker.mdx b/content/docs/components/locale-pickers/locale-picker.mdx new file mode 100644 index 0000000..4649e0a --- /dev/null +++ b/content/docs/components/locale-pickers/locale-picker.mdx @@ -0,0 +1,160 @@ +--- +title: Locale Picker +description: Region picker with locale-derived defaults — currency, date and number formats, and first day of week. Searchable, keyboard accessible, and RTL-aware. +--- + +import { PreviewComponents } from "@/components/docs/preview-components"; +import { LocalePickerDemo } from "@/components/docs/locale-picker-demo"; +import { Tabs, Tab } from "fumadocs-ui/components/tabs"; + + + + + +## Installation + + + + + ```bash npx shadcn@latest add @elements/locale-picker ``` + ```bash pnpm dlx shadcn@latest add @elements/locale-picker ``` + ```bash yarn dlx shadcn@latest add @elements/locale-picker ``` + ```bash bunx shadcn@latest add @elements/locale-picker ``` + + + + + ```bash npx @babelize/elements add locale-picker ``` + ```bash pnpm dlx @babelize/elements add locale-picker ``` + ```bash yarn dlx @babelize/elements add locale-picker ``` + ```bash bunx @babelize/elements add locale-picker ``` + + + + +## Usage + +```tsx +"use client"; + +import { useState } from "react"; +import { LocalePicker } from "@/components/ui/locale-picker"; + +export function SettingsRegion() { + const [locale, setLocale] = useState("en-IN"); + + return ( + + ); +} +``` + +## Props + +| Prop | Type | Required | Default | Description | +| --------------- | -------------------------- | -------- | ------------------- | -------------------------------------------------------------- | +| `regions` | `string[]` | Yes | — | Regions to choose from, ISO 3166-1 alpha-2 codes | +| `value` | `string` | No | — | Selected locale (BCP 47) — pass this to control the component | +| `defaultValue` | `string` | No | `en-` | Initial selected locale when uncontrolled | +| `onValueChange` | `(locale: string) => void` | No | — | Callback when the region changes — receives the new BCP 47 tag | +| `label` | `"native" \| "english"` | No | `"english"` | Region names in English, or localized to the selected locale | +| `showFlags` | `boolean` | No | `true` | Show the region's flag emoji next to its name | +| `maxHeight` | `number \| string` | No | `"15rem"` | Max height of the scrolling option list, before it scrolls | +| `className` | `string` | No | — | Additional CSS classes | + +The selected value is a full BCP 47 tag: its **region** subtag is the picked +region, and its **language** subtag survives region changes. Picking Germany +from `en-IN` yields `en-DE`; picking it from `fr-JP` yields `fr-DE`. + +## Derived defaults + +The summary card derives everything from the selected locale through `Intl` — +no data bundled beyond an ISO 4217 fallback table for the currency code: + +| Row | Source | +| -------------- | -------------------------------------------------------------------------------- | +| Currency | Region → ISO 4217 code, previewed with `Intl.NumberFormat` (`style: "currency"`) | +| Date format | `Intl.DateTimeFormat` (`dateStyle: "medium"`) on a fixed sample date | +| Number format | `Intl.NumberFormat` on a fixed sample amount | +| Week starts on | `Intl.Locale#getWeekInfo()` where available, with a regional fallback | + +Regions without a currency mapping show an em dash rather than a guess. + +## Keyboard support + +The popup follows the WAI-ARIA combobox pattern: focus stays in the search +field, which is exposed as a `role="combobox"`, and the highlighted option is +announced through `aria-activedescendant`. + +| Key | Action | +| -------------------------------- | ------------------------------------------------------------- | +| `Enter`, `Space`, or click | Open the popup from the trigger | +| `ArrowDown` / `ArrowUp` (closed) | Open with the first / last region highlighted | +| `ArrowDown` / `ArrowUp` | Move the highlight, wrapping at both ends | +| `Home` / `End` | Highlight the first / last region | +| `Enter` | Select the highlighted region and return focus to the trigger | +| `Escape` | Close the popup and return focus to the trigger | +| Typing | Filter by name or code; the highlight restarts at the top | + +## Features + +- **Derived defaults** — currency, date format, number format, and first day of week, resolved live through `Intl` +- **Dropdown with search** — filter regions by English name, localized name, or code +- **RTL-aware** — flips direction for RTL locales (ar, he, fa, ur) via `dir` on the root +- **Keyboard accessible** — arrow keys, Home / End, Enter, and Escape navigation +- **ARIA labels** — `role="combobox"` search field with `aria-activedescendant`, plus `aria-expanded`, `aria-haspopup`, and `aria-selected` +- **Dark mode** — built with Tailwind CSS, dark-mode compatible +- **Controlled or uncontrolled** — `value` / `defaultValue` / `onValueChange` + +## Examples + +### Controlled + +```tsx +"use client"; + +import { LocalePicker } from "@/components/ui/locale-picker"; + +export function SettingsRegion({ locale, setLocale }) { + return ( + + ); +} +``` + +### Native region names + +```tsx + +``` + +With `de-DE` selected, the trigger reads "Deutschland" and the options read +"Vereinigte Staaten", "Vereinigtes Königreich", and so on. + +## Dependencies + +This component uses: + +- `clsx` and `tailwind-merge` (bundled as dependencies) + +Peer dependencies: + +- `react` >= 18 +- `react-dom` >= 18 +- `tailwindcss` >= 3 + +## Credits + +Created by [ujwalbajaj](https://github.com/ujwalbajaj). diff --git a/content/docs/components/locale-pickers/meta.json b/content/docs/components/locale-pickers/meta.json new file mode 100644 index 0000000..3704989 --- /dev/null +++ b/content/docs/components/locale-pickers/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Locale Pickers", + "icon": "MapPin", + "pages": ["locale-picker"], + "collapsible": false +} diff --git a/content/docs/components/meta.json b/content/docs/components/meta.json index 7669044..f54e4b5 100644 --- a/content/docs/components/meta.json +++ b/content/docs/components/meta.json @@ -1,5 +1,12 @@ { "title": "Components", - "pages": ["language-switchers", "navigation", "inputs", "translation", "formatters"], + "pages": [ + "language-switchers", + "locale-pickers", + "navigation", + "inputs", + "translation", + "formatters" + ], "collapsible": false } diff --git a/src/components/docs/locale-picker-demo.tsx b/src/components/docs/locale-picker-demo.tsx new file mode 100644 index 0000000..23458f8 --- /dev/null +++ b/src/components/docs/locale-picker-demo.tsx @@ -0,0 +1,21 @@ +"use client"; + +import { useState } from "react"; +import { LocalePicker } from "@/registry/components/locale-picker"; + +export function LocalePickerDemo() { + const [locale, setLocale] = useState("en-IN"); + + return ( +
+ +

+ Selected locale: {locale} +

+
+ ); +} diff --git a/src/registry/components/index.ts b/src/registry/components/index.ts index a294292..0fe256c 100644 --- a/src/registry/components/index.ts +++ b/src/registry/components/index.ts @@ -22,3 +22,6 @@ export type { LocaleNumberProps } from "./locale-number"; export { RelativeTime } from "./relative-time"; export type { RelativeTimeProps } from "./relative-time"; + +export { LocalePicker } from "./locale-picker"; +export type { LocalePickerProps } from "./locale-picker"; diff --git a/src/registry/components/locale-picker.tsx b/src/registry/components/locale-picker.tsx new file mode 100644 index 0000000..d85a6e2 --- /dev/null +++ b/src/registry/components/locale-picker.tsx @@ -0,0 +1,566 @@ +"use client"; + +import * as React from "react"; +import { cn } from "@/lib/utils"; +import { useControllableState } from "@/lib/use-controllable-state"; +import { useListboxNavigation } from "@/lib/use-listbox-navigation"; +import type { Locale } from "./types"; +import { isRtl } from "./locale-data"; + +export type { Locale }; + +/** Sample amount behind the currency and number previews. */ +const SAMPLE_AMOUNT = 1234567.89; +/** + * Sample date behind the date preview. Built in local time, so every timezone + * renders 15 January 2026 and the server and the client agree byte for byte. + */ +const SAMPLE_DATE = new Date(2026, 0, 15); +/** Locale used for formatting when the selected value is not a well-formed tag. */ +const FALLBACK_LOCALE = "en"; + +/** + * ISO 4217 code for a region. Regions outside the table — territories that + * share a currency, the odd unrecognized code — show "—" rather than a guess. + */ +const REGION_CURRENCY: Record = { + AE: "AED", + AF: "AFN", + AL: "ALL", + AM: "AMD", + AR: "ARS", + AT: "EUR", + AU: "AUD", + AZ: "AZN", + BA: "BAM", + BD: "BDT", + BE: "EUR", + BG: "BGN", + BH: "BHD", + BR: "BRL", + BY: "BYN", + BZ: "BZD", + CA: "CAD", + CH: "CHF", + CL: "CLP", + CN: "CNY", + CO: "COP", + CR: "CRC", + CY: "EUR", + CZ: "CZK", + DE: "EUR", + DK: "DKK", + DO: "DOP", + DZ: "DZD", + EC: "USD", + EE: "EUR", + EG: "EGP", + ES: "EUR", + ET: "ETB", + FI: "EUR", + FR: "EUR", + GB: "GBP", + GE: "GEL", + GH: "GHS", + GR: "EUR", + GT: "GTQ", + HK: "HKD", + HR: "EUR", + HU: "HUF", + ID: "IDR", + IE: "EUR", + IL: "ILS", + IN: "INR", + IS: "ISK", + IT: "EUR", + JO: "JOD", + JP: "JPY", + KE: "KES", + KR: "KRW", + KW: "KWD", + KZ: "KZT", + LB: "LBP", + LK: "LKR", + LT: "EUR", + LU: "EUR", + LV: "EUR", + MA: "MAD", + MD: "MDL", + MK: "MKD", + MN: "MNT", + MO: "MOP", + MT: "EUR", + MX: "MXN", + MY: "MYR", + MV: "MVR", + NG: "NGN", + NO: "NOK", + NP: "NPR", + NZ: "NZD", + OM: "OMR", + PH: "PHP", + PK: "PKR", + PL: "PLN", + PR: "USD", + PT: "EUR", + QA: "QAR", + RO: "RON", + RS: "RSD", + RU: "RUB", + SA: "SAR", + SE: "SEK", + SG: "SGD", + SI: "EUR", + SK: "EUR", + TH: "THB", + TN: "TND", + TR: "TRY", + TW: "TWD", + TZ: "TZS", + UA: "UAH", + UG: "UGX", + US: "USD", + UY: "UYU", + UZ: "UZS", + VE: "VES", + VN: "VND", + ZA: "ZAR", +}; + +/** + * First day of week (1 = Monday … 7 = Sunday) for regions whose week starts + * somewhere other than Monday, consulted only on engines without + * `Intl.Locale.prototype.getWeekInfo`. + */ +const REGION_FIRST_DAY: Record = { + AF: 6, + BH: 6, + EG: 6, + IL: 7, + IN: 7, + IQ: 6, + IR: 6, + JO: 6, + JP: 7, + KW: 6, + LY: 6, + OM: 6, + QA: 6, + SA: 6, + SD: 6, + SY: 6, + US: 7, + YE: 6, +}; + +/** English weekday names, used only when `Intl.DateTimeFormat` cannot help. */ +const WEEKDAY_NAMES = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +]; + +interface WeekInfo { + /** 1 = Monday … 7 = Sunday, per the ECMA-402 `WeekInfo` record. */ + firstDay: number; + weekend?: number[]; + minimalDays?: number; +} + +/** `getWeekInfo` is not in the TS lib yet, and Safari shipped it as `weekInfo`. */ +type LocaleWithWeekInfo = { + getWeekInfo?(): WeekInfo; + readonly weekInfo?: WeekInfo; +}; + +/** ISO 3166-1 alpha-2 → regional-indicator pair, or the globe for anything else. */ +function regionFlag(region: string): string { + if (!/^[A-Za-z]{2}$/.test(region)) return "\u{1F310}"; + return String.fromCodePoint( + region.toUpperCase().charCodeAt(0) - 65 + 0x1f1e6, + region.toUpperCase().charCodeAt(1) - 65 + 0x1f1e6, + ); +} + +/** Canonicalizes a BCP 47 tag, falling back to `"en"` for malformed input. */ +function canonicalize(locale: string): string { + try { + return Intl.getCanonicalLocales(locale)[0] ?? FALLBACK_LOCALE; + } catch { + return FALLBACK_LOCALE; + } +} + +/** The tag's region subtag, or undefined when it has none. */ +function regionOf(locale: string): string | undefined { + try { + const parsed = new Intl.Locale(locale); + if (parsed.region) return parsed.region.toUpperCase(); + } catch { + // Fall through to the structural guess below. + } + // In a well-formed tag only a region subtag is exactly two letters, so a + // successful parse that found none still cannot be second-guessed here. + const parts = locale.split("-"); + const last = parts[parts.length - 1]; + return parts.length > 1 && /^[A-Za-z]{2}$/.test(last) ? last.toUpperCase() : undefined; +} + +/** The same tag with its region replaced — `en-IN` + US → `en-US`. */ +function withRegion(locale: string, region: string): string { + try { + return new Intl.Locale(locale, { region: region.toUpperCase() }).toString(); + } catch { + return `${locale}-${region.toUpperCase()}`; + } +} + +/** Localized display name for a region, or undefined when there is no region. */ +function regionDisplayName( + region: string | undefined, + locale: string, + mode: "native" | "english", +): string | undefined { + if (!region) return undefined; + const locales = mode === "english" ? ["en"] : [locale]; + try { + return new Intl.DisplayNames(locales, { type: "region" }).of(region) ?? region; + } catch { + return region; + } +} + +/** First day of week from `Intl`, else the table, else Monday. */ +function firstDayOfWeek(locale: string, region: string | undefined): number { + try { + const tag = new Intl.Locale(locale) as Intl.Locale & LocaleWithWeekInfo; + const info = tag.getWeekInfo?.() ?? tag.weekInfo; + if (info && Number.isInteger(info.firstDay)) return info.firstDay; + } catch { + // Fall through to the table below. + } + if (region) return REGION_FIRST_DAY[region] ?? 1; + return 1; +} + +/** Localized name of the weekday the week starts on. */ +function firstWeekdayName(locale: string, firstDay: number): string { + // `firstDay` is 1 = Monday … 7 = Sunday; `Date#getDay` is 0 = Sunday … 6 = Saturday. + const offset = firstDay === 7 ? 0 : firstDay; + // 4 January 2026 is a Sunday — a fixed anchor keeps SSR and client identical. + const date = new Date(2026, 0, 4 + offset); + try { + return new Intl.DateTimeFormat(locale, { weekday: "long" }).format(date); + } catch { + return WEEKDAY_NAMES[offset]; + } +} + +interface LocalePreferences { + /** ISO 4217 code for the region, when the table has one. */ + currency?: string; + /** Sample amount as currency — empty when the currency is unknown. */ + currencyPreview: string; + /** Sample date in the locale's medium date style. */ + datePreview: string; + /** Sample amount in the locale's default number style. */ + numberPreview: string; + /** Localized name of the weekday the week starts on. */ + firstWeekday: string; +} + +/** Everything the summary card shows, derived through `Intl` from the locale. */ +function derivePreferences(locale: string, region: string | undefined): LocalePreferences { + const currency = region ? REGION_CURRENCY[region] : undefined; + return { + currency, + currencyPreview: currency + ? new Intl.NumberFormat(locale, { style: "currency", currency }).format(SAMPLE_AMOUNT) + : "", + datePreview: new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(SAMPLE_DATE), + numberPreview: new Intl.NumberFormat(locale).format(SAMPLE_AMOUNT), + firstWeekday: firstWeekdayName(locale, firstDayOfWeek(locale, region)), + }; +} + +export interface LocalePickerProps extends Omit< + React.ComponentPropsWithoutRef<"div">, + "defaultValue" | "onChange" +> { + /** Regions to choose from, as ISO 3166-1 alpha-2 codes (e.g. `["IN", "US", "DE"]`). */ + regions: string[]; + /** + * Selected locale as a BCP 47 tag (e.g. `"en-IN"`). Its region subtag is the + * selected region; its language subtag survives region changes. Provide this + * to control the component. + */ + value?: string; + /** + * Initial selected locale when uncontrolled. Falls back to `en-` + * when omitted. + */ + defaultValue?: string; + /** Callback when the region changes — receives the full new BCP 47 tag. */ + onValueChange?: (locale: string) => void; + /** + * Region display names in English (default), or localized to the selected + * locale with `"native"`. + */ + label?: "native" | "english"; + /** Show the region's flag emoji next to its name (default: true). */ + showFlags?: boolean; + /** Max height of the scrolling option list, before it scrolls (default: "15rem"). */ + maxHeight?: number | string; +} + +/** A region rendered as the shared `Locale` shape, plus search plumbing. */ +interface RegionOption extends Locale { + /** The region this entry stands for. */ + region: string; + /** Pre-lowered text the search field matches against. */ + search: string; +} + +export const LocalePicker = React.forwardRef( + function LocalePicker( + { + regions, + value, + defaultValue, + onValueChange, + label: labelMode = "english", + showFlags = true, + maxHeight = "15rem", + className, + onKeyDown, + ...rest + }, + forwardedRef, + ) { + const [locale, setLocale] = useControllableState( + value, + defaultValue ?? (regions[0] ? withRegion(FALLBACK_LOCALE, regions[0]) : FALLBACK_LOCALE), + onValueChange, + ); + const [open, setOpen] = React.useState(false); + const [search, setSearch] = React.useState(""); + const ref = React.useRef(null); + const inputRef = React.useRef(null); + const triggerRef = React.useRef(null); + + React.useImperativeHandle(forwardedRef, () => ref.current as HTMLDivElement); + + const active = canonicalize(locale); + const region = regionOf(active); + const activeName = regionDisplayName(region, active, labelMode) ?? active; + + const options: RegionOption[] = regions.map((entry) => { + const upper = entry.toUpperCase(); + const code = withRegion(active, upper); + const label = regionDisplayName(upper, active, labelMode) ?? upper; + const english = regionDisplayName(upper, active, "english") ?? upper; + return { + code, + region: upper, + label, + flag: regionFlag(upper), + rtl: isRtl(code), + search: `${upper} ${label} ${english} ${code}`.toLowerCase(), + }; + }); + + const query = search.trim().toLowerCase(); + const filtered = query ? options.filter((option) => option.search.includes(query)) : options; + + const nav = useListboxNavigation({ + count: filtered.length, + open, + // Land on the current selection when the popup opens. + initialIndex: filtered.findIndex((option) => option.region === region), + onClose: () => { + setOpen(false); + setSearch(""); + }, + onOpen: () => setOpen(true), + onSelect: (index) => { + const picked = filtered[index]; + if (picked) setLocale(picked.code); + }, + triggerRef: () => triggerRef.current, + }); + + const preferences = derivePreferences(active, region); + const dir = isRtl(active) ? "rtl" : "ltr"; + + React.useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (ref.current && !ref.current.contains(event.target as Node)) { + setOpen(false); + setSearch(""); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + React.useEffect(() => { + if (open) inputRef.current?.focus(); + }, [open]); + + return ( +
{ + nav.onKeyDown(event); + onKeyDown?.(event); + }} + {...rest} + > + + + {open && ( +
+
+ { + setSearch(event.target.value); + nav.resetActive(); + }} + {...nav.comboboxProps} + className="w-full rounded-lg bg-zinc-100 px-3 py-1.5 text-sm text-zinc-900 outline-none placeholder:text-zinc-400 focus:ring-1 focus:ring-emerald-500/50 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder:text-zinc-500" + /> +
+ {/* The listbox owns the options only — the search field above sits + outside it, per ARIA. It scrolls on its own: the page never does. */} +
+ {filtered.map((option, index) => { + const isCurrent = option.region === region; + const isActive = index === nav.activeIndex; + return ( + + ); + })} + {filtered.length === 0 && ( +
No results
+ )} +
+
+ )} + +
+
+
Currency
+
+ {preferences.currency ? ( + <> + + {preferences.currency} + + {preferences.currencyPreview} + + ) : ( + — + )} +
+
+
+
Date format
+
+ {preferences.datePreview} +
+
+
+
Number format
+
+ {preferences.numberPreview} +
+
+
+
Week starts on
+
+ {preferences.firstWeekday} +
+
+
+
+ ); + }, +); diff --git a/src/registry/registry.ts b/src/registry/registry.ts index 730940b..5a2a26f 100644 --- a/src/registry/registry.ts +++ b/src/registry/registry.ts @@ -151,6 +151,22 @@ const ITEMS: ItemDef[] = [ source: "registry/components/relative-time", filePath: "ui/relative-time.tsx", }, + { + name: "locale-picker", + title: "Locale Picker", + description: + "Region picker with locale-derived defaults: currency, date and number formats, and first day of week.", + type: "registry:ui", + source: "registry/components/locale-picker", + filePath: "ui/locale-picker.tsx", + dependencies: ["clsx", "tailwind-merge"], + registryDependencies: [ + "locale-types", + "locale-data", + "use-controllable-state", + "use-listbox-navigation", + ], + }, ]; /** diff --git a/test/locale-picker.test.tsx b/test/locale-picker.test.tsx new file mode 100644 index 0000000..b7bd506 --- /dev/null +++ b/test/locale-picker.test.tsx @@ -0,0 +1,395 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import * as React from "react"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { renderToString } from "react-dom/server"; +import { LocalePicker } from "@/registry/components"; + +const regions = ["IN", "US", "GB", "DE", "JP"]; + +/** The samples the component's previews are built from. */ +const SAMPLE_AMOUNT = 1234567.89; +const SAMPLE_DATE = new Date(2026, 0, 15); + +/** Text of the summary-card value paired with a term. */ +function summaryValue(term: string): string { + return screen.getByText(term).nextElementSibling?.textContent ?? ""; +} + +/** Localized name of the weekday the given locale's week starts on. */ +function firstWeekday(locale: string): string { + const tag = new Intl.Locale(locale) as Intl.Locale & { + getWeekInfo?(): { firstDay: number }; + weekInfo?: { firstDay: number }; + }; + const firstDay = tag.getWeekInfo?.().firstDay ?? tag.weekInfo?.firstDay ?? 1; + const offset = firstDay === 7 ? 0 : firstDay; + return new Intl.DateTimeFormat(locale, { weekday: "long" }).format(new Date(2026, 0, 4 + offset)); +} + +const trigger = () => screen.getByRole("button", { name: /Current region/ }); +const searchBox = () => screen.getByRole("combobox", { name: "Search regions" }); + +describe("LocalePicker", () => { + it("renders the first region when no value is given", () => { + render(); + expect(trigger()).toHaveAccessibleName("Current region: India"); + }); + + it("shows the derived preferences of the selected locale", () => { + render(); + + expect(summaryValue("Currency")).toContain("INR"); + expect(summaryValue("Currency")).toContain( + new Intl.NumberFormat("en-IN", { style: "currency", currency: "INR" }).format(SAMPLE_AMOUNT), + ); + expect(summaryValue("Date format")).toBe( + new Intl.DateTimeFormat("en-IN", { dateStyle: "medium" }).format(SAMPLE_DATE), + ); + expect(summaryValue("Number format")).toBe( + new Intl.NumberFormat("en-IN").format(SAMPLE_AMOUNT), + ); + expect(summaryValue("Week starts on")).toBe(firstWeekday("en-IN")); + }); + + it("shows an em dash for regions without a currency mapping", () => { + render(); + expect(summaryValue("Currency")).toBe("—"); + }); + + it("honours defaultValue and exposes listbox semantics", async () => { + render(); + expect(trigger()).toHaveAccessibleName("Current region: United Kingdom"); + expect(trigger()).toHaveAttribute("aria-haspopup", "listbox"); + expect(trigger()).toHaveAttribute("aria-expanded", "false"); + + await userEvent.click(trigger()); + expect(trigger()).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByRole("listbox", { name: "Select region" })).toBeInTheDocument(); + }); + + it("shows the region flag by default and hides it on request", async () => { + const { rerender } = render(); + expect(trigger().textContent).toContain("\u{1F1EE}\u{1F1F3}"); + + rerender(); + expect(trigger().textContent).not.toContain("\u{1F1EE}\u{1F1F3}"); + await userEvent.click(trigger()); + expect(screen.getByRole("option", { name: /India/ }).textContent).not.toContain( + "\u{1F1EE}\u{1F1F3}", + ); + }); + + it("tags each option with the locale tag it selects", async () => { + render(); + await userEvent.click(trigger()); + + expect(screen.getByRole("option", { name: /United States/ })).toHaveAttribute( + "data-locale", + "en-US", + ); + expect(screen.getByRole("option", { name: /Japan/ })).toHaveAttribute("data-locale", "en-JP"); + }); + + it("calls onValueChange exactly once per selection with the new locale tag", async () => { + const onValueChange = vi.fn(); + render(); + + await userEvent.click(trigger()); + await userEvent.click(screen.getByRole("option", { name: /Germany/ })); + + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange).toHaveBeenCalledWith("en-DE"); + }); + + it("keeps the language subtag when the region changes", async () => { + const onValueChange = vi.fn(); + render(); + + await userEvent.click(trigger()); + await userEvent.click(screen.getByRole("option", { name: /Germany/ })); + + expect(onValueChange).toHaveBeenCalledWith("fr-DE"); + }); + + it("follows an externally changed value prop (controlled mode)", async () => { + function Harness() { + const [value, setValue] = React.useState("en-IN"); + return ( + <> + + + + ); + } + render(); + expect(trigger()).toHaveAccessibleName("Current region: India"); + expect(summaryValue("Currency")).toContain("INR"); + + await userEvent.click(screen.getByRole("button", { name: "set de-DE" })); + + expect(trigger()).toHaveAccessibleName("Current region: Germany"); + expect(summaryValue("Currency")).toContain("EUR"); + expect(summaryValue("Date format")).toBe( + new Intl.DateTimeFormat("de-DE", { dateStyle: "medium" }).format(SAMPLE_DATE), + ); + }); + + it("forwards a ref to the root element", () => { + const ref = React.createRef(); + render(); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); + + it("passes unknown props through to the root element", () => { + render(); + expect(screen.getByTestId("picker")).toBeInTheDocument(); + }); + + it("gives the search box an accessible name", async () => { + render(); + await userEvent.click(trigger()); + expect(searchBox()).toBeInTheDocument(); + }); + + it("marks the root rtl for an RTL locale and ltr otherwise", () => { + const { container, rerender } = render( + , + ); + expect(container.firstElementChild).toHaveAttribute("dir", "rtl"); + + rerender(); + expect(container.firstElementChild).toHaveAttribute("dir", "ltr"); + }); + + it("lets a caller override the direction", () => { + const { container } = render( + , + ); + expect(container.firstElementChild).toHaveAttribute("dir", "ltr"); + }); + + it("displays region names in English by default and in the selected locale on request", async () => { + const { rerender } = render(); + expect(trigger()).toHaveAccessibleName("Current region: Germany"); + + rerender(); + expect(trigger()).toHaveAccessibleName("Current region: Deutschland"); + + await userEvent.click(trigger()); + expect(screen.getByRole("option", { name: /Vereinigte Staaten/ })).toBeInTheDocument(); + }); + + it("filters by English name and by code", async () => { + render(); + await userEvent.click(trigger()); + + await userEvent.type(searchBox(), "ger"); + expect(screen.getAllByRole("option")).toHaveLength(1); + expect(screen.getByRole("option", { name: /Germany/ })).toBeInTheDocument(); + + await userEvent.clear(searchBox()); + await userEvent.type(searchBox(), "jp"); + expect(screen.getAllByRole("option")).toHaveLength(1); + expect(screen.getByRole("option", { name: /Japan/ })).toBeInTheDocument(); + }); + + it("filters by the localized name when label is native", async () => { + render(); + await userEvent.click(trigger()); + + await userEvent.type(searchBox(), "deutsch"); + expect(screen.getAllByRole("option")).toHaveLength(1); + expect(screen.getByRole("option", { name: /Deutschland/ })).toBeInTheDocument(); + }); + + it("renders deterministic markup for the server", () => { + const html = renderToString(); + + expect(html).toContain( + new Intl.DateTimeFormat("en-IN", { dateStyle: "medium" }).format(SAMPLE_DATE), + ); + expect(html).toContain("Week starts on"); + }); +}); + +describe("LocalePicker keyboard navigation", () => { + const activeOptionId = () => searchBox().getAttribute("aria-activedescendant"); + + it("opens on ArrowDown from the trigger with the first option active", async () => { + render(); + trigger().focus(); + + await userEvent.keyboard("{ArrowDown}"); + + expect(screen.getByRole("listbox")).toBeInTheDocument(); + expect(document.activeElement).toBe(searchBox()); + expect(searchBox()).toHaveAttribute("aria-expanded", "true"); + expect(activeOptionId()).toBe(screen.getByRole("option", { name: /India/ }).id); + }); + + it("lands on the current selection when opened with the mouse", async () => { + render(); + await userEvent.click(trigger()); + + expect(activeOptionId()).toBe(screen.getByRole("option", { name: /Germany/ }).id); + }); + + it("moves the active option with ArrowDown and ArrowUp, wrapping at both ends", async () => { + render(); + await userEvent.click(trigger()); + expect(activeOptionId()).toBe(screen.getByRole("option", { name: /India/ }).id); + + await userEvent.keyboard("{ArrowDown}"); + expect(activeOptionId()).toBe(screen.getByRole("option", { name: /United States/ }).id); + + await userEvent.keyboard("{ArrowUp}{ArrowUp}"); + expect(activeOptionId()).toBe(screen.getByRole("option", { name: /Japan/ }).id); + + await userEvent.keyboard("{ArrowDown}"); + expect(activeOptionId()).toBe(screen.getByRole("option", { name: /India/ }).id); + }); + + it("jumps to the first and last option with Home and End", async () => { + render(); + await userEvent.click(trigger()); + + await userEvent.keyboard("{End}"); + expect(activeOptionId()).toBe(screen.getByRole("option", { name: /Japan/ }).id); + + await userEvent.keyboard("{Home}"); + expect(activeOptionId()).toBe(screen.getByRole("option", { name: /India/ }).id); + }); + + it("selects the active option with Enter, closes, and returns focus to the trigger", async () => { + const onValueChange = vi.fn(); + render(); + await userEvent.click(trigger()); + + await userEvent.keyboard("{ArrowDown}{Enter}"); + + expect(onValueChange).toHaveBeenCalledTimes(1); + expect(onValueChange).toHaveBeenCalledWith("en-US"); + expect(screen.queryByRole("listbox")).toBeNull(); + expect(document.activeElement).toBe(trigger()); + }); + + it("closes on Escape and returns focus to the trigger", async () => { + render(); + await userEvent.click(trigger()); + + await userEvent.keyboard("{Escape}"); + + expect(screen.queryByRole("listbox")).toBeNull(); + expect(document.activeElement).toBe(trigger()); + }); + + it("ignores Enter while an IME composition is in progress", async () => { + const onValueChange = vi.fn(); + render(); + await userEvent.click(trigger()); + + // The Enter that commits a composition is not a request to select. + fireEvent.keyDown(searchBox(), { key: "Enter", isComposing: true }); + // Legacy browsers never set `isComposing` and only report the 229 keyCode. + fireEvent.keyDown(searchBox(), { key: "Enter", keyCode: 229 }); + + expect(onValueChange).not.toHaveBeenCalled(); + expect(screen.getByRole("listbox")).toBeInTheDocument(); + + // A real Enter still selects. + await userEvent.keyboard("{ArrowDown}"); + fireEvent.keyDown(searchBox(), { key: "Enter" }); + expect(onValueChange).toHaveBeenCalledWith("en-US"); + }); + + it("restarts the highlight at the top when the filter changes", async () => { + render(); + await userEvent.click(trigger()); + await userEvent.keyboard("{ArrowDown}"); // United States + + await userEvent.type(searchBox(), "ger"); + + expect(screen.getAllByRole("option")).toHaveLength(1); + expect(activeOptionId()).toBe(screen.getByRole("option", { name: /Germany/ }).id); + }); + + it("keeps the navigation keys harmless when the filter matches nothing", async () => { + const onValueChange = vi.fn(); + render(); + await userEvent.click(trigger()); + await userEvent.type(searchBox(), "zzz"); + + expect(screen.queryAllByRole("option")).toHaveLength(0); + await userEvent.keyboard("{ArrowDown}{ArrowUp}{Home}{End}{Enter}"); + + expect(onValueChange).not.toHaveBeenCalled(); + }); +}); + +describe("LocalePicker first day of week", () => { + it("names the weekday in the selected locale", async () => { + render(); + // Germany's week starts on Monday, named in German. + expect(summaryValue("Week starts on")).toBe("Montag"); + + await userEvent.click(trigger()); + await userEvent.click(screen.getByRole("option", { name: /United States/ })); + // The region drives the week start (Sunday in the US); the surviving + // `de` language subtag names the weekday in German. + expect(summaryValue("Week starts on")).toBe("Sonntag"); + }); +}); + +/** + * Every entry in `REGION_CURRENCY` must be a real ISO 4217 code attached to a + * real ISO 3166-1 region. The table is read from the source so entries added + * later are covered automatically — an unknown code renders as itself through + * `Intl.DisplayNames`, which is exactly the failure a hand-picked typo would + * hide. + */ +const pickerSource = readFileSync( + resolve(process.cwd(), "src/registry/components/locale-picker.tsx"), + "utf8", +); +const currencyTableBlock = pickerSource.slice( + pickerSource.indexOf("const REGION_CURRENCY"), + pickerSource.indexOf("};", pickerSource.indexOf("const REGION_CURRENCY")), +); +const CURRENCY_TABLE = Object.fromEntries( + [...currencyTableBlock.matchAll(/^ {2}([A-Z]{2}): "([A-Z]{3})",?$/gm)].map((match) => [ + match[1], + match[2], + ]), +); + +describe("LocalePicker currency table", () => { + const regionNames = new Intl.DisplayNames(["en"], { type: "region" }); + const currencyNames = new Intl.DisplayNames(["en"], { type: "currency" }); + + it("collects the whole table from the source", () => { + expect(Object.keys(CURRENCY_TABLE).length).toBeGreaterThan(80); + expect(CURRENCY_TABLE).toMatchObject({ IN: "INR", US: "USD", DE: "EUR", JP: "JPY", GB: "GBP" }); + }); + + it("maps every region to a valid ISO 3166-1 region", () => { + for (const region of Object.keys(CURRENCY_TABLE)) { + const name = regionNames.of(region); + // Intl.DisplayNames falls back to the raw code for unknown regions. + expect(name, `${region} is not a valid region`).toBeTruthy(); + expect(name, `${region} is not a valid region`).not.toBe(region); + } + }); + + it("maps every region to a valid ISO 4217 currency", () => { + for (const [region, code] of Object.entries(CURRENCY_TABLE)) { + const name = currencyNames.of(code); + expect(name, `${region} maps to invalid currency ${code}`).toBeTruthy(); + expect(name, `${region} maps to invalid currency ${code}`).not.toBe(code); + } + }); +}); From 0f1ab684d8bbd43d329625f721592044920020f8 Mon Sep 17 00:00:00 2001 From: Ujwal Bajaj Date: Sat, 17 Oct 2026 18:02:58 +0530 Subject: [PATCH 2/7] fix(locale-picker): map BG to EUR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bulgaria adopted the euro on 1 January 2026, so the region's currency is EUR, not BGN. The ISO 4217 validity test passes for both codes — it cannot catch a valid-but-obsolete currency — so pin BG: "EUR" in the table expectation instead. --- src/registry/components/locale-picker.tsx | 2 +- test/locale-picker.test.tsx | 10 +++++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/registry/components/locale-picker.tsx b/src/registry/components/locale-picker.tsx index d85a6e2..1b7c943 100644 --- a/src/registry/components/locale-picker.tsx +++ b/src/registry/components/locale-picker.tsx @@ -35,7 +35,7 @@ const REGION_CURRENCY: Record = { BA: "BAM", BD: "BDT", BE: "EUR", - BG: "BGN", + BG: "EUR", BH: "BHD", BR: "BRL", BY: "BYN", diff --git a/test/locale-picker.test.tsx b/test/locale-picker.test.tsx index b7bd506..75dc431 100644 --- a/test/locale-picker.test.tsx +++ b/test/locale-picker.test.tsx @@ -373,7 +373,15 @@ describe("LocalePicker currency table", () => { it("collects the whole table from the source", () => { expect(Object.keys(CURRENCY_TABLE).length).toBeGreaterThan(80); - expect(CURRENCY_TABLE).toMatchObject({ IN: "INR", US: "USD", DE: "EUR", JP: "JPY", GB: "GBP" }); + expect(CURRENCY_TABLE).toMatchObject({ + IN: "INR", + US: "USD", + DE: "EUR", + JP: "JPY", + GB: "GBP", + // Bulgaria adopted the euro on 1 January 2026 — was BGN. + BG: "EUR", + }); }); it("maps every region to a valid ISO 3166-1 region", () => { From bb11af8b2431aad4f44e1484590526bb523082c6 Mon Sep 17 00:00:00 2001 From: Ujwal Bajaj Date: Sun, 18 Oct 2026 13:26:49 +0530 Subject: [PATCH 3/7] fix(locale-picker): complete first-day table and refine option search Complete the REGION_FIRST_DAY fallback to mirror current CLDR for every currency-mapped region whose week does not start on Monday (40 entries): add the 27 missing regions (incl. ID and MV, which starts Friday), change SA to Sunday, and drop IQ, IR, LY, SD, SY and YE, which current CLDR reports as Monday-starting and which would contradict getWeekInfo on modern engines. AE is correctly absent (Monday). Omit the BCP 47 tag from an option's search text: every option shares the active language subtag, so typing it used to match every region. The region part of the tag is already the option's region code. Pin the fallback table to the runtime's week info with a source-reading test that compares it against getWeekInfo for all currency regions, and add a search regression test for the shared language subtag. Align the docs Usage section, the preview demo, and the PR snippet: imports block plus a bare component block, no state or wrapper. --- .../locale-pickers/locale-picker.mdx | 35 +++------ src/components/docs/locale-picker-demo.tsx | 18 ++--- src/registry/components/locale-picker.tsx | 48 ++++++++++--- test/locale-picker.test.tsx | 72 +++++++++++++++++++ 4 files changed, 122 insertions(+), 51 deletions(-) diff --git a/content/docs/components/locale-pickers/locale-picker.mdx b/content/docs/components/locale-pickers/locale-picker.mdx index 4649e0a..620e056 100644 --- a/content/docs/components/locale-pickers/locale-picker.mdx +++ b/content/docs/components/locale-pickers/locale-picker.mdx @@ -39,22 +39,15 @@ import { Tabs, Tab } from "fumadocs-ui/components/tabs"; ## Usage ```tsx -"use client"; - -import { useState } from "react"; import { LocalePicker } from "@/components/ui/locale-picker"; +``` -export function SettingsRegion() { - const [locale, setLocale] = useState("en-IN"); - - return ( - - ); -} +```tsx + console.log(locale)} +/> ``` ## Props @@ -119,19 +112,7 @@ announced through `aria-activedescendant`. ### Controlled ```tsx -"use client"; - -import { LocalePicker } from "@/components/ui/locale-picker"; - -export function SettingsRegion({ locale, setLocale }) { - return ( - - ); -} + ``` ### Native region names diff --git a/src/components/docs/locale-picker-demo.tsx b/src/components/docs/locale-picker-demo.tsx index 23458f8..598069e 100644 --- a/src/components/docs/locale-picker-demo.tsx +++ b/src/components/docs/locale-picker-demo.tsx @@ -1,21 +1,13 @@ "use client"; -import { useState } from "react"; import { LocalePicker } from "@/registry/components/locale-picker"; export function LocalePickerDemo() { - const [locale, setLocale] = useState("en-IN"); - return ( -
- -

- Selected locale: {locale} -

-
+ console.log(locale)} + /> ); } diff --git a/src/registry/components/locale-picker.tsx b/src/registry/components/locale-picker.tsx index 1b7c943..0e3d432 100644 --- a/src/registry/components/locale-picker.tsx +++ b/src/registry/components/locale-picker.tsx @@ -128,29 +128,53 @@ const REGION_CURRENCY: Record = { }; /** - * First day of week (1 = Monday … 7 = Sunday) for regions whose week starts - * somewhere other than Monday, consulted only on engines without - * `Intl.Locale.prototype.getWeekInfo`. + * First day of week (1 = Monday … 7 = Sunday) for every currency-mapped + * region whose CLDR week does not start on Monday, consulted only on engines + * without `Intl.Locale.prototype.getWeekInfo`. Mirrors current CLDR (Monday- + * starting regions are omitted) and is kept in sync with the runtime's week + * data by the tests. */ const REGION_FIRST_DAY: Record = { AF: 6, + BD: 7, BH: 6, + BR: 7, + BZ: 7, + CA: 7, + CO: 7, + DO: 7, + DZ: 6, EG: 6, + ET: 7, + GT: 7, + HK: 7, + ID: 7, IL: 7, IN: 7, - IQ: 6, - IR: 6, + IS: 7, JO: 6, JP: 7, + KE: 7, + KR: 7, KW: 6, - LY: 6, + MO: 7, + MT: 7, + MX: 7, + MV: 5, + NP: 7, OM: 6, + PH: 7, + PK: 7, + PR: 7, + PT: 7, QA: 6, - SA: 6, - SD: 6, - SY: 6, + SA: 7, + SG: 7, + TH: 7, + TW: 7, US: 7, - YE: 6, + VE: 7, + ZA: 7, }; /** English weekday names, used only when `Intl.DateTimeFormat` cannot help. */ @@ -369,7 +393,9 @@ export const LocalePicker = React.forwardRef( label, flag: regionFlag(upper), rtl: isRtl(code), - search: `${upper} ${label} ${english} ${code}`.toLowerCase(), + // The BCP 47 tag is omitted: every option shares the active language + // subtag, so it would match all of them. The region subtag is `upper`. + search: `${upper} ${label} ${english}`.toLowerCase(), }; }); diff --git a/test/locale-picker.test.tsx b/test/locale-picker.test.tsx index 75dc431..d2edbfe 100644 --- a/test/locale-picker.test.tsx +++ b/test/locale-picker.test.tsx @@ -207,6 +207,23 @@ describe("LocalePicker", () => { expect(screen.getByRole("option", { name: /Deutschland/ })).toBeInTheDocument(); }); + it("does not match every option on the shared language subtag", async () => { + render(); + await userEvent.click(trigger()); + + // Every option's tag shares the active language subtag ("en-IN", "en-US", + // …), so the tag must stay out of the search text — only the region part + // of the tag is useful, and that is the region code itself. + await userEvent.type(searchBox(), "en"); + expect(screen.queryAllByRole("option")).toHaveLength(0); + expect(screen.getByText("No results")).toBeInTheDocument(); + + await userEvent.clear(searchBox()); + await userEvent.type(searchBox(), "ind"); + expect(screen.getAllByRole("option")).toHaveLength(1); + expect(screen.getByRole("option", { name: /India/ })).toBeInTheDocument(); + }); + it("renders deterministic markup for the server", () => { const html = renderToString(); @@ -401,3 +418,58 @@ describe("LocalePicker currency table", () => { } }); }); + +/** + * The fallback table exists only for engines without `getWeekInfo` — if it + * drifts from what `getWeekInfo` reports, those engines contradict modern + * ones. Reading it from the source and comparing against the runtime's week + * data pins every currency-mapped region: entries present, values current, + * Monday-starting regions absent. + */ +const firstDayTableBlock = pickerSource.slice( + pickerSource.indexOf("const REGION_FIRST_DAY"), + pickerSource.indexOf("};", pickerSource.indexOf("const REGION_FIRST_DAY")), +); +const FIRST_DAY_TABLE = Object.fromEntries( + [...firstDayTableBlock.matchAll(/^ {2}([A-Z]{2}): (\d),?$/gm)].map((match) => [ + match[1], + Number(match[2]), + ]), +); + +const weekInfoSupported = + typeof (new Intl.Locale("en") as Intl.Locale & { getWeekInfo?(): unknown }).getWeekInfo === + "function"; + +describe("LocalePicker first-day fallback table", () => { + it.skipIf(!weekInfoSupported)( + "mirrors the runtime's week info for every currency-mapped region", + () => { + const expected: Record = {}; + for (const region of Object.keys(CURRENCY_TABLE)) { + const tag = new Intl.Locale(`en-${region}`) as Intl.Locale & { + getWeekInfo(): { firstDay: number }; + }; + const firstDay = tag.getWeekInfo().firstDay; + if (firstDay !== 1) expected[region] = firstDay; + } + expect(FIRST_DAY_TABLE).toEqual(expected); + }, + ); + + it("only holds non-Monday days for real regions", () => { + expect(Object.keys(FIRST_DAY_TABLE).length).toBeGreaterThanOrEqual(35); + expect(Object.values(FIRST_DAY_TABLE)).not.toContain(1); + expect(FIRST_DAY_TABLE).toMatchObject({ + // Indonesia and Saudi Arabia start the week on Sunday. + ID: 7, + SA: 7, + // The Maldives start it on Friday. + MV: 5, + // The United Arab Emirates start on Monday and must not appear. + }); + expect(FIRST_DAY_TABLE.AE).toBeUndefined(); + expect(FIRST_DAY_TABLE).toHaveProperty("IN", 7); + expect(FIRST_DAY_TABLE).toHaveProperty("US", 7); + }); +}); From c816118b63eae5662f848f593052a53fe310aa19 Mon Sep 17 00:00:00 2001 From: Ujwal Bajaj Date: Tue, 20 Oct 2026 18:17:05 +0530 Subject: [PATCH 4/7] feat(locale-picker): shadcn tokens, motion popup, and LanguageSwitcher parity --- .../locale-pickers/locale-picker.mdx | 114 ++++- content/docs/components/meta.json | 9 +- src/components/docs/locale-picker-demo.tsx | 8 +- src/lib/elements-config.ts | 3 + src/registry/components/index.ts | 10 +- src/registry/components/locale-picker.tsx | 459 ++++++++++++------ src/registry/registry.ts | 19 +- test/locale-picker.test.tsx | 185 ++++++- test/menu-reopen.test.tsx | 12 +- 9 files changed, 602 insertions(+), 217 deletions(-) diff --git a/content/docs/components/locale-pickers/locale-picker.mdx b/content/docs/components/locale-pickers/locale-picker.mdx index 620e056..f2299bb 100644 --- a/content/docs/components/locale-pickers/locale-picker.mdx +++ b/content/docs/components/locale-pickers/locale-picker.mdx @@ -20,19 +20,69 @@ import { Tabs, Tab } from "fumadocs-ui/components/tabs"; > - ```bash npx shadcn@latest add @elements/locale-picker ``` - ```bash pnpm dlx shadcn@latest add @elements/locale-picker ``` - ```bash yarn dlx shadcn@latest add @elements/locale-picker ``` - ```bash bunx shadcn@latest add @elements/locale-picker ``` + + + ```bash + npx shadcn@latest add @babelize/locale-picker + ``` + + + + + ```bash + pnpm dlx shadcn@latest add @babelize/locale-picker + ``` + + + + + ```bash + yarn dlx shadcn@latest add @babelize/locale-picker + ``` + + + + + ```bash + bunx shadcn@latest add @babelize/locale-picker + ``` + + + - ```bash npx @babelize/elements add locale-picker ``` - ```bash pnpm dlx @babelize/elements add locale-picker ``` - ```bash yarn dlx @babelize/elements add locale-picker ``` - ```bash bunx @babelize/elements add locale-picker ``` + + + ```bash + npx @babelize/elements add locale-picker + ``` + + + + + ```bash + pnpm dlx @babelize/elements add locale-picker + ``` + + + + + ```bash + yarn dlx @babelize/elements add locale-picker + ``` + + + + + ```bash + bunx @babelize/elements add locale-picker + ``` + + + @@ -40,28 +90,44 @@ import { Tabs, Tab } from "fumadocs-ui/components/tabs"; ```tsx import { LocalePicker } from "@/components/ui/locale-picker"; +import { regions } from "@/lib/elements-config"; ``` ```tsx console.log(locale)} + onValueChange={(locale) => console.log("Selected locale:", locale)} + label="english" // native, english + variant="outline" // outline, ghost, secondary + size="default" // sm, default, lg /> ``` +The sample `regions` list comes from the docs site's `lib/elements-config.ts`; +point it at your own array of ISO 3166-1 alpha-2 codes. + ## Props -| Prop | Type | Required | Default | Description | -| --------------- | -------------------------- | -------- | ------------------- | -------------------------------------------------------------- | -| `regions` | `string[]` | Yes | — | Regions to choose from, ISO 3166-1 alpha-2 codes | -| `value` | `string` | No | — | Selected locale (BCP 47) — pass this to control the component | -| `defaultValue` | `string` | No | `en-` | Initial selected locale when uncontrolled | -| `onValueChange` | `(locale: string) => void` | No | — | Callback when the region changes — receives the new BCP 47 tag | -| `label` | `"native" \| "english"` | No | `"english"` | Region names in English, or localized to the selected locale | -| `showFlags` | `boolean` | No | `true` | Show the region's flag emoji next to its name | -| `maxHeight` | `number \| string` | No | `"15rem"` | Max height of the scrolling option list, before it scrolls | -| `className` | `string` | No | — | Additional CSS classes | +| Prop | Type | Required | Default | Description | +| ------------------- | ------------------------------------- | -------- | --------------------- | ------------------------------------------------------------------------------------ | +| `regions` | `string[]` | Yes | — | Regions to choose from, ISO 3166-1 alpha-2 codes | +| `value` | `string` | No | — | Selected locale (BCP 47) — pass this to control the component | +| `defaultValue` | `string` | No | `en-` | Initial selected locale when uncontrolled | +| `onValueChange` | `(locale: string) => void` | No | — | Callback when the region changes — receives the new BCP 47 tag | +| `label` | `"native" \| "english"` | No | `"english"` | Region names in English, or localized to the selected locale | +| `showFlags` | `boolean` | No | `true` | Show the region's flag emoji next to its name | +| `maxHeight` | `number \| string` | No | `"15rem"` | Max height of the scrolling option list, before it scrolls | +| `variant` | `"outline" \| "ghost" \| "secondary"` | No | `"outline"` | Trigger style | +| `size` | `"sm" \| "default" \| "lg"` | No | `"default"` | Trigger size | +| `align` | `"start" \| "end"` | No | `"start"` | Edge of the trigger the popup lines up with | +| `side` | `"top" \| "bottom"` | No | `"bottom"` | Preferred popup side — flips when the viewport has no room | +| `searchable` | `boolean` | No | `true` | Show the search field | +| `searchPlaceholder` | `string` | No | `"Search regions..."` | Placeholder for the search field | +| `emptyMessage` | `React.ReactNode` | No | `"No results"` | Content shown when the search matches nothing | +| `disabled` | `boolean` | No | `false` | Disable the trigger | +| `classNames` | `LocalePickerClassNames` | No | — | Per-part classes: `trigger`, `content`, `search`, `list`, `item`, `empty`, `summary` | +| `className` | `string` | No | — | Additional CSS classes | The selected value is a full BCP 47 tag: its **region** subtag is the picked region, and its **language** subtag survives region changes. Picking Germany @@ -100,11 +166,13 @@ announced through `aria-activedescendant`. ## Features - **Derived defaults** — currency, date format, number format, and first day of week, resolved live through `Intl` -- **Dropdown with search** — filter regions by English name, localized name, or code +- **Dropdown with search** — filter regions by English name, localized name, or region code, with a clear button +- **Motion** — the popup fades and slides via `motion/react`, honouring `prefers-reduced-motion`, and leaves the accessibility tree as it closes - **RTL-aware** — flips direction for RTL locales (ar, he, fa, ur) via `dir` on the root - **Keyboard accessible** — arrow keys, Home / End, Enter, and Escape navigation - **ARIA labels** — `role="combobox"` search field with `aria-activedescendant`, plus `aria-expanded`, `aria-haspopup`, and `aria-selected` -- **Dark mode** — built with Tailwind CSS, dark-mode compatible +- **Themed with shadcn tokens** — `bg-popover`, `border-input`, `bg-accent` and friends, so it inherits your theme; dark mode comes from the tokens +- **Customisable** — `variant` / `size` triggers plus per-part `classNames` (`LocalePickerClassNames` is exported from the barrel), with `lucide-react` icons throughout - **Controlled or uncontrolled** — `value` / `defaultValue` / `onValueChange` ## Examples @@ -129,6 +197,8 @@ With `de-DE` selected, the trigger reads "Deutschland" and the options read This component uses: - `clsx` and `tailwind-merge` (bundled as dependencies) +- `lucide-react` (icons) +- `motion` (popup animation) Peer dependencies: diff --git a/content/docs/components/meta.json b/content/docs/components/meta.json index f54e4b5..db8c5c8 100644 --- a/content/docs/components/meta.json +++ b/content/docs/components/meta.json @@ -1,12 +1,5 @@ { "title": "Components", - "pages": [ - "language-switchers", - "locale-pickers", - "navigation", - "inputs", - "translation", - "formatters" - ], + "pages": ["language-switchers", "locale-pickers", "navigation", "inputs", "translation"], "collapsible": false } diff --git a/src/components/docs/locale-picker-demo.tsx b/src/components/docs/locale-picker-demo.tsx index 598069e..1d2825c 100644 --- a/src/components/docs/locale-picker-demo.tsx +++ b/src/components/docs/locale-picker-demo.tsx @@ -1,13 +1,17 @@ "use client"; import { LocalePicker } from "@/registry/components/locale-picker"; +import { regions } from "@/lib/elements-config"; export function LocalePickerDemo() { return ( console.log(locale)} + onValueChange={(locale) => console.log("Selected locale:", locale)} + label="english" // native, english + variant="outline" // outline, ghost, secondary + size="default" // sm, default, lg /> ); } diff --git a/src/lib/elements-config.ts b/src/lib/elements-config.ts index 80ff2ab..b0f11e7 100644 --- a/src/lib/elements-config.ts +++ b/src/lib/elements-config.ts @@ -21,6 +21,9 @@ export const pillLocales: Locale[] = [ { code: "ar", label: "عربي" }, ]; +/** ISO 3166-1 alpha-2 regions the LocalePicker demo offers. */ +export const regions = ["IN", "US", "GB", "DE", "JP"]; + /** NavBar links and CTA, translated for every demo locale. */ export const navCopy: Record = { en: { links: links("Product", "Docs", "Pricing"), cta: "Get started" }, diff --git a/src/registry/components/index.ts b/src/registry/components/index.ts index 7934cac..39a25c8 100644 --- a/src/registry/components/index.ts +++ b/src/registry/components/index.ts @@ -22,13 +22,7 @@ export { NavBar } from "./navbar"; export type { NavBarProps, NavBarClassNames, NavLink } from "./navbar"; export { TranslationWidget } from "./translation-widget"; -export type { TranslationWidgetProps } from "./translation-widget"; - -export { LocaleNumber } from "./locale-number"; -export type { LocaleNumberProps } from "./locale-number"; - -export { RelativeTime } from "./relative-time"; -export type { RelativeTimeProps } from "./relative-time"; +export type { TranslationWidgetProps, TranslationWidgetClassNames } from "./translation-widget"; export { LocalePicker } from "./locale-picker"; -export type { LocalePickerProps } from "./locale-picker"; +export type { LocalePickerProps, LocalePickerClassNames } from "./locale-picker"; diff --git a/src/registry/components/locale-picker.tsx b/src/registry/components/locale-picker.tsx index 0e3d432..7a197fe 100644 --- a/src/registry/components/locale-picker.tsx +++ b/src/registry/components/locale-picker.tsx @@ -1,14 +1,75 @@ "use client"; import * as React from "react"; +import { + AnimatePresence, + MotionConfig, + motion, + useIsPresent, + type HTMLMotionProps, +} from "motion/react"; +import { CheckIcon, ChevronDownIcon, GlobeIcon, SearchIcon, XIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { useControllableState } from "@/lib/use-controllable-state"; import { useListboxNavigation } from "@/lib/use-listbox-navigation"; import type { Locale } from "./types"; -import { isRtl } from "./locale-data"; +import { getFlag, isRtl } from "./locale-data"; export type { Locale }; +/** Per-part class overrides, merged after the defaults with `cn`. */ +export interface LocalePickerClassNames { + /** The button that opens the region list */ + trigger?: string; + /** The floating popup panel */ + content?: string; + /** The search row, including its icon and clear button */ + search?: string; + /** The scrolling option list */ + list?: string; + /** Each option row */ + item?: string; + /** The empty-state message */ + empty?: string; + /** The summary card of derived locale preferences */ + summary?: string; +} + +/** + * The popup panel. While it plays its exit animation it is still mounted, so it + * leaves the accessibility tree first: a closing menu is not a menu. + */ +const MenuPanel = React.forwardRef>( + function MenuPanel(props, ref) { + const isPresent = useIsPresent(); + return ( + + ); + }, +); + +const TRIGGER_VARIANTS = { + outline: + "border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground aria-expanded:bg-accent aria-expanded:text-accent-foreground dark:bg-input/30 dark:hover:bg-input/50 dark:aria-expanded:bg-input/50", + ghost: + "hover:bg-accent hover:text-accent-foreground aria-expanded:bg-accent aria-expanded:text-accent-foreground dark:hover:bg-accent/50", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80 aria-expanded:bg-secondary/80", +} as const; + +const TRIGGER_SIZES = { + sm: "h-8 gap-1.5 rounded-md px-2.5", + default: "h-9 gap-2 rounded-md px-3", + lg: "h-10 gap-2 rounded-md px-4", +} as const; + /** Sample amount behind the currency and number previews. */ const SAMPLE_AMOUNT = 1234567.89; /** @@ -201,15 +262,6 @@ type LocaleWithWeekInfo = { readonly weekInfo?: WeekInfo; }; -/** ISO 3166-1 alpha-2 → regional-indicator pair, or the globe for anything else. */ -function regionFlag(region: string): string { - if (!/^[A-Za-z]{2}$/.test(region)) return "\u{1F310}"; - return String.fromCodePoint( - region.toUpperCase().charCodeAt(0) - 65 + 0x1f1e6, - region.toUpperCase().charCodeAt(1) - 65 + 0x1f1e6, - ); -} - /** Canonicalizes a BCP 47 tag, falling back to `"en"` for malformed input. */ function canonicalize(locale: string): string { try { @@ -339,6 +391,27 @@ export interface LocalePickerProps extends Omit< showFlags?: boolean; /** Max height of the scrolling option list, before it scrolls (default: "15rem"). */ maxHeight?: number | string; + /** Trigger style (default: "outline") */ + variant?: "outline" | "ghost" | "secondary"; + /** Trigger size (default: "default") */ + size?: "sm" | "default" | "lg"; + /** Edge of the trigger the popup lines up with, in reading direction (default: "start") */ + align?: "start" | "end"; + /** + * Preferred side for the popup (default: "bottom"). It flips to the other side + * when there is not enough room in the viewport. + */ + side?: "top" | "bottom"; + /** Show the search field (default: true) */ + searchable?: boolean; + /** Placeholder for the search field (default: "Search regions...") */ + searchPlaceholder?: string; + /** Content shown when the search matches nothing (default: "No results") */ + emptyMessage?: React.ReactNode; + /** Disable the trigger */ + disabled?: boolean; + /** Per-part class overrides */ + classNames?: LocalePickerClassNames; } /** A region rendered as the shared `Locale` shape, plus search plumbing. */ @@ -359,6 +432,15 @@ export const LocalePicker = React.forwardRef( label: labelMode = "english", showFlags = true, maxHeight = "15rem", + variant = "outline", + size = "default", + align = "start", + side: preferredSide = "bottom", + searchable = true, + searchPlaceholder = "Search regions...", + emptyMessage = "No results", + disabled = false, + classNames, className, onKeyDown, ...rest @@ -372,9 +454,11 @@ export const LocalePicker = React.forwardRef( ); const [open, setOpen] = React.useState(false); const [search, setSearch] = React.useState(""); + const [side, setSide] = React.useState(preferredSide); const ref = React.useRef(null); const inputRef = React.useRef(null); const triggerRef = React.useRef(null); + const popupRef = React.useRef(null); React.useImperativeHandle(forwardedRef, () => ref.current as HTMLDivElement); @@ -391,7 +475,9 @@ export const LocalePicker = React.forwardRef( code, region: upper, label, - flag: regionFlag(upper), + // The full tag — `getFlag` resolves flags from a region subtag, and a + // bare region like "IN" would be read as a language code instead. + flag: getFlag(code), rtl: isRtl(code), // The BCP 47 tag is omitted: every option shares the active language // subtag, so it would match all of them. The region subtag is `upper`. @@ -405,13 +491,16 @@ export const LocalePicker = React.forwardRef( const nav = useListboxNavigation({ count: filtered.length, open, + // Without a search field there is nothing to hold `aria-activedescendant`, + // so focus moves onto the options themselves. + strategy: searchable ? "activedescendant" : "roving", // Land on the current selection when the popup opens. initialIndex: filtered.findIndex((option) => option.region === region), onClose: () => { setOpen(false); setSearch(""); }, - onOpen: () => setOpen(true), + onOpen: () => !disabled && setOpen(true), onSelect: (index) => { const picked = filtered[index]; if (picked) setLocale(picked.code); @@ -434,159 +523,217 @@ export const LocalePicker = React.forwardRef( }, []); React.useEffect(() => { - if (open) inputRef.current?.focus(); - }, [open]); + if (open && searchable) inputRef.current?.focus(); + }, [open, searchable]); + + // Flip to the other side when the preferred one has no room. Measured before + // paint, so the popup never flashes on the wrong side. + React.useLayoutEffect(() => { + if (!open) return; + const trigger = triggerRef.current; + const popup = popupRef.current; + if (!trigger || !popup) return; + const rect = trigger.getBoundingClientRect(); + const needed = popup.offsetHeight + 8; + const below = window.innerHeight - rect.bottom; + const above = rect.top; + if (preferredSide === "bottom" && below < needed && above > below) setSide("top"); + else if (preferredSide === "top" && above < needed && below > above) setSide("bottom"); + else setSide(preferredSide); + }, [open, preferredSide]); + + const offset = side === "bottom" ? -4 : 4; return ( -
{ - nav.onKeyDown(event); - onKeyDown?.(event); - }} - {...rest} - > - - - {open && ( -
-
- { - setSearch(event.target.value); - nav.resetActive(); - }} - {...nav.comboboxProps} - className="w-full rounded-lg bg-zinc-100 px-3 py-1.5 text-sm text-zinc-900 outline-none placeholder:text-zinc-400 focus:ring-1 focus:ring-emerald-500/50 dark:bg-zinc-800 dark:text-zinc-100 dark:placeholder:text-zinc-500" - /> -
- {/* The listbox owns the options only — the search field above sits - outside it, per ARIA. It scrolls on its own: the page never does. */} -
- {filtered.map((option, index) => { - const isCurrent = option.region === region; - const isActive = index === nav.activeIndex; - return ( - + + + {open && ( + + {searchable && ( +
+
-
- )} +
+ )} + {/* The listbox owns the options only — the search field above sits + outside it, per ARIA. It scrolls on its own: the page never does. */} +
+ {filtered.map((option, index) => { + const isCurrent = option.region === region; + return ( + + ); + })} + {filtered.length === 0 && ( +
+ {emptyMessage} +
+ )} +
+ + )} + -
-
-
Currency
-
- {preferences.currency ? ( - <> - - {preferences.currency} - - {preferences.currencyPreview} - - ) : ( - — - )} -
-
-
-
Date format
-
- {preferences.datePreview} -
-
-
-
Number format
-
- {preferences.numberPreview} -
-
-
-
Week starts on
-
- {preferences.firstWeekday} -
-
-
-
+
+
+
Currency
+
+ {preferences.currency ? ( + <> + + {preferences.currency} + + {preferences.currencyPreview} + + ) : ( + — + )} +
+
+
+
Date format
+
{preferences.datePreview}
+
+
+
Number format
+
{preferences.numberPreview}
+
+
+
Week starts on
+
{preferences.firstWeekday}
+
+
+ + ); }, ); diff --git a/src/registry/registry.ts b/src/registry/registry.ts index 4376fdb..19f88b7 100644 --- a/src/registry/registry.ts +++ b/src/registry/registry.ts @@ -139,23 +139,6 @@ const ITEMS: ItemDef[] = [ filePath: "lib/utils.ts", dependencies: ["clsx", "tailwind-merge"], }, - { - name: "locale-number", - title: "Locale Number", - description: - "Locale-aware number formatting for decimals, percentages, units, and compact notation.", - type: "registry:ui", - source: "registry/components/locale-number", - filePath: "ui/locale-number.tsx", - }, - { - name: "relative-time", - title: "Relative Time", - description: "Locale-aware relative time formatting with optional live updates.", - type: "registry:ui", - source: "registry/components/relative-time", - filePath: "ui/relative-time.tsx", - }, { name: "locale-picker", title: "Locale Picker", @@ -164,7 +147,7 @@ const ITEMS: ItemDef[] = [ type: "registry:ui", source: "registry/components/locale-picker", filePath: "ui/locale-picker.tsx", - dependencies: ["clsx", "tailwind-merge"], + dependencies: ["clsx", "tailwind-merge", "lucide-react", "motion"], registryDependencies: [ "locale-types", "locale-data", diff --git a/test/locale-picker.test.tsx b/test/locale-picker.test.tsx index d2edbfe..c3931cb 100644 --- a/test/locale-picker.test.tsx +++ b/test/locale-picker.test.tsx @@ -1,10 +1,11 @@ -import { describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import * as React from "react"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { renderToString } from "react-dom/server"; +import { MotionGlobalConfig } from "motion/react"; import { LocalePicker } from "@/registry/components"; const regions = ["IN", "US", "GB", "DE", "JP"]; @@ -473,3 +474,183 @@ describe("LocalePicker first-day fallback table", () => { expect(FIRST_DAY_TABLE).toHaveProperty("US", 7); }); }); + +/** + * The popup panel — the listbox's parent, per the component's structure. Hidden + * elements are included so the panel can be found while it exits (`aria-hidden`). + */ +const popupPanel = () => screen.getByRole("listbox", { hidden: true }).parentElement as HTMLElement; + +describe("LocalePicker appearance props", () => { + it("applies the trigger variant and size", () => { + const { rerender } = render(); + expect(trigger()).toHaveClass("border-input", "h-8"); + + rerender(); + expect(trigger()).toHaveClass("hover:bg-accent", "h-9"); + expect(trigger()).not.toHaveClass("border-input"); + + rerender(); + expect(trigger()).toHaveClass("bg-secondary", "h-10"); + }); + + it("keeps the popup on the preferred side when the viewport has room", async () => { + render(); + await userEvent.click(trigger()); + + expect(popupPanel()).toHaveClass("top-full", "mt-1.5", "start-0"); + expect(popupPanel()).toHaveAttribute("data-side", "bottom"); + }); + + it("flips the popup to the other side when the preferred one has no room", async () => { + render(); + // Pin the trigger against the bottom of the viewport: there is no room below. + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockReturnValue({ + x: 0, + y: 700, + top: 700, + right: 100, + bottom: 765, + left: 0, + width: 100, + height: 65, + toJSON: () => ({}), + } as DOMRect); + + await userEvent.click(trigger()); + + expect(popupPanel()).toHaveClass("bottom-full", "mb-1.5"); + expect(popupPanel()).toHaveAttribute("data-side", "top"); + vi.restoreAllMocks(); + }); + + it("lines the popup up with the far edge of the trigger when asked", async () => { + render(); + await userEvent.click(trigger()); + + expect(popupPanel()).toHaveClass("end-0"); + expect(popupPanel()).not.toHaveClass("start-0"); + }); +}); + +describe("LocalePicker popup props", () => { + it("honours searchPlaceholder", async () => { + render(); + await userEvent.click(trigger()); + expect(searchBox()).toHaveAttribute("placeholder", "Find a region"); + }); + + it("shows a custom emptyMessage when nothing matches", async () => { + render(); + await userEvent.click(trigger()); + await userEvent.type(searchBox(), "zzz"); + expect(screen.getByText("Nothing here")).toBeInTheDocument(); + expect(screen.queryAllByRole("option")).toHaveLength(0); + }); + + it("clears the search with the clear button and restores focus", async () => { + render(); + await userEvent.click(trigger()); + await userEvent.type(searchBox(), "ger"); + expect(screen.getAllByRole("option")).toHaveLength(1); + + await userEvent.click(screen.getByRole("button", { name: "Clear search" })); + + expect(searchBox()).toHaveValue(""); + expect(screen.getAllByRole("option")).toHaveLength(5); + expect(document.activeElement).toBe(searchBox()); + }); + + it("hides the search field when searchable is false and still lists regions", async () => { + render(); + await userEvent.click(trigger()); + + expect(screen.queryByRole("combobox")).toBeNull(); + expect(screen.getByRole("listbox")).toHaveAttribute("aria-label", "Select region"); + expect(screen.getAllByRole("option")).toHaveLength(5); + }); + + it("does not open when disabled", async () => { + render(); + expect(trigger()).toBeDisabled(); + + await userEvent.click(trigger()); + expect(screen.queryByRole("listbox")).toBeNull(); + }); + + it("marks the selected option with a check icon", async () => { + render(); + await userEvent.click(trigger()); + + const selected = screen.getByRole("option", { name: /Germany/ }); + const other = screen.getByRole("option", { name: /India/ }); + const selectedCheck = selected.querySelector("svg"); + const otherCheck = other.querySelector("svg"); + + expect(selectedCheck).not.toBeNull(); + expect(selectedCheck?.className.baseVal ?? selectedCheck?.getAttribute("class")).toContain( + "text-primary", + ); + expect(selectedCheck?.getAttribute("class")).toContain("opacity-100"); + expect(otherCheck?.getAttribute("class")).toContain("opacity-0"); + expect(selected).toHaveAttribute("aria-selected", "true"); + }); + + it("applies every classNames part to its element", async () => { + render( + , + ); + + expect(trigger()).toHaveClass("PART_TRIGGER"); + expect(screen.getByText("Currency").closest("dl")).toHaveClass("PART_SUMMARY"); + + await userEvent.click(trigger()); + expect(popupPanel()).toHaveClass("PART_CONTENT"); + expect(searchBox().parentElement).toHaveClass("PART_SEARCH"); + expect(screen.getByRole("listbox")).toHaveClass("PART_LIST"); + expect(screen.getAllByRole("option")[0]).toHaveClass("PART_ITEM"); + + await userEvent.type(searchBox(), "zzz"); + expect(screen.getByText("No results")).toHaveClass("PART_EMPTY"); + }); +}); + +describe("LocalePicker closing motion", () => { + beforeEach(() => { + MotionGlobalConfig.skipAnimations = false; + }); + afterEach(() => { + MotionGlobalConfig.skipAnimations = true; + }); + + it("marks the exiting popup aria-hidden until it is removed", async () => { + render(); + await userEvent.click(trigger()); + expect(popupPanel()).not.toHaveAttribute("aria-hidden"); + + await userEvent.click(trigger()); // close — the exit animation starts + + expect(popupPanel()).toHaveAttribute("aria-hidden", "true"); + await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull()); + }); + + it("reopens mid-exit as the same clickable panel", async () => { + render(); + await userEvent.click(trigger()); + await userEvent.click(trigger()); + await userEvent.click(trigger()); + await userEvent.click(screen.getByRole("option", { name: /Japan/ })); + expect(trigger()).toHaveAccessibleName("Current region: Japan"); + }); +}); diff --git a/test/menu-reopen.test.tsx b/test/menu-reopen.test.tsx index 64ba554..8407a0a 100644 --- a/test/menu-reopen.test.tsx +++ b/test/menu-reopen.test.tsx @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { MotionGlobalConfig } from "motion/react"; -import { LanguageSwitcher, NavBar, PhoneInput } from "@/registry/components"; +import { LocalePicker, LanguageSwitcher, NavBar, PhoneInput } from "@/registry/components"; /** * A menu reopened while its exit animation is still running is the same element @@ -38,6 +38,16 @@ describe("menus reopened mid-exit stay clickable", () => { expect(trigger).toHaveTextContent("+91"); }); + it("LocalePicker", async () => { + render(); + const trigger = screen.getByRole("button", { name: /Current region/ }); + await userEvent.click(trigger); + await userEvent.click(trigger); + await userEvent.click(trigger); + await userEvent.click(screen.getByRole("option", { name: /Germany/ })); + expect(trigger).toHaveAccessibleName(/Germany/); + }); + it("NavBar language menu", async () => { render(); const trigger = () => screen.getAllByRole("button", { name: /Current language/ })[0]; From 0fb4b1ab0a8baa1b12153d5f5a13cde3f7897a9d Mon Sep 17 00:00:00 2001 From: Ujwal Bajaj Date: Tue, 20 Oct 2026 18:23:36 +0530 Subject: [PATCH 5/7] Update src/registry/components/locale-picker.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- src/registry/components/locale-picker.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/registry/components/locale-picker.tsx b/src/registry/components/locale-picker.tsx index 7a197fe..9bec62d 100644 --- a/src/registry/components/locale-picker.tsx +++ b/src/registry/components/locale-picker.tsx @@ -560,7 +560,10 @@ export const LocalePicker = React.forwardRef( ref={triggerRef} type="button" disabled={disabled} - onClick={() => setOpen(!open)} + onClick={() => { + if (open) setSearch(""); + setOpen(!open); + }} data-state={open ? "open" : "closed"} className={cn( "inline-flex w-full items-center justify-between text-sm font-medium whitespace-nowrap transition-[color,background-color,border-color,box-shadow] outline-none select-none", From 9769de9dfff49b37d4c2ebc2831ed48c44e731ce Mon Sep 17 00:00:00 2001 From: Ujwal Bajaj Date: Wed, 21 Oct 2026 22:06:26 +0530 Subject: [PATCH 6/7] feat(locale-picker): translatable labels, showSummary, and memoized options --- .../locale-pickers/locale-picker.mdx | 42 +++- content/docs/customization/interfaces.mdx | 29 +++ src/components/docs/locale-picker-demo.tsx | 3 +- src/lib/elements-config.ts | 3 - src/registry/components/index.ts | 6 +- src/registry/components/locale-picker.tsx | 207 ++++++++++++------ test/locale-picker.test.tsx | 86 ++++++++ 7 files changed, 304 insertions(+), 72 deletions(-) diff --git a/content/docs/components/locale-pickers/locale-picker.mdx b/content/docs/components/locale-pickers/locale-picker.mdx index f2299bb..2d8ba02 100644 --- a/content/docs/components/locale-pickers/locale-picker.mdx +++ b/content/docs/components/locale-pickers/locale-picker.mdx @@ -90,12 +90,11 @@ import { Tabs, Tab } from "fumadocs-ui/components/tabs"; ```tsx import { LocalePicker } from "@/components/ui/locale-picker"; -import { regions } from "@/lib/elements-config"; ``` ```tsx console.log("Selected locale:", locale)} label="english" // native, english @@ -104,9 +103,6 @@ import { regions } from "@/lib/elements-config"; /> ``` -The sample `regions` list comes from the docs site's `lib/elements-config.ts`; -point it at your own array of ISO 3166-1 alpha-2 codes. - ## Props | Prop | Type | Required | Default | Description | @@ -126,6 +122,8 @@ point it at your own array of ISO 3166-1 alpha-2 codes. | `searchPlaceholder` | `string` | No | `"Search regions..."` | Placeholder for the search field | | `emptyMessage` | `React.ReactNode` | No | `"No results"` | Content shown when the search matches nothing | | `disabled` | `boolean` | No | `false` | Disable the trigger | +| `showSummary` | `boolean` | No | `true` | Show the summary card of derived locale defaults | +| `labels` | `Partial` | No | — | Override the built-in English UI text (see below) | | `classNames` | `LocalePickerClassNames` | No | — | Per-part classes: `trigger`, `content`, `search`, `list`, `item`, `empty`, `summary` | | `className` | `string` | No | — | Additional CSS classes | @@ -192,6 +190,40 @@ announced through `aria-activedescendant`. With `de-DE` selected, the trigger reads "Deutschland" and the options read "Vereinigte Staaten", "Vereinigtes Königreich", and so on. +### Translated UI text + +The search placeholder, empty state, accessible labels and summary row labels +default to English. Pass `labels` to translate them — any key you leave out +keeps its English default. The `searchPlaceholder` and `emptyMessage` props +still work, and win over `labels` when both are set. + +```tsx +"use client"; + +import { LocalePicker } from "@/components/ui/locale-picker"; + +export default function App() { + return ( + `Région actuelle : ${name}`, + currency: "Devise", + dateFormat: "Format de date", + numberFormat: "Format de nombre", + weekStart: "La semaine commence le", + }} + /> + ); +} +``` + ## Dependencies This component uses: diff --git a/content/docs/customization/interfaces.mdx b/content/docs/customization/interfaces.mdx index aa67cbe..4c90076 100644 --- a/content/docs/customization/interfaces.mdx +++ b/content/docs/customization/interfaces.mdx @@ -254,6 +254,35 @@ export interface TranslationWidgetLabels { } ``` +## Locale Picker + +### LocalePickerLabels + +```ts +export interface LocalePickerLabels { + /** Placeholder for the search field (default: "Search regions...") */ + searchPlaceholder: string; + /** Accessible label for the search field (default: "Search regions") */ + searchLabel: string; + /** Accessible label for the button that clears the search (default: "Clear search") */ + clearSearch: string; + /** Content shown when the search matches nothing (default: "No results") */ + emptyMessage: React.ReactNode; + /** Accessible label for the option list (default: "Select region") */ + listbox: string; + /** Accessible label for the trigger (default: "Current region: {name}") */ + current: (name: string) => string; + /** Summary row label for the currency preview (default: "Currency") */ + currency: string; + /** Summary row label for the date preview (default: "Date format") */ + dateFormat: string; + /** Summary row label for the number preview (default: "Number format") */ + numberFormat: string; + /** Summary row label for the first day of week (default: "Week starts on") */ + weekStart: string; +} +``` + ## Extending Interfaces Because Babelize Elements components are fully owned by you once installed, you can extend these interfaces directly in the component files. For example, to add a custom `meta` field to each locale, add it to the `Locale` interface in your local copy: diff --git a/src/components/docs/locale-picker-demo.tsx b/src/components/docs/locale-picker-demo.tsx index 1d2825c..7bd0851 100644 --- a/src/components/docs/locale-picker-demo.tsx +++ b/src/components/docs/locale-picker-demo.tsx @@ -1,12 +1,11 @@ "use client"; import { LocalePicker } from "@/registry/components/locale-picker"; -import { regions } from "@/lib/elements-config"; export function LocalePickerDemo() { return ( console.log("Selected locale:", locale)} label="english" // native, english diff --git a/src/lib/elements-config.ts b/src/lib/elements-config.ts index b0f11e7..80ff2ab 100644 --- a/src/lib/elements-config.ts +++ b/src/lib/elements-config.ts @@ -21,9 +21,6 @@ export const pillLocales: Locale[] = [ { code: "ar", label: "عربي" }, ]; -/** ISO 3166-1 alpha-2 regions the LocalePicker demo offers. */ -export const regions = ["IN", "US", "GB", "DE", "JP"]; - /** NavBar links and CTA, translated for every demo locale. */ export const navCopy: Record = { en: { links: links("Product", "Docs", "Pricing"), cta: "Get started" }, diff --git a/src/registry/components/index.ts b/src/registry/components/index.ts index 69b7e6d..7ba1477 100644 --- a/src/registry/components/index.ts +++ b/src/registry/components/index.ts @@ -35,4 +35,8 @@ export type { } from "./translation-widget"; export { LocalePicker } from "./locale-picker"; -export type { LocalePickerProps, LocalePickerClassNames } from "./locale-picker"; +export type { + LocalePickerProps, + LocalePickerClassNames, + LocalePickerLabels, +} from "./locale-picker"; diff --git a/src/registry/components/locale-picker.tsx b/src/registry/components/locale-picker.tsx index 9bec62d..92d29dc 100644 --- a/src/registry/components/locale-picker.tsx +++ b/src/registry/components/locale-picker.tsx @@ -35,6 +35,43 @@ export interface LocalePickerClassNames { summary?: string; } +/** Built-in UI text. Every key is optional in `labels`; omitted keys keep the English default. */ +export interface LocalePickerLabels { + /** Placeholder for the search field (default: "Search regions...") */ + searchPlaceholder: string; + /** Accessible label for the search field (default: "Search regions") */ + searchLabel: string; + /** Accessible label for the button that clears the search (default: "Clear search") */ + clearSearch: string; + /** Content shown when the search matches nothing (default: "No results") */ + emptyMessage: React.ReactNode; + /** Accessible label for the option list (default: "Select region") */ + listbox: string; + /** Accessible label for the trigger (default: "Current region: {name}") */ + current: (name: string) => string; + /** Summary row label for the currency preview (default: "Currency") */ + currency: string; + /** Summary row label for the date preview (default: "Date format") */ + dateFormat: string; + /** Summary row label for the number preview (default: "Number format") */ + numberFormat: string; + /** Summary row label for the first day of week (default: "Week starts on") */ + weekStart: string; +} + +const DEFAULT_LABELS: LocalePickerLabels = { + searchPlaceholder: "Search regions...", + searchLabel: "Search regions", + clearSearch: "Clear search", + emptyMessage: "No results", + listbox: "Select region", + current: (name) => `Current region: ${name}`, + currency: "Currency", + dateFormat: "Date format", + numberFormat: "Number format", + weekStart: "Week starts on", +}; + /** * The popup panel. While it plays its exit animation it is still mounted, so it * leaves the accessibility tree first: a closing menu is not a menu. @@ -412,6 +449,10 @@ export interface LocalePickerProps extends Omit< disabled?: boolean; /** Per-part class overrides */ classNames?: LocalePickerClassNames; + /** Show the summary card of derived locale defaults (default: true) */ + showSummary?: boolean; + /** Override the built-in English UI text; omitted keys keep their default */ + labels?: Partial; } /** A region rendered as the shared `Locale` shape, plus search plumbing. */ @@ -437,9 +478,11 @@ export const LocalePicker = React.forwardRef( align = "start", side: preferredSide = "bottom", searchable = true, - searchPlaceholder = "Search regions...", - emptyMessage = "No results", + searchPlaceholder, + emptyMessage, disabled = false, + showSummary = true, + labels, classNames, className, onKeyDown, @@ -464,26 +507,66 @@ export const LocalePicker = React.forwardRef( const active = canonicalize(locale); const region = regionOf(active); + + const text: LocalePickerLabels = { + searchPlaceholder: + searchPlaceholder ?? labels?.searchPlaceholder ?? DEFAULT_LABELS.searchPlaceholder, + searchLabel: labels?.searchLabel ?? DEFAULT_LABELS.searchLabel, + clearSearch: labels?.clearSearch ?? DEFAULT_LABELS.clearSearch, + emptyMessage: + emptyMessage !== undefined + ? emptyMessage + : (labels?.emptyMessage ?? DEFAULT_LABELS.emptyMessage), + listbox: labels?.listbox ?? DEFAULT_LABELS.listbox, + current: labels?.current ?? DEFAULT_LABELS.current, + currency: labels?.currency ?? DEFAULT_LABELS.currency, + dateFormat: labels?.dateFormat ?? DEFAULT_LABELS.dateFormat, + numberFormat: labels?.numberFormat ?? DEFAULT_LABELS.numberFormat, + weekStart: labels?.weekStart ?? DEFAULT_LABELS.weekStart, + }; const activeName = regionDisplayName(region, active, labelMode) ?? active; - const options: RegionOption[] = regions.map((entry) => { - const upper = entry.toUpperCase(); - const code = withRegion(active, upper); - const label = regionDisplayName(upper, active, labelMode) ?? upper; - const english = regionDisplayName(upper, active, "english") ?? upper; - return { - code, - region: upper, - label, - // The full tag — `getFlag` resolves flags from a region subtag, and a - // bare region like "IN" would be read as a language code instead. - flag: getFlag(code), - rtl: isRtl(code), - // The BCP 47 tag is omitted: every option shares the active language - // subtag, so it would match all of them. The region subtag is `upper`. - search: `${upper} ${label} ${english}`.toLowerCase(), + // Rebuilt only when the selection, the label mode, or the list itself + // changes — not on every search keystroke. One `DisplayNames` per mode + // serves the whole list instead of two instances per region. + const options: RegionOption[] = React.useMemo(() => { + const displayNames = (locales: string[]) => { + try { + return new Intl.DisplayNames(locales, { type: "region" }); + } catch { + return undefined; + } }; - }); + const names = displayNames([active]); + const englishNames = displayNames(["en"]); + const nameOf = (instance: Intl.DisplayNames | undefined, code: string) => { + try { + return instance?.of(code) ?? code; + } catch { + return code; + } + }; + // Repeated regions — the same code twice, or mixed case — would collide + // as React keys, so the list keeps one option per distinct code. + const unique = [...new Set(regions.map((entry) => entry.toUpperCase()))]; + return unique.map((upper) => { + const code = withRegion(active, upper); + const label = nameOf(labelMode === "english" ? englishNames : names, upper); + const english = nameOf(englishNames, upper); + return { + code, + region: upper, + label, + // The full tag — `getFlag` resolves flags from a region subtag, and a + // bare region like "IN" would be read as a language code instead. + flag: getFlag(code), + rtl: isRtl(code), + // The BCP 47 tag is omitted: every option shares the active language + // subtag, so it would match all of them. The region subtag is `upper`. + search: `${upper} ${label} ${english}`.toLowerCase(), + }; + }); + }, [regions, active, labelMode]); const query = search.trim().toLowerCase(); const filtered = query ? options.filter((option) => option.search.includes(query)) : options; @@ -508,7 +591,7 @@ export const LocalePicker = React.forwardRef( triggerRef: () => triggerRef.current, }); - const preferences = derivePreferences(active, region); + const preferences = React.useMemo(() => derivePreferences(active, region), [active, region]); const dir = isRtl(active) ? "rtl" : "ltr"; React.useEffect(() => { @@ -575,7 +658,7 @@ export const LocalePicker = React.forwardRef( )} aria-expanded={open} aria-haspopup="listbox" - aria-label={`Current region: ${activeName}`} + aria-label={text.current(activeName)} > {showFlags && region ? ( @@ -616,8 +699,8 @@ export const LocalePicker = React.forwardRef( (