@ugurdemirel/landcraft 0.1.1 → 0.1.3

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.
@@ -1,4 +1,4 @@
1
- import { useId, useState, type HTMLAttributes, type ReactNode } from "react";
1
+ import { useId, useState, type ElementType, type HTMLAttributes, type ReactNode } from "react";
2
2
  import { cn } from "../utils/cn";
3
3
  import { getContrastText } from "../utils/contrast";
4
4
  import { ChevronDown, Menu, X } from "../icons";
@@ -49,7 +49,11 @@ export interface MegaMenuProps extends Omit<HTMLAttributes<HTMLElement>, "title"
49
49
  /** Rendered at the right side of the bar (desktop) and inside the mobile panel. */
50
50
  actions?: ReactNode;
51
51
  cta?: ReactNode;
52
+ /** Optional language switcher rendered on the right (desktop) and beside the menu button (mobile). */
53
+ languageSwitcher?: ReactNode;
52
54
  sticky?: boolean;
55
+ /** Component used to render brand, trigger and panel links. Defaults to `<a>`. Pass your router's `<Link>` (Next.js, Remix, React Router…) for framework-aware navigation. */
56
+ LinkComponent?: ElementType;
53
57
  }
54
58
 
55
59
  const triggerStyles = {
@@ -70,11 +74,14 @@ export const MegaMenu = ({
70
74
  items,
71
75
  actions,
72
76
  cta,
77
+ languageSwitcher,
73
78
  sticky = true,
79
+ LinkComponent,
74
80
  onKeyDown,
75
81
  ...props
76
82
  }: MegaMenuProps) => {
77
83
  const ids = useId();
84
+ const Link = LinkComponent ?? "a";
78
85
  const [openIndex, setOpenIndex] = useState<number | null>(null);
79
86
  const [mobileOpen, setMobileOpen] = useState(false);
80
87
  const [mobileExpanded, setMobileExpanded] = useState<number | null>(null);
@@ -166,7 +173,7 @@ export const MegaMenu = ({
166
173
  <ul className={cn(column.title && "mt-3", "space-y-0.5")}>
167
174
  {column.links.map((link) => (
168
175
  <li key={link.href}>
169
- <a
176
+ <Link
170
177
  href={link.href}
171
178
  onClick={closeAll}
172
179
  className={cn(
@@ -189,7 +196,7 @@ export const MegaMenu = ({
189
196
  {link.description}
190
197
  </span>
191
198
  ) : null}
192
- </a>
199
+ </Link>
193
200
  </li>
194
201
  ))}
195
202
  </ul>
@@ -206,7 +213,7 @@ export const MegaMenu = ({
206
213
  : undefined;
207
214
 
208
215
  return (
209
- <a
216
+ <Link
210
217
  href={featured.href}
211
218
  onClick={closeAll}
212
219
  className={cn(
@@ -237,7 +244,7 @@ export const MegaMenu = ({
237
244
  aria-hidden
238
245
  className="pointer-events-none absolute -right-8 -top-8 h-28 w-28 rounded-full opacity-20 blur-2xl bg-black/40"
239
246
  />
240
- </a>
247
+ </Link>
241
248
  );
242
249
  };
243
250
 
@@ -255,9 +262,9 @@ export const MegaMenu = ({
255
262
  {...props}
256
263
  >
257
264
  <nav className={bar} onMouseLeave={closePanels} aria-label="Mega menu">
258
- <a href={brandHref} className="shrink-0">
265
+ <Link href={brandHref} className="shrink-0">
259
266
  {brandNode}
260
- </a>
267
+ </Link>
261
268
 
262
269
  <ul className="hidden items-center gap-1 lg:flex">
263
270
  {items.map((item, i) => {
@@ -304,14 +311,14 @@ export const MegaMenu = ({
304
311
  />
305
312
  </button>
306
313
  ) : (
307
- <a href={item.href ?? "#"} className={triggerClasses}>
314
+ <Link href={item.href ?? "#"} className={triggerClasses}>
308
315
  {item.label}
309
316
  {item.badge ? (
310
317
  <span className="rounded-full bg-primary-soft px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-primary">
311
318
  {item.badge}
312
319
  </span>
313
320
  ) : null}
314
- </a>
321
+ </Link>
315
322
  )}
316
323
  </li>
317
324
  );
@@ -319,10 +326,15 @@ export const MegaMenu = ({
319
326
  </ul>
320
327
 
321
328
  <div className="hidden items-center gap-3 lg:flex">
329
+ {languageSwitcher}
322
330
  {actions}
323
331
  {cta}
324
332
  </div>
325
333
 
334
+ {languageSwitcher ? (
335
+ <div className="flex items-center lg:hidden">{languageSwitcher}</div>
336
+ ) : null}
337
+
326
338
  <button
327
339
  type="button"
328
340
  aria-label={mobileOpen ? "Close menu" : "Open menu"}
@@ -350,7 +362,8 @@ export const MegaMenu = ({
350
362
  aria-label={`${item.label} menu`}
351
363
  onMouseEnter={() => setOpenIndex(i)}
352
364
  className={cn(
353
- "absolute inset-x-0 top-full z-50 pt-2 transition-all duration-150",
365
+ "absolute top-full z-50 pt-2 transition-all duration-150",
366
+ variant === "floating" ? "inset-x-0 mx-auto w-full max-w-5xl" : "inset-x-0",
354
367
  open
355
368
  ? "visible translate-y-0 opacity-100"
356
369
  : "pointer-events-none invisible -translate-y-1 opacity-0",
@@ -439,7 +452,7 @@ export const MegaMenu = ({
439
452
  </li>
440
453
  ) : (
441
454
  <li key={item.label} className="px-5">
442
- <a
455
+ <Link
443
456
  href={item.href ?? "#"}
444
457
  onClick={() => setMobileOpen(false)}
445
458
  className={cn(
@@ -450,7 +463,7 @@ export const MegaMenu = ({
450
463
  )}
451
464
  >
452
465
  {item.label}
453
- </a>
466
+ </Link>
454
467
  </li>
455
468
  ),
456
469
  )}
@@ -0,0 +1,124 @@
1
+ import { forwardRef, useEffect, useId, useRef, type HTMLAttributes, type ReactNode } from "react";
2
+ import { cn } from "../utils/cn";
3
+ import { X } from "../icons";
4
+
5
+ export interface ModalProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
6
+ open: boolean;
7
+ onClose: () => void;
8
+ /** Dialog heading (required for a11y; auto-linked via `aria-labelledby`). */
9
+ title?: ReactNode;
10
+ description?: ReactNode;
11
+ /** Rendered below the content, e.g. actions. */
12
+ footer?: ReactNode;
13
+ /** Content width. Defaults to `md`. */
14
+ size?: "sm" | "md" | "lg";
15
+ }
16
+
17
+ const sizes: Record<NonNullable<ModalProps["size"]>, string> = {
18
+ sm: "max-w-sm",
19
+ md: "max-w-lg",
20
+ lg: "max-w-2xl",
21
+ };
22
+
23
+ /**
24
+ * Accessible dialog built on the HTML Popover API.
25
+ *
26
+ * `popover="auto"` renders in the top layer and gives light-dismiss (outside
27
+ * click + `Escape`), automatic focus movement and restoration for free, so no
28
+ * portal, focus trap or key handlers are needed. State stays controlled via
29
+ * `open` / `onClose`.
30
+ */
31
+ export const Modal = forwardRef<HTMLDivElement, ModalProps>(
32
+ ({ className, open, onClose, title, description, footer, size = "md", children, ...props }, ref) => {
33
+ const titleId = useId();
34
+ const descriptionId = useId();
35
+ const internalRef = useRef<HTMLDivElement | null>(null);
36
+ const onCloseRef = useRef(onClose);
37
+
38
+ useEffect(() => {
39
+ onCloseRef.current = onClose;
40
+ }, [onClose]);
41
+
42
+ useEffect(() => {
43
+ const el = internalRef.current;
44
+ if (!el) return;
45
+ if (!el.hasAttribute("popover")) el.setAttribute("popover", "auto");
46
+ }, []);
47
+
48
+ useEffect(() => {
49
+ const el = internalRef.current;
50
+ if (!el) return;
51
+ if (open) {
52
+ if (!el.matches(":popover-open")) el.showPopover();
53
+ document.body.style.overflow = "hidden";
54
+ } else {
55
+ if (el.matches(":popover-open")) el.hidePopover();
56
+ document.body.style.overflow = "";
57
+ }
58
+ }, [open]);
59
+
60
+ useEffect(() => {
61
+ const el = internalRef.current;
62
+ if (!el) return;
63
+ const onToggle = (event: Event) => {
64
+ if ((event as ToggleEvent).newState === "closed") onCloseRef.current?.();
65
+ };
66
+ el.addEventListener("toggle", onToggle);
67
+ return () => el.removeEventListener("toggle", onToggle);
68
+ }, []);
69
+
70
+ useEffect(
71
+ () => () => {
72
+ document.body.style.overflow = "";
73
+ },
74
+ [],
75
+ );
76
+
77
+ return (
78
+ <div
79
+ ref={(node) => {
80
+ internalRef.current = node;
81
+ if (typeof ref === "function") ref(node);
82
+ else if (ref) ref.current = node;
83
+ }}
84
+ role="dialog"
85
+ aria-modal="true"
86
+ aria-labelledby={title ? titleId : undefined}
87
+ aria-describedby={description ? descriptionId : undefined}
88
+ className={cn(
89
+ "modal-anim modal-backdrop fixed inset-0 z-[100] m-auto h-fit max-h-[calc(100vh-2rem)] w-fit max-w-[calc(100vw-2rem)] overflow-y-auto rounded-2xl border border-border bg-surface p-6 text-foreground shadow-overlay",
90
+ sizes[size],
91
+ className,
92
+ )}
93
+ {...props}
94
+ >
95
+ {title ? (
96
+ <h2
97
+ id={titleId}
98
+ className="pr-8 font-display text-lg font-semibold tracking-tight text-foreground"
99
+ >
100
+ {title}
101
+ </h2>
102
+ ) : null}
103
+ {description ? (
104
+ <p id={descriptionId} className="mt-1.5 text-sm leading-6 text-muted-foreground">
105
+ {description}
106
+ </p>
107
+ ) : null}
108
+ <button
109
+ type="button"
110
+ aria-label="Close dialog"
111
+ onClick={onClose}
112
+ className="absolute right-4 top-4 inline-flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 hover:bg-surface-strong hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
113
+ >
114
+ <X className="h-4 w-4" />
115
+ </button>
116
+ <div className={title || description ? "mt-4" : "pt-8"}>{children}</div>
117
+ {footer ? (
118
+ <div className="mt-6 flex flex-wrap items-center justify-end gap-3">{footer}</div>
119
+ ) : null}
120
+ </div>
121
+ );
122
+ },
123
+ );
124
+ Modal.displayName = "Modal";
@@ -1,4 +1,4 @@
1
- import { useState, type HTMLAttributes, type ReactNode } from "react";
1
+ import { useState, type ElementType, type HTMLAttributes, type ReactNode } from "react";
2
2
  import { cn } from "../utils/cn";
3
3
  import { Menu, X } from "../icons";
4
4
 
@@ -21,8 +21,12 @@ export interface NavbarProps extends Omit<HTMLAttributes<HTMLElement>, "title">
21
21
  brandHref?: string;
22
22
  links: NavLink[];
23
23
  actions?: ReactNode;
24
- sticky?: boolean;
25
24
  cta?: ReactNode;
25
+ /** Optional language switcher rendered on the right (desktop) and beside the menu button (mobile). */
26
+ languageSwitcher?: ReactNode;
27
+ sticky?: boolean;
28
+ /** Component used to render the brand and nav links. Defaults to `<a>`. Pass your router's `<Link>` (Next.js, Remix, React Router…) for framework-aware navigation. */
29
+ LinkComponent?: ElementType;
26
30
  }
27
31
 
28
32
  const linkStyles = {
@@ -43,11 +47,14 @@ export const Navbar = ({
43
47
  links,
44
48
  actions,
45
49
  cta,
50
+ languageSwitcher,
46
51
  sticky = true,
52
+ LinkComponent,
47
53
  ...props
48
54
  }: NavbarProps) => {
49
55
  const [open, setOpen] = useState(false);
50
56
  const inverse = variant === "inverse";
57
+ const Link = LinkComponent ?? "a";
51
58
 
52
59
  const shell = cn(
53
60
  "w-full transition-colors duration-200",
@@ -99,14 +106,14 @@ export const Navbar = ({
99
106
  return (
100
107
  <header className={cn(shell, variant === "floating" && "px-3 sm:px-6", className)} {...props}>
101
108
  <nav className={bar} aria-label="Main navigation">
102
- <a href={brandHref} className="shrink-0">
109
+ <Link href={brandHref} className="shrink-0">
103
110
  {brandNode}
104
- </a>
111
+ </Link>
105
112
 
106
113
  <ul className="hidden items-center gap-7 md:flex">
107
114
  {links.map((link) => (
108
115
  <li key={link.href}>
109
- <a
116
+ <Link
110
117
  href={link.href}
111
118
  className={cn(
112
119
  "text-sm font-medium transition-colors duration-150",
@@ -114,16 +121,21 @@ export const Navbar = ({
114
121
  )}
115
122
  >
116
123
  {link.label}
117
- </a>
124
+ </Link>
118
125
  </li>
119
126
  ))}
120
127
  </ul>
121
128
 
122
129
  <div className="hidden items-center gap-3 md:flex">
130
+ {languageSwitcher}
123
131
  {actions}
124
132
  {cta}
125
133
  </div>
126
134
 
135
+ {languageSwitcher ? (
136
+ <div className="flex items-center md:hidden">{languageSwitcher}</div>
137
+ ) : null}
138
+
127
139
  <button
128
140
  type="button"
129
141
  aria-label={open ? "Close menu" : "Open menu"}
@@ -155,7 +167,7 @@ export const Navbar = ({
155
167
  <ul className={cn("flex flex-col py-4", variant !== "floating" && "px-5")}>
156
168
  {links.map((link) => (
157
169
  <li key={link.href}>
158
- <a
170
+ <Link
159
171
  href={link.href}
160
172
  onClick={() => setOpen(false)}
161
173
  className={cn(
@@ -166,7 +178,7 @@ export const Navbar = ({
166
178
  )}
167
179
  >
168
180
  {link.label}
169
- </a>
181
+ </Link>
170
182
  </li>
171
183
  ))}
172
184
  {cta ? (
@@ -0,0 +1,78 @@
1
+ import {
2
+ Children,
3
+ cloneElement,
4
+ forwardRef,
5
+ isValidElement,
6
+ type HTMLAttributes,
7
+ type ReactElement,
8
+ type ReactNode,
9
+ type Ref,
10
+ } from "react";
11
+ import { cn } from "../utils/cn";
12
+
13
+ export interface SlotProps extends Omit<HTMLAttributes<HTMLElement>, "ref"> {
14
+ children?: ReactNode;
15
+ }
16
+
17
+ type AnyProps = Record<string, any>;
18
+
19
+ function mergeProps(slotProps: AnyProps, childProps: AnyProps): AnyProps {
20
+ const overrideProps: AnyProps = { ...childProps };
21
+
22
+ for (const propName in childProps) {
23
+ const slotPropValue = slotProps[propName];
24
+ const childPropValue = childProps[propName];
25
+ const isHandler = /^on[A-Z]/.test(propName);
26
+
27
+ if (isHandler) {
28
+ if (slotPropValue && childPropValue) {
29
+ overrideProps[propName] = (...args: any[]) => {
30
+ childPropValue(...args);
31
+ slotPropValue(...args);
32
+ };
33
+ } else if (slotPropValue) {
34
+ overrideProps[propName] = slotPropValue;
35
+ }
36
+ } else if (propName === "style") {
37
+ overrideProps[propName] = { ...slotPropValue, ...childPropValue };
38
+ } else if (propName === "className") {
39
+ overrideProps[propName] = cn(slotPropValue, childPropValue);
40
+ }
41
+ }
42
+
43
+ return { ...slotProps, ...overrideProps };
44
+ }
45
+
46
+ function useComposedRefs(...refs: Array<Ref<unknown> | undefined>) {
47
+ return (node: unknown) => {
48
+ for (const ref of refs) {
49
+ if (typeof ref === "function") {
50
+ ref(node);
51
+ } else if (ref != null) {
52
+ (ref as { current: unknown }).current = node;
53
+ }
54
+ }
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Merges its props onto a single child element instead of rendering its own
60
+ * DOM node. Used with the `asChild` prop so consumers can render a framework
61
+ * router `<Link>` (Next.js, Remix, React Router…) with a component's styling.
62
+ */
63
+ export const Slot = forwardRef<HTMLElement, SlotProps>((props, forwardedRef) => {
64
+ const { children, ...slotProps } = props;
65
+
66
+ if (!isValidElement(children)) {
67
+ return null;
68
+ }
69
+
70
+ const child = Children.only(children) as ReactElement & { ref?: Ref<unknown> };
71
+ const composedRef = useComposedRefs(forwardedRef, child.ref);
72
+
73
+ return cloneElement(child, {
74
+ ...mergeProps(slotProps as AnyProps, child.props as AnyProps),
75
+ ref: composedRef,
76
+ });
77
+ });
78
+ Slot.displayName = "Slot";
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { Button, type ButtonProps, type ButtonVariant, type ButtonSize } from "./components/Button";
2
+ export { Slot, type SlotProps } from "./components/Slot";
2
3
  export { Badge, type BadgeProps, type BadgeVariant, type BadgeSize } from "./components/Badge";
3
4
  export {
4
5
  Card,
@@ -48,6 +49,13 @@ export {
48
49
  export { CTA, type CTAProps } from "./components/CTA";
49
50
  export { FAQ, type FAQProps, type FaqItem } from "./components/FAQ";
50
51
  export { Footer, type FooterProps, type FooterColumn, type FooterLink } from "./components/Footer";
52
+ export {
53
+ LanguageSwitcher,
54
+ type LanguageSwitcherProps,
55
+ type LanguageSwitcherOption,
56
+ type LanguageOption,
57
+ } from "./components/LanguageSwitcher";
58
+ export { Modal, type ModalProps } from "./components/Modal";
51
59
  export { Newsletter, type NewsletterProps } from "./components/Newsletter";
52
60
  export { Prose, ProseLead, type ProseProps, type ProseSize } from "./components/Prose";
53
61
  export {
@@ -70,6 +78,7 @@ export {
70
78
  export { cn } from "./utils/cn";
71
79
  export { getContrastText, type ColorInput } from "./utils/contrast";
72
80
  export { useTokenForeground } from "./utils/useTokenForeground";
81
+ export { iso2ToFlagEmoji } from "./utils/iso2Flag";
73
82
 
74
83
  export {
75
84
  ArrowRight,
@@ -1,53 +1,8 @@
1
1
  @import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&display=swap");
2
-
3
- @tailwind base;
4
- @tailwind components;
5
- @tailwind utilities;
2
+ @import "tailwindcss";
3
+ @import "./theme.css";
6
4
 
7
5
  @layer base {
8
- :root {
9
- /* ── Brand ─────────────────────────────────────────────── */
10
- --color-primary: 79 70 229; /* indigo-600 */
11
- --color-primary-hover: 67 56 202; /* indigo-700 */
12
- --color-primary-soft: 238 242 255; /* indigo-50 */
13
- --color-on-primary: 255 255 255;
14
-
15
- --color-secondary: 15 15 15; /* ink */
16
- --color-secondary-hover: 38 38 38;
17
- --color-on-secondary: 250 250 250;
18
-
19
- --color-accent: 5 150 105; /* emerald-600 */
20
- --color-accent-hover: 4 120 87;
21
- --color-on-accent: 255 255 255;
22
-
23
- /* ── Surfaces & text (warm paper, not pure white) ──────── */
24
- --color-background: 250 250 250;
25
- --color-foreground: 17 17 17;
26
- --color-muted-foreground: 97 97 97;
27
- --color-surface: 255 255 255;
28
- --color-surface-strong: 244 244 242;
29
- --color-border: 229 229 228;
30
- --color-ring: 79 70 229;
31
- --color-danger: 217 45 32;
32
- --color-danger-soft: 255 241 242;
33
-
34
- /* ── Type & shape ──────────────────────────────────────── */
35
- --font-display: "Space Grotesk", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
36
- --font-sans: "DM Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
37
-
38
- --radius-sm: 0.375rem;
39
- --radius-md: 0.625rem;
40
- --radius-lg: 0.875rem;
41
- --radius-xl: 1.125rem;
42
- --radius-2xl: 1.5rem;
43
- --radius-3xl: 2rem;
44
-
45
- /* ── Elevation ─────────────────────────────────────────── */
46
- --shadow-soft: 0 1px 2px rgb(17 17 17 / 0.04), 0 2px 8px rgb(17 17 17 / 0.04);
47
- --shadow-raised: 0 2px 4px rgb(17 17 17 / 0.05), 0 12px 32px rgb(17 17 17 / 0.08);
48
- --shadow-overlay: 0 24px 64px -12px rgb(17 17 17 / 0.18);
49
- }
50
-
51
6
  html {
52
7
  -webkit-tap-highlight-color: transparent;
53
8
  scroll-behavior: smooth;
@@ -61,27 +16,3 @@
61
16
  background-color: rgb(var(--color-primary) / 0.18);
62
17
  }
63
18
  }
64
-
65
- @layer utilities {
66
- /* Subtle film grain for dark/statement surfaces */
67
- .grain {
68
- background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.45'/%3E%3C/svg%3E");
69
- }
70
-
71
- .animate-marquee {
72
- animation: marquee 38s linear infinite;
73
- }
74
-
75
- .mask-fade-x {
76
- mask-image: linear-gradient(to right, transparent, black 12%, black 88%, transparent);
77
- }
78
- }
79
-
80
- @keyframes marquee {
81
- from {
82
- transform: translateX(0);
83
- }
84
- to {
85
- transform: translateX(-50%);
86
- }
87
- }