@djangocfg/ui-core 2.1.304 → 2.1.308
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/forms/phone-input/index.tsx +3 -10
- 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/country-select.tsx +7 -9
- 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 +22 -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` and covers every ISO country (~270 entries); `LanguageFlag` resolves a locale (`'en'`, `'pt-BR'`, `'zh_CN'`) to a country and renders the flag. Both render `null` for unknown codes. Used by `LocaleSwitcher`, `PhoneInput`, and `CountrySelect` — no emoji fallbacks anywhere.
|
|
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.308",
|
|
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.308",
|
|
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.308",
|
|
163
164
|
"@djangocfg/playground": "workspace:*",
|
|
164
|
-
"@djangocfg/typescript-config": "^2.1.
|
|
165
|
+
"@djangocfg/typescript-config": "^2.1.308",
|
|
165
166
|
"@types/node": "^24.7.2",
|
|
166
167
|
"@types/react": "^19.1.0",
|
|
167
168
|
"@types/react-dom": "^19.1.0",
|
|
@@ -13,13 +13,7 @@ import { cn } from '../../../lib';
|
|
|
13
13
|
import {
|
|
14
14
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger
|
|
15
15
|
} from '../../navigation/dropdown-menu';
|
|
16
|
-
|
|
17
|
-
// Generate country flag emoji from country code
|
|
18
|
-
const getCountryFlag = (countryCode: CountryCode): string => {
|
|
19
|
-
return countryCode
|
|
20
|
-
.toUpperCase()
|
|
21
|
-
.replace(/./g, char => String.fromCodePoint(char.charCodeAt(0) + 127397))
|
|
22
|
-
}
|
|
16
|
+
import { Flag } from '../../specialized/flag';
|
|
23
17
|
|
|
24
18
|
// Get country name from country code using browser's built-in Intl.DisplayNames
|
|
25
19
|
const getCountryName = (countryCode: CountryCode): string => {
|
|
@@ -37,7 +31,6 @@ const getAllCountries = () => {
|
|
|
37
31
|
return getCountries().map(countryCode => ({
|
|
38
32
|
code: countryCode,
|
|
39
33
|
name: getCountryName(countryCode),
|
|
40
|
-
flag: getCountryFlag(countryCode),
|
|
41
34
|
dialCode: `+${getCountryCallingCode(countryCode)}`
|
|
42
35
|
})).sort((a, b) => a.name.localeCompare(b.name))
|
|
43
36
|
}
|
|
@@ -215,7 +208,7 @@ const PhoneInput = React.forwardRef<HTMLInputElement, PhoneInputProps>(
|
|
|
215
208
|
className="h-10 px-3 rounded-r-none border-r-0 flex items-center gap-2"
|
|
216
209
|
disabled={disabled}
|
|
217
210
|
>
|
|
218
|
-
<
|
|
211
|
+
<Flag countryCode={currentCountry.code} rounded className="h-3 w-4" />
|
|
219
212
|
<span className="text-sm font-mono">{currentCountry.dialCode}</span>
|
|
220
213
|
<ChevronDown className="h-3 w-3 opacity-50" />
|
|
221
214
|
</Button>
|
|
@@ -251,7 +244,7 @@ const PhoneInput = React.forwardRef<HTMLInputElement, PhoneInputProps>(
|
|
|
251
244
|
index === highlightedIndex && "bg-accent"
|
|
252
245
|
)}
|
|
253
246
|
>
|
|
254
|
-
<
|
|
247
|
+
<Flag countryCode={country.code} rounded className="h-3 w-4" />
|
|
255
248
|
<span className="flex-1 text-sm">{country.name}</span>
|
|
256
249
|
<span className="text-sm font-mono text-muted-foreground">
|
|
257
250
|
{country.dialCode}
|
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
|
))
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Check, ChevronsUpDown, Search, X } from 'lucide-react';
|
|
4
4
|
import * as React from 'react';
|
|
5
|
-
import { countries,
|
|
5
|
+
import { countries, type TCountryCode } from 'countries-list';
|
|
6
6
|
|
|
7
7
|
import { cn } from '../../lib/utils';
|
|
8
8
|
import { Badge } from '../data/badge';
|
|
@@ -14,12 +14,12 @@ import {
|
|
|
14
14
|
import { Input } from '../forms/input';
|
|
15
15
|
import { Popover, PopoverContent, PopoverTrigger } from '../overlay/popover';
|
|
16
16
|
import { ScrollArea } from '../layout/scroll-area';
|
|
17
|
+
import { Flag } from '../specialized/flag';
|
|
17
18
|
|
|
18
19
|
export interface CountryOption {
|
|
19
20
|
code: TCountryCode;
|
|
20
21
|
name: string;
|
|
21
22
|
native: string;
|
|
22
|
-
emoji: string;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
export type CountrySelectVariant = 'dropdown' | 'inline';
|
|
@@ -64,7 +64,7 @@ export interface CountrySelectProps {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
* Country Select component with
|
|
67
|
+
* Country Select component with SVG flags
|
|
68
68
|
*
|
|
69
69
|
* Supports:
|
|
70
70
|
* - Single and multiple selection
|
|
@@ -156,7 +156,6 @@ export function CountrySelect({
|
|
|
156
156
|
code,
|
|
157
157
|
name: getCountryName?.(code) ?? countries[code].name,
|
|
158
158
|
native: countries[code].native,
|
|
159
|
-
emoji: getEmojiFlag(code),
|
|
160
159
|
}))
|
|
161
160
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
162
161
|
}, [getCountryName, allowedCountries, excludedCountries]);
|
|
@@ -256,7 +255,7 @@ export function CountrySelect({
|
|
|
256
255
|
{isSelected && <div className="h-2 w-2 rounded-full bg-primary-foreground" />}
|
|
257
256
|
</div>
|
|
258
257
|
)}
|
|
259
|
-
<
|
|
258
|
+
<Flag countryCode={country.code} rounded className="h-4 w-6 shrink-0" />
|
|
260
259
|
<div className="flex flex-col flex-1 min-w-0">
|
|
261
260
|
<span className={cn('text-sm', isSelected && 'font-medium')}>
|
|
262
261
|
{country.name}
|
|
@@ -287,7 +286,7 @@ export function CountrySelect({
|
|
|
287
286
|
const country = selectedCountries[0]!;
|
|
288
287
|
return (
|
|
289
288
|
<div className="flex items-center gap-2">
|
|
290
|
-
<
|
|
289
|
+
<Flag countryCode={country.code} rounded className="h-3 w-4 shrink-0" />
|
|
291
290
|
<span>{country.name}</span>
|
|
292
291
|
</div>
|
|
293
292
|
);
|
|
@@ -304,7 +303,7 @@ export function CountrySelect({
|
|
|
304
303
|
variant="secondary"
|
|
305
304
|
className="mr-1 text-xs"
|
|
306
305
|
>
|
|
307
|
-
<
|
|
306
|
+
<Flag countryCode={country.code} rounded className="mr-1 h-3 w-4 shrink-0" />
|
|
308
307
|
{country.name}
|
|
309
308
|
<button
|
|
310
309
|
className="ml-1 rounded-full hover:bg-muted-foreground/20"
|
|
@@ -379,7 +378,7 @@ export function CountrySelect({
|
|
|
379
378
|
isSelected ? "opacity-100" : "opacity-0"
|
|
380
379
|
)}
|
|
381
380
|
/>
|
|
382
|
-
<
|
|
381
|
+
<Flag countryCode={country.code} rounded className="mr-2 h-4 w-6 shrink-0" />
|
|
383
382
|
<div className="flex flex-col flex-1 min-w-0">
|
|
384
383
|
<span className="truncate">{country.name}</span>
|
|
385
384
|
{showNativeName && country.native !== country.name && (
|
|
@@ -402,4 +401,3 @@ export function CountrySelect({
|
|
|
402
401
|
|
|
403
402
|
// Re-export types for convenience
|
|
404
403
|
export type { TCountryCode } from 'countries-list';
|
|
405
|
-
export { getEmojiFlag } from 'countries-list';
|
|
@@ -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,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static map: ISO 3166-1 alpha-2 country code → SVG React component.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports every flag from `country-flag-icons` (~270 entries) so the
|
|
5
|
+
* `Flag` component covers every consumer — locale switchers, phone-input
|
|
6
|
+
* country picker, country select, etc. Tree-shaking keeps unused flags out
|
|
7
|
+
* of the final bundle when consumers reach for `<Flag countryCode>`
|
|
8
|
+
* directly with a constant code.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type * as React from 'react';
|
|
12
|
+
import * as Flags from 'country-flag-icons/react/3x2';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* `country-flag-icons` types its components against `HTMLSVGElement`
|
|
16
|
+
* (their custom intersection of HTMLElement + SVGElement). We treat them
|
|
17
|
+
* as opaque components — call sites use `<Flag />` rather than touching
|
|
18
|
+
* the DOM type directly.
|
|
19
|
+
*/
|
|
20
|
+
export type FlagSvgComponent = (props: React.SVGProps<SVGSVGElement> & React.HTMLAttributes<SVGSVGElement>) => React.JSX.Element;
|
|
21
|
+
|
|
22
|
+
export const FLAG_COMPONENTS = Flags 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
|
+
}
|