@djangocfg/layouts 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 +1 -1
- package/package.json +18 -18
- package/src/layouts/AppLayout/AppLayout.tsx +14 -11
- package/src/layouts/AppLayout/BaseApp.tsx +3 -1
- package/src/layouts/AppLayout/LayoutI18nProvider.tsx +59 -0
- package/src/layouts/AppLayout/index.ts +7 -0
- package/src/layouts/PrivateLayout/PrivateLayout.tsx +1 -5
- package/src/layouts/PrivateLayout/components/PrivateSidebar.tsx +2 -4
- package/src/layouts/PublicLayout/README.md +13 -13
- package/src/layouts/PublicLayout/footers/DefaultFooter/DefaultFooter.tsx +6 -11
- package/src/layouts/PublicLayout/footers/DefaultFooter/types.ts +15 -6
- package/src/layouts/PublicLayout/navbars/FloatingNavbar/FloatingNavbar.tsx +1 -5
- package/src/layouts/PublicLayout/navbars/FlushNavbar/FlushNavbar.tsx +1 -5
- package/src/layouts/PublicLayout/navbars/MinimalNavbar/MinimalNavbar.tsx +1 -6
- package/src/layouts/PublicLayout/primitives/NavControls.tsx +5 -9
- package/src/layouts/PublicLayout/shared/MobileDrawerShell.tsx +2 -6
- package/src/layouts/PublicLayout/shared/NavbarShell.tsx +1 -7
- package/src/layouts/_components/LocaleSwitcher.tsx +40 -178
- package/src/layouts/_components/PrivateSidebarAccount.tsx +6 -8
- package/src/layouts/_components/UserMenu.tsx +15 -19
- package/src/layouts/_components/index.ts +23 -2
- package/src/layouts/_components/locale-switcher/LocaleCard.tsx +91 -0
- package/src/layouts/_components/locale-switcher/LocaleGrid.tsx +128 -0
- package/src/layouts/_components/locale-switcher/LocaleSwitcher.tsx +100 -0
- package/src/layouts/_components/locale-switcher/LocaleSwitcherDialog.tsx +168 -0
- package/src/layouts/_components/locale-switcher/LocaleSwitcherDropdown.tsx +101 -0
- package/src/layouts/_components/locale-switcher/LocaleSwitcherTrigger.tsx +103 -0
- package/src/layouts/_components/locale-switcher/index.ts +27 -0
- package/src/layouts/_components/locale-switcher/localeMeta.ts +109 -0
- package/src/layouts/_components/locale-switcher/types.ts +48 -0
- package/src/layouts/types/layout.types.ts +37 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Globe, X } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
Dialog,
|
|
8
|
+
DialogContent,
|
|
9
|
+
DialogTitle,
|
|
10
|
+
DialogClose,
|
|
11
|
+
} from '@djangocfg/ui-core/components';
|
|
12
|
+
import { useIsMobile } from '@djangocfg/ui-core/hooks';
|
|
13
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
14
|
+
|
|
15
|
+
import { LocaleGrid } from './LocaleGrid';
|
|
16
|
+
import type {
|
|
17
|
+
LocaleSwitcherBrand,
|
|
18
|
+
LocaleSwitcherLabels,
|
|
19
|
+
LocaleSwitcherSharedProps,
|
|
20
|
+
} from './types';
|
|
21
|
+
|
|
22
|
+
export interface LocaleSwitcherDialogProps extends LocaleSwitcherSharedProps {
|
|
23
|
+
open: boolean;
|
|
24
|
+
onOpenChange: (open: boolean) => void;
|
|
25
|
+
brand?: LocaleSwitcherBrand;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_LABELS: Required<LocaleSwitcherLabels> = {
|
|
29
|
+
dialogTitle: 'Choose your language',
|
|
30
|
+
dialogSubtitle: 'Pick the language you want to browse the site in.',
|
|
31
|
+
searchPlaceholder: 'Search language…',
|
|
32
|
+
emptyResults: 'No languages found',
|
|
33
|
+
closeLabel: 'Close',
|
|
34
|
+
triggerLabel: 'Change language',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Fullscreen language picker dialog. Slides up from the bottom on mobile,
|
|
39
|
+
* fades into a centred fullscreen sheet on tablet/desktop.
|
|
40
|
+
*
|
|
41
|
+
* The dialog uses ui-core's `<DialogContent fullscreen>` and provides a
|
|
42
|
+
* custom pill-shaped close button via `closeButton` so the default Radix
|
|
43
|
+
* X glyph does not stack on top.
|
|
44
|
+
*/
|
|
45
|
+
export function LocaleSwitcherDialog({
|
|
46
|
+
locale,
|
|
47
|
+
locales,
|
|
48
|
+
onChange,
|
|
49
|
+
open,
|
|
50
|
+
onOpenChange,
|
|
51
|
+
brand,
|
|
52
|
+
labels,
|
|
53
|
+
i18nLabels,
|
|
54
|
+
}: LocaleSwitcherDialogProps) {
|
|
55
|
+
const isMobile = useIsMobile();
|
|
56
|
+
|
|
57
|
+
const merged = React.useMemo(
|
|
58
|
+
() => ({ ...DEFAULT_LABELS, ...i18nLabels }),
|
|
59
|
+
[i18nLabels],
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const handleSelect = React.useCallback(
|
|
63
|
+
(code: string) => {
|
|
64
|
+
onChange(code);
|
|
65
|
+
onOpenChange(false);
|
|
66
|
+
},
|
|
67
|
+
[onChange, onOpenChange],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const closeButton = React.useMemo(
|
|
71
|
+
() => (
|
|
72
|
+
<DialogClose
|
|
73
|
+
aria-label={merged.closeLabel}
|
|
74
|
+
className={cn(
|
|
75
|
+
'absolute right-4 top-4 inline-flex h-10 w-10 items-center justify-center',
|
|
76
|
+
'cursor-pointer rounded-full border border-border/60 bg-background/90 text-muted-foreground shadow-sm',
|
|
77
|
+
'transition-colors hover:bg-accent hover:text-foreground',
|
|
78
|
+
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40',
|
|
79
|
+
)}
|
|
80
|
+
>
|
|
81
|
+
<X className="h-4 w-4" />
|
|
82
|
+
</DialogClose>
|
|
83
|
+
),
|
|
84
|
+
[merged.closeLabel],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
89
|
+
<DialogContent
|
|
90
|
+
fullscreen
|
|
91
|
+
closeButton={closeButton}
|
|
92
|
+
className={cn(
|
|
93
|
+
'bg-background/95 backdrop-blur-md',
|
|
94
|
+
isMobile
|
|
95
|
+
? 'data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom'
|
|
96
|
+
: null,
|
|
97
|
+
)}
|
|
98
|
+
>
|
|
99
|
+
<DialogTitle className="sr-only">{merged.dialogTitle}</DialogTitle>
|
|
100
|
+
|
|
101
|
+
{/*
|
|
102
|
+
Single padded shell — both header and grid use the same horizontal
|
|
103
|
+
rhythm, so the search input lines up with the cards beneath it.
|
|
104
|
+
The shell scrolls; the grid below uses overflow-hidden so the focus
|
|
105
|
+
ring on the search input isn't clipped by an inner scroll edge.
|
|
106
|
+
*/}
|
|
107
|
+
<div className="mx-auto flex h-full w-full max-w-5xl flex-col px-4 sm:px-6 lg:px-8">
|
|
108
|
+
<LocaleSwitcherDialogHeader brand={brand} labels={merged} />
|
|
109
|
+
|
|
110
|
+
<div className="flex-1 min-h-0 pb-6 lg:pb-10">
|
|
111
|
+
<LocaleGrid
|
|
112
|
+
locale={locale}
|
|
113
|
+
locales={locales}
|
|
114
|
+
onSelect={handleSelect}
|
|
115
|
+
labels={labels}
|
|
116
|
+
searchPlaceholder={merged.searchPlaceholder}
|
|
117
|
+
emptyResults={merged.emptyResults}
|
|
118
|
+
compact={isMobile}
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
</div>
|
|
122
|
+
</DialogContent>
|
|
123
|
+
</Dialog>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface DialogHeaderProps {
|
|
128
|
+
brand?: LocaleSwitcherBrand;
|
|
129
|
+
labels: Required<LocaleSwitcherLabels>;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function LocaleSwitcherDialogHeader({ brand, labels }: DialogHeaderProps) {
|
|
133
|
+
const hasBrand = Boolean(brand?.logo || brand?.name);
|
|
134
|
+
|
|
135
|
+
return (
|
|
136
|
+
<header className="shrink-0 pb-5 pt-6 sm:pt-8 lg:pb-7 lg:pt-10">
|
|
137
|
+
<div className="mb-5 inline-flex items-center gap-2.5 lg:mb-7">
|
|
138
|
+
{hasBrand ? (
|
|
139
|
+
<>
|
|
140
|
+
{brand?.logo ? (
|
|
141
|
+
<span className="inline-flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-primary/10 text-primary">
|
|
142
|
+
{brand.logo}
|
|
143
|
+
</span>
|
|
144
|
+
) : null}
|
|
145
|
+
{brand?.name ? (
|
|
146
|
+
<span className="text-sm font-semibold tracking-tight text-foreground">
|
|
147
|
+
{brand.name}
|
|
148
|
+
</span>
|
|
149
|
+
) : null}
|
|
150
|
+
</>
|
|
151
|
+
) : (
|
|
152
|
+
<span className="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
|
153
|
+
<Globe className="h-4 w-4" aria-hidden />
|
|
154
|
+
</span>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<h2 className="text-2xl font-semibold tracking-tight text-foreground sm:text-3xl">
|
|
159
|
+
{labels.dialogTitle}
|
|
160
|
+
</h2>
|
|
161
|
+
{labels.dialogSubtitle && (
|
|
162
|
+
<p className="mt-1.5 text-sm text-muted-foreground sm:text-base">
|
|
163
|
+
{labels.dialogSubtitle}
|
|
164
|
+
</p>
|
|
165
|
+
)}
|
|
166
|
+
</header>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Globe } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
Button,
|
|
8
|
+
DropdownMenu,
|
|
9
|
+
DropdownMenuContent,
|
|
10
|
+
DropdownMenuItem,
|
|
11
|
+
DropdownMenuTrigger,
|
|
12
|
+
LanguageFlag,
|
|
13
|
+
} from '@djangocfg/ui-core/components';
|
|
14
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
15
|
+
|
|
16
|
+
import { getLocaleMeta } from './localeMeta';
|
|
17
|
+
import type { LocaleSwitcherSharedProps } from './types';
|
|
18
|
+
|
|
19
|
+
export interface LocaleSwitcherDropdownProps extends LocaleSwitcherSharedProps {
|
|
20
|
+
showCode?: boolean;
|
|
21
|
+
variant?: 'ghost' | 'outline' | 'default';
|
|
22
|
+
size?: 'sm' | 'default' | 'lg' | 'icon';
|
|
23
|
+
showIcon?: boolean;
|
|
24
|
+
showFlag?: boolean;
|
|
25
|
+
showTriggerLabel?: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Compact dropdown locale switcher — kept for navbar and dense layouts where
|
|
30
|
+
* a fullscreen dialog feels heavy.
|
|
31
|
+
*/
|
|
32
|
+
export function LocaleSwitcherDropdown({
|
|
33
|
+
locale,
|
|
34
|
+
locales,
|
|
35
|
+
onChange,
|
|
36
|
+
labels,
|
|
37
|
+
showCode = false,
|
|
38
|
+
variant = 'ghost',
|
|
39
|
+
size = 'sm',
|
|
40
|
+
showIcon = true,
|
|
41
|
+
showFlag = true,
|
|
42
|
+
showTriggerLabel = true,
|
|
43
|
+
className,
|
|
44
|
+
}: LocaleSwitcherDropdownProps) {
|
|
45
|
+
const currentMeta = getLocaleMeta(locale, labels);
|
|
46
|
+
const currentLabel = showCode ? locale.toUpperCase() : currentMeta.native;
|
|
47
|
+
const isIconOnly = size === 'icon';
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<DropdownMenu>
|
|
51
|
+
<DropdownMenuTrigger asChild>
|
|
52
|
+
{isIconOnly ? (
|
|
53
|
+
<Button
|
|
54
|
+
variant={variant}
|
|
55
|
+
size={size}
|
|
56
|
+
aria-label={currentLabel}
|
|
57
|
+
className={cn('overflow-hidden rounded-full p-0', className)}
|
|
58
|
+
>
|
|
59
|
+
{showFlag ? (
|
|
60
|
+
<LanguageFlag code={locale} className="h-full w-full object-cover" />
|
|
61
|
+
) : (
|
|
62
|
+
<Globe className="h-4 w-4" aria-hidden />
|
|
63
|
+
)}
|
|
64
|
+
</Button>
|
|
65
|
+
) : (
|
|
66
|
+
<Button variant={variant} size={size} className={className}>
|
|
67
|
+
{showFlag ? (
|
|
68
|
+
<LanguageFlag
|
|
69
|
+
code={locale}
|
|
70
|
+
rounded
|
|
71
|
+
className={cn('h-3 w-4 shrink-0', showTriggerLabel && 'mr-1.5')}
|
|
72
|
+
/>
|
|
73
|
+
) : showIcon ? (
|
|
74
|
+
<Globe className={cn('h-4 w-4 shrink-0', showTriggerLabel && 'mr-1')} />
|
|
75
|
+
) : null}
|
|
76
|
+
{showTriggerLabel && <span>{currentLabel}</span>}
|
|
77
|
+
</Button>
|
|
78
|
+
)}
|
|
79
|
+
</DropdownMenuTrigger>
|
|
80
|
+
<DropdownMenuContent align="end">
|
|
81
|
+
{locales.map((code) => {
|
|
82
|
+
const meta = getLocaleMeta(code, labels);
|
|
83
|
+
const active = code === locale;
|
|
84
|
+
return (
|
|
85
|
+
<DropdownMenuItem
|
|
86
|
+
key={code}
|
|
87
|
+
onClick={() => onChange(code)}
|
|
88
|
+
className={cn(
|
|
89
|
+
'flex items-center gap-2',
|
|
90
|
+
active && 'bg-accent',
|
|
91
|
+
)}
|
|
92
|
+
>
|
|
93
|
+
{showFlag && <LanguageFlag code={code} rounded className="h-3 w-4 shrink-0" />}
|
|
94
|
+
<span>{showCode ? `${code.toUpperCase()} - ${meta.native}` : meta.native}</span>
|
|
95
|
+
</DropdownMenuItem>
|
|
96
|
+
);
|
|
97
|
+
})}
|
|
98
|
+
</DropdownMenuContent>
|
|
99
|
+
</DropdownMenu>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import { Globe } from 'lucide-react';
|
|
5
|
+
|
|
6
|
+
import { Button, LanguageFlag } from '@djangocfg/ui-core/components';
|
|
7
|
+
import { cn } from '@djangocfg/ui-core/lib';
|
|
8
|
+
|
|
9
|
+
import { getLocaleMeta } from './localeMeta';
|
|
10
|
+
|
|
11
|
+
export interface LocaleSwitcherTriggerProps {
|
|
12
|
+
locale: string;
|
|
13
|
+
labels?: Record<string, string>;
|
|
14
|
+
/** Show the flag in the trigger. @default true */
|
|
15
|
+
showFlag?: boolean;
|
|
16
|
+
/** Show the locale label / native name in the trigger. @default true */
|
|
17
|
+
showLabel?: boolean;
|
|
18
|
+
/** Show locale code instead of the native name (`'EN'` instead of `'English'`). */
|
|
19
|
+
showCode?: boolean;
|
|
20
|
+
variant?: 'ghost' | 'outline' | 'default';
|
|
21
|
+
size?: 'sm' | 'default' | 'lg' | 'icon';
|
|
22
|
+
className?: string;
|
|
23
|
+
ariaLabel?: string;
|
|
24
|
+
onClick?: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Footer / navbar trigger that opens the dialog. Visually matches the
|
|
29
|
+
* existing dropdown trigger so swapping `variant` does not shift the row.
|
|
30
|
+
*/
|
|
31
|
+
export const LocaleSwitcherTrigger = React.forwardRef<
|
|
32
|
+
HTMLButtonElement,
|
|
33
|
+
LocaleSwitcherTriggerProps
|
|
34
|
+
>(function LocaleSwitcherTrigger(
|
|
35
|
+
{
|
|
36
|
+
locale,
|
|
37
|
+
labels,
|
|
38
|
+
showFlag = true,
|
|
39
|
+
showLabel = true,
|
|
40
|
+
showCode = false,
|
|
41
|
+
variant = 'outline',
|
|
42
|
+
size = 'default',
|
|
43
|
+
className,
|
|
44
|
+
ariaLabel,
|
|
45
|
+
onClick,
|
|
46
|
+
},
|
|
47
|
+
ref,
|
|
48
|
+
) {
|
|
49
|
+
const meta = getLocaleMeta(locale, labels);
|
|
50
|
+
const labelText = showCode ? locale.toUpperCase() : meta.native;
|
|
51
|
+
const isIconOnly = size === 'icon';
|
|
52
|
+
|
|
53
|
+
// Icon-only trigger: full-bleed flag inside a circular pill that lines up
|
|
54
|
+
// with neighbouring avatars / icon buttons. No padding, no inner gap.
|
|
55
|
+
if (isIconOnly) {
|
|
56
|
+
return (
|
|
57
|
+
<Button
|
|
58
|
+
ref={ref}
|
|
59
|
+
type="button"
|
|
60
|
+
variant={variant}
|
|
61
|
+
size={size}
|
|
62
|
+
onClick={onClick}
|
|
63
|
+
aria-label={ariaLabel ?? labelText}
|
|
64
|
+
className={cn(
|
|
65
|
+
'overflow-hidden rounded-full p-0',
|
|
66
|
+
className,
|
|
67
|
+
)}
|
|
68
|
+
>
|
|
69
|
+
{showFlag ? (
|
|
70
|
+
<LanguageFlag
|
|
71
|
+
code={locale}
|
|
72
|
+
className="h-full w-full object-cover"
|
|
73
|
+
/>
|
|
74
|
+
) : (
|
|
75
|
+
<Globe className="h-4 w-4" aria-hidden />
|
|
76
|
+
)}
|
|
77
|
+
</Button>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<Button
|
|
83
|
+
ref={ref}
|
|
84
|
+
type="button"
|
|
85
|
+
variant={variant}
|
|
86
|
+
size={size}
|
|
87
|
+
onClick={onClick}
|
|
88
|
+
aria-label={ariaLabel}
|
|
89
|
+
className={cn('inline-flex items-center gap-2', className)}
|
|
90
|
+
>
|
|
91
|
+
{showFlag ? (
|
|
92
|
+
<LanguageFlag
|
|
93
|
+
code={locale}
|
|
94
|
+
rounded
|
|
95
|
+
className="h-3 w-4 shrink-0"
|
|
96
|
+
/>
|
|
97
|
+
) : (
|
|
98
|
+
<Globe className="h-4 w-4 shrink-0" aria-hidden />
|
|
99
|
+
)}
|
|
100
|
+
{showLabel && <span className="truncate">{labelText}</span>}
|
|
101
|
+
</Button>
|
|
102
|
+
);
|
|
103
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export { LocaleSwitcher } from './LocaleSwitcher';
|
|
2
|
+
export type { LocaleSwitcherProps } from './LocaleSwitcher';
|
|
3
|
+
|
|
4
|
+
export { LocaleSwitcherDialog } from './LocaleSwitcherDialog';
|
|
5
|
+
export type { LocaleSwitcherDialogProps } from './LocaleSwitcherDialog';
|
|
6
|
+
|
|
7
|
+
export { LocaleSwitcherDropdown } from './LocaleSwitcherDropdown';
|
|
8
|
+
export type { LocaleSwitcherDropdownProps } from './LocaleSwitcherDropdown';
|
|
9
|
+
|
|
10
|
+
export { LocaleSwitcherTrigger } from './LocaleSwitcherTrigger';
|
|
11
|
+
export type { LocaleSwitcherTriggerProps } from './LocaleSwitcherTrigger';
|
|
12
|
+
|
|
13
|
+
export { LocaleGrid } from './LocaleGrid';
|
|
14
|
+
export type { LocaleGridProps } from './LocaleGrid';
|
|
15
|
+
|
|
16
|
+
export { LocaleCard } from './LocaleCard';
|
|
17
|
+
export type { LocaleCardProps } from './LocaleCard';
|
|
18
|
+
|
|
19
|
+
export { getLocaleMeta } from './localeMeta';
|
|
20
|
+
export type { LocaleMeta } from './localeMeta';
|
|
21
|
+
|
|
22
|
+
export type {
|
|
23
|
+
LocaleSwitcherBrand,
|
|
24
|
+
LocaleSwitcherLabels,
|
|
25
|
+
LocaleSwitcherSharedProps,
|
|
26
|
+
LocaleSwitcherVariant,
|
|
27
|
+
} from './types';
|