@aravindc26/velu 0.10.0 → 0.11.0

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.
@@ -0,0 +1,87 @@
1
+ import type { ReactNode } from 'react';
2
+ import { DocsLayout } from 'fumadocs-ui/layouts/docs';
3
+ import { baseOptions } from '@/lib/layout.shared';
4
+ import { source } from '@/lib/source';
5
+ import { getLanguages, getVersionOptions, getProductOptions, type VeluVersionOption, type VeluProductOption } from '@/lib/velu';
6
+ import { SidebarLinks } from '@/components/sidebar-links';
7
+ import { ProductSwitcher } from '@/components/product-switcher';
8
+
9
+ interface LayoutParams {
10
+ slug?: string[];
11
+ }
12
+
13
+ interface SlugLayoutProps {
14
+ children: ReactNode;
15
+ params: Promise<LayoutParams>;
16
+ }
17
+
18
+ function resolveLocale(slugInput: string[] | undefined): string {
19
+ const languages = getLanguages();
20
+ const defaultLanguage = languages[0] ?? 'en';
21
+ const slug = slugInput ?? [];
22
+ const firstSeg = slug[0];
23
+
24
+ return languages.includes(firstSeg ?? '') ? firstSeg! : defaultLanguage;
25
+ }
26
+
27
+ function resolveCurrentVersion(slugInput: string[] | undefined, versions: VeluVersionOption[]): VeluVersionOption | undefined {
28
+ if (versions.length === 0) return undefined;
29
+ const firstSeg = (slugInput ?? [])[0] ?? '';
30
+ return versions.find((v) => v.slug === firstSeg) ?? versions.find((v) => v.isDefault) ?? versions[0];
31
+ }
32
+
33
+ function filterTreeBySlugPrefix<T extends { children?: unknown[] }>(tree: T, prefix?: string): T {
34
+ if (!prefix) return tree;
35
+
36
+ const children = Array.isArray(tree.children) ? tree.children : [];
37
+
38
+ const filtered = children.filter((node) => {
39
+ if (typeof node !== 'object' || node === null) return false;
40
+ const entry = node as { url?: unknown; path?: unknown; $ref?: { metaFile?: unknown; file?: unknown } };
41
+ const candidates = [entry.url, entry.path, entry.$ref?.metaFile, entry.$ref?.file]
42
+ .filter((value): value is string => typeof value === 'string');
43
+ return candidates.some((value) => value.includes(`${prefix}/`));
44
+ });
45
+
46
+ if (filtered.length === 0) return tree;
47
+ return { ...tree, children: filtered } as T;
48
+ }
49
+
50
+ function resolveCurrentProduct(slugInput: string[] | undefined, products: VeluProductOption[]): VeluProductOption | undefined {
51
+ if (products.length === 0) return undefined;
52
+ const firstSeg = (slugInput ?? [])[0] ?? '';
53
+ return products.find((p) => p.slug === firstSeg) ?? products[0];
54
+ }
55
+
56
+ export default async function SlugLayout({ children, params }: SlugLayoutProps) {
57
+ const resolvedParams = await params;
58
+ const locale = resolveLocale(resolvedParams.slug);
59
+ const versions = getVersionOptions();
60
+ const products = getProductOptions();
61
+ const currentVersion = resolveCurrentVersion(resolvedParams.slug, versions);
62
+ const currentProduct = resolveCurrentProduct(resolvedParams.slug, products);
63
+
64
+ const activePrefix = currentVersion?.slug ?? currentProduct?.slug;
65
+ const tree = filterTreeBySlugPrefix(source.getPageTree(locale), activePrefix);
66
+
67
+ return (
68
+ <DocsLayout
69
+ tree={tree}
70
+ sidebar={{
71
+ collapsible: false,
72
+ banner: products.length > 1 ? (
73
+ <div className="velu-sidebar-banner">
74
+ <ProductSwitcher products={products} />
75
+ </div>
76
+ ) : undefined,
77
+ footer: <SidebarLinks />,
78
+ }}
79
+ {...baseOptions()}
80
+ themeSwitch={{ enabled: false }}
81
+ >
82
+ {children}
83
+ </DocsLayout>
84
+ );
85
+ }
86
+
87
+ export { generateStaticParams } from './page';
@@ -9,6 +9,7 @@ import {
9
9
  } from 'fumadocs-ui/layouts/docs/page';
10
10
  import { getMDXComponents } from '@/mdx-components';
11
11
  import { source } from '@/lib/source';
