@djangocfg/layouts 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.
Files changed (29) hide show
  1. package/package.json +18 -18
  2. package/src/layouts/AppLayout/AppLayout.tsx +14 -11
  3. package/src/layouts/AppLayout/BaseApp.tsx +3 -1
  4. package/src/layouts/AppLayout/LayoutI18nProvider.tsx +59 -0
  5. package/src/layouts/AppLayout/index.ts +7 -0
  6. package/src/layouts/PrivateLayout/PrivateLayout.tsx +1 -5
  7. package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +2 -4
  8. package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +6 -11
  9. package/src/layouts/PublicLayout/footers/DefaultFooter/types.ts +15 -6
  10. package/src/layouts/PublicLayout/navbars/FloatingNavbar/FloatingNavbar.tsx +1 -5
  11. package/src/layouts/PublicLayout/navbars/FlushNavbar/FlushNavbar.tsx +1 -5
  12. package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalNavbar.tsx +1 -6
  13. package/src/layouts/PublicLayout/primitives/NavControls.tsx +5 -9
  14. package/src/layouts/PublicLayout/shared/MobileDrawerShell.tsx +2 -6
  15. package/src/layouts/PublicLayout/shared/NavbarShell.tsx +1 -7
  16. package/src/layouts/_components/LocaleSwitcher.tsx +40 -178
  17. package/src/layouts/_components/PrivateSidebarAccount.tsx +6 -8
  18. package/src/layouts/_components/UserMenu.tsx +15 -19
  19. package/src/layouts/_components/index.ts +23 -2
  20. package/src/layouts/_components/locale-switcher/LocaleCard.tsx +91 -0
  21. package/src/layouts/_components/locale-switcher/LocaleGrid.tsx +128 -0
  22. package/src/layouts/_components/locale-switcher/LocaleSwitcher.tsx +100 -0
  23. package/src/layouts/_components/locale-switcher/LocaleSwitcherDialog.tsx +168 -0
  24. package/src/layouts/_components/locale-switcher/LocaleSwitcherDropdown.tsx +85 -0
  25. package/src/layouts/_components/locale-switcher/LocaleSwitcherTrigger.tsx +74 -0
  26. package/src/layouts/_components/locale-switcher/index.ts +27 -0
  27. package/src/layouts/_components/locale-switcher/localeMeta.ts +109 -0
  28. package/src/layouts/_components/locale-switcher/types.ts +48 -0
  29. package/src/layouts/types/layout.types.ts +37 -1
@@ -1,185 +1,47 @@
1
1
  /**
2
- * LocaleSwitcher Component (Presentational)
2
+ * Re-export shim — kept for backwards compatibility with code that imports
3
+ * `LocaleSwitcher` / `LOCALE_LABELS` from this path.
3
4
  *
4
- * "Dumb" locale switcher that receives data via props.
5
- * For the "smart" version with next-intl hooks, use @djangocfg/nextjs/i18n/components
6
- *
7
- * @example
8
- * ```tsx
9
- * <LocaleSwitcher
10
- * locale="en"
11
- * locales={['en', 'ru', 'ko']}
12
- * onChange={(locale) => router.push(`/${locale}`)}
13
- * />
14
- * ```
5
+ * The implementation now lives in `./locale-switcher/`.
15
6
  */
16
7
 
17
8
  'use client';
18
9
 
