@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.
@@ -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";
@@ -50,6 +50,8 @@ export interface MegaMenuProps extends Omit<HTMLAttributes<HTMLElement>, "title"
50
50
  actions?: ReactNode;
51
51
  cta?: ReactNode;
52
52
  sticky?: boolean;
53
+ /** 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. */
54
+ LinkComponent?: ElementType;
53
55
  }
54
56
 
55
57
  const triggerStyles = {
@@ -71,10 +73,12 @@ export const MegaMenu = ({
71
73
  actions,
72
74
  cta,
73
75
  sticky = true,
76
+ LinkComponent,
74
77
  onKeyDown,
75
78
  ...props
76
79
  }: MegaMenuProps) => {
77
80
  const ids = useId();
81
+ const Link = LinkComponent ?? "a";
78
82
  const [openIndex, setOpenIndex] = useState<number | null>(null);
79
83
  const [mobileOpen, setMobileOpen] = useState(false);
80
84
  const [mobileExpanded, setMobileExpanded] = useState<number | null>(null);
@@ -166,7 +170,7 @@ export const MegaMenu = ({
166
170
  <ul className={cn(column.title && "mt-3", "space-y-0.5")}>
167
171
  {column.links.map((link) => (
168
172
  <li key={link.href}>
169
- <a
173
+ <Link
170
174
  href={link.href}
171
175
  onClick={closeAll}
172
176
  className={cn(
@@ -189,7 +193,7 @@ export const MegaMenu = ({
189
193
  {link.description}
190
194
  </span>
191
195
  ) : null}
192
- </a>
196
+ </Link>
193
197
  </li>
194
198
  ))}
195
199
  </ul>
@@ -206,7 +210,7 @@ export const MegaMenu = ({
206
210
  : undefined;
207
211
 
208
212
  return (
209
- <a
213
+ <Link
210
214
  href={featured.href}
211
215
  onClick={closeAll}
212
216
  className={cn(
@@ -237,7 +241,7 @@ export const MegaMenu = ({
237
241
  aria-hidden
238
242
  className="pointer-events-none absolute -right-8 -top-8 h-28 w-28 rounded-full opacity-20 blur-2xl bg-black/40"
239
243
  />
240
- </a>
244
+ </Link>
241
245
  );
242
246
  };
243
247
 
@@ -255,9 +259,9 @@ export const MegaMenu = ({
255
259
  {...props}
256
260
  >
257
261
  <nav className={bar} onMouseLeave={closePanels} aria-label="Mega menu">
258
- <a href={brandHref} className="shrink-0">
262
+ <Link href={brandHref} className="shrink-0">
259
263
  {brandNode}
260
- </a>
264
+ </Link>
261
265
 
262
266
  <ul className="hidden items-center gap-1 lg:flex">
263
267
  {items.map((item, i) => {
@@ -304,14 +308,14 @@ export const MegaMenu = ({
304
308
  />
305
309
  </button>
306
310
  ) : (
307
- <a href={item.href ?? "#"} className={triggerClasses}>
311
+ <Link href={item.href ?? "#"} className={triggerClasses}>
308
312
  {item.label}
309
313
  {item.badge ? (
310
314
  <span className="rounded-full bg-primary-soft px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-primary">
311
315
  {item.badge}
312
316
  </span>
313
317
  ) : null}
314
- </a>
318
+ </Link>
315
319
  )}
316
320
  </li>
317
321
  );
@@ -439,7 +443,7 @@ export const MegaMenu = ({
439
443
  </li>
440
444
  ) : (
441
445
  <li key={item.label} className="px-5">
442
- <a
446
+ <Link
443
447
  href={item.href ?? "#"}
444
448
  onClick={() => setMobileOpen(false)}
445
449
  className={cn(
@@ -450,7 +454,7 @@ export const MegaMenu = ({
450
454
  )}
451
455
  >
452
456
  {item.label}
453
- </a>
457
+ </Link>
454
458
  </li>
455
459
  ),
456
460
  )}
@@ -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
- }