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..2d8ba02 --- /dev/null +++ b/content/docs/components/locale-pickers/locale-picker.mdx @@ -0,0 +1,243 @@ +--- +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 @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 + ``` + + + + + + + +## Usage + +```tsx +import { LocalePicker } from "@/components/ui/locale-picker"; +``` + +```tsx + console.log("Selected locale:", locale)} + label="english" // native, english + variant="outline" // outline, ghost, secondary + size="default" // sm, default, lg +/> +``` + +## 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 | +| `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 | +| `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 | + +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 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` +- **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 + +### Controlled + +```tsx + +``` + +### Native region names + +```tsx + +``` + +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: + +- `clsx` and `tailwind-merge` (bundled as dependencies) +- `lucide-react` (icons) +- `motion` (popup animation) + +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 fbcbafd..147c280 100644 --- a/content/docs/components/meta.json +++ b/content/docs/components/meta.json @@ -1,4 +1,4 @@ { "title": "Components", - "pages": ["index", "language-switchers", "navigation", "inputs", "translation"] + "pages": ["index", "language-switchers", "locale-pickers", "navigation", "inputs", "translation"] } 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 new file mode 100644 index 0000000..7bd0851 --- /dev/null +++ b/src/components/docs/locale-picker-demo.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { LocalePicker } from "@/registry/components/locale-picker"; + +export function LocalePickerDemo() { + return ( + console.log("Selected locale:", locale)} + label="english" // native, english + variant="outline" // outline, ghost, secondary + size="default" // sm, default, lg + /> + ); +} diff --git a/src/registry/components/index.ts b/src/registry/components/index.ts index d31a9de..7ba1477 100644 --- a/src/registry/components/index.ts +++ b/src/registry/components/index.ts @@ -33,3 +33,10 @@ export type { TranslationWidgetClassNames, TranslationWidgetLabels, } from "./translation-widget"; + +export { LocalePicker } 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 new file mode 100644 index 0000000..92d29dc --- /dev/null +++ b/src/registry/components/locale-picker.tsx @@ -0,0 +1,827 @@ +"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 { 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; +} + +/** 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. + */ +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; +/** + * 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: "EUR", + 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 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, + IS: 7, + JO: 6, + JP: 7, + KE: 7, + KR: 7, + KW: 6, + MO: 7, + MT: 7, + MX: 7, + MV: 5, + NP: 7, + OM: 6, + PH: 7, + PK: 7, + PR: 7, + PT: 7, + QA: 6, + SA: 7, + SG: 7, + TH: 7, + TW: 7, + US: 7, + VE: 7, + ZA: 7, +}; + +/** 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; +}; + +/** 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; + /** 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; + /** 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. */ +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", + variant = "outline", + size = "default", + align = "start", + side: preferredSide = "bottom", + searchable = true, + searchPlaceholder, + emptyMessage, + disabled = false, + showSummary = true, + labels, + classNames, + 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 [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); + + 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; + + // 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; + + 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: () => !disabled && setOpen(true), + onSelect: (index) => { + const picked = filtered[index]; + if (picked) setLocale(picked.code); + }, + triggerRef: () => triggerRef.current, + }); + + const preferences = React.useMemo(() => derivePreferences(active, region), [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 && 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 && ( + + {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 && ( +
+ {text.emptyMessage} +
+ )} +
+
+ )} +
+ + {showSummary && ( +
+
+
{text.currency}
+
+ {preferences.currency ? ( + <> + + {preferences.currency} + + {preferences.currencyPreview} + + ) : ( + — + )} +
+
+
+
{text.dateFormat}
+
{preferences.datePreview}
+
+
+
{text.numberFormat}
+
{preferences.numberPreview}
+
+
+
{text.weekStart}
+
{preferences.firstWeekday}
+
+
+ )} +
+
+ ); + }, +); diff --git a/src/registry/registry.ts b/src/registry/registry.ts index 52884c5..19f88b7 100644 --- a/src/registry/registry.ts +++ b/src/registry/registry.ts @@ -139,6 +139,22 @@ const ITEMS: ItemDef[] = [ filePath: "lib/utils.ts", dependencies: ["clsx", "tailwind-merge"], }, + { + 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", "lucide-react", "motion"], + 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..e5089e5 --- /dev/null +++ b/test/locale-picker.test.tsx @@ -0,0 +1,745 @@ +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"]; + +/** 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("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(); + + 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", + // Bulgaria adopted the euro on 1 January 2026 — was BGN. + BG: "EUR", + }); + }); + + 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); + } + }); +}); + +/** + * 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"; + +// The test below pins the fallback table to the *runtime's* CLDR week data. +// A failure after a Node/ICU upgrade means the runtime's data moved — the +// component prefers runtime `getWeekInfo` anyway and the table only backstops +// older engines, so refresh REGION_FIRST_DAY to match the new runtime. + +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); + }); +}); + +/** + * 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"); + + // Synchronous close: nothing awaits before the assertion below, so the + // 0.15s exit animation cannot have unmounted the panel first — a real + // `userEvent.click` yields long enough for it to finish on slow machines. + fireEvent.click(trigger()); + + 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"); + }); +}); + +describe("LocalePicker labels", () => { + it("renders custom labels in place of the English defaults", async () => { + render( + `Région actuelle : ${name}`, + currency: "Devise", + dateFormat: "Format de date", + numberFormat: "Format de nombre", + weekStart: "La semaine commence le", + }} + />, + ); + await userEvent.click(screen.getByRole("button", { name: "Région actuelle : India" })); + + const search = screen.getByRole("combobox", { name: "Rechercher une région" }); + expect(search).toHaveAttribute("placeholder", "Rechercher…"); + expect(screen.getByRole("listbox", { name: "Choisir la région" })).toBeInTheDocument(); + + await userEvent.type(search, "zzz"); + expect(screen.getByText("Aucune région trouvée")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Effacer la recherche" })).toBeInTheDocument(); + + expect(screen.getByText("Devise")).toBeInTheDocument(); + expect(screen.getByText("Format de date")).toBeInTheDocument(); + expect(screen.getByText("Format de nombre")).toBeInTheDocument(); + expect(screen.getByText("La semaine commence le")).toBeInTheDocument(); + }); + + it("keeps the English default for any label left out", async () => { + render(); + await userEvent.click(trigger()); + + expect(screen.getByRole("combobox", { name: "Search regions" })).toHaveAttribute( + "placeholder", + "Search regions...", + ); + expect(screen.getByRole("listbox", { name: "Choisir la région" })).toBeInTheDocument(); + expect(trigger()).toHaveAccessibleName("Current region: India"); + }); + + it("lets the searchPlaceholder prop win over labels", async () => { + render( + , + ); + await userEvent.click(trigger()); + + expect(searchBox()).toHaveAttribute("placeholder", "Find a region"); + }); +}); + +describe("LocalePicker summary card", () => { + it("renders by default and hides with showSummary={false}", () => { + const { rerender } = render(); + expect(screen.getByText("Currency")).toBeInTheDocument(); + + rerender(); + expect(screen.queryByText("Currency")).toBeNull(); + expect(screen.queryByText("Date format")).toBeNull(); + }); +}); + +describe("LocalePicker regions list", () => { + it("renders one option per distinct region", async () => { + render(); + await userEvent.click(trigger()); + + expect(screen.getAllByRole("option")).toHaveLength(2); + }); +}); 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];