@djangocfg/ui-core 2.1.304 → 2.1.307

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -54,9 +54,25 @@ import { Button, Dialog, Table } from '@djangocfg/ui-core';
54
54
  ### Overlay (9)
55
55
  `Dialog` `AlertDialog` `Sheet` `Drawer` `Popover` `HoverCard` `Tooltip` `ResponsiveSheet` `SidePanel`
56
56
 
57
- Default **`TooltipContent`** styling uses semantic **popover** tokens (`bg-popover`, `text-popover-foreground`, `border-border`, shadow) not `primary` so hints read as neutral floating UI.
57
+ **`Dialog`** — `DialogContent` adds two props on top of Radix:
58
58
 
59
- **`SidePanel`** — non-modal side drawer for inspector panels, playgrounds, filters. Slides in from the right (`side="right"`, default) or left edge. Unlike `Sheet`/`Drawer`, it does NOT lock the rest of the page surrounding UI stays clickable and focusable. Esc-to-close (toggleable) and swipe-to-close via vaul. Use when you want a slide-in surface that co-exists with the page below; for modal side surfaces, prefer `Sheet`.
59
+ - `fullscreen` — drops card chrome and stretches to `100dvw × 100dvh`.
60
+ - `closeButton` — `undefined` (default `X`), `false` (none), or a `ReactNode` (wrap in `DialogClose`).
61
+
62
+ ```tsx
63
+ <DialogContent
64
+ fullscreen
65
+ closeButton={
66
+ <DialogClose className="absolute right-4 top-4 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border bg-background/90 hover:bg-accent">
67
+ <X className="h-4 w-4" />
68
+ </DialogClose>
69
+ }
70
+ >
71
+ …your fullscreen layout…
72
+ </DialogContent>
73
+ ```
74
+
75
+ **`SidePanel`** — non-modal side drawer (right by default, or left). Surrounding UI stays interactive; Esc-to-close + swipe-to-close via vaul. Prefer `Sheet` for modal side surfaces.
60
76
 
61
77
  ```tsx
62
78
  <SidePanel open={open} onOpenChange={setOpen} side="right">
@@ -71,7 +87,7 @@ Default **`TooltipContent`** styling uses semantic **popover** tokens (`bg-popov
71
87
  </SidePanel>
72
88
  ```
73
89
 
74
- **`Drawer`** — modal vaul-based panel that slides in from any edge (`top` / `right` / `bottom` / `left`). Picks a size from a preset table or takes an explicit CSS length; both are applied via inline `style` so vaul's first-paint measurement matches the final layout (no inset miscalc).
90
+ **`Drawer`** — modal vaul panel sliding from any edge (`top` / `right` / `bottom` / `left`). Sizes: `sm` `md` `lg` `xl` `full`, default `md`; or pass an explicit `width` / `height`.
75
91
 
76
92
  ```tsx
77
93
  <Drawer direction="right">
