@ugurdemirel/landcraft 0.1.5 → 0.1.9
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 +4 -4
- package/src/components/Badge.test.tsx +0 -55
- package/src/components/Badge.tsx +0 -68
- package/src/components/Blog/Blog.test.tsx +0 -64
- package/src/components/Blog/BlogCard.tsx +0 -12
- package/src/components/Blog/BlogSection.tsx +0 -95
- package/src/components/Blog/index.ts +0 -8
- package/src/components/Blog/parts.tsx +0 -38
- package/src/components/Blog/types.ts +0 -42
- package/src/components/Blog/variants/Card.tsx +0 -62
- package/src/components/Blog/variants/Row.tsx +0 -41
- package/src/components/Button.test.tsx +0 -91
- package/src/components/Button.tsx +0 -113
- package/src/components/CTA/CTA.test.tsx +0 -53
- package/src/components/CTA/CTA.tsx +0 -12
- package/src/components/CTA/index.ts +0 -2
- package/src/components/CTA/parts.tsx +0 -26
- package/src/components/CTA/types.ts +0 -12
- package/src/components/CTA/variants/Inverse.tsx +0 -50
- package/src/components/CTA/variants/Panel.tsx +0 -60
- package/src/components/CTA/variants/Surface.tsx +0 -34
- package/src/components/Card.test.tsx +0 -49
- package/src/components/Card.tsx +0 -80
- package/src/components/Container.test.tsx +0 -52
- package/src/components/Container.tsx +0 -86
- package/src/components/FAQ/FAQ.test.tsx +0 -54
- package/src/components/FAQ/FAQ.tsx +0 -48
- package/src/components/FAQ/index.ts +0 -2
- package/src/components/FAQ/parts.tsx +0 -74
- package/src/components/FAQ/types.ts +0 -18
- package/src/components/FAQ/variants/Accordion.tsx +0 -46
- package/src/components/FAQ/variants/Cards.tsx +0 -40
- package/src/components/FAQ/variants/Split.tsx +0 -44
- package/src/components/Features/FeatureCard.tsx +0 -44
- package/src/components/Features/FeatureGrid.tsx +0 -14
- package/src/components/Features/Features.test.tsx +0 -49
- package/src/components/Features/index.ts +0 -3
- package/src/components/Features/parts.ts +0 -15
- package/src/components/Features/types.ts +0 -25
- package/src/components/Features/variants/Bento.tsx +0 -32
- package/src/components/Features/variants/Columns.tsx +0 -21
- package/src/components/Features/variants/EditorialRows.tsx +0 -38
- package/src/components/Footer/Footer.test.tsx +0 -49
- package/src/components/Footer/Footer.tsx +0 -11
- package/src/components/Footer/index.ts +0 -2
- package/src/components/Footer/parts.tsx +0 -101
- package/src/components/Footer/types.ts +0 -32
- package/src/components/Footer/variants/Classic.tsx +0 -59
- package/src/components/Footer/variants/Editorial.tsx +0 -47
- package/src/components/Footer/variants/Minimal.tsx +0 -33
- package/src/components/Hero/Hero.test.tsx +0 -71
- package/src/components/Hero/Hero.tsx +0 -12
- package/src/components/Hero/index.ts +0 -2
- package/src/components/Hero/parts.tsx +0 -29
- package/src/components/Hero/types.ts +0 -20
- package/src/components/Hero/variants/Centered.tsx +0 -54
- package/src/components/Hero/variants/Split.tsx +0 -53
- package/src/components/Hero/variants/Statement.tsx +0 -60
- package/src/components/LanguageSwitcher/LanguageSwitcher.test.tsx +0 -64
- package/src/components/LanguageSwitcher/LanguageSwitcher.tsx +0 -37
- package/src/components/LanguageSwitcher/index.ts +0 -6
- package/src/components/LanguageSwitcher/parts.tsx +0 -144
- package/src/components/LanguageSwitcher/types.ts +0 -40
- package/src/components/LanguageSwitcher/useLanguageSwitcher.ts +0 -23
- package/src/components/LanguageSwitcher/variants/Dropdown.tsx +0 -88
- package/src/components/LanguageSwitcher/variants/Modal.tsx +0 -56
- package/src/components/LogoCloud/LogoCloud.test.tsx +0 -35
- package/src/components/LogoCloud/LogoCloud.tsx +0 -14
- package/src/components/LogoCloud/index.ts +0 -2
- package/src/components/LogoCloud/parts.tsx +0 -32
- package/src/components/LogoCloud/types.ts +0 -16
- package/src/components/LogoCloud/variants/Marquee.tsx +0 -30
- package/src/components/LogoCloud/variants/Quiet.tsx +0 -26
- package/src/components/LogoCloud/variants/Strip.tsx +0 -25
- package/src/components/MegaMenu/MegaMenu.test.tsx +0 -89
- package/src/components/MegaMenu/MegaMenu.tsx +0 -454
- package/src/components/MegaMenu/index.ts +0 -9
- package/src/components/MegaMenu/types.ts +0 -56
- package/src/components/Modal.test.tsx +0 -80
- package/src/components/Modal.tsx +0 -125
- package/src/components/Navbar/Navbar.test.tsx +0 -91
- package/src/components/Navbar/Navbar.tsx +0 -185
- package/src/components/Navbar/index.ts +0 -2
- package/src/components/Navbar/types.ts +0 -30
- package/src/components/Newsletter/Newsletter.test.tsx +0 -45
- package/src/components/Newsletter/Newsletter.tsx +0 -12
- package/src/components/Newsletter/index.ts +0 -2
- package/src/components/Newsletter/parts.tsx +0 -23
- package/src/components/Newsletter/types.ts +0 -12
- package/src/components/Newsletter/useNewsletterForm.ts +0 -27
- package/src/components/Newsletter/variants/Card.tsx +0 -62
- package/src/components/Newsletter/variants/Inline.tsx +0 -54
- package/src/components/Newsletter/variants/Underline.tsx +0 -52
- package/src/components/Pricing/Pricing.test.tsx +0 -67
- package/src/components/Pricing/Pricing.tsx +0 -37
- package/src/components/Pricing/index.ts +0 -2
- package/src/components/Pricing/parts.tsx +0 -132
- package/src/components/Pricing/types.ts +0 -28
- package/src/components/Pricing/variants/Bento.tsx +0 -72
- package/src/components/Pricing/variants/Cards.tsx +0 -79
- package/src/components/Pricing/variants/Compact.tsx +0 -69
- package/src/components/Prose.test.tsx +0 -49
- package/src/components/Prose.tsx +0 -55
- package/src/components/Section.test.tsx +0 -51
- package/src/components/Section.tsx +0 -106
- package/src/components/Slot.test.tsx +0 -45
- package/src/components/Slot.tsx +0 -78
- package/src/components/Stats/Stats.test.tsx +0 -47
- package/src/components/Stats/Stats.tsx +0 -16
- package/src/components/Stats/index.ts +0 -2
- package/src/components/Stats/parts.tsx +0 -29
- package/src/components/Stats/types.ts +0 -23
- package/src/components/Stats/variants/Cells.tsx +0 -34
- package/src/components/Stats/variants/Editorial.tsx +0 -41
- package/src/components/Stats/variants/Hairline.tsx +0 -31
- package/src/components/Stats/variants/Ticker.tsx +0 -36
- package/src/components/Testimonials/TestimonialCard.tsx +0 -61
- package/src/components/Testimonials/Testimonials.test.tsx +0 -48
- package/src/components/Testimonials/Testimonials.tsx +0 -14
- package/src/components/Testimonials/index.ts +0 -3
- package/src/components/Testimonials/parts.ts +0 -7
- package/src/components/Testimonials/types.ts +0 -24
- package/src/components/Testimonials/variants/Carousel.tsx +0 -94
- package/src/components/Testimonials/variants/Grid.tsx +0 -19
- package/src/components/Testimonials/variants/Marquee.tsx +0 -28
- package/src/icons.tsx +0 -290
- package/src/index.ts +0 -120
- package/src/styles/index.css +0 -18
- package/src/test/setup.ts +0 -7
- package/src/utils/cn.test.ts +0 -21
- package/src/utils/cn.ts +0 -6
- package/src/utils/contrast.test.ts +0 -71
- package/src/utils/contrast.ts +0 -107
- package/src/utils/iso2Flag.test.ts +0 -26
- package/src/utils/iso2Flag.ts +0 -26
- package/src/utils/useTokenForeground.test.ts +0 -50
- package/src/utils/useTokenForeground.ts +0 -39
- /package/{src/styles → dist}/theme.css +0 -0
package/src/components/Modal.tsx
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
import { forwardRef, useEffect, useId, useRef, type HTMLAttributes, type ReactNode } from "react";
|
|
3
|
-
import { cn } from "../utils/cn";
|
|
4
|
-
import { X } from "../icons";
|
|
5
|
-
|
|
6
|
-
export interface ModalProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
|
7
|
-
open: boolean;
|
|
8
|
-
onClose: () => void;
|
|
9
|
-
/** Dialog heading (required for a11y; auto-linked via `aria-labelledby`). */
|
|
10
|
-
title?: ReactNode;
|
|
11
|
-
description?: ReactNode;
|
|
12
|
-
/** Rendered below the content, e.g. actions. */
|
|
13
|
-
footer?: ReactNode;
|
|
14
|
-
/** Content width. Defaults to `md`. */
|
|
15
|
-
size?: "sm" | "md" | "lg";
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
const sizes: Record<NonNullable<ModalProps["size"]>, string> = {
|
|
19
|
-
sm: "max-w-sm",
|
|
20
|
-
md: "max-w-lg",
|
|
21
|
-
lg: "max-w-2xl",
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Accessible dialog built on the HTML Popover API.
|
|
26
|
-
*
|
|
27
|
-
* `popover="auto"` renders in the top layer and gives light-dismiss (outside
|
|
28
|
-
* click + `Escape`), automatic focus movement and restoration for free, so no
|
|
29
|
-
* portal, focus trap or key handlers are needed. State stays controlled via
|
|
30
|
-
* `open` / `onClose`.
|
|
31
|
-
*/
|
|
32
|
-
export const Modal = forwardRef<HTMLDivElement, ModalProps>(
|
|
33
|
-
({ className, open, onClose, title, description, footer, size = "md", children, ...props }, ref) => {
|
|
34
|
-
const titleId = useId();
|
|
35
|
-
const descriptionId = useId();
|
|
36
|
-
const internalRef = useRef<HTMLDivElement | null>(null);
|
|
37
|
-
const onCloseRef = useRef(onClose);
|
|
38
|
-
|
|
39
|
-
useEffect(() => {
|
|
40
|
-
onCloseRef.current = onClose;
|
|
41
|
-
}, [onClose]);
|
|
42
|
-
|
|
43
|
-
useEffect(() => {
|
|
44
|
-
const el = internalRef.current;
|
|
45
|
-
if (!el) return;
|
|
46
|
-
if (!el.hasAttribute("popover")) el.setAttribute("popover", "auto");
|
|
47
|
-
}, []);
|
|
48
|
-
|
|
49
|
-
useEffect(() => {
|
|
50
|
-
const el = internalRef.current;
|
|
51
|
-
if (!el) return;
|
|
52
|
-
if (open) {
|
|
53
|
-
if (!el.matches(":popover-open")) el.showPopover();
|
|
54
|
-
document.body.style.overflow = "hidden";
|
|
55
|
-
} else {
|
|
56
|
-
if (el.matches(":popover-open")) el.hidePopover();
|
|
57
|
-
document.body.style.overflow = "";
|
|
58
|
-
}
|
|
59
|
-
}, [open]);
|
|
60
|
-
|
|
61
|
-
useEffect(() => {
|
|
62
|
-
const el = internalRef.current;
|
|
63
|
-
if (!el) return;
|
|
64
|
-
const onToggle = (event: Event) => {
|
|
65
|
-
if ((event as ToggleEvent).newState === "closed") onCloseRef.current?.();
|
|
66
|
-
};
|
|
67
|
-
el.addEventListener("toggle", onToggle);
|
|
68
|
-
return () => el.removeEventListener("toggle", onToggle);
|
|
69
|
-
}, []);
|
|
70
|
-
|
|
71
|
-
useEffect(
|
|
72
|
-
() => () => {
|
|
73
|
-
document.body.style.overflow = "";
|
|
74
|
-
},
|
|
75
|
-
[],
|
|
76
|
-
);
|
|
77
|
-
|
|
78
|
-
return (
|
|
79
|
-
<div
|
|
80
|
-
ref={(node) => {
|
|
81
|
-
internalRef.current = node;
|
|
82
|
-
if (typeof ref === "function") ref(node);
|
|
83
|
-
else if (ref) ref.current = node;
|
|
84
|
-
}}
|
|
85
|
-
role="dialog"
|
|
86
|
-
aria-modal="true"
|
|
87
|
-
aria-labelledby={title ? titleId : undefined}
|
|
88
|
-
aria-describedby={description ? descriptionId : undefined}
|
|
89
|
-
className={cn(
|
|
90
|
-
"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",
|
|
91
|
-
sizes[size],
|
|
92
|
-
className,
|
|
93
|
-
)}
|
|
94
|
-
{...props}
|
|
95
|
-
>
|
|
96
|
-
{title ? (
|
|
97
|
-
<h2
|
|
98
|
-
id={titleId}
|
|
99
|
-
className="pr-8 font-display text-lg font-semibold tracking-tight text-foreground"
|
|
100
|
-
>
|
|
101
|
-
{title}
|
|
102
|
-
</h2>
|
|
103
|
-
) : null}
|
|
104
|
-
{description ? (
|
|
105
|
-
<p id={descriptionId} className="mt-1.5 text-sm leading-6 text-muted-foreground">
|
|
106
|
-
{description}
|
|
107
|
-
</p>
|
|
108
|
-
) : null}
|
|
109
|
-
<button
|
|
110
|
-
type="button"
|
|
111
|
-
aria-label="Close dialog"
|
|
112
|
-
onClick={onClose}
|
|
113
|
-
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"
|
|
114
|
-
>
|
|
115
|
-
<X className="h-4 w-4" />
|
|
116
|
-
</button>
|
|
117
|
-
<div className={title || description ? "mt-4" : "pt-8"}>{children}</div>
|
|
118
|
-
{footer ? (
|
|
119
|
-
<div className="mt-6 flex flex-wrap items-center justify-end gap-3">{footer}</div>
|
|
120
|
-
) : null}
|
|
121
|
-
</div>
|
|
122
|
-
);
|
|
123
|
-
},
|
|
124
|
-
);
|
|
125
|
-
Modal.displayName = "Modal";
|
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { render, screen, within } from "@testing-library/react";
|
|
3
|
-
import userEvent from "@testing-library/user-event";
|
|
4
|
-
import { Navbar } from "./Navbar";
|
|
5
|
-
|
|
6
|
-
const links = [
|
|
7
|
-
{ label: "Product", href: "#product" },
|
|
8
|
-
{ label: "Pricing", href: "#pricing" },
|
|
9
|
-
];
|
|
10
|
-
|
|
11
|
-
describe("Navbar", () => {
|
|
12
|
-
it("renders brand, nav links and the action slot", () => {
|
|
13
|
-
render(<Navbar brand="Acurio" links={links} cta={<button>Start</button>} />);
|
|
14
|
-
const nav = screen.getByRole("navigation", { name: "Main navigation" });
|
|
15
|
-
|
|
16
|
-
expect(screen.getByText("Acurio")).toBeInTheDocument();
|
|
17
|
-
// Both the desktop list and the mobile panel render a "Product" link.
|
|
18
|
-
const productLinks = within(nav).getAllByRole("link", { name: "Product" });
|
|
19
|
-
expect(productLinks.length).toBeGreaterThan(0);
|
|
20
|
-
expect(productLinks[0]).toHaveAttribute("href", "#product");
|
|
21
|
-
expect(screen.getAllByRole("button", { name: "Start" }).length).toBeGreaterThan(0);
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("renders the brand href via brandHref", () => {
|
|
25
|
-
render(<Navbar brand="Acurio" links={links} brandHref="/" />);
|
|
26
|
-
const nav = screen.getByRole("navigation", { name: "Main navigation" });
|
|
27
|
-
expect(nav.querySelector('a[href="/"]')).toBeInTheDocument();
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("applies the classic variant shell", () => {
|
|
31
|
-
render(<Navbar brand="Acurio" links={links} variant="classic" />);
|
|
32
|
-
expect(screen.getByRole("banner")).toHaveClass("bg-background/85");
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("applies the inverse variant shell and on-secondary link tokens", () => {
|
|
36
|
-
render(<Navbar brand="Acurio" links={links} variant="inverse" />);
|
|
37
|
-
expect(screen.getByRole("banner")).toHaveClass("bg-[#101010]/85");
|
|
38
|
-
const productLinks = screen.getAllByRole("link", { name: "Product" });
|
|
39
|
-
expect(productLinks[0]).toHaveClass("text-on-secondary/60");
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
it("renders a custom logo instead of the wordmark", () => {
|
|
43
|
-
render(
|
|
44
|
-
<Navbar brand="Acurio" links={links} logo={<span data-testid="logo">Logo</span>} />,
|
|
45
|
-
);
|
|
46
|
-
expect(screen.getByTestId("logo")).toBeInTheDocument();
|
|
47
|
-
expect(screen.queryByText("Acurio")).not.toBeInTheDocument();
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
it("starts collapsed and toggles the mobile menu via the hamburger", async () => {
|
|
51
|
-
const user = userEvent.setup();
|
|
52
|
-
render(<Navbar brand="Acurio" links={links} />);
|
|
53
|
-
expect(screen.getByRole("button", { name: "Open menu" })).toHaveAttribute(
|
|
54
|
-
"aria-expanded",
|
|
55
|
-
"false",
|
|
56
|
-
);
|
|
57
|
-
|
|
58
|
-
await user.click(screen.getByRole("button", { name: "Open menu" }));
|
|
59
|
-
expect(screen.getByRole("button", { name: "Close menu" })).toHaveAttribute(
|
|
60
|
-
"aria-expanded",
|
|
61
|
-
"true",
|
|
62
|
-
);
|
|
63
|
-
|
|
64
|
-
await user.click(screen.getByRole("button", { name: "Close menu" }));
|
|
65
|
-
expect(screen.getByRole("button", { name: "Open menu" })).toHaveAttribute(
|
|
66
|
-
"aria-expanded",
|
|
67
|
-
"false",
|
|
68
|
-
);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
it("closes the mobile menu when a mobile link is clicked", async () => {
|
|
72
|
-
const user = userEvent.setup();
|
|
73
|
-
render(<Navbar brand="Acurio" links={links} />);
|
|
74
|
-
|
|
75
|
-
await user.click(screen.getByRole("button", { name: "Open menu" }));
|
|
76
|
-
expect(screen.getByRole("button", { name: "Close menu" })).toBeInTheDocument();
|
|
77
|
-
|
|
78
|
-
// Two "Product" links exist (desktop + mobile); tapping one collapses.
|
|
79
|
-
const product = screen.getAllByRole("link", { name: "Product" })[1];
|
|
80
|
-
await user.click(product);
|
|
81
|
-
expect(screen.getByRole("button", { name: "Open menu" })).toHaveAttribute(
|
|
82
|
-
"aria-expanded",
|
|
83
|
-
"false",
|
|
84
|
-
);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it("is not sticky when sticky is disabled", () => {
|
|
88
|
-
render(<Navbar brand="Acurio" links={links} sticky={false} />);
|
|
89
|
-
expect(screen.getByRole("banner")).not.toHaveClass("sticky");
|
|
90
|
-
});
|
|
91
|
-
});
|
|
@@ -1,185 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
import { useState } from "react";
|
|
3
|
-
import { cn } from "../../utils/cn";
|
|
4
|
-
import { Menu, X } from "../../icons";
|
|
5
|
-
import type { NavbarProps, NavbarVariant } from "./types";
|
|
6
|
-
|
|
7
|
-
interface NavbarStyle {
|
|
8
|
-
shell: string;
|
|
9
|
-
bar: string;
|
|
10
|
-
header: string;
|
|
11
|
-
link: string;
|
|
12
|
-
mobilePanel: string;
|
|
13
|
-
mobileLink: string;
|
|
14
|
-
menuButton: string;
|
|
15
|
-
brandText: string;
|
|
16
|
-
brandDot: string;
|
|
17
|
-
desktopList: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
const inverse = "text-on-secondary";
|
|
21
|
-
const light = "text-foreground";
|
|
22
|
-
|
|
23
|
-
const styles: Record<NavbarVariant, NavbarStyle> = {
|
|
24
|
-
classic: {
|
|
25
|
-
shell: "border-b border-border bg-background/85 backdrop-blur-md supports-[backdrop-filter]:bg-background/75",
|
|
26
|
-
bar: "mx-auto max-w-6xl px-5 sm:px-8",
|
|
27
|
-
header: "px-0",
|
|
28
|
-
link: "text-muted-foreground hover:text-foreground",
|
|
29
|
-
mobilePanel: "border-t border-border bg-background",
|
|
30
|
-
mobileLink:
|
|
31
|
-
"text-muted-foreground hover:bg-surface hover:text-foreground",
|
|
32
|
-
menuButton: `${light} hover:bg-surface`,
|
|
33
|
-
brandText: light,
|
|
34
|
-
brandDot: "bg-foreground",
|
|
35
|
-
desktopList: "hidden items-center gap-7 md:flex",
|
|
36
|
-
},
|
|
37
|
-
floating: {
|
|
38
|
-
shell: "px-0",
|
|
39
|
-
bar: "mx-auto mt-3 max-w-5xl rounded-xl border border-border bg-surface/90 px-5 shadow-soft backdrop-blur-md supports-[backdrop-filter]:bg-surface/70",
|
|
40
|
-
header: "px-3 sm:px-6",
|
|
41
|
-
link: "text-muted-foreground hover:text-foreground",
|
|
42
|
-
mobilePanel:
|
|
43
|
-
"mx-3 mb-3 max-w-5xl overflow-hidden rounded-b-xl border border-border bg-surface",
|
|
44
|
-
mobileLink:
|
|
45
|
-
"text-muted-foreground hover:bg-surface hover:text-foreground",
|
|
46
|
-
menuButton: `${light} hover:bg-surface`,
|
|
47
|
-
brandText: light,
|
|
48
|
-
brandDot: "bg-foreground",
|
|
49
|
-
desktopList: "hidden items-center gap-7 md:flex",
|
|
50
|
-
},
|
|
51
|
-
inverse: {
|
|
52
|
-
shell: "border-b border-white/10 bg-[#101010]/85 backdrop-blur-md",
|
|
53
|
-
bar: "mx-auto max-w-6xl px-5 sm:px-8",
|
|
54
|
-
header: "px-0",
|
|
55
|
-
link: "text-on-secondary/60 hover:text-on-secondary",
|
|
56
|
-
mobilePanel: "border-t border-white/10 bg-[#101010]",
|
|
57
|
-
mobileLink:
|
|
58
|
-
"text-on-secondary/70 hover:bg-white/10 hover:text-on-secondary",
|
|
59
|
-
menuButton: `${inverse} hover:bg-white/10`,
|
|
60
|
-
brandText: inverse,
|
|
61
|
-
brandDot: "bg-on-secondary",
|
|
62
|
-
desktopList: "hidden items-center gap-7 md:flex",
|
|
63
|
-
},
|
|
64
|
-
};
|
|
65
|
-
|
|
66
|
-
export const Navbar = ({
|
|
67
|
-
className,
|
|
68
|
-
variant = "classic",
|
|
69
|
-
brand,
|
|
70
|
-
logo,
|
|
71
|
-
logoSrc,
|
|
72
|
-
logoAlt,
|
|
73
|
-
logoClassName,
|
|
74
|
-
brandHref = "#",
|
|
75
|
-
links,
|
|
76
|
-
actions,
|
|
77
|
-
cta,
|
|
78
|
-
languageSwitcher,
|
|
79
|
-
sticky = true,
|
|
80
|
-
LinkComponent,
|
|
81
|
-
...props
|
|
82
|
-
}: NavbarProps) => {
|
|
83
|
-
const [open, setOpen] = useState(false);
|
|
84
|
-
const Link = LinkComponent ?? "a";
|
|
85
|
-
const s = styles[variant];
|
|
86
|
-
|
|
87
|
-
const logoNode =
|
|
88
|
-
logo ??
|
|
89
|
-
(logoSrc ? (
|
|
90
|
-
<img
|
|
91
|
-
src={logoSrc}
|
|
92
|
-
alt={logoAlt ?? (typeof brand === "string" ? brand : "Logo")}
|
|
93
|
-
className={cn("h-7 w-auto", logoClassName)}
|
|
94
|
-
/>
|
|
95
|
-
) : null);
|
|
96
|
-
|
|
97
|
-
const brandNode = logoNode ? (
|
|
98
|
-
<span className="flex items-center">{logoNode}</span>
|
|
99
|
-
) : (
|
|
100
|
-
<span className={cn("flex items-center gap-2 font-display text-[17px] font-bold tracking-tight", s.brandText)}>
|
|
101
|
-
<span aria-hidden className={cn("inline-block h-2.5 w-2.5 rounded-sm", s.brandDot)} />
|
|
102
|
-
{brand}
|
|
103
|
-
</span>
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
return (
|
|
107
|
-
<header className={cn("w-full transition-colors duration-200", sticky && "sticky top-0 z-50", s.shell, s.header, className)} {...props}>
|
|
108
|
-
<nav className={cn("flex h-16 w-full items-center justify-between gap-4 transition-colors duration-200", s.bar)} aria-label="Main navigation">
|
|
109
|
-
<Link href={brandHref} className="shrink-0">
|
|
110
|
-
{brandNode}
|
|
111
|
-
</Link>
|
|
112
|
-
|
|
113
|
-
<ul className={s.desktopList}>
|
|
114
|
-
{links.map((link) => (
|
|
115
|
-
<li key={link.href}>
|
|
116
|
-
<Link
|
|
117
|
-
href={link.href}
|
|
118
|
-
className={cn("text-sm font-medium transition-colors duration-150", s.link)}
|
|
119
|
-
>
|
|
120
|
-
{link.label}
|
|
121
|
-
</Link>
|
|
122
|
-
</li>
|
|
123
|
-
))}
|
|
124
|
-
</ul>
|
|
125
|
-
|
|
126
|
-
<div className="hidden items-center gap-3 md:flex">
|
|
127
|
-
{languageSwitcher}
|
|
128
|
-
{actions}
|
|
129
|
-
{cta}
|
|
130
|
-
</div>
|
|
131
|
-
|
|
132
|
-
{languageSwitcher ? (
|
|
133
|
-
<div className="flex items-center md:hidden">{languageSwitcher}</div>
|
|
134
|
-
) : null}
|
|
135
|
-
|
|
136
|
-
<button
|
|
137
|
-
type="button"
|
|
138
|
-
aria-label={open ? "Close menu" : "Open menu"}
|
|
139
|
-
aria-expanded={open}
|
|
140
|
-
onClick={() => setOpen((o) => !o)}
|
|
141
|
-
className={cn(
|
|
142
|
-
"inline-flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-md md:hidden",
|
|
143
|
-
"transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
144
|
-
s.menuButton,
|
|
145
|
-
)}
|
|
146
|
-
>
|
|
147
|
-
{open ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
|
148
|
-
</button>
|
|
149
|
-
</nav>
|
|
150
|
-
|
|
151
|
-
<div
|
|
152
|
-
className={cn(
|
|
153
|
-
"grid transition-[grid-template-rows,opacity] duration-200 md:hidden",
|
|
154
|
-
open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
|
|
155
|
-
s.mobilePanel,
|
|
156
|
-
)}
|
|
157
|
-
>
|
|
158
|
-
<div className="min-h-0 overflow-hidden">
|
|
159
|
-
<ul className={cn("flex flex-col py-4", variant !== "floating" && "px-5")}>
|
|
160
|
-
{links.map((link) => (
|
|
161
|
-
<li key={link.href}>
|
|
162
|
-
<Link
|
|
163
|
-
href={link.href}
|
|
164
|
-
onClick={() => setOpen(false)}
|
|
165
|
-
className={cn(
|
|
166
|
-
"block rounded-md px-2 py-2.5 text-sm font-medium transition-colors duration-150",
|
|
167
|
-
s.mobileLink,
|
|
168
|
-
)}
|
|
169
|
-
>
|
|
170
|
-
{link.label}
|
|
171
|
-
</Link>
|
|
172
|
-
</li>
|
|
173
|
-
))}
|
|
174
|
-
{cta ? (
|
|
175
|
-
<li className="mt-3 px-2 pb-2">{cta}</li>
|
|
176
|
-
) : actions ? (
|
|
177
|
-
<li className="mt-3 flex flex-wrap gap-3 px-2 pb-2">{actions}</li>
|
|
178
|
-
) : null}
|
|
179
|
-
</ul>
|
|
180
|
-
</div>
|
|
181
|
-
</div>
|
|
182
|
-
</header>
|
|
183
|
-
);
|
|
184
|
-
};
|
|
185
|
-
Navbar.displayName = "Navbar";
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import type { ElementType, HTMLAttributes, ReactNode } from "react";
|
|
2
|
-
|
|
3
|
-
export interface NavLink {
|
|
4
|
-
label: string;
|
|
5
|
-
href: string;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export type NavbarVariant = "classic" | "floating" | "inverse";
|
|
9
|
-
|
|
10
|
-
export interface NavbarProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
|
11
|
-
variant?: NavbarVariant;
|
|
12
|
-
/** Wordmark or fallback text rendered when no logo is supplied. */
|
|
13
|
-
brand?: ReactNode;
|
|
14
|
-
/** Custom logo node (component, <img>, inline SVG…). Replaces the wordmark. */
|
|
15
|
-
logo?: ReactNode;
|
|
16
|
-
/** Quick logo image source. */
|
|
17
|
-
logoSrc?: string;
|
|
18
|
-
logoAlt?: string;
|
|
19
|
-
/** Size / position tuning for logo images. Defaults to `h-7 w-auto`. */
|
|
20
|
-
logoClassName?: string;
|
|
21
|
-
brandHref?: string;
|
|
22
|
-
links: NavLink[];
|
|
23
|
-
actions?: ReactNode;
|
|
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;
|
|
30
|
-
}
|
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
import { render, screen } from "@testing-library/react";
|
|
3
|
-
import userEvent from "@testing-library/user-event";
|
|
4
|
-
import { Newsletter } from "./Newsletter";
|
|
5
|
-
|
|
6
|
-
describe("Newsletter", () => {
|
|
7
|
-
it("renders the field and submit button (inline by default)", () => {
|
|
8
|
-
render(<Newsletter />);
|
|
9
|
-
expect(screen.getByLabelText("Your email address")).toBeInTheDocument();
|
|
10
|
-
expect(screen.getByRole("button", { name: "Join" })).toBeInTheDocument();
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it("calls onSubmit with a valid email and shows the success message", async () => {
|
|
14
|
-
const user = userEvent.setup();
|
|
15
|
-
const onSubmit = vi.fn();
|
|
16
|
-
render(<Newsletter onSubmit={onSubmit} successMessage="You're in!" />);
|
|
17
|
-
await user.type(screen.getByLabelText("Your email address"), "foo@bar.com");
|
|
18
|
-
await user.click(screen.getByRole("button", { name: "Join" }));
|
|
19
|
-
|
|
20
|
-
expect(onSubmit).toHaveBeenCalledWith("foo@bar.com");
|
|
21
|
-
expect(screen.getByRole("status")).toHaveTextContent("You're in!");
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("shows a validation error when an empty/invalid value is submitted", async () => {
|
|
25
|
-
const user = userEvent.setup();
|
|
26
|
-
const onSubmit = vi.fn();
|
|
27
|
-
render(<Newsletter onSubmit={onSubmit} />);
|
|
28
|
-
// Submit without entering a value; the component's own regex rejects it.
|
|
29
|
-
await user.click(screen.getByRole("button", { name: "Join" }));
|
|
30
|
-
|
|
31
|
-
expect(onSubmit).not.toHaveBeenCalled();
|
|
32
|
-
expect(screen.getByRole("alert")).toHaveTextContent("valid email");
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("renders the note text when provided", () => {
|
|
36
|
-
render(<Newsletter note="Once a month." />);
|
|
37
|
-
expect(screen.getByText("Once a month.")).toBeInTheDocument();
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
it("renders the card and underline options", () => {
|
|
41
|
-
const { container } = render(<Newsletter option="card" />);
|
|
42
|
-
expect(container.querySelector("form")).toBeInTheDocument();
|
|
43
|
-
render(<Newsletter option="underline" />);
|
|
44
|
-
});
|
|
45
|
-
});
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import type { NewsletterProps } from "./types";
|
|
2
|
-
import { NewsletterInline } from "./variants/Inline";
|
|
3
|
-
import { NewsletterCard } from "./variants/Card";
|
|
4
|
-
import { NewsletterUnderline } from "./variants/Underline";
|
|
5
|
-
|
|
6
|
-
export const Newsletter = (props: NewsletterProps) => {
|
|
7
|
-
const { option = "inline", ...rest } = props;
|
|
8
|
-
if (option === "card") return <NewsletterCard {...rest} />;
|
|
9
|
-
if (option === "underline") return <NewsletterUnderline {...rest} />;
|
|
10
|
-
return <NewsletterInline {...rest} />;
|
|
11
|
-
};
|
|
12
|
-
Newsletter.displayName = "Newsletter";
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import { Check } from "../../icons";
|
|
2
|
-
|
|
3
|
-
export const inputBase =
|
|
4
|
-
"w-full rounded-lg border border-border bg-surface px-4 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background";
|
|
5
|
-
|
|
6
|
-
export function NewsletterSuccess({ message }: { message: string }) {
|
|
7
|
-
return (
|
|
8
|
-
<p className="flex items-center gap-2 rounded-lg border border-accent/40 bg-accent/10 px-4 py-3.5 text-sm font-medium text-accent" role="status">
|
|
9
|
-
<Check className="h-4 w-4" />
|
|
10
|
-
{message}
|
|
11
|
-
</p>
|
|
12
|
-
);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export function NewsletterNote({ error, note }: { error: string | null; note?: string }) {
|
|
16
|
-
if (error) {
|
|
17
|
-
return <p className="mt-2 text-sm font-medium text-red-500" role="alert">{error}</p>;
|
|
18
|
-
}
|
|
19
|
-
if (note) {
|
|
20
|
-
return <p className="mt-3 text-xs leading-5 text-muted-foreground">{note}</p>;
|
|
21
|
-
}
|
|
22
|
-
return null;
|
|
23
|
-
}
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import type { HTMLAttributes } from "react";
|
|
2
|
-
|
|
3
|
-
export type NewsletterOption = "inline" | "card" | "underline";
|
|
4
|
-
|
|
5
|
-
export interface NewsletterProps extends Omit<HTMLAttributes<HTMLFormElement>, "onSubmit"> {
|
|
6
|
-
option?: NewsletterOption;
|
|
7
|
-
placeholder?: string;
|
|
8
|
-
buttonLabel?: string;
|
|
9
|
-
successMessage?: string;
|
|
10
|
-
note?: string;
|
|
11
|
-
onSubmit?: (email: string) => void;
|
|
12
|
-
}
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
import { useState, type FormEvent } from "react";
|
|
3
|
-
|
|
4
|
-
export function useNewsletterForm(onSubmit?: (email: string) => void) {
|
|
5
|
-
const [email, setEmail] = useState("");
|
|
6
|
-
const [done, setDone] = useState(false);
|
|
7
|
-
const [error, setError] = useState<string | null>(null);
|
|
8
|
-
|
|
9
|
-
const handleSubmit = (event: FormEvent) => {
|
|
10
|
-
event.preventDefault();
|
|
11
|
-
const value = email.trim();
|
|
12
|
-
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
|
|
13
|
-
setError("Please enter a valid email.");
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
|
-
setError(null);
|
|
17
|
-
setDone(true);
|
|
18
|
-
onSubmit?.(value);
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
const updateEmail = (value: string) => {
|
|
22
|
-
setEmail(value);
|
|
23
|
-
if (error) setError(null);
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
return { email, done, error, handleSubmit, updateEmail };
|
|
27
|
-
}
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
import { cn } from "../../../utils/cn";
|
|
3
|
-
import { ArrowRight, Mail } from "../../../icons";
|
|
4
|
-
import { useNewsletterForm } from "../useNewsletterForm";
|
|
5
|
-
import { inputBase, NewsletterSuccess, NewsletterNote } from "../parts";
|
|
6
|
-
import type { NewsletterProps } from "../types";
|
|
7
|
-
|
|
8
|
-
interface CardProps extends Omit<NewsletterProps, "option"> {}
|
|
9
|
-
|
|
10
|
-
export function NewsletterCard({
|
|
11
|
-
className,
|
|
12
|
-
placeholder = "you@company.com",
|
|
13
|
-
buttonLabel = "Join",
|
|
14
|
-
successMessage = "You’re on the list!",
|
|
15
|
-
note,
|
|
16
|
-
onSubmit,
|
|
17
|
-
...props
|
|
18
|
-
}: CardProps) {
|
|
19
|
-
const { email, done, error, handleSubmit, updateEmail } = useNewsletterForm(onSubmit);
|
|
20
|
-
|
|
21
|
-
return (
|
|
22
|
-
<form onSubmit={handleSubmit} className={cn("w-full", className)} {...props}>
|
|
23
|
-
{done ? (
|
|
24
|
-
<NewsletterSuccess message={successMessage} />
|
|
25
|
-
) : (
|
|
26
|
-
<div className="rounded-xl border border-border bg-surface p-7">
|
|
27
|
-
<div className="mb-5 flex items-center gap-3">
|
|
28
|
-
<span className="inline-flex h-9 w-9 items-center justify-center rounded-lg bg-primary-soft text-primary">
|
|
29
|
-
<Mail className="h-4 w-4" />
|
|
30
|
-
</span>
|
|
31
|
-
<p className="font-display text-lg font-semibold tracking-tight text-foreground">
|
|
32
|
-
Monthly digest
|
|
33
|
-
</p>
|
|
34
|
-
</div>
|
|
35
|
-
<label htmlFor="newsletter-email" className="sr-only">
|
|
36
|
-
Your email address
|
|
37
|
-
</label>
|
|
38
|
-
<div className="flex flex-col gap-3 sm:flex-row">
|
|
39
|
-
<input
|
|
40
|
-
id="newsletter-email"
|
|
41
|
-
type="email"
|
|
42
|
-
autoComplete="email"
|
|
43
|
-
value={email}
|
|
44
|
-
onChange={(e) => updateEmail(e.target.value)}
|
|
45
|
-
placeholder={placeholder}
|
|
46
|
-
className={cn(inputBase, "h-11")}
|
|
47
|
-
/>
|
|
48
|
-
<button
|
|
49
|
-
type="submit"
|
|
50
|
-
className="inline-flex h-11 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-lg bg-primary px-5 text-sm font-semibold text-on-primary transition-colors duration-200 hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
|
|
51
|
-
>
|
|
52
|
-
{buttonLabel}
|
|
53
|
-
<ArrowRight className="h-4 w-4 opacity-60" />
|
|
54
|
-
</button>
|
|
55
|
-
</div>
|
|
56
|
-
<NewsletterNote error={error} note={note} />
|
|
57
|
-
</div>
|
|
58
|
-
)}
|
|
59
|
-
</form>
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
NewsletterCard.displayName = "NewsletterCard";
|