19
- import { Globe } from 'lucide-react';
20
-
21
- import {
22
- Button,
23
- DropdownMenu,
24
- DropdownMenuContent,
25
- DropdownMenuItem,
26
- DropdownMenuTrigger,
27
- getLanguageFlag,
28
- } from '@djangocfg/ui-core/components';
29
-
30
- // Default locale labels (supports both simple codes and regional variants)
31
- export const LOCALE_LABELS: Record<string, string> = {
32
- // Major languages
33
- en: 'English',
34
- ru: 'Русский',
35
- ko: '한국어',
36
- zh: '中文',
37
- ja: '日本語',
38
- es: 'Español',
39
- fr: 'Français',
40
- de: 'Deutsch',
41
- pt: 'Português',
42
- it: 'Italiano',
43
- ar: 'العربية',
44
- hi: 'हिन्दी',
45
- tr: 'Türkçe',
46
- pl: 'Polski',
47
- nl: 'Nederlands',
48
- uk: 'Українська',
49
-
50
- // Regional variants
51
- 'pt-br': 'Português (Brasil)',
52
- 'pt-BR': 'Português (Brasil)',
53
- 'pt-pt': 'Português (Portugal)',
54
- 'pt-PT': 'Português (Portugal)',
55
- 'zh-cn': '简体中文',
56
- 'zh-CN': '简体中文',
57
- 'zh-tw': '繁體中文',
58
- 'zh-TW': '繁體中文',
59
- 'en-us': 'English (US)',
60
- 'en-US': 'English (US)',
61
- 'en-gb': 'English (UK)',
62
- 'en-GB': 'English (UK)',
63
- 'es-mx': 'Español (México)',
64
- 'es-MX': 'Español (México)',
65
- 'es-es': 'Español (España)',
66
- 'es-ES': 'Español (España)',
67
- 'fr-ca': 'Français (Canada)',
68
- 'fr-CA': 'Français (Canada)',
69
-
70
- // Scandinavian languages
71
- sv: 'Svenska',
72
- no: 'Norsk',
73
- nb: 'Norsk Bokmål',
74
- nn: 'Norsk Nynorsk',
75
- da: 'Dansk',
76
- fi: 'Suomi',
77
- is: 'Íslenska',
78
-
79
- // Other European
80
- cs: 'Čeština',
81
- sk: 'Slovenčina',
82
- hu: 'Magyar',
83
- ro: 'Română',
84
- bg: 'Български',
85
- hr: 'Hrvatski',
86
- sr: 'Српски',
87
- sl: 'Slovenščina',
88
- et: 'Eesti',
89
- lv: 'Latviešu',
90
- lt: 'Lietuvių',
91
- el: 'Ελληνικά',
92
-
93
- // Other
94
- th: 'ไทย',
95
- vi: 'Tiếng Việt',
96
- id: 'Indonesia',
97
- ms: 'Bahasa Melayu',
98
- he: 'עברית',
99
- fa: 'فارسی',
100
- };
10
+ export {
11
+ LocaleSwitcher,
12
+ LocaleSwitcherDialog,
13
+ LocaleSwitcherDropdown,
14
+ LocaleSwitcherTrigger,
15
+ LocaleGrid,
16
+ LocaleCard,
17
+ getLocaleMeta,
18
+ } from './locale-switcher';
19
+ export type {
20
+ LocaleSwitcherProps,
21
+ LocaleSwitcherDialogProps,
22
+ LocaleSwitcherDropdownProps,
23
+ LocaleSwitcherTriggerProps,
24
+ LocaleGridProps,
25
+ LocaleCardProps,
26
+ LocaleMeta,
27
+ LocaleSwitcherBrand,
28
+ LocaleSwitcherLabels,
29
+ LocaleSwitcherSharedProps,
30
+ LocaleSwitcherVariant,
31
+ } from './locale-switcher';
32
+
33
+ import { getLocaleMeta as _getLocaleMeta } from './locale-switcher';
101
34
 
