@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/package.json CHANGED
@@ -1,9 +1,13 @@
1
1
  {
2
2
  "name": "@ugurdemirel/landcraft",
3
- "version": "0.1.0",
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
- "./tailwind-preset": "./tailwind.preset.cjs"
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": "^3.4.0",
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": "^3.4.17",
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"
@@ -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
- <a
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
- </a>
96
+ </Link>
93
97
  );
94
98
  }
95
99
 
96
100
  const author = post.author;
97
101
  return (
98
- <a
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
- </a>
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
- <a
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
- </a>
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={cn(
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
- {brandNode}
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
- <a href={link.href} className="text-sm text-muted-foreground transition-colors duration-150 hover:text-foreground">
88
+ <Link href={link.href} className="text-sm text-muted-foreground transition-colors duration-150 hover:text-foreground">
52
89
  {link.label}
53
- </a>
90
+ </Link>
54
91
  </li>
55
92
  ))}
56
93
  </ul>
57
- <div className="text-sm text-muted-foreground">{bottom}</div>
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
- <span className="font-display text-5xl font-bold tracking-[-0.03em] text-foreground sm:text-7xl">
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
- <a href={link.href} className="text-[15px] text-foreground/80 transition-colors duration-150 hover:text-foreground">
125
+ <Link href={link.href} className="text-[15px] text-foreground/80 transition-colors duration-150 hover:text-foreground">
88
126
  {link.label}
89
- </a>
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))" }}>{brandNode}</span>
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
- <a
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
- </a>
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";