@@ -85,20 +101,6 @@ Default **`TooltipContent`** styling uses semantic **popover** tokens (`bg-popov
85
101
  </Drawer>
86
102
  ```
87
103
 
88
- Size presets — `sm` `md` `lg` `xl` `full`. Maps to width for `left`/`right`, height for `top`/`bottom`:
89
-
90
- | Size | Horizontal width | Vertical height |
91
- |------|-------------------|------------------|
92
- | sm | `min(100vw, 360px)` | `min(100vh, 240px)` |
93
- | md | `min(100vw, 480px)` | `min(100vh, 360px)` |
94
- | lg | `min(100vw, 640px)` | `min(100vh, 480px)` |
95
- | xl | `min(100vw, 800px)` | `min(100vh, 640px)` |
96
- | full | `100vw` | `100vh` |
97
-
98
- If you need an exact size, pass `width` / `height` (a CSS length string or number → px). Inline-applied so the vaul measurement is correct on first paint.
99
-
100
- **Migration note** — the default size changed (was hardcoded `280px` for left/right, `auto` for top/bottom). New default is `size="md"`. Pass `size="sm"` (~360px) for the closest old left/right behavior, or set an explicit `width` / `height`.
101
-
102
104
  ### Navigation (8)
103
105
  `Tabs` `Accordion` `Collapsible` `Command` `ContextMenu` `DropdownMenu` `Menubar` `NavigationMenu`
104
106
 
@@ -108,8 +110,17 @@ If you need an exact size, pass `width` / `height` (a CSS length string or numbe
108
110
  ### Feedback (5)
109
111
  `Alert` `Toaster` (Sonner) `Spinner` `Empty` `Preloader`
110
112
 
111
- ### Specialized (7)
112
- `Kbd` `TokenIcon` `Item` `Portal` `ImageWithFallback` `CopyButton` `CopyField`
113
+ ### Specialized (9)
114
+ `Kbd` `TokenIcon` `Item` `Portal` `ImageWithFallback` `CopyButton` `CopyField` `Flag` `LanguageFlag`
115
+
116
+ **`Flag`** / **`LanguageFlag`** — SVG country flags (3:2 aspect) backed by `country-flag-icons`. `Flag` takes an ISO 3166-1 alpha-2 `countryCode`; `LanguageFlag` resolves a locale (`'en'`, `'pt-BR'`, `'zh_CN'`) to a country and renders the flag. Both render `null` for unknown codes.
117
+
118
+ ```tsx
119
+ <Flag countryCode="JP" rounded className="h-3 w-4" />
120
+ <LanguageFlag code="pt-BR" rounded className="h-3 w-4" />
121
+ ```
122
+
123
+ Helper: `getLanguageCountryCode(locale)` returns the resolved alpha-2 code (or `null`). The static `LANGUAGE_TO_COUNTRY` map is exported for callers that need their own lookup.
113
124
 
114
125
  ## Hooks (30+)
115
126
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/ui-core",
3
- "version": "2.1.304",
3
+ "version": "2.1.307",
4
4
  "description": "Pure React UI component library without Next.js dependencies - for Electron, Vite, CRA apps",
5
5
  "keywords": [
6
6
  "ui-components",
@@ -91,7 +91,7 @@
91
91
  "playground": "playground dev"
92
92
  },
93
93
  "peerDependencies": {
94
- "@djangocfg/i18n": "^2.1.304",
94
+ "@djangocfg/i18n": "^2.1.307",
95
95
  "consola": "^3.4.2",
96
96
  "lucide-react": "^0.545.0",
97
97
  "moment": "^2.30.1",
@@ -144,6 +144,7 @@
144
144
  "clsx": "^2.1.1",
145
145
  "cmdk": "1.1.1",
146
146
  "countries-list": "^3.2.2",
147
+ "country-flag-icons": "^1.6.16",
147
148
  "date-fns": "^4.1.0",
148
149
  "embla-carousel-react": "8.6.0",
149
150
  "i18n-iso-countries": "^7.14.0",
@@ -159,9 +160,9 @@
159
160
  "vaul": "1.1.2"
160
161
  },
161
162
  "devDependencies": {
162
- "@djangocfg/i18n": "^2.1.304",
163
+ "@djangocfg/i18n": "^2.1.307",
163
164
  "@djangocfg/playground": "workspace:*",
164
- "@djangocfg/typescript-config": "^2.1.304",
165
+ "@djangocfg/typescript-config": "^2.1.307",
165
166
  "@types/node": "^24.7.2",
166
167
  "@types/react": "^19.1.0",
167
168
  "@types/react-dom": "^19.1.0",
@@ -121,6 +121,8 @@ export { Portal } from './specialized/portal';
121
121
  export type { PortalProps } from './specialized/portal';
122
122
  export { ImageWithFallback } from './specialized/image-with-fallback';
123
123
  export type { ImageWithFallbackProps } from './specialized/image-with-fallback';
124
+ export { Flag, LanguageFlag, LANGUAGE_TO_COUNTRY, getLanguageCountryCode, FLAG_COMPONENTS } from './specialized/flag';
125
+ export type { FlagProps, LanguageFlagProps, FlagSvgComponent } from './specialized/flag';
124
126
 
125
127
  // ─────────────────────────────────────────────────────────────────────────────
126
128
  // Effects
@@ -1,3 +1,4 @@
1
+ import { ArrowLeft, X } from 'lucide-react';
1
2
  import { defineStory } from '@djangocfg/playground';
2
3
  import {
3
4
  Dialog,
@@ -99,6 +100,80 @@ export const Confirmation = () => (
99
100
  </Dialog>
100
101
  );
101
102
 
103
+ export const Fullscreen = () => (
104
+ <Dialog>
105
+ <DialogTrigger asChild>
106
+ <Button variant="outline">Open fullscreen dialog</Button>
107
+ </DialogTrigger>
108
+ <DialogContent fullscreen>
109
+ <div className="mx-auto flex h-full w-full max-w-3xl flex-col px-4 py-10 sm:px-6 lg:px-8">
110
+ <DialogTitle className="text-2xl font-semibold tracking-tight">
111
+ Fullscreen layout
112
+ </DialogTitle>
113
+ <DialogDescription className="mt-1 text-muted-foreground">
114
+ `fullscreen` removes the centred-card chrome and stretches the content to
115
+ the viewport. Build your own header / footer inside.
116
+ </DialogDescription>
117
+ <div className="mt-6 flex-1 rounded-xl border border-dashed border-border/60 bg-muted/30 p-6 text-sm text-muted-foreground">
118
+ Slot for a custom layout — gallery, onboarding, command palette, etc.
119
+ </div>
120
+ </div>
121
+ </DialogContent>
122
+ </Dialog>
123
+ );
124
+
125
+ export const CustomCloseButton = () => (
126
+ <Dialog>
127
+ <DialogTrigger asChild>
128
+ <Button variant="outline">Open with pill close</Button>
129
+ </DialogTrigger>
130
+ <DialogContent
131
+ fullscreen
132
+ closeButton={
133
+ <DialogClose
134
+ aria-label="Close"
135
+ className="absolute right-4 top-4 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-border/60 bg-background/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-foreground"
136
+ >
137
+ <X className="h-4 w-4" />
138
+ </DialogClose>
139
+ }
140
+ >
141
+ <div className="mx-auto flex h-full w-full max-w-2xl flex-col px-4 py-10">
142
+ <DialogTitle>Custom close affordance</DialogTitle>
143
+ <DialogDescription className="mt-1 text-muted-foreground">
144
+ Pass `closeButton` to override the default Radix `X`. Pass `false` to
145
+ drop the affordance entirely.
146
+ </DialogDescription>
147
+ </div>
148
+ </DialogContent>
149
+ </Dialog>
150
+ );
151
+
152
+ export const NoCloseButton = () => (
153
+ <Dialog>
154
+ <DialogTrigger asChild>
155
+ <Button variant="outline">Modal step (no close)</Button>
156
+ </DialogTrigger>
157
+ <DialogContent closeButton={false}>
158
+ <DialogHeader>
159
+ <DialogTitle>Saving…</DialogTitle>
160
+ <DialogDescription>
161
+ The dialog has no close affordance — dismissal is driven by the action
162
+ buttons below or programmatically.
163
+ </DialogDescription>
164
+ </DialogHeader>
165
+ <DialogFooter>
166
+ <DialogClose asChild>
167
+ <Button variant="outline">
168
+ <ArrowLeft className="mr-1.5 h-3.5 w-3.5" />
169
+ Back
170
+ </Button>
171
+ </DialogClose>
172
+ </DialogFooter>
173
+ </DialogContent>
174
+ </Dialog>
175
+ );
176
+
102
177
  export const LongContent = () => (
103
178
  <Dialog>
104
179
  <DialogTrigger asChild>
@@ -31,26 +31,52 @@ const DialogOverlay = React.forwardRef<
31
31
  ))
32
32
  DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
33
33
 
34
+ interface DialogContentProps
35
+ extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
36
+ /**
37
+ * Stretch the content to fill the viewport (`100dvw × 100dvh`) and drop
38
+ * the centred-card chrome (no `max-w`, no `rounded`, no `border`, no
39
+ * default padding). Use for full-screen pickers, galleries, onboarding.
40
+ */
41
+ fullscreen?: boolean
42
+ /**
43
+ * Close affordance shown in the top-right corner.
44
+ * - `undefined` (default): renders the built-in `X` button.
45
+ * - `false`: no close button — use this when the surrounding UI provides
46
+ * its own close trigger (or the dialog has no dismiss).
47
+ * - `ReactNode`: replaces the built-in button. Wrap your node in
48
+ * `DialogClose` so it dismisses the dialog.
49
+ */
50
+ closeButton?: React.ReactNode | false
51
+ }
52
+
34
53
  const DialogContent = React.forwardRef<
35
54
  React.ElementRef<typeof DialogPrimitive.Content>,
36
- React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
37
- >(({ className, children, ...props }, ref) => (
55
+ DialogContentProps
56
+ >(({ className, children, fullscreen = false, closeButton, ...props }, ref) => (
38
57
  <DialogPortal>
39
58
  <DialogOverlay />
40
59
  <DialogPrimitive.Content
41
60
  ref={ref}
42
61
  className={cn(
43
- "fixed left-1/2 top-1/2 z-600 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg",
62
+ "fixed z-600 bg-background duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
63
+ fullscreen
64
+ ? "left-0 top-0 grid h-[100dvh] w-screen max-w-none gap-0 border-0 p-0 shadow-none data-[state=closed]:zoom-out-[0.98] data-[state=open]:zoom-in-[0.98]"
65
+ : "left-1/2 top-1/2 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border p-6 shadow-lg sm:rounded-lg data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
44
66
  className
45
67
  )}
46
68
  {...props}
47
69
  >
48
70
  {children}
49
71
  <DialogPrimitive.Description className="sr-only" />
50
- <DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
51
- <Cross2Icon className="h-4 w-4" />
52
- <span className="sr-only">Close</span>
53
- </DialogPrimitive.Close>
72
+ {closeButton === undefined ? (
73
+ <DialogPrimitive.Close className="absolute right-4 top-4 cursor-pointer rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
74
+ <Cross2Icon className="h-4 w-4" />
75
+ <span className="sr-only">Close</span>
76
+ </DialogPrimitive.Close>
77
+ ) : closeButton === false ? null : (
78
+ closeButton
79
+ )}
54
80
  </DialogPrimitive.Content>
55
81
  </DialogPortal>
56
82
  ))
@@ -58,10 +58,9 @@ export type { OptionBuilderConfig } from './helpers';
58
58
  export { CountrySelect } from './country-select';
59
59
  export type { CountrySelectProps, CountryOption } from './country-select';
60
60
  export type { TCountryCode } from 'countries-list';
61
- export { getEmojiFlag } from 'countries-list';
62
61
 
63
62
  // LanguageSelect
64
- export { LanguageSelect, getLanguageFlag } from './language-select';
63
+ export { LanguageSelect } from './language-select';
65
64
  export type { LanguageSelectProps, LanguageOption, TLanguageCode } from './language-select';
66
65
 
67
66
  // Shared types
@@ -2,289 +2,11 @@
2
2
 
3
3
  import { Check, ChevronsUpDown, Search, X } from 'lucide-react';
4
4
  import * as React from 'react';
5
- import { languages, getEmojiFlag, type TLanguageCode, type TCountryCode } from 'countries-list';
5
+ import { languages, type TLanguageCode } from 'countries-list';
6
6
 
7
7
  import { cn } from '../../lib/utils';
8
+ import { LanguageFlag } from '../specialized/flag';
8
9
 
9
- /**
10
- * Mapping of language codes (ISO 639-1) to primary country codes (ISO 3166-1 alpha-2)
11
- * Used to display country flag emoji for each language
12
- *
13
- * Note: Some languages are spoken in multiple countries, we pick the most representative one.
14
- */
15
- const LANGUAGE_TO_COUNTRY: Partial<Record<TLanguageCode, TCountryCode>> = {
16
- // Major world languages
17
- en: 'US', // English → United States
18
- zh: 'CN', // Chinese → China
19
- es: 'ES', // Spanish → Spain
20
- ar: 'SA', // Arabic → Saudi Arabia
21
- hi: 'IN', // Hindi → India
22
- bn: 'BD', // Bengali → Bangladesh
23
- pt: 'BR', // Portuguese → Brazil
24
- ru: 'RU', // Russian → Russia
25
- ja: 'JP', // Japanese → Japan
26
- de: 'DE', // German → Germany
27
- fr: 'FR', // French → France
28
- ko: 'KR', // Korean → South Korea
29
- it: 'IT', // Italian → Italy
30
- tr: 'TR', // Turkish → Turkey
31
- vi: 'VN', // Vietnamese → Vietnam
32
- pl: 'PL', // Polish → Poland
33
- uk: 'UA', // Ukrainian → Ukraine
34
- nl: 'NL', // Dutch → Netherlands
35
-
36
- // European languages
37
- el: 'GR', // Greek → Greece
38
- cs: 'CZ', // Czech → Czech Republic
39
- sv: 'SE', // Swedish → Sweden
40
- hu: 'HU', // Hungarian → Hungary
41
- ro: 'RO', // Romanian → Romania
42
- da: 'DK', // Danish → Denmark
43
- fi: 'FI', // Finnish → Finland
44
- no: 'NO', // Norwegian → Norway
45
- nb: 'NO', // Norwegian Bokmål → Norway
46
- nn: 'NO', // Norwegian Nynorsk → Norway
47
- sk: 'SK', // Slovak → Slovakia
48
- bg: 'BG', // Bulgarian → Bulgaria
49
- hr: 'HR', // Croatian → Croatia
50
- sr: 'RS', // Serbian → Serbia
51
- sl: 'SI', // Slovenian → Slovenia
52
- et: 'EE', // Estonian → Estonia
53
- lv: 'LV', // Latvian → Latvia
54
- lt: 'LT', // Lithuanian → Lithuania
55
- be: 'BY', // Belarusian → Belarus
56
- mk: 'MK', // Macedonian → North Macedonia
57
- sq: 'AL', // Albanian → Albania
58
- is: 'IS', // Icelandic → Iceland
59
- mt: 'MT', // Maltese → Malta
60
- ga: 'IE', // Irish → Ireland
61
- cy: 'GB', // Welsh → United Kingdom
62
- gd: 'GB', // Scottish Gaelic → United Kingdom
63
- br: 'FR', // Breton → France
64
- lb: 'LU', // Luxembourgish → Luxembourg
65
- fo: 'FO', // Faroese → Faroe Islands
66
- bs: 'BA', // Bosnian → Bosnia
67
-
68
- // Spanish regional
69
- eu: 'ES', // Basque → Spain
70
- ca: 'ES', // Catalan → Spain
71
- gl: 'ES', // Galician → Spain
72
-
73
- // Asian languages
74
- th: 'TH', // Thai → Thailand
75
- id: 'ID', // Indonesian → Indonesia
76
- ms: 'MY', // Malay → Malaysia
77
- tl: 'PH', // Tagalog → Philippines
78
- jv: 'ID', // Javanese → Indonesia
79
- su: 'ID', // Sundanese → Indonesia
80
-
81
- // South Asian languages
82
- ta: 'IN', // Tamil → India
83
- te: 'IN', // Telugu → India
84
- mr: 'IN', // Marathi → India
85
- gu: 'IN', // Gujarati → India
86
- kn: 'IN', // Kannada → India
87
- ml: 'IN', // Malayalam → India
88
- pa: 'IN', // Punjabi → India
89
- or: 'IN', // Odia → India
90
- as: 'IN', // Assamese → India
91
- ne: 'NP', // Nepali → Nepal
92
- si: 'LK', // Sinhala → Sri Lanka
93
- dz: 'BT', // Dzongkha → Bhutan
94
-
95
- // Middle East & Central Asia
96
- fa: 'IR', // Persian/Farsi → Iran
97
- ur: 'PK', // Urdu → Pakistan
98
- ps: 'AF', // Pashto → Afghanistan
99
- he: 'IL', // Hebrew → Israel
100
- ku: 'IQ', // Kurdish → Iraq
101
- hy: 'AM', // Armenian → Armenia
102
- ka: 'GE', // Georgian → Georgia
103
- az: 'AZ', // Azerbaijani → Azerbaijan
104
- kk: 'KZ', // Kazakh → Kazakhstan
105
- uz: 'UZ', // Uzbek → Uzbekistan
106
- tg: 'TJ', // Tajik → Tajikistan
107
- ky: 'KG', // Kyrgyz → Kyrgyzstan
108
- tk: 'TM', // Turkmen → Turkmenistan
109
- mn: 'MN', // Mongolian → Mongolia
110
-
111
- // Southeast Asian
112
- my: 'MM', // Burmese → Myanmar
113
- km: 'KH', // Khmer → Cambodia
114
- lo: 'LA', // Lao → Laos
115
-
116
- // African languages
117
- sw: 'KE', // Swahili → Kenya
118
- am: 'ET', // Amharic → Ethiopia
119
- ha: 'NG', // Hausa → Nigeria
120
- yo: 'NG', // Yoruba → Nigeria
121
- ig: 'NG', // Igbo → Nigeria
122
- zu: 'ZA', // Zulu → South Africa
123
- xh: 'ZA', // Xhosa → South Africa
124
- af: 'ZA', // Afrikaans → South Africa
125
- st: 'ZA', // Southern Sotho → South Africa
126
- tn: 'BW', // Tswana → Botswana
127
- sn: 'ZW', // Shona → Zimbabwe
128
- rw: 'RW', // Kinyarwanda → Rwanda
129
- rn: 'BI', // Kirundi → Burundi
130
- so: 'SO', // Somali → Somalia
131
- ti: 'ER', // Tigrinya → Eritrea
132
- mg: 'MG', // Malagasy → Madagascar
133
- ny: 'MW', // Chichewa → Malawi
134
- lg: 'UG', // Luganda → Uganda
135
- wo: 'SN', // Wolof → Senegal
136
- ff: 'SN', // Fulah → Senegal
137
- bm: 'ML', // Bambara → Mali
138
-
139
- // Caribbean & Creole
140
- ht: 'HT', // Haitian Creole → Haiti
141
-
142
- // Pacific
143
- mi: 'NZ', // Maori → New Zealand
144
- sm: 'WS', // Samoan → Samoa
145
- to: 'TO', // Tongan → Tonga
146
- fj: 'FJ', // Fijian → Fiji
147
- mh: 'MH', // Marshallese → Marshall Islands
148
- ty: 'PF', // Tahitian → French Polynesia
149
- na: 'NR', // Nauruan → Nauru
150
- bi: 'VU', // Bislama → Vanuatu
151
- ch: 'GU', // Chamorro → Guam
152
-
153
- // Other constructed/historical
154
- // Note: Constructed languages have no country, using symbolic mappings
155
- // eo: Esperanto - no country flag available
156
- la: 'VA', // Latin → Vatican
157
- // ia: Interlingua - no country flag available
158
- // ie: Interlingue - no country flag available
159
- // io: Ido - no country flag available
160
- // vo: Volapük - no country flag available
161
-
162
- // China minority languages
163
- bo: 'CN', // Tibetan → China
164
- ug: 'CN', // Uyghur → China
165
- za: 'CN', // Zhuang → China
166
- ii: 'CN', // Sichuan Yi → China
167
-
168
- // South Asia
169
- dv: 'MV', // Dhivehi → Maldives
170
- ks: 'IN', // Kashmiri → India
171
- sd: 'PK', // Sindhi → Pakistan
172
- sa: 'IN', // Sanskrit → India
173
- pi: 'IN', // Pali → India (historical Buddhist)
174
-
175
- // Baltic-Finnic
176
- fy: 'NL', // Western Frisian → Netherlands
177
-
178
- // Caucasian
179
- os: 'RU', // Ossetic → Russia
180
- ce: 'RU', // Chechen → Russia
181
- av: 'RU', // Avar → Russia
182
- ab: 'GE', // Abkhazian → Georgia
183
-
184
- // Turkic in Russia
185
- tt: 'RU', // Tatar → Russia
186
- ba: 'RU', // Bashkir → Russia
187
- cv: 'RU', // Chuvash → Russia
188
-
189
- // Russia minority
190
- kv: 'RU', // Komi → Russia
191
-
192
- // Africa additional
193
- aa: 'ET', // Afar → Ethiopia
194
- ak: 'GH', // Akan → Ghana
195
- ee: 'GH', // Ewe → Ghana
196
- tw: 'GH', // Twi → Ghana
197
- ln: 'CD', // Lingala → DR Congo
198
- lu: 'CD', // Luba-Katanga → DR Congo
199
- kg: 'CD', // Kongo → DR Congo
200
- sg: 'CF', // Sango → Central African Republic
201
- ki: 'KE', // Kikuyu → Kenya
202
- om: 'ET', // Oromo → Ethiopia
203
- kr: 'NE', // Kanuri → Niger
204
- kj: 'NA', // Kuanyama → Namibia
205
- hz: 'NA', // Herero → Namibia
206
- ng: 'NA', // Ndonga → Namibia
207
- nd: 'ZW', // North Ndebele → Zimbabwe
208
- nr: 'ZA', // South Ndebele → South Africa
209
- ss: 'SZ', // Swati → Eswatini (Swaziland)
210
- ts: 'ZA', // Tsonga → South Africa
211
- ve: 'ZA', // Venda → South Africa
212
-
213
- // Americas indigenous
214
- gn: 'PY', // Guarani → Paraguay
215
- qu: 'PE', // Quechua → Peru
216
- ay: 'BO', // Aymara → Bolivia
217
- nv: 'US', // Navajo → United States
218
- oj: 'CA', // Ojibwa → Canada
219
- cr: 'CA', // Cree → Canada
220
- iu: 'CA', // Inuktitut → Canada
221
- ik: 'US', // Inupiak → United States (Alaska)
222
-
223
- // Greenland
224
- kl: 'GL', // Greenlandic → Greenland
225
-
226
- // Other European
227
- an: 'ES', // Aragonese → Spain
228
- wa: 'BE', // Walloon → Belgium
229
- li: 'NL', // Limburgian → Netherlands
230
-
231
- // Slavic historical
232
- cu: 'BG', // Old Church Slavonic → Bulgaria
233
- mo: 'MD', // Moldovan → Moldova
234
-
235
- // Jewish
236
- yi: 'IL', // Yiddish → Israel
237
-
238
- // Bihari (group)
239
- bh: 'IN', // Bihari → India
240
-
241
- // Papua New Guinea
242
- ho: 'PG', // Hiri Motu → Papua New Guinea
243
-
244
- // Other Slavic
245
- sh: 'RS', // Serbo-Croatian → Serbia
246
-
247
- // Romance
248
- oc: 'FR', // Occitan → France
249
- co: 'FR', // Corsican → France
250
- sc: 'IT', // Sardinian → Italy
251
- rm: 'CH', // Romansh → Switzerland
252
-
253
- // Germanic
254
- gv: 'IM', // Manx → Isle of Man
255
- kw: 'GB', // Cornish → United Kingdom
256
-
257
- // Uralic
258
- se: 'NO', // Northern Sami → Norway
259
-
260
- // Note: ch (Chamorro) already defined above in Pacific section
261
- };
262
-
263
- /**
264
- * Get emoji flag for a language code
265
- * @param languageCode - ISO 639-1 language code (e.g., 'en', 'ru', 'ko')
266
- * @returns Emoji flag string or empty string if no mapping exists
267
- */
268
- export function getLanguageFlag(languageCode: TLanguageCode | string): string {
269
- const code = languageCode.toLowerCase();
270
-
271
- // Handle locale with region: pt-BR, pt-br, zh-CN, etc.
272
- if (code.includes('-') || code.includes('_')) {
273
- const parts = code.split(/[-_]/);
274
- const region = parts[1]?.toUpperCase() as TCountryCode;
275
- // Try region code as country first (pt-BR → BR)
276
- if (region && region.length === 2) {
277
- const flag = getEmojiFlag(region);
278
- if (flag) return flag;
279
- }
280
- }
281
-
282
- // Fall back to language → country mapping
283
- const langCode = code.split(/[-_]/)[0] as TLanguageCode;
284
- const countryCode = LANGUAGE_TO_COUNTRY[langCode];
285
- if (!countryCode) return '';
286
- return getEmojiFlag(countryCode);
287
- }
288
10
  import { Badge } from '../data/badge';
289
11
  import { Button } from '../forms/button';
290
12
  import { Checkbox } from '../forms/checkbox';
@@ -299,8 +21,6 @@ export interface LanguageOption {
299
21
  code: TLanguageCode;
300
22
  name: string;
301
23
  native: string;
302
- /** Emoji flag (if available) */
303
- flag: string;
304
24
  }
305
25
 
306
26
  export type LanguageSelectVariant = 'dropdown' | 'inline';
@@ -334,7 +54,7 @@ export interface LanguageSelectProps {
334
54
  allowedLanguages?: TLanguageCode[];
335
55
  /** Exclude specific language codes */
336
56
  excludedLanguages?: TLanguageCode[];
337
- /** Show flag emoji (default: true) */
57
+ /** Show country-flag SVG (default: true) */
338
58
  showFlag?: boolean;
339
59
  /** Show language code (default: false) */
340
60
  showCode?: boolean;
@@ -443,7 +163,6 @@ export function LanguageSelect({
443
163
  code,
444
164
  name: getLanguageName?.(code) ?? languages[code].name,
445
165
  native: languages[code].native,
446
- flag: getLanguageFlag(code),
447
166
  }))
448
167
  .sort((a, b) => a.name.localeCompare(b.name));
449
168
  }, [getLanguageName, allowedLanguages, excludedLanguages]);
@@ -553,8 +272,8 @@ export function LanguageSelect({
553
272
  {isSelected && <div className="h-2 w-2 rounded-full bg-primary-foreground" />}
554
273
  </div>
555
274
  )}
556
- {showFlag && language.flag && (
557
- <span className="text-lg">{language.flag}</span>
275
+ {showFlag && (
276
+ <LanguageFlag code={language.code} className="h-3.5 w-5" rounded />
558
277
  )}
559
278
  <div className="flex flex-col flex-1 min-w-0">
560
279
  <span className={cn('text-sm', isSelected && 'font-medium')}>
@@ -591,7 +310,7 @@ export function LanguageSelect({
591
310
  const language = selectedLanguages[0]!;
592
311
  return (
593
312
  <div className="flex items-center gap-2">
594
- {showFlag && language.flag && <span>{language.flag}</span>}
313
+ {showFlag && <LanguageFlag code={language.code} className="h-3 w-4" rounded />}
595
314
  {showCode && <span className="text-xs text-muted-foreground uppercase">{language.code}</span>}
596
315
  <span>{language.name}</span>
597
316
  </div>
@@ -609,7 +328,7 @@ export function LanguageSelect({
609
328
  variant="secondary"
610
329
  className="mr-1 text-xs"
611
330
  >
612
- {showFlag && language.flag && <span className="mr-1">{language.flag}</span>}
331
+ {showFlag && <LanguageFlag code={language.code} className="mr-1 h-3 w-4" rounded />}
613
332
  {showCode && <span className="mr-1 text-muted-foreground uppercase">{language.code}</span>}
614
333
  {language.name}
615
334
  <button
@@ -685,8 +404,8 @@ export function LanguageSelect({
685
404
  isSelected ? "opacity-100" : "opacity-0"
686
405
  )}
687
406
  />
688
- {showFlag && language.flag && (
689
- <span className="mr-2 text-lg">{language.flag}</span>
407
+ {showFlag && (
408
+ <LanguageFlag code={language.code} className="mr-2 h-3.5 w-5" rounded />
690
409
  )}
691
410
  <div className="flex flex-col flex-1 min-w-0">
692
411
  <span className="truncate">{language.name}</span>
@@ -715,4 +434,4 @@ export function LanguageSelect({
715
434
 
716
435
  // Re-export types for convenience
717
436
  export type { TLanguageCode } from 'countries-list';
718
- export { LANGUAGE_TO_COUNTRY };
437
+ export { LANGUAGE_TO_COUNTRY } from '../specialized/flag';
@@ -0,0 +1,55 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ import { cn } from '../../../lib/utils';
6
+
7
+ import { FLAG_COMPONENTS } from './flag-map';
8
+ import { getLanguageCountryCode } from './language-to-country';
9
+
10
+ export interface FlagProps extends Omit<React.SVGAttributes<SVGSVGElement>, 'children'> {
11
+ /** ISO 3166-1 alpha-2 country code (e.g. `'US'`, `'JP'`). Case-insensitive. */
12
+ countryCode: string;
13
+ /** Optional rounded corners — common for tile / chip presentations. */
14
+ rounded?: boolean;
15
+ }
16
+
17
+ /**
18
+ * SVG country flag (3:2 aspect). Renders nothing for unknown country codes
19
+ * — call sites should reserve space if they want a stable layout.
20
+ */
21
+ export function Flag({ countryCode, rounded, className, ...props }: FlagProps) {
22
+ const code = countryCode?.toUpperCase();
23
+ const Component = code ? FLAG_COMPONENTS[code] : undefined;
24
+ if (!Component) return null;
25
+ // `country-flag-icons` types its components against an HTML+SVG intersection;
26
+ // we narrow to plain `SVGAttributes` for our consumers.
27
+ const Tag = Component as unknown as React.ComponentType<React.SVGAttributes<SVGSVGElement>>;
28
+ return (
29
+ <Tag
30
+ className={cn(
31
+ 'inline-block shrink-0 select-none',
32
+ rounded && 'overflow-hidden rounded-[2px]',
33
+ className,
34
+ )}
35
+ aria-hidden="true"
36
+ focusable={false}
37
+ {...props}
38
+ />
39
+ );
40
+ }
41
+
42
+ export interface LanguageFlagProps extends Omit<FlagProps, 'countryCode'> {
43
+ /** Locale / language code (`'en'`, `'pt-BR'`, `'zh_CN'`, …). */
44
+ code: string;
45
+ }
46
+
47
+ /**
48
+ * Convenience wrapper — resolves a locale to a country code and renders `Flag`.
49
+ * Returns `null` when the locale has no country mapping.
50
+ */
51
+ export function LanguageFlag({ code, ...props }: LanguageFlagProps) {
52
+ const country = getLanguageCountryCode(code);
53
+ if (!country) return null;
54
+ return <Flag countryCode={country} {...props} />;
55
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Static map: ISO 3166-1 alpha-2 country code → SVG React component.
3
+ *
4
+ * Only the country codes referenced by `LANGUAGE_TO_COUNTRY` are imported
5
+ * — keeps bundle size predictable while covering every locale supported
6
+ * by the language picker. Add an entry here when a new locale joins the
7
+ * mapping and its country code is not already in the list.
8
+ */
9
+
10
+ import type * as React from 'react';
11
+
12
+ import {
13
+ AF, AL, AM, AZ,
14
+ BA, BD, BE, BG, BI, BO, BR, BT, BW, BY,
15
+ CA, CD, CF, CH, CN, CZ,
16
+ DE, DK,
17
+ EE, ER, ES, ET,
18
+ FI, FJ, FO, FR,
19
+ GB, GE, GH, GL, GR, GU,
20
+ HR, HT, HU,
21
+ ID, IE, IL, IM, IN, IQ, IR, IS, IT,
22
+ JP,
23
+ KE, KG, KH, KR, KZ,
24
+ LA, LK, LT, LU, LV,
25
+ MD, MG, MH, MK, ML, MM, MN, MT, MV, MW, MY,
26
+ NA, NE, NG, NL, NO, NP, NR, NZ,
27
+ PE, PF, PG, PH, PK, PL, PY,
28
+ RO, RS, RU, RW,
29
+ SA, SE, SI, SK, SN, SO, SZ,
30
+ TH, TJ, TM, TO, TR,
31
+ UA, UG, US, UZ,
32
+ VA, VN, VU,
33
+ WS,
34
+ ZA, ZW,
35
+ } from 'country-flag-icons/react/3x2';
36
+
37
+ /**
38
+ * `country-flag-icons` types its components against `HTMLSVGElement`
39
+ * (their custom intersection of HTMLElement + SVGElement). We treat them
40
+ * as opaque components — call sites use `<Flag />` rather than touching
41
+ * the DOM type directly.
42
+ */
43
+ export type FlagSvgComponent = (props: React.SVGProps<SVGSVGElement> & React.HTMLAttributes<SVGSVGElement>) => React.JSX.Element;
44
+
45
+ export const FLAG_COMPONENTS = {
46
+ AF, AL, AM, AZ,
47
+ BA, BD, BE, BG, BI, BO, BR, BT, BW, BY,
48
+ CA, CD, CF, CH, CN, CZ,
49
+ DE, DK,
50
+ EE, ER, ES, ET,
51
+ FI, FJ, FO, FR,
52
+ GB, GE, GH, GL, GR, GU,
53
+ HR, HT, HU,
54
+ ID, IE, IL, IM, IN, IQ, IR, IS, IT,
55
+ JP,
56
+ KE, KG, KH, KR, KZ,
57
+ LA, LK, LT, LU, LV,
58
+ MD, MG, MH, MK, ML, MM, MN, MT, MV, MW, MY,
59
+ NA, NE, NG, NL, NO, NP, NR, NZ,
60
+ PE, PF, PG, PH, PK, PL, PY,
61
+ RO, RS, RU, RW,
62
+ SA, SE, SI, SK, SN, SO, SZ,
63
+ TH, TJ, TM, TO, TR,
64
+ UA, UG, US, UZ,
65
+ VA, VN, VU,
66
+ WS,
67
+ ZA, ZW,
68
+ } as unknown as Record<string, FlagSvgComponent>;
@@ -0,0 +1,82 @@
1
+ import * as React from 'react';
2
+
3
+ import { defineStory, useSelect } from '@djangocfg/playground';
4
+
5
+ import { Flag, LanguageFlag } from '.';
6
+ import { Input } from '../../forms/input';
7
+
8
+ export default defineStory({
9
+ title: 'Core/Flag',
10
+ component: Flag,
11
+ description:
12
+ 'SVG country flag (3:2). Flag takes a country code; LanguageFlag resolves a locale → country.',
13
+ });
14
+
15
+ export const Default = () => <Flag countryCode="US" className="h-6 w-9" />;
16
+
17
+ export const Rounded = () => (
18
+ <div className="flex items-center gap-3">
19
+ <Flag countryCode="US" className="h-6 w-9" />
20
+ <Flag countryCode="US" rounded className="h-6 w-9" />
21
+ </div>
22
+ );
23
+
24
+ export const Interactive = () => {
25
+ const [code] = useSelect('country', {
26
+ options: ['US', 'JP', 'BR', 'DE', 'FR', 'KR', 'CN', 'RU', 'GB', 'IL'] as const,
27
+ defaultValue: 'US',
28
+ label: 'Country code',
29
+ });
30
+ const [size] = useSelect('size', {
31
+ options: ['h-3 w-4', 'h-4 w-6', 'h-6 w-9', 'h-10 w-14'] as const,
32
+ defaultValue: 'h-6 w-9',
33
+ label: 'Size',
34
+ });
35
+ return <Flag countryCode={code} rounded className={size} />;
36
+ };
37
+
38
+ export const LanguageInteractive = () => {
39
+ const [code, setCode] = React.useState('pt-BR');
40
+ return (
41
+ <div className="space-y-3">
42
+ <div className="flex items-center gap-3 text-sm">
43
+ <LanguageFlag code={code} rounded className="h-6 w-9" />
44
+ <span className="font-mono text-muted-foreground">{code}</span>
45
+ </div>
46
+ <Input
47
+ value={code}
48
+ onChange={(e) => setCode(e.target.value)}
49
+ placeholder="Locale (en, pt-BR, zh-CN, …)"
50
+ className="max-w-xs"
51
+ />
52
+ </div>
53
+ );
54
+ };
55
+
56
+ export const Grid = () => {
57
+ const codes = [
58
+ 'US', 'GB', 'CA', 'BR', 'DE', 'FR', 'IT', 'ES', 'PT',
59
+ 'NL', 'SE', 'NO', 'DK', 'FI', 'PL', 'CZ', 'GR', 'TR',
60
+ 'RU', 'UA', 'JP', 'KR', 'CN', 'IN', 'TH', 'VN', 'ID', 'IL', 'SA',
61
+ ];
62
+ return (
63
+ <div className="grid grid-cols-6 gap-3">
64
+ {codes.map((c) => (
65
+ <div key={c} className="flex flex-col items-center gap-1">
66
+ <Flag countryCode={c} rounded className="h-6 w-9" />
67
+ <span className="font-mono text-xs text-muted-foreground">{c}</span>
68
+ </div>
69
+ ))}
70
+ </div>
71
+ );
72
+ };
73
+
74
+ export const UnknownCode = () => (
75
+ <div className="text-sm text-muted-foreground">
76
+ Unknown code renders nothing:{' '}
77
+ <span className="inline-flex items-center gap-1.5">
78
+ <Flag countryCode="ZZ" className="h-3 w-4" />
79
+ <span>(no flag here →)</span>
80
+ </span>
81
+ </div>
82
+ );
@@ -0,0 +1,5 @@
1
+ export { Flag, LanguageFlag } from './Flag';
2
+ export type { FlagProps, LanguageFlagProps } from './Flag';
3
+ export { LANGUAGE_TO_COUNTRY, getLanguageCountryCode } from './language-to-country';
4
+ export { FLAG_COMPONENTS } from './flag-map';
5
+ export type { FlagSvgComponent } from './flag-map';
@@ -0,0 +1,258 @@
1
+ /**
2
+ * Mapping of language codes (ISO 639-1) → primary country code (ISO 3166-1 alpha-2).
3
+ *
4
+ * Used to render a country flag SVG for a language code. Many languages are
5
+ * spoken in multiple countries — we pick the most representative one.
6
+ */
7
+
8
+ import type { TLanguageCode, TCountryCode } from 'countries-list';
9
+
10
+ export const LANGUAGE_TO_COUNTRY: Partial<Record<TLanguageCode, TCountryCode>> = {
11
+ // Major world languages
12
+ en: 'US',
13
+ zh: 'CN',
14
+ es: 'ES',
15
+ ar: 'SA',
16
+ hi: 'IN',
17
+ bn: 'BD',
18
+ pt: 'BR',
19
+ ru: 'RU',
20
+ ja: 'JP',
21
+ de: 'DE',
22
+ fr: 'FR',
23
+ ko: 'KR',
24
+ it: 'IT',
25
+ tr: 'TR',
26
+ vi: 'VN',
27
+ pl: 'PL',
28
+ uk: 'UA',
29
+ nl: 'NL',
30
+
31
+ // European
32
+ el: 'GR',
33
+ cs: 'CZ',
34
+ sv: 'SE',
35
+ hu: 'HU',
36
+ ro: 'RO',
37
+ da: 'DK',
38
+ fi: 'FI',
39
+ no: 'NO',
40
+ nb: 'NO',
41
+ nn: 'NO',
42
+ sk: 'SK',
43
+ bg: 'BG',
44
+ hr: 'HR',
45
+ sr: 'RS',
46
+ sl: 'SI',
47
+ et: 'EE',
48
+ lv: 'LV',
49
+ lt: 'LT',
50
+ be: 'BY',
51
+ mk: 'MK',
52
+ sq: 'AL',
53
+ is: 'IS',
54
+ mt: 'MT',
55
+ ga: 'IE',
56
+ cy: 'GB',
57
+ gd: 'GB',
58
+ br: 'FR',
59
+ lb: 'LU',
60
+ fo: 'FO',
61
+ bs: 'BA',
62
+
63
+ // Spanish regional
64
+ eu: 'ES',
65
+ ca: 'ES',
66
+ gl: 'ES',
67
+
68
+ // Asian
69
+ th: 'TH',
70
+ id: 'ID',
71
+ ms: 'MY',
72
+ tl: 'PH',
73
+ jv: 'ID',
74
+ su: 'ID',
75
+
76
+ // South Asian
77
+ ta: 'IN',
78
+ te: 'IN',
79
+ mr: 'IN',
80
+ gu: 'IN',
81
+ kn: 'IN',
82
+ ml: 'IN',
83
+ pa: 'IN',
84
+ or: 'IN',
85
+ as: 'IN',
86
+ ne: 'NP',
87
+ si: 'LK',
88
+ dz: 'BT',
89
+
90
+ // Middle East & Central Asia
91
+ fa: 'IR',
92
+ ur: 'PK',
93
+ ps: 'AF',
94
+ he: 'IL',
95
+ ku: 'IQ',
96
+ hy: 'AM',
97
+ ka: 'GE',
98
+ az: 'AZ',
99
+ kk: 'KZ',
100
+ uz: 'UZ',
101
+ tg: 'TJ',
102
+ ky: 'KG',
103
+ tk: 'TM',
104
+ mn: 'MN',
105
+
106
+ // Southeast Asian
107
+ my: 'MM',
108
+ km: 'KH',
109
+ lo: 'LA',
110
+
111
+ // African
112
+ sw: 'KE',
113
+ am: 'ET',
114
+ ha: 'NG',
115
+ yo: 'NG',
116
+ ig: 'NG',
117
+ zu: 'ZA',
118
+ xh: 'ZA',
119
+ af: 'ZA',
120
+ st: 'ZA',
121
+ tn: 'BW',
122
+ sn: 'ZW',
123
+ rw: 'RW',
124
+ rn: 'BI',
125
+ so: 'SO',
126
+ ti: 'ER',
127
+ mg: 'MG',
128
+ ny: 'MW',
129
+ lg: 'UG',
130
+ wo: 'SN',
131
+ ff: 'SN',
132
+ bm: 'ML',
133
+
134
+ // Caribbean & Creole
135
+ ht: 'HT',
136
+
137
+ // Pacific
138
+ mi: 'NZ',
139
+ sm: 'WS',
140
+ to: 'TO',
141
+ fj: 'FJ',
142
+ mh: 'MH',
143
+ ty: 'PF',
144
+ na: 'NR',
145
+ bi: 'VU',
146
+ ch: 'GU',
147
+
148
+ // Historical / constructed
149
+ la: 'VA',
150
+
151
+ // China minorities
152
+ bo: 'CN',
153
+ ug: 'CN',
154
+ za: 'CN',
155
+ ii: 'CN',
156
+
157
+ // South Asia extras
158
+ dv: 'MV',
159
+ ks: 'IN',
160
+ sd: 'PK',
161
+ sa: 'IN',
162
+ pi: 'IN',
163
+
164
+ fy: 'NL',
165
+
166
+ // Caucasian
167
+ os: 'RU',
168
+ ce: 'RU',
169
+ av: 'RU',
170
+ ab: 'GE',
171
+
172
+ // Turkic in Russia
173
+ tt: 'RU',
174
+ ba: 'RU',
175
+ cv: 'RU',
176
+ kv: 'RU',
177
+
178
+ // Africa extras
179
+ aa: 'ET',
180
+ ak: 'GH',
181
+ ee: 'GH',
182
+ tw: 'GH',
183
+ ln: 'CD',
184
+ lu: 'CD',
185
+ kg: 'CD',
186
+ sg: 'CF',
187
+ ki: 'KE',
188
+ om: 'ET',
189
+ kr: 'NE',
190
+ kj: 'NA',
191
+ hz: 'NA',
192
+ ng: 'NA',
193
+ nd: 'ZW',
194
+ nr: 'ZA',
195
+ ss: 'SZ',
196
+ ts: 'ZA',
197
+ ve: 'ZA',
198
+
199
+ // Americas indigenous
200
+ gn: 'PY',
201
+ qu: 'PE',
202
+ ay: 'BO',
203
+ nv: 'US',
204
+ oj: 'CA',
205
+ cr: 'CA',
206
+ iu: 'CA',
207
+ ik: 'US',
208
+ kl: 'GL',
209
+
210
+ // Other European
211
+ an: 'ES',
212
+ wa: 'BE',
213
+ li: 'NL',
214
+
215
+ // Slavic historical
216
+ cu: 'BG',
217
+ mo: 'MD',
218
+
219
+ yi: 'IL',
220
+ bh: 'IN',
221
+ ho: 'PG',
222
+ sh: 'RS',
223
+
224
+ // Romance
225
+ oc: 'FR',
226
+ co: 'FR',
227
+ sc: 'IT',
228
+ rm: 'CH',
229
+
230
+ // Germanic
231
+ gv: 'IM',
232
+ kw: 'GB',
233
+
234
+ // Uralic
235
+ se: 'NO',
236
+ };
237
+
238
+ /**
239
+ * Resolve a locale (`'en'`, `'pt-BR'`, `'zh_CN'`, …) to an ISO 3166-1 alpha-2
240
+ * country code. Region suffixes win when present (`pt-BR` → `BR`).
241
+ *
242
+ * Returns `null` when no mapping exists — callers should render a graceful
243
+ * fallback (no flag).
244
+ */
245
+ export function getLanguageCountryCode(localeCode: string): TCountryCode | null {
246
+ if (!localeCode) return null;
247
+ const normalized = localeCode.replace('_', '-');
248
+
249
+ // Region wins: pt-BR → BR, zh-CN → CN.
250
+ const dash = normalized.indexOf('-');
251
+ if (dash !== -1) {
252
+ const region = normalized.slice(dash + 1).toUpperCase();
253
+ if (region.length === 2) return region as TCountryCode;
254
+ }
255
+
256
+ const lang = normalized.slice(0, dash === -1 ? normalized.length : dash).toLowerCase() as TLanguageCode;
257
+ return LANGUAGE_TO_COUNTRY[lang] ?? null;
258
+ }