102
- export interface LocaleSwitcherProps {
103
- /** Current locale */
104
- locale: string;
105
- /** Available locales */
106
- locales: string[];
107
- /** Callback when locale changes */
108
- onChange: (locale: string) => void;
109
- /** Custom labels for locales */
110
- labels?: Record<string, string>;
111
- /** Show locale code instead of/with label */
112
- showCode?: boolean;
113
- /** Button variant */
114
- variant?: 'ghost' | 'outline' | 'default';
115
- /** Button size */
116
- size?: 'sm' | 'default' | 'lg' | 'icon';
117
- /** Show icon (Globe icon, default: true) */
118
- showIcon?: boolean;
119
- /** Show flag emoji (default: true) */
120
- showFlag?: boolean;
121
- /** Show label text in trigger button (default: true) */
122
- showTriggerLabel?: boolean;
123
- /** Custom className */
124
- className?: string;
125
- }
126
-
127
- export function LocaleSwitcher({
128
- locale,
129
- locales,
130
- onChange,
131
- labels = {},
132
- showCode = false,
133
- variant = 'ghost',
134
- size = 'sm',
135
- showIcon = true,
136
- showFlag = true,
137
- showTriggerLabel = true,
138
- className,
139
- }: LocaleSwitcherProps) {
140
- const allLabels = { ...LOCALE_LABELS, ...labels };
141
-
142
- const getLabel = (code: string) => {
143
- const label = allLabels[code] || code.toUpperCase();
144
- if (showCode) {
145
- return `${code.toUpperCase()} - ${label}`;
146
- }
147
- return label;
148
- };
149
-
150
- const getFlag = (code: string) => {
151
- if (!showFlag) return null;
152
- const flag = getLanguageFlag(code);
153
- return flag || null;
154
- };
155
-
156
- const currentLabel = showCode ? locale.toUpperCase() : getLabel(locale);
157
- const currentFlag = getFlag(locale);
158
-
159
- return (
160
- <DropdownMenu>
161
- <DropdownMenuTrigger asChild>
162
- <Button variant={variant} size={size} className={className}>
163
- {showIcon && !currentFlag && <Globe className={showTriggerLabel ? "h-4 w-4 mr-1" : "h-4 w-4"} />}
164
- {currentFlag && <span className={showTriggerLabel ? "mr-1" : ""}>{currentFlag}</span>}
165
- {showTriggerLabel && <span>{currentLabel}</span>}
166
- </Button>
167
- </DropdownMenuTrigger>
168
- <DropdownMenuContent align="end">
169
- {locales.map((code) => {
170
- const flag = getFlag(code);
171
- return (
172
- <DropdownMenuItem
173
- key={code}
174
- onClick={() => onChange(code)}
175
- className={code === locale ? 'bg-accent' : ''}
176
- >
177
- {flag && <span className="mr-2">{flag}</span>}
178
- {getLabel(code)}
179
- </DropdownMenuItem>
180
- );
181
- })}
182
- </DropdownMenuContent>
183
- </DropdownMenu>
184
- );
185
- }
35
+ /**
36
+ * Native-name labels for common locales. Built from the central locale-meta
37
+ * registry — kept as a `Record` for legacy callers that index it directly.
38
+ */
39
+ export const LOCALE_LABELS: Record<string, string> = new Proxy(
40
+ {} as Record<string, string>,
41
+ {
42
+ get(_target, prop: string) {
43
+ if (typeof prop !== 'string') return undefined;
44
+ return _getLocaleMeta(prop).native;
45
+ },
46
+ },
47
+ );
@@ -26,8 +26,8 @@ import { ThemeToggle } from '@djangocfg/ui-nextjs/theme';
26
26
 
27
27
  import { useLogout } from '../../hooks';
28
28
  import { LocaleSwitcher } from './LocaleSwitcher';
29
+ import { useLayoutI18nOptional } from '../AppLayout/LayoutI18nProvider';
29
30
 
30
- import type { I18nLayoutConfig } from '../types';
31
31
  import type { HeaderConfig } from '../PrivateLayout/PrivateLayout';
32
32
 
33
33
  /** Radix portals (dropdown, select, popover) render outside the account node — ignore those clicks for “outside”. */
