@ugurdemirel/landcraft 0.1.0 → 0.1.2
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 +103 -19
- package/dist/index.cjs +7 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +102 -4
- package/dist/index.js +1336 -1053
- package/dist/index.js.map +1 -1
- package/dist/styles.css +3721 -5900
- package/package.json +9 -7
- package/src/components/Blog.tsx +18 -10
- package/src/components/Button.tsx +27 -8
- package/src/components/Footer.tsx +53 -13
- package/src/components/LanguageSwitcher.tsx +223 -0
- package/src/components/MegaMenu.tsx +15 -11
- package/src/components/Modal.tsx +124 -0
- package/src/components/Navbar.tsx +20 -8
- package/src/components/Slot.tsx +78 -0
- package/src/index.ts +9 -0
- package/src/styles/index.css +2 -71
- package/src/styles/theme.css +242 -0
- package/src/utils/iso2Flag.ts +26 -0
- package/tailwind.preset.cjs +0 -121
package/package.json
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ugurdemirel/landcraft",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/ugurrdemirel/landcraft.git"
|
|
10
|
+
},
|
|
7
11
|
"description": "Marketing/landing-page focused React component library built on Tailwind CSS with token-based theming and automatic contrast.",
|
|
8
12
|
"type": "module",
|
|
9
13
|
"main": "./dist/index.cjs",
|
|
@@ -16,11 +20,10 @@
|
|
|
16
20
|
"require": "./dist/index.cjs"
|
|
17
21
|
},
|
|
18
22
|
"./styles.css": "./dist/styles.css",
|
|
19
|
-
"./
|
|
23
|
+
"./theme.css": "./src/styles/theme.css"
|
|
20
24
|
},
|
|
21
25
|
"files": [
|
|
22
26
|
"dist",
|
|
23
|
-
"tailwind.preset.cjs",
|
|
24
27
|
"src",
|
|
25
28
|
"README.md"
|
|
26
29
|
],
|
|
@@ -38,7 +41,7 @@
|
|
|
38
41
|
"peerDependencies": {
|
|
39
42
|
"react": ">=18.0.0",
|
|
40
43
|
"react-dom": ">=18.0.0",
|
|
41
|
-
"tailwindcss": "^
|
|
44
|
+
"tailwindcss": "^4.0.0",
|
|
42
45
|
"@tailwindcss/typography": "^0.5.15"
|
|
43
46
|
},
|
|
44
47
|
"peerDependenciesMeta": {
|
|
@@ -51,15 +54,14 @@
|
|
|
51
54
|
"tailwind-merge": "^2.5.5"
|
|
52
55
|
},
|
|
53
56
|
"devDependencies": {
|
|
57
|
+
"@tailwindcss/cli": "^4.0.0",
|
|
54
58
|
"@tailwindcss/typography": "^0.5.15",
|
|
55
59
|
"@types/node": "^22.9.0",
|
|
56
60
|
"@types/react": "^18.3.12",
|
|
57
61
|
"@types/react-dom": "^18.3.1",
|
|
58
62
|
"@vitejs/plugin-react": "^4.3.4",
|
|
59
|
-
"autoprefixer": "^10.4.20",
|
|
60
|
-
"postcss": "^8.4.49",
|
|
61
63
|
"rimraf": "^6.0.1",
|
|
62
|
-
"tailwindcss": "^
|
|
64
|
+
"tailwindcss": "^4.0.0",
|
|
63
65
|
"typescript": "^5.6.3",
|
|
64
66
|
"vite": "^5.4.11",
|
|
65
67
|
"vite-plugin-dts": "^4.3.0"
|
package/src/components/Blog.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
|
|
1
|
+
import { forwardRef, type ElementType, type HTMLAttributes, type ReactNode } from "react";
|
|
2
2
|
import { cn } from "../utils/cn";
|
|
3
3
|
import { ArrowRight, ArrowUpRight } from "../icons";
|
|
4
4
|
import { Badge } from "./Badge";
|
|
@@ -41,6 +41,8 @@ function AuthorMark({ name, avatar }: { name: string; avatar?: ReactNode }) {
|
|
|
41
41
|
export interface BlogCardProps extends HTMLAttributes<HTMLAnchorElement> {
|
|
42
42
|
post: BlogPost;
|
|
43
43
|
option?: "card" | "row";
|
|
44
|
+
/** Component used to render the card link. Defaults to `<a>`. Pass your router's `<Link>` (Next.js, Remix, React Router…) for framework-aware navigation. */
|
|
45
|
+
LinkComponent?: ElementType;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
const Cover = ({ post }: { post: BlogPost }) =>
|
|
@@ -65,10 +67,12 @@ const Cover = ({ post }: { post: BlogPost }) =>
|
|
|
65
67
|
);
|
|
66
68
|
|
|
67
69
|
export const BlogCard = forwardRef<HTMLAnchorElement, BlogCardProps>(
|
|
68
|
-
({ className, post, option = "card", ...props }, ref) => {
|
|
70
|
+
({ className, post, option = "card", LinkComponent, ...props }, ref) => {
|
|
71
|
+
const Link = LinkComponent ?? "a";
|
|
72
|
+
|
|
69
73
|
if (option === "row") {
|
|
70
74
|
return (
|
|
71
|
-
<
|
|
75
|
+
<Link
|
|
72
76
|
ref={ref}
|
|
73
77
|
href={post.href ?? "#"}
|
|
74
78
|
className={cn("group flex cursor-pointer items-baseline gap-5 py-6", className)}
|
|
@@ -89,13 +93,13 @@ export const BlogCard = forwardRef<HTMLAnchorElement, BlogCardProps>(
|
|
|
89
93
|
<span className="text-foreground/30 transition-all duration-200 group-hover:translate-x-0.5 group-hover:text-foreground">
|
|
90
94
|
<ArrowUpRight className="h-5 w-5" />
|
|
91
95
|
</span>
|
|
92
|
-
</
|
|
96
|
+
</Link>
|
|
93
97
|
);
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
const author = post.author;
|
|
97
101
|
return (
|
|
98
|
-
<
|
|
102
|
+
<Link
|
|
99
103
|
ref={ref}
|
|
100
104
|
href={post.href ?? "#"}
|
|
101
105
|
className={cn(
|
|
@@ -134,7 +138,7 @@ export const BlogCard = forwardRef<HTMLAnchorElement, BlogCardProps>(
|
|
|
134
138
|
<ArrowRight className="h-4 w-4 text-foreground/35 transition-all duration-200 group-hover:translate-x-0.5 group-hover:text-foreground" />
|
|
135
139
|
</div>
|
|
136
140
|
</div>
|
|
137
|
-
</
|
|
141
|
+
</Link>
|
|
138
142
|
);
|
|
139
143
|
},
|
|
140
144
|
);
|
|
@@ -153,6 +157,8 @@ export interface BlogSectionProps
|
|
|
153
157
|
description?: ReactNode;
|
|
154
158
|
showAllLabel?: string;
|
|
155
159
|
showAllHref?: string;
|
|
160
|
+
/** Component used to render the "show all" link and post cards. Defaults to `<a>`. Pass your router's `<Link>` (Next.js, Remix, React Router…) for framework-aware navigation. */
|
|
161
|
+
LinkComponent?: ElementType;
|
|
156
162
|
}
|
|
157
163
|
|
|
158
164
|
const gridCols: Record<NonNullable<BlogSectionProps["columns"]>, string> = {
|
|
@@ -174,11 +180,13 @@ export const BlogSection = forwardRef<HTMLElement, BlogSectionProps>(
|
|
|
174
180
|
description,
|
|
175
181
|
showAllLabel,
|
|
176
182
|
showAllHref,
|
|
183
|
+
LinkComponent,
|
|
177
184
|
...props
|
|
178
185
|
},
|
|
179
186
|
ref,
|
|
180
187
|
) => {
|
|
181
188
|
const visible = limit ? posts.slice(0, limit) : posts;
|
|
189
|
+
const Link = LinkComponent ?? "a";
|
|
182
190
|
|
|
183
191
|
return (
|
|
184
192
|
<section ref={ref} className={cn("w-full py-20 sm:py-24", className)} {...props}>
|
|
@@ -211,13 +219,13 @@ export const BlogSection = forwardRef<HTMLElement, BlogSectionProps>(
|
|
|
211
219
|
) : null}
|
|
212
220
|
</div>
|
|
213
221
|
{showAllHref ? (
|
|
214
|
-
<
|
|
222
|
+
<Link
|
|
215
223
|
href={showAllHref}
|
|
216
224
|
className="group inline-flex shrink-0 cursor-pointer items-center gap-1.5 text-sm font-semibold text-foreground transition-colors duration-150 hover:text-primary"
|
|
217
225
|
>
|
|
218
226
|
{showAllLabel ?? "All posts"}
|
|
219
227
|
<ArrowRight className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-0.5" />
|
|
220
|
-
</
|
|
228
|
+
</Link>
|
|
221
229
|
) : null}
|
|
222
230
|
</div>
|
|
223
231
|
)}
|
|
@@ -225,13 +233,13 @@ export const BlogSection = forwardRef<HTMLElement, BlogSectionProps>(
|
|
|
225
233
|
{option === "row" ? (
|
|
226
234
|
<div className="divide-y divide-border border-t border-border">
|
|
227
235
|
{visible.map((post) => (
|
|
228
|
-
<BlogCard key={post.title} post={post} option="row" />
|
|
236
|
+
<BlogCard key={post.title} post={post} option="row" LinkComponent={LinkComponent} />
|
|
229
237
|
))}
|
|
230
238
|
</div>
|
|
231
239
|
) : (
|
|
232
240
|
<div className={cn("grid grid-cols-1 gap-4", gridCols[columns])}>
|
|
233
241
|
{visible.map((post) => (
|
|
234
|
-
<BlogCard key={post.title} post={post} option="card" />
|
|
242
|
+
<BlogCard key={post.title} post={post} option="card" LinkComponent={LinkComponent} />
|
|
235
243
|
))}
|
|
236
244
|
</div>
|
|
237
245
|
)}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { forwardRef, type ButtonHTMLAttributes, type CSSProperties, type ReactNode } from "react";
|
|
2
2
|
import { cn } from "../utils/cn";
|
|
3
3
|
import { getContrastText } from "../utils/contrast";
|
|
4
|
+
import { Slot } from "./Slot";
|
|
4
5
|
|
|
5
6
|
export type ButtonVariant = "primary" | "dark" | "outline" | "ghost" | "link";
|
|
6
7
|
export type ButtonSize = "sm" | "md" | "lg";
|
|
@@ -13,6 +14,13 @@ export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
|
13
14
|
/** Paints the button with an arbitrary CSS color; text color is computed for contrast. */
|
|
14
15
|
customColor?: string;
|
|
15
16
|
fullWidth?: boolean;
|
|
17
|
+
/**
|
|
18
|
+
* When `true`, the button styling is applied to a single child element
|
|
19
|
+
* instead of a `<button>`. Use it to render a framework router `<Link>`
|
|
20
|
+
* (Next.js, Remix, React Router…). `iconLeft` / `iconRight` are ignored;
|
|
21
|
+
* pass icons inside the child instead.
|
|
22
|
+
*/
|
|
23
|
+
asChild?: boolean;
|
|
16
24
|
}
|
|
17
25
|
|
|
18
26
|
const variants: Record<ButtonVariant, string> = {
|
|
@@ -43,6 +51,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
43
51
|
iconRight,
|
|
44
52
|
customColor,
|
|
45
53
|
fullWidth,
|
|
54
|
+
asChild,
|
|
46
55
|
style,
|
|
47
56
|
type = "button",
|
|
48
57
|
children,
|
|
@@ -60,19 +69,29 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
60
69
|
|
|
61
70
|
const isLink = variant === "link";
|
|
62
71
|
|
|
72
|
+
const classes = cn(
|
|
73
|
+
base,
|
|
74
|
+
sizes[size],
|
|
75
|
+
customColor ? "hover:brightness-95" : variants[variant],
|
|
76
|
+
isLink && "underline-offset-4 hover:underline",
|
|
77
|
+
fullWidth && "w-full",
|
|
78
|
+
className,
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
if (asChild) {
|
|
82
|
+
return (
|
|
83
|
+
<Slot ref={ref} style={customStyle} className={classes} {...props}>
|
|
84
|
+
{children}
|
|
85
|
+
</Slot>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
63
89
|
return (
|
|
64
90
|
<button
|
|
65
91
|
ref={ref}
|
|
66
92
|
type={type}
|
|
67
93
|
style={customStyle}
|
|
68
|
-
className={
|
|
69
|
-
base,
|
|
70
|
-
sizes[size],
|
|
71
|
-
customColor ? "hover:brightness-95" : variants[variant],
|
|
72
|
-
isLink && "underline-offset-4 hover:underline",
|
|
73
|
-
fullWidth && "w-full",
|
|
74
|
-
className,
|
|
75
|
-
)}
|
|
94
|
+
className={classes}
|
|
76
95
|
{...props}
|
|
77
96
|
>
|
|
78
97
|
{iconLeft ? <span className={cn("shrink-0", !isLink && "opacity-70")}>{iconLeft}</span> : null}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type HTMLAttributes, type ReactNode } from "react";
|
|
1
|
+
import { type ElementType, type HTMLAttributes, type ReactNode } from "react";
|
|
2
2
|
import { cn } from "../utils/cn";
|
|
3
3
|
import { Container } from "./Container";
|
|
4
4
|
|
|
@@ -14,25 +14,54 @@ export interface FooterColumn {
|
|
|
14
14
|
|
|
15
15
|
export interface FooterProps extends HTMLAttributes<HTMLElement> {
|
|
16
16
|
brand?: ReactNode;
|
|
17
|
+
/** Custom logo node (component, <img>, inline SVG…). Replaces the wordmark. */
|
|
18
|
+
logo?: ReactNode;
|
|
19
|
+
/** Quick logo image source. */
|
|
20
|
+
logoSrc?: string;
|
|
21
|
+
logoAlt?: string;
|
|
22
|
+
/** Size / position tuning for logo images. Defaults to `h-7 w-auto`. */
|
|
23
|
+
logoClassName?: string;
|
|
17
24
|
description?: string;
|
|
18
25
|
columns: FooterColumn[];
|
|
19
26
|
socials?: ReactNode;
|
|
20
27
|
bottom?: ReactNode;
|
|
28
|
+
/** Optional language switcher rendered in the footer's bottom bar. */
|
|
29
|
+
languageSwitcher?: ReactNode;
|
|
21
30
|
option?: "classic" | "minimal" | "editorial";
|
|
22
31
|
badge?: ReactNode;
|
|
32
|
+
/** Component used to render footer links. Defaults to `<a>`. Pass your router's `<Link>` (Next.js, Remix, React Router…) for framework-aware navigation. */
|
|
33
|
+
LinkComponent?: ElementType;
|
|
23
34
|
}
|
|
24
35
|
|
|
25
36
|
export const Footer = ({
|
|
26
37
|
className,
|
|
27
38
|
brand,
|
|
39
|
+
logo,
|
|
40
|
+
logoSrc,
|
|
41
|
+
logoAlt,
|
|
42
|
+
logoClassName,
|
|
28
43
|
description,
|
|
29
44
|
columns,
|
|
30
45
|
socials,
|
|
31
46
|
bottom,
|
|
47
|
+
languageSwitcher,
|
|
32
48
|
option = "classic",
|
|
33
49
|
badge,
|
|
50
|
+
LinkComponent,
|
|
34
51
|
...props
|
|
35
52
|
}: FooterProps) => {
|
|
53
|
+
const Link = LinkComponent ?? "a";
|
|
54
|
+
|
|
55
|
+
const logoNode = (imageClassName = "h-7 w-auto") =>
|
|
56
|
+
logo ??
|
|
57
|
+
(logoSrc ? (
|
|
58
|
+
<img
|
|
59
|
+
src={logoSrc}
|
|
60
|
+
alt={logoAlt ?? (typeof brand === "string" ? brand : "Logo")}
|
|
61
|
+
className={cn(imageClassName, logoClassName)}
|
|
62
|
+
/>
|
|
63
|
+
) : null);
|
|
64
|
+
|
|
36
65
|
const brandNode = (
|
|
37
66
|
<span className="flex items-center gap-2 font-display text-lg font-bold tracking-tight">
|
|
38
67
|
<span aria-hidden className="inline-block h-2.5 w-2.5 rounded-sm bg-current" />
|
|
@@ -40,21 +69,32 @@ export const Footer = ({
|
|
|
40
69
|
</span>
|
|
41
70
|
);
|
|
42
71
|
|
|
72
|
+
const classicBrand = logoNode() ?? brandNode;
|
|
73
|
+
const minimalBrand = logoNode() ?? brandNode;
|
|
74
|
+
const editorialBrand = logoNode("h-12 w-auto") ?? (
|
|
75
|
+
<span className="font-display text-5xl font-bold tracking-[-0.03em] text-foreground sm:text-7xl">
|
|
76
|
+
{brand}
|
|
77
|
+
</span>
|
|
78
|
+
);
|
|
79
|
+
|
|
43
80
|
if (option === "minimal") {
|
|
44
81
|
return (
|
|
45
82
|
<footer className={cn("w-full border-t border-border bg-background", className)} {...props}>
|
|
46
83
|
<Container className="flex flex-col items-center justify-between gap-6 py-10 sm:flex-row">
|
|
47
|
-
{
|
|
84
|
+
{minimalBrand}
|
|
48
85
|
<ul className="flex flex-wrap items-center justify-center gap-x-8 gap-y-3">
|
|
49
86
|
{columns.flatMap((c) => c.links).slice(0, 6).map((link) => (
|
|
50
87
|
<li key={link.href}>
|
|
51
|
-
<
|
|
88
|
+
<Link href={link.href} className="text-sm text-muted-foreground transition-colors duration-150 hover:text-foreground">
|
|
52
89
|
{link.label}
|
|
53
|
-
</
|
|
90
|
+
</Link>
|
|
54
91
|
</li>
|
|
55
92
|
))}
|
|
56
93
|
</ul>
|
|
57
|
-
<div className="
|
|
94
|
+
<div className="flex flex-col items-center gap-3">
|
|
95
|
+
{languageSwitcher ? <div>{languageSwitcher}</div> : null}
|
|
96
|
+
<div className="text-sm text-muted-foreground">{bottom}</div>
|
|
97
|
+
</div>
|
|
58
98
|
</Container>
|
|
59
99
|
</footer>
|
|
60
100
|
);
|
|
@@ -66,9 +106,7 @@ export const Footer = ({
|
|
|
66
106
|
<Container className="py-16 sm:py-20">
|
|
67
107
|
<div className="mb-14 flex flex-col justify-between gap-8 sm:flex-row sm:items-end">
|
|
68
108
|
<div>
|
|
69
|
-
|
|
70
|
-
{brand}
|
|
71
|
-
</span>
|
|
109
|
+
{editorialBrand}
|
|
72
110
|
{description ? (
|
|
73
111
|
<p className="mt-3 max-w-sm text-sm leading-6 text-muted-foreground">{description}</p>
|
|
74
112
|
) : null}
|
|
@@ -84,9 +122,9 @@ export const Footer = ({
|
|
|
84
122
|
<ul className="mt-4 space-y-2.5">
|
|
85
123
|
{column.links.map((link) => (
|
|
86
124
|
<li key={link.href}>
|
|
87
|
-
<
|
|
125
|
+
<Link href={link.href} className="text-[15px] text-foreground/80 transition-colors duration-150 hover:text-foreground">
|
|
88
126
|
{link.label}
|
|
89
|
-
</
|
|
127
|
+
</Link>
|
|
90
128
|
</li>
|
|
91
129
|
))}
|
|
92
130
|
</ul>
|
|
@@ -95,6 +133,7 @@ export const Footer = ({
|
|
|
95
133
|
</div>
|
|
96
134
|
<div className="mt-12 flex flex-col items-center justify-between gap-4 border-t border-border pt-8 text-sm text-muted-foreground sm:flex-row">
|
|
97
135
|
<span>{bottom}</span>
|
|
136
|
+
{languageSwitcher ? <div>{languageSwitcher}</div> : null}
|
|
98
137
|
<span>© {new Date().getFullYear()} {brand}. All rights reserved.</span>
|
|
99
138
|
</div>
|
|
100
139
|
</Container>
|
|
@@ -107,7 +146,7 @@ export const Footer = ({
|
|
|
107
146
|
<Container className="py-16">
|
|
108
147
|
<div className="grid grid-cols-2 gap-10 md:grid-cols-4 lg:grid-cols-6">
|
|
109
148
|
<div className="col-span-2">
|
|
110
|
-
<span style={{ color: "rgb(var(--color-on-secondary))" }}>{
|
|
149
|
+
<span style={{ color: "rgb(var(--color-on-secondary))" }}>{classicBrand}</span>
|
|
111
150
|
{description ? (
|
|
112
151
|
<p className="mt-4 max-w-xs text-sm leading-6 text-on-secondary/60">{description}</p>
|
|
113
152
|
) : null}
|
|
@@ -122,12 +161,12 @@ export const Footer = ({
|
|
|
122
161
|
<ul className="mt-4 space-y-2.5">
|
|
123
162
|
{column.links.map((link) => (
|
|
124
163
|
<li key={link.href}>
|
|
125
|
-
<
|
|
164
|
+
<Link
|
|
126
165
|
href={link.href}
|
|
127
166
|
className="text-sm text-on-secondary/70 transition-colors duration-150 hover:text-on-secondary"
|
|
128
167
|
>
|
|
129
168
|
{link.label}
|
|
130
|
-
</
|
|
169
|
+
</Link>
|
|
131
170
|
</li>
|
|
132
171
|
))}
|
|
133
172
|
</ul>
|
|
@@ -136,6 +175,7 @@ export const Footer = ({
|
|
|
136
175
|
</div>
|
|
137
176
|
<div className="mt-14 flex flex-col items-center justify-between gap-4 border-t border-white/10 pt-8 text-sm text-on-secondary/50 sm:flex-row">
|
|
138
177
|
<span>{bottom}</span>
|
|
178
|
+
{languageSwitcher ? <div>{languageSwitcher}</div> : null}
|
|
139
179
|
<span>© {new Date().getFullYear()} {brand}. All rights reserved.</span>
|
|
140
180
|
</div>
|
|
141
181
|
</Container>
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { useEffect, useId, useRef, useState, type CSSProperties, type HTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "../utils/cn";
|
|
3
|
+
import { iso2ToFlagEmoji } from "../utils/iso2Flag";
|
|
4
|
+
import { Modal } from "./Modal";
|
|
5
|
+
import { Check, ChevronDown, Globe } from "../icons";
|
|
6
|
+
|
|
7
|
+
export interface LanguageOption {
|
|
8
|
+
/** Language code (ISO 3166-1 alpha-2, e.g. `"tr"`) — this is the value passed to `onChange` and the flag source. */
|
|
9
|
+
code: string;
|
|
10
|
+
/** Native display name, e.g. `"Türkçe"`. */
|
|
11
|
+
label: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type LanguageSwitcherOption = "dropdown" | "modal";
|
|
15
|
+
|
|
16
|
+
export interface LanguageSwitcherProps extends Omit<HTMLAttributes<HTMLDivElement>, "onChange"> {
|
|
17
|
+
/** `dropdown` opens the anchored menu; `modal` opens a full dialog via the Modal component. */
|
|
18
|
+
option?: LanguageSwitcherOption;
|
|
19
|
+
/** Languages to switch between. Required — no built-in default language list. */
|
|
20
|
+
languages: LanguageOption[];
|
|
21
|
+
/** Controlled current language code. */
|
|
22
|
+
value?: string;
|
|
23
|
+
/** Uncontrolled initial language code. Defaults to the first language. */
|
|
24
|
+
defaultValue?: string;
|
|
25
|
+
/** Called with the selected language code. */
|
|
26
|
+
onChange?: (code: string) => void;
|
|
27
|
+
/** Dropdown menu alignment relative to its trigger. Defaults to `end`. */
|
|
28
|
+
align?: "start" | "end";
|
|
29
|
+
/** Heading rendered inside the `modal` option. Optional — pass it to show a title. */
|
|
30
|
+
modalTitle?: ReactNode;
|
|
31
|
+
/** Replace the globe icon with the selected language's flag emoji (and show flags in options). */
|
|
32
|
+
showFlag?: boolean;
|
|
33
|
+
/** Adapts colors for dark surfaces (inverse Navbar, classic Footer). */
|
|
34
|
+
inverse?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const LanguageSwitcher = ({
|
|
38
|
+
className,
|
|
39
|
+
languages,
|
|
40
|
+
option = "dropdown",
|
|
41
|
+
value,
|
|
42
|
+
defaultValue,
|
|
43
|
+
onChange,
|
|
44
|
+
align = "end",
|
|
45
|
+
modalTitle,
|
|
46
|
+
showFlag = false,
|
|
47
|
+
inverse = false,
|
|
48
|
+
...props
|
|
49
|
+
}: LanguageSwitcherProps) => {
|
|
50
|
+
const listboxId = useId();
|
|
51
|
+
const anchorName = `--ls-${listboxId.replace(/:/g, "")}`;
|
|
52
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
53
|
+
const [open, setOpen] = useState(false);
|
|
54
|
+
const [internal, setInternal] = useState(defaultValue ?? languages[0]?.code ?? "");
|
|
55
|
+
const current = value ?? internal;
|
|
56
|
+
const currentLang = languages.find((l) => l.code === current) ?? languages[0];
|
|
57
|
+
|
|
58
|
+
const select = (code: string) => {
|
|
59
|
+
setInternal(code);
|
|
60
|
+
setOpen(false);
|
|
61
|
+
onChange?.(code);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const renderFlagOrGlobe = (
|
|
65
|
+
code: string,
|
|
66
|
+
{ size = "text-sm", globeClass = "text-muted-foreground" }: { size?: string; globeClass?: string } = {},
|
|
67
|
+
) => {
|
|
68
|
+
const flag = iso2ToFlagEmoji(code);
|
|
69
|
+
return flag ? (
|
|
70
|
+
<span aria-hidden className={cn(size, "leading-none")}>
|
|
71
|
+
{flag}
|
|
72
|
+
</span>
|
|
73
|
+
) : (
|
|
74
|
+
<Globe className={cn("h-4 w-4 shrink-0", globeClass)} />
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
useEffect(() => {
|
|
79
|
+
if (!open || option !== "dropdown") return;
|
|
80
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
81
|
+
if (rootRef.current && !rootRef.current.contains(event.target as Node)) {
|
|
82
|
+
setOpen(false);
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
86
|
+
if (event.key === "Escape") setOpen(false);
|
|
87
|
+
};
|
|
88
|
+
document.addEventListener("pointerdown", onPointerDown);
|
|
89
|
+
document.addEventListener("keydown", onKeyDown);
|
|
90
|
+
return () => {
|
|
91
|
+
document.removeEventListener("pointerdown", onPointerDown);
|
|
92
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
93
|
+
};
|
|
94
|
+
}, [open, option]);
|
|
95
|
+
|
|
96
|
+
if (languages.length === 0) return null;
|
|
97
|
+
|
|
98
|
+
const trigger = (
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
aria-haspopup={option === "dropdown" ? "listbox" : "dialog"}
|
|
102
|
+
aria-expanded={open}
|
|
103
|
+
aria-controls={option === "dropdown" ? listboxId : undefined}
|
|
104
|
+
onClick={() => setOpen((o) => !o)}
|
|
105
|
+
style={{ anchorName } as CSSProperties}
|
|
106
|
+
className={cn(
|
|
107
|
+
"inline-flex h-9 cursor-pointer items-center gap-1.5 rounded-lg border px-3 text-[13px] font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
108
|
+
inverse
|
|
109
|
+
? "border-white/10 text-on-secondary hover:bg-white/10"
|
|
110
|
+
: "border-border text-foreground hover:bg-surface",
|
|
111
|
+
)}
|
|
112
|
+
>
|
|
113
|
+
{showFlag ? (
|
|
114
|
+
renderFlagOrGlobe(currentLang.code, { globeClass: inverse ? "opacity-70" : "text-muted-foreground" })
|
|
115
|
+
) : (
|
|
116
|
+
<Globe
|
|
117
|
+
className={cn("h-4 w-4", inverse ? "opacity-70" : "text-muted-foreground")}
|
|
118
|
+
/>
|
|
119
|
+
)}
|
|
120
|
+
<span className="tracking-tight">{currentLang.label}</span>
|
|
121
|
+
<ChevronDown
|
|
122
|
+
className={cn(
|
|
123
|
+
"h-3.5 w-3.5 transition-transform duration-150",
|
|
124
|
+
open && "rotate-180",
|
|
125
|
+
inverse ? "opacity-70" : "text-muted-foreground",
|
|
126
|
+
)}
|
|
127
|
+
/>
|
|
128
|
+
</button>
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
if (option === "modal") {
|
|
132
|
+
return (
|
|
133
|
+
<>
|
|
134
|
+
<div ref={rootRef} className={cn("relative inline-flex", className)} {...props}>
|
|
135
|
+
{trigger}
|
|
136
|
+
</div>
|
|
137
|
+
<Modal open={open} onClose={() => setOpen(false)} title={modalTitle} size="sm">
|
|
138
|
+
<div role="listbox" aria-label="Select language" className="grid min-w-[19rem] gap-1.5">
|
|
139
|
+
{languages.map((lang) => {
|
|
140
|
+
const active = lang.code === current;
|
|
141
|
+
return (
|
|
142
|
+
<button
|
|
143
|
+
key={lang.code}
|
|
144
|
+
type="button"
|
|
145
|
+
role="option"
|
|
146
|
+
aria-selected={active}
|
|
147
|
+
onClick={() => select(lang.code)}
|
|
148
|
+
className={cn(
|
|
149
|
+
"flex w-full cursor-pointer items-center justify-between gap-3 rounded-lg border px-4 py-3 text-left transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
150
|
+
active
|
|
151
|
+
? "border-primary/40 bg-primary-soft text-foreground"
|
|
152
|
+
: "border-border text-foreground hover:bg-surface-strong",
|
|
153
|
+
)}
|
|
154
|
+
>
|
|
155
|
+
<span className="flex items-center gap-3">
|
|
156
|
+
{showFlag ? renderFlagOrGlobe(lang.code, { size: "text-base" }) : null}
|
|
157
|
+
<span className="text-sm font-medium">{lang.label}</span>
|
|
158
|
+
</span>
|
|
159
|
+
<span className="flex items-center gap-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
|
|
160
|
+
{lang.code}
|
|
161
|
+
{active ? <Check className="h-4 w-4 text-primary" /> : null}
|
|
162
|
+
</span>
|
|
163
|
+
</button>
|
|
164
|
+
);
|
|
165
|
+
})}
|
|
166
|
+
</div>
|
|
167
|
+
</Modal>
|
|
168
|
+
</>
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div ref={rootRef} className={cn("relative inline-flex", className)} {...props}>
|
|
174
|
+
{trigger}
|
|
175
|
+
|
|
176
|
+
<div
|
|
177
|
+
id={listboxId}
|
|
178
|
+
role="listbox"
|
|
179
|
+
aria-label="Select language"
|
|
180
|
+
style={
|
|
181
|
+
{
|
|
182
|
+
positionAnchor: anchorName,
|
|
183
|
+
top: "anchor(bottom)",
|
|
184
|
+
...(align === "start" ? { left: "anchor(left)" } : { right: "anchor(right)" }),
|
|
185
|
+
} as CSSProperties
|
|
186
|
+
}
|
|
187
|
+
className={cn(
|
|
188
|
+
"absolute top-full z-50 mt-2 min-w-[10rem] rounded-lg border border-border bg-surface p-1 shadow-raised transition-all duration-150",
|
|
189
|
+
align === "start" ? "left-0" : "right-0",
|
|
190
|
+
open
|
|
191
|
+
? "visible translate-y-0 opacity-100"
|
|
192
|
+
: "pointer-events-none invisible -translate-y-1 opacity-0",
|
|
193
|
+
)}
|
|
194
|
+
>
|
|
195
|
+
{languages.map((lang) => {
|
|
196
|
+
const active = lang.code === current;
|
|
197
|
+
return (
|
|
198
|
+
<button
|
|
199
|
+
key={lang.code}
|
|
200
|
+
type="button"
|
|
201
|
+
role="option"
|
|
202
|
+
aria-selected={active}
|
|
203
|
+
onClick={() => select(lang.code)}
|
|
204
|
+
className={cn(
|
|
205
|
+
"flex w-full cursor-pointer items-center justify-between gap-3 rounded-md px-3 py-2 text-[13px] font-medium transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
206
|
+
active
|
|
207
|
+
? "bg-surface-strong text-foreground"
|
|
208
|
+
: "text-muted-foreground hover:bg-surface-strong hover:text-foreground",
|
|
209
|
+
)}
|
|
210
|
+
>
|
|
211
|
+
<span className="flex items-center gap-2.5">
|
|
212
|
+
{showFlag ? renderFlagOrGlobe(lang.code) : null}
|
|
213
|
+
{lang.label}
|
|
214
|
+
</span>
|
|
215
|
+
{active ? <Check className="h-3.5 w-3.5 text-primary" /> : null}
|
|
216
|
+
</button>
|
|
217
|
+
);
|
|
218
|
+
})}
|
|
219
|
+
</div>
|
|
220
|
+
</div>
|
|
221
|
+
);
|
|
222
|
+
};
|
|
223
|
+
LanguageSwitcher.displayName = "LanguageSwitcher";
|