12
+ import { getLanguages, getVersionOptions, getProductOptions } from '@/lib/velu';
12
13
  import { CopyPageButton } from '@/components/copy-page';
13
14
 
14
15
  interface RouteParams {
@@ -19,17 +20,79 @@ interface PageProps {
19
20
  params: Promise<RouteParams>;
20
21
  }
21
22
 
23
+ function resolveLocaleSlug(slugInput: string[] | undefined) {
24
+ const languages = getLanguages();
25
+ const defaultLanguage = languages[0] ?? 'en';
26
+ const slug = slugInput ?? [];
27
+ const firstSeg = slug[0];
28
+ const hasLocalePrefix = languages.includes(firstSeg ?? '');
29
+
30
+ return {
31
+ defaultLanguage,
32
+ locale: hasLocalePrefix ? firstSeg! : defaultLanguage,
33
+ pageSlug: hasLocalePrefix ? slug.slice(1) : slug,
34
+ };
35
+ }
36
+
37
+ function resolveContextFromSlug(slugInput: string[] | undefined) {
38
+ const languages = getLanguages();
39
+ const versions = getVersionOptions();
40
+ const products = getProductOptions();
41
+ const slug = slugInput ?? [];
42
+
43
+ // Check for language prefix
44
+ const firstSeg = slug[0];
45
+ const hasLocalePrefix = languages.includes(firstSeg ?? '');
46
+ const locale = hasLocalePrefix ? firstSeg! : (languages[0] ?? 'en');
47
+ const remainingSlug = hasLocalePrefix ? slug.slice(1) : slug;
48
+
49
+ // Check for version/product in remaining slug
50
+ const contextSeg = remainingSlug[0] ?? '';
51
+ const version = versions.find((v) => v.slug === contextSeg);
52
+ const product = products.find((p) => p.slug === contextSeg);
53
+
54
+ return {
55
+ locale,
56
+ version: version?.slug,
57
+ product: product?.slug,
58
+ };
59
+ }
60
+
22
61
  export default async function Page({ params }: PageProps) {
23
62
  const resolvedParams = await params;
24
- const page = source.getPage(resolvedParams.slug);
63
+ const { locale, pageSlug } = resolveLocaleSlug(resolvedParams.slug);
64
+ const { locale: filterLocale, version, product } = resolveContextFromSlug(resolvedParams.slug);
65
+ const hasI18n = getLanguages().length > 1;
66
+
67
+ const page = hasI18n ? source.getPage(pageSlug, locale) : source.getPage(pageSlug);
25
68
 
26
69
  if (!page) notFound();
27
70
 
28
71
  const MDX = page.data.body;
29
72
 
73
+ // Build pagefind filter attributes
74
+ const metaAttrs: string[] = [`title:${page.data.title}`];
75
+ const filterAttrs: string[] = [];
76
+ if (hasI18n) {
77
+ metaAttrs.push(`language:${filterLocale}`);
78
+ filterAttrs.push(`language:${filterLocale}`);
79
+ }
80
+ if (version) {
81
+ metaAttrs.push(`version:${version}`);
82
+ filterAttrs.push(`version:${version}`);
83
+ }
84
+ if (product) {
85
+ metaAttrs.push(`product:${product}`);
86
+ filterAttrs.push(`product:${product}`);
87
+ }
88
+
30
89
  return (
31
90
  <DocsPage toc={page.data.toc} full={page.data.full}>
32
- <div data-pagefind-body data-pagefind-meta={`title:${page.data.title}`}>
91
+ <div
92
+ data-pagefind-body
93
+ data-pagefind-meta={metaAttrs.join(',')}
94
+ data-pagefind-filter={filterAttrs.length > 0 ? filterAttrs.join(',') : undefined}
95
+ >
33
96
  <div className="velu-title-row">
34
97
  <DocsTitle>{page.data.title}</DocsTitle>
35
98
  <CopyPageButton />
@@ -51,14 +114,28 @@ export default async function Page({ params }: PageProps) {
51
114
  }
52
115
 
53
116
  export async function generateStaticParams() {
54
- const params = source.generateParams();
55
- // Include root path for the optional catch-all [[...slug]]
56
- return [{ slug: [] }, ...params];
117
+ const generated = source.generateParams('slug') as Array<{ slug?: string[] }>;
118
+ const seen = new Set<string>();
119
+
120
+ const nonRoot = generated.filter((entry) => {
121
+ const slug = entry.slug ?? [];
122
+ if (slug.length === 0) return false;
123
+ const key = slug.join('/');
124
+ if (seen.has(key)) return false;
125
+ seen.add(key);
126
+ return true;
127
+ });
128
+
129
+ // Include root variants for optional catch-all [[...slug]] in export mode.
130
+ return [{}, { slug: undefined }, { slug: [] }, ...nonRoot];
57
131
  }
58
132
 
59
133
  export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
60
134
  const resolvedParams = await params;
61
- const page = source.getPage(resolvedParams.slug);
135
+ const { locale, pageSlug } = resolveLocaleSlug(resolvedParams.slug);
136
+ const hasI18n = getLanguages().length > 1;
137
+
138
+ const page = hasI18n ? source.getPage(pageSlug, locale) : source.getPage(pageSlug);
62
139
 
63
140
  if (!page) notFound();
64
141
 
@@ -1,16 +1,4 @@
1
1
  import type { ReactNode } from 'react';
2
- import { DocsLayout } from 'fumadocs-ui/layouts/docs';
3
- import { baseOptions } from '@/lib/layout.shared';
4
- import { source } from '@/lib/source';
5
-
6
2
  export default function DocsRootLayout({ children }: { children: ReactNode }) {
7
- return (
8
- <DocsLayout
9
- tree={source.getPageTree()}
10
- sidebar={{ collapsible: false }}
11
- {...baseOptions()}
12
- >
13
- {children}
14
- </DocsLayout>
15
- );
3
+ return children;
16
4
  }
@@ -7,6 +7,10 @@ body {
7
7
  }
8
8
 
9
9
  /* Ensure sidebar/toc widths are set on the grid layout */
10
+ nextjs-portal {
11
+ display: none !important;
12
+ }
13
+
10
14
  @media (min-width: 768px) {
11
15
  #nd-docs-layout[data-sidebar-collapsed='false'] {
12
16
  --fd-sidebar-width: 268px;
@@ -19,6 +23,329 @@ body {
19
23
  }
20
24
  }
21
25
 
26
+ .velu-sidebar-footer {
27
+ display: flex;
28
+ flex-direction: column;
29
+ gap: 0.5rem;
30
+ padding-top: 0.5rem;
31
+ }
32
+
33
+ .velu-sidebar-banner {
34
+ display: flex;
35
+ width: 100%;
36
+ padding: 0.125rem 0;
37
+ order: -1;
38
+ }
39
+
40
+ /* Keep the sidebar title row above the product switcher */
41
+ #nd-sidebar .flex.flex-col > :first-child {
42
+ order: -2;
43
+ }
44
+
45
+ .velu-header-version-switcher {
46
+ display: flex;
47
+ align-items: center;
48
+ justify-content: flex-end;
49
+ }
50
+
51
+ .velu-version-switcher-wrap {
52
+ position: relative;
53
+ }
54
+
55
+ .velu-version-switcher {
56
+ display: inline-flex;
57
+ align-items: center;
58
+ gap: 0.35rem;
59
+ padding: 0.25rem 0.5rem;
60
+ border: none;
61
+ border-radius: 0.375rem;
62
+ background: transparent;
63
+ color: var(--color-fd-muted-foreground);
64
+ font-size: 0.75rem;
65
+ font-weight: 500;
66
+ cursor: pointer;
67
+ transition: color 0.15s, background-color 0.15s;
68
+ }
69
+
70
+ .velu-version-switcher:hover {
71
+ color: var(--color-fd-foreground);
72
+ background-color: var(--color-fd-accent);
73
+ }
74
+
75
+ .velu-version-switcher svg {
76
+ width: 0.9rem;
77
+ height: 0.9rem;
78
+ }
79
+
80
+ .velu-version-switcher svg:last-child {
81
+ width: 0.8rem;
82
+ height: 0.8rem;
83
+ }
84
+
85
+ .velu-version-menu {
86
+ position: absolute;
87
+ top: calc(100% + 0.25rem);
88
+ right: 0;
89
+ min-width: 7.5rem;
90
+ padding: 0.25rem;
91
+ border-radius: 0.5rem;
92
+ background-color: var(--color-fd-popover);
93
+ border: 1px solid var(--color-fd-border);
94
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
95
+ z-index: 60;
96
+ }
97
+
98
+ .velu-version-option {
99
+ display: block;
100
+ width: 100%;
101
+ padding: 0.35rem 0.5rem;
102
+ border: none;
103
+ border-radius: 0.375rem;
104
+ background: transparent;
105
+ color: var(--color-fd-muted-foreground);
106
+ font-size: 0.8rem;
107
+ text-align: left;
108
+ cursor: pointer;
109
+ transition: color 0.15s, background-color 0.15s;
110
+ }
111
+
112
+ .velu-version-option:hover {
113
+ background-color: var(--color-fd-accent);
114
+ color: var(--color-fd-foreground);
115
+ }
116
+
117
+ .velu-version-option.active {
118
+ color: var(--color-fd-primary);
119
+ }
120
+
121
+ .velu-product-switcher-wrap {
122
+ position: relative;
123
+ width: 100%;
124
+ }
125
+
126
+ .velu-product-switcher {
127
+ display: flex;
128
+ align-items: center;
129
+ justify-content: space-between;
130
+ width: 100%;
131
+ padding: 0.5rem 0.625rem;
132
+ border: 1px solid var(--color-fd-border);
133
+ border-radius: 0.5rem;
134
+ background: var(--color-fd-background);
135
+ color: var(--color-fd-foreground);
136
+ font-size: 0.8125rem;
137
+ font-weight: 500;
138
+ cursor: pointer;
139
+ transition: border-color 0.15s, background-color 0.15s;
140
+ }
141
+
142
+ .velu-product-switcher:hover {
143
+ border-color: var(--color-fd-primary);
144
+ background-color: var(--color-fd-accent);
145
+ }
146
+
147
+ .velu-product-switcher svg {
148
+ width: 0.875rem;
149
+ height: 0.875rem;
150
+ flex-shrink: 0;
151
+ opacity: 0.6;
152
+ }
153
+
154
+ .velu-product-switcher-label {
155
+ flex: 1;
156
+ text-align: left;
157
+ }
158
+
159
+ .velu-product-menu {
160
+ position: absolute;
161
+ top: calc(100% + 0.25rem);
162
+ left: 0;
163
+ right: 0;
164
+ padding: 0.25rem;
165
+ border-radius: 0.5rem;
166
+ background-color: var(--color-fd-popover);
167
+ border: 1px solid var(--color-fd-border);
168
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
169
+ z-index: 60;
170
+ }
171
+
172
+ .velu-product-option {
173
+ display: flex;
174
+ flex-direction: column;
175
+ width: 100%;
176
+ padding: 0.5rem 0.625rem;
177
+ border: none;
178
+ border-radius: 0.375rem;
179
+ background: transparent;
180
+ text-align: left;
181
+ cursor: pointer;
182
+ transition: background-color 0.15s;
183
+ }
184
+
185
+ .velu-product-option:hover {
186
+ background-color: var(--color-fd-accent);
187
+ }
188
+
189
+ .velu-product-option.active {
190
+ background-color: var(--color-fd-accent);
191
+ }
192
+
193
+ .velu-product-option-name {
194
+ font-size: 0.8125rem;
195
+ font-weight: 500;
196
+ color: var(--color-fd-foreground);
197
+ }
198
+
199
+ .velu-product-option.active .velu-product-option-name {
200
+ color: var(--color-fd-primary);
201
+ }
202
+
203
+ .velu-product-option-desc {
204
+ font-size: 0.6875rem;
205
+ color: var(--color-fd-muted-foreground);
206
+ margin-top: 0.125rem;
207
+ }
208
+
209
+ .velu-sidebar-footer-row {
210
+ display: flex;
211
+ align-items: center;
212
+ justify-content: space-between;
213
+ }
214
+
215
+ .velu-sidebar-links {
216
+ display: flex;
217
+ flex-direction: column;
218
+ gap: 0;
219
+ }
220
+
221
+ .velu-sidebar-link {
222
+ display: flex;
223
+ align-items: center;
224
+ justify-content: space-between;
225
+ padding: 0.375rem 0.5rem;
226
+ border-radius: 0.375rem;
227
+ font-size: 0.875rem;
228
+ color: var(--color-fd-muted-foreground);
229
+ text-decoration: none;
230
+ transition: color 0.15s, background-color 0.15s;
231
+ }
232
+
233
+ .velu-sidebar-link:hover {
234
+ color: var(--color-fd-foreground);
235
+ background-color: var(--color-fd-accent);
236
+ }
237
+
238
+ .velu-sidebar-link-text {
239
+ flex: 1;
240
+ }
241
+
242
+ .velu-sidebar-link-icon {
243
+ width: 0.875rem;
244
+ height: 0.875rem;
245
+ opacity: 0.5;
246
+ flex-shrink: 0;
247
+ }
248
+
249
+ .velu-sidebar-link:hover .velu-sidebar-link-icon {
250
+ opacity: 0.8;
251
+ }
252
+
253
+ .velu-theme-toggle {
254
+ display: flex;
255
+ align-items: center;
256
+ gap: 0.25rem;
257
+ padding: 0.25rem;
258
+ border-radius: 0.5rem;
259
+ background-color: var(--color-fd-accent);
260
+ width: fit-content;
261
+ margin-left: auto;
262
+ }
263
+
264
+ .velu-lang-switcher {
265
+ display: flex;
266
+ align-items: center;
267
+ gap: 0.375rem;
268
+ padding: 0.375rem 0.5rem;
269
+ border: none;
270
+ border-radius: 0.375rem;
271
+ background: transparent;
272
+ color: var(--color-fd-muted-foreground);
273
+ font-size: 0.875rem;
274
+ cursor: pointer;
275
+ transition: color 0.15s, background-color 0.15s;
276
+ position: relative;
277
+ }
278
+
279
+ .velu-lang-switcher:hover {
280
+ color: var(--color-fd-foreground);
281
+ background-color: var(--color-fd-accent);
282
+ }
283
+
284
+ .velu-lang-switcher svg {
285
+ width: 1rem;
286
+ height: 1rem;
287
+ }
288
+
289
+ .velu-lang-menu {
290
+ position: absolute;
291
+ bottom: 100%;
292
+ left: 0;
293
+ margin-bottom: 0.25rem;
294
+ min-width: 8rem;
295
+ padding: 0.25rem;
296
+ border-radius: 0.5rem;
297
+ background-color: var(--color-fd-popover);
298
+ border: 1px solid var(--color-fd-border);
299
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
300
+ z-index: 50;
301
+ }
302
+
303
+ .velu-lang-option {
304
+ display: block;
305
+ width: 100%;
306
+ padding: 0.375rem 0.5rem;
307
+ border: none;
308
+ border-radius: 0.375rem;
309
+ background: transparent;
310
+ color: var(--color-fd-muted-foreground);
311
+ font-size: 0.875rem;
312
+ text-align: left;
313
+ cursor: pointer;
314
+ transition: color 0.15s, background-color 0.15s;
315
+ }
316
+
317
+ .velu-lang-option:hover {
318
+ background-color: var(--color-fd-accent);
319
+ color: var(--color-fd-foreground);
320
+ }
321
+
322
+ .velu-lang-option.active {
323
+ color: var(--color-fd-primary);
324
+ }
325
+
326
+ .velu-theme-btn {
327
+ display: flex;
328
+ align-items: center;
329
+ justify-content: center;
330
+ padding: 0.375rem;
331
+ border: none;
332
+ border-radius: 0.375rem;
333
+ background: transparent;
334
+ color: var(--color-fd-muted-foreground);
335
+ cursor: pointer;
336
+ transition: color 0.15s, background-color 0.15s;
337
+ }
338
+
339
+ .velu-theme-btn:hover {
340
+ color: var(--color-fd-foreground);
341
+ }
342
+
343
+ .velu-theme-btn.active {
344
+ background-color: var(--color-fd-background);
345
+ color: var(--color-fd-foreground);
346
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
347
+ }
348
+
22
349
  .velu-footer {
23
350
  margin-top: 3rem;
24
351
  padding-top: 1.5rem;
@@ -1,7 +1,6 @@
1
1
  import type { ReactNode } from 'react';
2
- import { RootProvider } from 'fumadocs-ui/provider/next';
3
2
  import { getAppearance } from '@/lib/velu';
4
- import { PagefindSearch } from '@/components/search';
3
+ import { Providers } from '@/components/providers';
5
4
  import { VeluAssistant } from '@/components/assistant';
6
5
  import './global.css';
7
6
  import './search.css';
@@ -21,13 +20,10 @@ export default function RootLayout({ children }: { children: ReactNode }) {
21
20
  return (
22
21
  <html lang="en" suppressHydrationWarning>
23
22
  <body className="min-h-screen" suppressHydrationWarning>
24
- <RootProvider
25
- theme={theme}
26
- search={{ SearchDialog: PagefindSearch }}
27
- >
23
+ <Providers theme={theme}>
28
24
  {children}
29
25
  <VeluAssistant />
30
- </RootProvider>
26
+ </Providers>
31
27
  </body>
32
28
  </html>
33
29
  );
@@ -116,3 +116,23 @@
116
116
  border-radius: 2px;
117
117
  padding: 0 2px;
118
118
  }
119
+
120
+ /* Search filter indicator */
121
+ .fd-search-filters {
122
+ display: flex;
123
+ align-items: center;
124
+ gap: 8px;
125
+ padding: 8px 16px;
126
+ border-bottom: 1px solid var(--color-fd-border, #27272a);
127
+ background: var(--color-fd-accent, #27272a);
128
+ font-size: 0.75rem;
129
+ }
130
+
131
+ .fd-search-filter-label {
132
+ color: var(--color-fd-muted-foreground, #a1a1aa);
133
+ }
134
+
135
+ .fd-search-filter-value {
136
+ color: var(--color-fd-primary, #818cf8);
137
+ font-weight: 500;
138
+ }
@@ -0,0 +1,95 @@
1
+ 'use client';
2
+
3
+ import { useState, useRef, useEffect, useMemo } from 'react';
4
+ import { usePathname } from 'next/navigation';
5
+
6
+ function GlobeIcon() {
7
+ return (
8
+ <svg
9
+ xmlns="http://www.w3.org/2000/svg"
10
+ viewBox="0 0 24 24"
11
+ fill="none"
12
+ stroke="currentColor"
13
+ strokeWidth={2}
14
+ strokeLinecap="round"
15
+ strokeLinejoin="round"
16
+ >
17
+ <circle cx="12" cy="12" r="10" />
18
+ <path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" />
19
+ <path d="M2 12h20" />
20
+ </svg>
21
+ );
22
+ }
23
+
24
+ function nativeLabel(code: string): string {
25
+ try {
26
+ const dn = new Intl.DisplayNames([code], { type: 'language' });
27
+ const name = dn.of(code);
28
+ if (name) return name.charAt(0).toUpperCase() + name.slice(1);
29
+ } catch {}
30
+ return code.toUpperCase();
31
+ }
32
+
33
+ export function LanguageSwitcher({ languages, defaultLang }: { languages: string[]; defaultLang: string }) {
34
+ const [open, setOpen] = useState(false);
35
+ const [mounted, setMounted] = useState(false);
36
+ const ref = useRef<HTMLDivElement>(null);
37
+ const pathname = usePathname();
38
+
39
+ useEffect(() => setMounted(true), []);
40
+
41
+ const current = useMemo(() => {
42
+ const firstSeg = pathname.split('/').filter(Boolean)[0];
43
+ return languages.includes(firstSeg ?? '') ? firstSeg! : defaultLang;
44
+ }, [pathname, languages, defaultLang]);
45
+
46
+ useEffect(() => {
47
+ function handleClick(e: MouseEvent) {
48
+ if (ref.current && !ref.current.contains(e.target as Node)) {
49
+ setOpen(false);
50
+ }
51
+ }
52
+ document.addEventListener('mousedown', handleClick);
53
+ return () => document.removeEventListener('mousedown', handleClick);
54
+ }, []);
55
+
56
+ if (languages.length <= 1) return null;
57
+
58
+ function switchTo(code: string) {
59
+ setOpen(false);
60
+ const segments = pathname.split('/').filter(Boolean);
61
+ const isLangPrefix = languages.includes(segments[0] ?? '');
62
+ const rest = isLangPrefix ? segments.slice(1) : segments;
63
+ const newPath = code === defaultLang
64
+ ? '/' + rest.join('/')
65
+ : '/' + code + '/' + rest.join('/');
66
+ window.location.href = newPath;
67
+ }
68
+
69
+ return (
70
+ <div ref={ref} style={{ position: 'relative', ...(mounted ? {} : { opacity: 0, pointerEvents: 'none' as const }) }}>
71
+ <button
72
+ type="button"
73
+ className="velu-lang-switcher"
74
+ onClick={() => mounted && setOpen(!open)}
75
+ >
76
+ <GlobeIcon />
77
+ <span>{nativeLabel(current)}</span>
78
+ </button>
79
+ {open && (
80
+ <div className="velu-lang-menu">
81
+ {languages.map((code) => (
82
+ <button
83
+ key={code}
84
+ type="button"
85
+ className={`velu-lang-option ${code === current ? 'active' : ''}`}
86
+ onClick={() => switchTo(code)}
87
+ >
88
+ {nativeLabel(code)}
89
+ </button>
90
+ ))}
91
+ </div>
92
+ )}
93
+ </div>
94
+ );
95
+ }