@@ -43,13 +43,13 @@ function isPointerFromRadixOverlay(target: EventTarget | null): boolean {
43
43
 
44
44
  interface PrivateSidebarAccountProps {
45
45
  header?: HeaderConfig;
46
- i18n?: I18nLayoutConfig;
47
46
  }
48
47
 
49
- export function PrivateSidebarAccount({ header, i18n }: PrivateSidebarAccountProps) {
48
+ export function PrivateSidebarAccount({ header }: PrivateSidebarAccountProps) {
50
49
  const { user } = useAuth();
51
50
  const handleLogout = useLogout();
52
51
  const t = useAppT();
52
+ const layoutI18n = useLayoutI18nOptional();
53
53
  const { state, setOpen: setSidebarOpen } = useSidebar();
54
54
  const [accountOpen, setAccountOpen] = React.useState(false);
55
55
  const accountRootRef = React.useRef<HTMLDivElement>(null);
@@ -150,12 +150,10 @@ export function PrivateSidebarAccount({ header, i18n }: PrivateSidebarAccountPro
150
150
  </>
151
151
  );
152
152
 
153
- const localeThemeGroup = i18n ? (
153
+ const localeThemeGroup = layoutI18n ? (
154
154
  <LocaleSwitcher
155
- locale={i18n.locale}
156
- locales={i18n.locales}
157
- onChange={i18n.onLocaleChange}
158
- variant="ghost"
155
+ variant="dropdown"
156
+ buttonVariant="ghost"
159
157
  size="icon"
160
158
  showTriggerLabel={false}
161
159
  showIcon={false}
@@ -50,7 +50,7 @@ import {
50
50
  DropdownMenuSubContent,
51
51
  DropdownMenuSubTrigger,
52
52
  DropdownMenuTrigger,
53
- getLanguageFlag,
53
+ LanguageFlag,
54
54
  } from '@djangocfg/ui-core/components';
55
55
 
56
56
  import { LOCALE_LABELS } from './LocaleSwitcher';
@@ -224,20 +224,19 @@ export function UserMenu({
224
224
  </p>
225
225
  <div className="mt-2 flex flex-wrap gap-2">
226
226
  {localeMenu.codes.map((code) => {
227
- const flag = getLanguageFlag(code);
228
227
  const active = code === localeMenu.current;
229
228
  return (
230
229
  <button
231
230
  key={code}
232
231
  type="button"
233
232
  onClick={() => localeMenu.onChange(code)}
234
- className={`rounded-md border px-2.5 py-1.5 text-sm font-medium transition-colors ${
233
+ className={`inline-flex items-center gap-1.5 rounded-md border px-2.5 py-1.5 text-sm font-medium transition-colors ${
235
234
  active
236
235
  ? 'border-primary bg-accent text-foreground'
237
236
  : 'border-border/60 text-muted-foreground hover:bg-accent hover:text-foreground'
238
237
  }`}
239
238
  >
240
- {flag ? <span className="mr-1.5">{flag}</span> : null}
239
+ <LanguageFlag code={code} className="h-3 w-4" rounded />
241
240
  {localeLabel(code)}
242
241
  </button>
243
242
  );
@@ -333,21 +332,18 @@ export function UserMenu({
333
332
  <span>{labels.language}</span>
334
333
  </DropdownMenuSubTrigger>
335
334
  <DropdownMenuSubContent>
336
- {localeMenu.codes.map((code) => {
337
- const flag = getLanguageFlag(code);
338
- return (
339
- <DropdownMenuItem
340
- key={code}
341
- onSelect={() => {
342
- localeMenu.onChange(code);
343
- }}
344
- className={code === localeMenu.current ? 'bg-accent' : ''}
345
- >
346
- {flag ? <span className="mr-2">{flag}</span> : null}
347
- {localeLabel(code)}
348
- </DropdownMenuItem>
349
- );
350
- })}
335
+ {localeMenu.codes.map((code) => (
336
+ <DropdownMenuItem
337
+ key={code}
338
+ onSelect={() => {
339
+ localeMenu.onChange(code);
340
+ }}
341
+ className={code === localeMenu.current ? 'bg-accent' : ''}
342
+ >
343
+ <LanguageFlag code={code} className="mr-2 h-3 w-4" rounded />
344
+ {localeLabel(code)}
345
+ </DropdownMenuItem>
346
+ ))}
351
347
  </DropdownMenuSubContent>
352
348
  </DropdownMenuSub>
353
349
  </>
@@ -2,8 +2,29 @@
2
2
  * Shared Layout Components
3
3
  */
4
4
 
5
- export { LocaleSwitcher, LOCALE_LABELS } from './LocaleSwitcher';
6
- export type { LocaleSwitcherProps } from './LocaleSwitcher';
5
+ export {
6
+ LocaleSwitcher,
7
+ LocaleSwitcherDialog,
8
+ LocaleSwitcherDropdown,
9
+ LocaleSwitcherTrigger,
10
+ LocaleGrid,
11
+ LocaleCard,
12
+ getLocaleMeta,
13
+ LOCALE_LABELS,
14
+ } from './LocaleSwitcher';
15
+ export type {
16
+ LocaleSwitcherProps,
17
+ LocaleSwitcherDialogProps,
18
+ LocaleSwitcherDropdownProps,
19
+ LocaleSwitcherTriggerProps,
20
+ LocaleGridProps,
21
+ LocaleCardProps,
22
+ LocaleMeta,
23
+ LocaleSwitcherBrand,
24
+ LocaleSwitcherLabels,
25
+ LocaleSwitcherSharedProps,
26
+ LocaleSwitcherVariant,
27
+ } from './LocaleSwitcher';
7
28
 
8
29
  export { UserMenu } from './UserMenu';
9
30
  export type { UserMenuProps } from './UserMenu';
@@ -0,0 +1,91 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Check } from 'lucide-react';
5
+
6
+ import { LanguageFlag } from '@djangocfg/ui-core/components';
7
+ import { cn } from '@djangocfg/ui-core/lib';
8
+
9
+ import type { LocaleMeta } from './localeMeta';
10
+
11
+ export interface LocaleCardProps {
12
+ code: string;
13
+ meta: LocaleMeta;
14
+ active: boolean;
15
+ onSelect: (code: string) => void;
16
+ /** Compact list-row layout used on small viewports. @default false (grid tile) */
17
+ compact?: boolean;
18
+ }
19
+
20
+ /**
21
+ * One locale tile. Two flavours: spacious grid tile (desktop) and compact
22
+ * list row (mobile). Both share the same flag + native + english structure.
23
+ */
24
+ export const LocaleCard = React.memo(function LocaleCard({
25
+ code,
26
+ meta,
27
+ active,
28
+ onSelect,
29
+ compact = false,
30
+ }: LocaleCardProps) {
31
+ const handleClick = React.useCallback(() => {
32
+ onSelect(code);
33
+ }, [code, onSelect]);
34
+
35
+ const baseClass = cn(
36
+ 'group relative flex w-full cursor-pointer items-center gap-3 rounded-xl border text-left transition-colors',
37
+ 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-1 focus-visible:ring-offset-background',
38
+ active
39
+ ? 'border-primary bg-primary/[0.06] ring-1 ring-primary/25'
40
+ : 'border-border/60 bg-card hover:border-primary/40 hover:bg-accent/40',
41
+ );
42
+
43
+ if (compact) {
44
+ return (
45
+ <button
46
+ type="button"
47
+ onClick={handleClick}
48
+ aria-current={active ? 'true' : undefined}
49
+ className={cn(baseClass, 'min-h-[56px] px-3.5 py-2.5')}
50
+ >
51
+ <LanguageFlag
52
+ code={code}
53
+ rounded
54
+ className="h-5 w-7 shrink-0 shadow-[0_0_0_1px_rgb(0_0_0/0.06)]"
55
+ />
56
+ <div className="min-w-0 flex-1">
57
+ <div className="truncate text-sm font-medium text-foreground">{meta.native}</div>
58
+ {meta.english && meta.english !== meta.native && (
59
+ <div className="truncate text-xs text-muted-foreground">{meta.english}</div>
60
+ )}
61
+ </div>
62
+ {active && <Check className="h-4 w-4 shrink-0 text-primary" aria-hidden />}
63
+ </button>
64
+ );
65
+ }
66
+
67
+ return (
68
+ <button
69
+ type="button"
70
+ onClick={handleClick}
71
+ aria-current={active ? 'true' : undefined}
72
+ dir={meta.rtl ? 'rtl' : undefined}
73
+ className={cn(baseClass, 'min-h-[88px] flex-col items-start gap-2 px-4 py-3.5 sm:min-h-[96px]')}
74
+ >
75
+ <div className="flex w-full items-center justify-between">
76
+ <LanguageFlag
77
+ code={code}
78
+ rounded
79
+ className="h-6 w-9 shadow-[0_0_0_1px_rgb(0_0_0/0.08)]"
80
+ />
81
+ {active && <Check className="h-4 w-4 text-primary" aria-hidden />}
82
+ </div>
83
+ <div className="min-w-0 flex-1">
84
+ <div className="truncate text-sm font-semibold text-foreground">{meta.native}</div>
85
+ {meta.english && meta.english !== meta.native && (
86
+ <div className="truncate text-xs text-muted-foreground">{meta.english}</div>
87
+ )}
88
+ </div>
89
+ </button>
90
+ );
91
+ });
@@ -0,0 +1,128 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import { Search, X } from 'lucide-react';
5
+
6
+ import { Input } from '@djangocfg/ui-core/components';
7
+ import { cn } from '@djangocfg/ui-core/lib';
8
+
9
+ import { LocaleCard } from './LocaleCard';
10
+ import { getLocaleMeta, type LocaleMeta } from './localeMeta';
11
+
12
+ export interface LocaleGridProps {
13
+ locale: string;
14
+ locales: string[];
15
+ onSelect: (locale: string) => void;
16
+ labels?: Record<string, string>;
17
+ searchPlaceholder?: string;
18
+ emptyResults?: string;
19
+ /** Force compact (single-column list) layout. @default false (grid on ≥sm) */
20
+ compact?: boolean;
21
+ /** Search input visibility. Auto-shown when more than `searchThreshold` locales. */
22
+ searchThreshold?: number;
23
+ }
24
+
25
+ interface LocaleEntry {
26
+ code: string;
27
+ meta: LocaleMeta;
28
+ }
29
+
30
+ const DEFAULT_SEARCH_THRESHOLD = 8;
31
+
32
+ export function LocaleGrid({
33
+ locale,
34
+ locales,
35
+ onSelect,
36
+ labels,
37
+ searchPlaceholder = 'Search language…',
38
+ emptyResults = 'No languages found',
39
+ compact = false,
40
+ searchThreshold = DEFAULT_SEARCH_THRESHOLD,
41
+ }: LocaleGridProps) {
42
+ const [query, setQuery] = React.useState('');
43
+
44
+ const entries = React.useMemo<LocaleEntry[]>(
45
+ () =>
46
+ locales.map((code) => ({
47
+ code,
48
+ meta: getLocaleMeta(code, labels),
49
+ })),
50
+ [locales, labels],
51
+ );
52
+
53
+ const showSearch = entries.length > searchThreshold;
54
+
55
+ const filtered = React.useMemo(() => {
56
+ if (!query.trim()) return entries;
57
+ const q = query.trim().toLowerCase();
58
+ return entries.filter(
59
+ (e) =>
60
+ e.code.toLowerCase().includes(q) ||
61
+ e.meta.native.toLowerCase().includes(q) ||
62
+ e.meta.english.toLowerCase().includes(q),
63
+ );
64
+ }, [entries, query]);
65
+
66
+ return (
67
+ <div className="flex h-full min-h-0 flex-col gap-4">
68
+ {showSearch && (
69
+ // `p-px` reserves the focus-ring offset so it isn't clipped by the
70
+ // scroll container below.
71
+ <div className="relative shrink-0 p-px">
72
+ <Search
73
+ className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
74
+ aria-hidden
75
+ />
76
+ <Input
77
+ type="search"
78
+ value={query}
79
+ onChange={(e) => setQuery(e.target.value)}
80
+ placeholder={searchPlaceholder}
81
+ className="h-11 rounded-xl pl-10 pr-9"
82
+ // Avoid forcing the keyboard open on mobile.
83
+ autoFocus={false}
84
+ />
85
+ {query && (
86
+ <button
87
+ type="button"
88
+ onClick={() => setQuery('')}
89
+ aria-label="Clear search"
90
+ className="absolute right-2.5 top-1/2 inline-flex h-7 w-7 -translate-y-1/2 cursor-pointer items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
91
+ >
92
+ <X className="h-3.5 w-3.5" />
93
+ </button>
94
+ )}
95
+ </div>
96
+ )}
97
+
98
+ {/* `p-px` reserves the focus / active ring offset around cards. */}
99
+ <div className="flex-1 min-h-0 overflow-y-auto p-px">
100
+ {filtered.length === 0 ? (
101
+ <div className="flex h-full items-center justify-center py-12 text-sm text-muted-foreground">
102
+ {emptyResults}
103
+ </div>
104
+ ) : (
105
+ <div
106
+ className={cn(
107
+ 'grid gap-2.5',
108
+ compact
109
+ ? 'grid-cols-1'
110
+ : 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4',
111
+ )}
112
+ >
113
+ {filtered.map(({ code, meta }) => (
114
+ <LocaleCard
115
+ key={code}
116
+ code={code}
117
+ meta={meta}
118
+ active={code === locale}
119
+ onSelect={onSelect}
120
+ compact={compact}
121
+ />
122
+ ))}
123
+ </div>
124
+ )}
125
+ </div>
126
+ </div>
127
+ );
128
+ }
@@ -0,0 +1,100 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+
5
+ import { useLayoutI18n } from '../../AppLayout/LayoutI18nProvider';
6
+
7
+ import { LocaleSwitcherDialog } from './LocaleSwitcherDialog';
8
+ import { LocaleSwitcherDropdown } from './LocaleSwitcherDropdown';
9
+ import { LocaleSwitcherTrigger } from './LocaleSwitcherTrigger';
10
+ import type { LocaleSwitcherVariant } from './types';
11
+
12
+ export interface LocaleSwitcherProps {
13
+ /**
14
+ * UI flavour.
15
+ * - `dialog` (default): fullscreen language picker with brand header, search, grid.
16
+ * - `dropdown`: compact dropdown menu — best for navbars and dense rails.
17
+ */
18
+ variant?: LocaleSwitcherVariant;
19
+ /** Override / add native names per locale (`{ es: 'Español' }`). */
20
+ labels?: Record<string, string>;
21
+
22
+ // Trigger styling — applies to both variants.
23
+ showCode?: boolean;
24
+ buttonVariant?: 'ghost' | 'outline' | 'default';
25
+ size?: 'sm' | 'default' | 'lg' | 'icon';
26
+ /** Show the leading Globe icon when no flag is rendered. @default true */
27
+ showIcon?: boolean;
28
+ /** Show the country flag in the trigger. @default true */
29
+ showFlag?: boolean;
30
+ /** Show the label text in the trigger. @default true */
31
+ showTriggerLabel?: boolean;
32
+ /** Extra trigger className. */
33
+ className?: string;
34
+ }
35
+
36
+ /**
37
+ * Locale switcher with two flavours: a compact dropdown and a fullscreen
38
+ * dialog. Pulls `locale` / `locales` / `onChange` / `brand` / `dialogLabels`
39
+ * from `LayoutI18nProvider` (mounted by `BaseApp`) — no need to thread them
40
+ * through props.
41
+ */
42
+ export function LocaleSwitcher({
43
+ variant = 'dialog',
44
+ labels,
45
+ className,
46
+ showCode = false,
47
+ buttonVariant = 'ghost',
48
+ size = 'sm',
49
+ showIcon = true,
50
+ showFlag = true,
51
+ showTriggerLabel = true,
52
+ }: LocaleSwitcherProps) {
53
+ const i18n = useLayoutI18n();
54
+ const [open, setOpen] = React.useState(false);
55
+
56
+ if (variant === 'dropdown') {
57
+ return (
58
+ <LocaleSwitcherDropdown
59
+ locale={i18n.locale}
60
+ locales={i18n.locales}
61
+ onChange={i18n.onLocaleChange}
62
+ labels={labels}
63
+ className={className}
64
+ showCode={showCode}
65
+ variant={buttonVariant}
66
+ size={size}
67
+ showIcon={showIcon}
68
+ showFlag={showFlag}
69
+ showTriggerLabel={showTriggerLabel}
70
+ />
71
+ );
72
+ }
73
+
74
+ return (
75
+ <>
76
+ <LocaleSwitcherTrigger
77
+ locale={i18n.locale}
78
+ labels={labels}
79
+ showFlag={showFlag}
80
+ showLabel={showTriggerLabel}
81
+ showCode={showCode}
82
+ variant={buttonVariant}
83
+ size={size}
84
+ className={className}
85
+ ariaLabel={i18n.dialogLabels?.triggerLabel ?? 'Change language'}
86
+ onClick={() => setOpen(true)}
87
+ />
88
+ <LocaleSwitcherDialog
89
+ locale={i18n.locale}
90
+ locales={i18n.locales}
91
+ onChange={i18n.onLocaleChange}
92
+ labels={labels}
93
+ i18nLabels={i18n.dialogLabels}
94
+ brand={i18n.brand}
95
+ open={open}
96
+ onOpenChange={setOpen}
97
+ />
98
+ </>
99
+ );
100
+ }