@djangocfg/ui-core 2.1.303 → 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 +30 -19
- package/package.json +5 -4
- package/src/components/index.ts +2 -0
- package/src/components/overlay/dialog/dialog.story.tsx +75 -0
- package/src/components/overlay/dialog/index.tsx +33 -7
- package/src/components/select/index.ts +1 -2
- package/src/components/select/language-select.tsx +10 -291
- package/src/components/specialized/flag/Flag.tsx +55 -0
- package/src/components/specialized/flag/flag-map.ts +68 -0
- package/src/components/specialized/flag/flag.story.tsx +82 -0
- package/src/components/specialized/flag/index.ts +5 -0
- package/src/components/specialized/flag/language-to-country.ts +258 -0
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
|
-
|
|
57
|
+
**`Dialog`** — `DialogContent` adds two props on top of Radix:
|
|
58
58
|
|
|
59
|
-
|
|
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
|
|
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 (
|
|
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.
|
|
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.
|
|
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.
|
|
163
|
+
"@djangocfg/i18n": "^2.1.307",
|
|
163
164
|
"@djangocfg/playground": "workspace:*",
|
|
164
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
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",
|
package/src/components/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
51
|
-
<
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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,
|
|
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
|
|
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 &&
|
|
557
|
-
<
|
|
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 &&
|
|
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.
|
|
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 &&
|
|
689
|
-
<
|
|
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
|
+
}
|