@ugurdemirel/landcraft 0.1.3 → 0.1.5
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/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +38 -21
- package/dist/index.js +2276 -1956
- package/dist/index.js.map +1 -1
- package/dist/styles.css +15 -0
- package/package.json +11 -4
- package/src/components/Badge.test.tsx +55 -0
- package/src/components/Blog/Blog.test.tsx +64 -0
- package/src/components/Blog/BlogCard.tsx +12 -0
- package/src/components/Blog/BlogSection.tsx +95 -0
- package/src/components/Blog/index.ts +8 -0
- package/src/components/Blog/parts.tsx +38 -0
- package/src/components/Blog/types.ts +42 -0
- package/src/components/Blog/variants/Card.tsx +62 -0
- package/src/components/Blog/variants/Row.tsx +41 -0
- package/src/components/Button.test.tsx +91 -0
- package/src/components/CTA/CTA.test.tsx +53 -0
- package/src/components/CTA/CTA.tsx +12 -0
- package/src/components/CTA/index.ts +2 -0
- package/src/components/CTA/parts.tsx +26 -0
- package/src/components/CTA/types.ts +12 -0
- package/src/components/CTA/variants/Inverse.tsx +50 -0
- package/src/components/CTA/variants/Panel.tsx +60 -0
- package/src/components/CTA/variants/Surface.tsx +34 -0
- package/src/components/Card.test.tsx +49 -0
- package/src/components/Container.test.tsx +52 -0
- package/src/components/FAQ/FAQ.test.tsx +54 -0
- package/src/components/FAQ/FAQ.tsx +48 -0
- package/src/components/FAQ/index.ts +2 -0
- package/src/components/FAQ/parts.tsx +74 -0
- package/src/components/FAQ/types.ts +18 -0
- package/src/components/FAQ/variants/Accordion.tsx +46 -0
- package/src/components/FAQ/variants/Cards.tsx +40 -0
- package/src/components/FAQ/variants/Split.tsx +44 -0
- package/src/components/Features/FeatureCard.tsx +44 -0
- package/src/components/Features/FeatureGrid.tsx +14 -0
- package/src/components/Features/Features.test.tsx +49 -0
- package/src/components/Features/index.ts +3 -0
- package/src/components/Features/parts.ts +15 -0
- package/src/components/Features/types.ts +25 -0
- package/src/components/Features/variants/Bento.tsx +32 -0
- package/src/components/Features/variants/Columns.tsx +21 -0
- package/src/components/Features/variants/EditorialRows.tsx +38 -0
- package/src/components/Footer/Footer.test.tsx +49 -0
- package/src/components/Footer/Footer.tsx +11 -0
- package/src/components/Footer/index.ts +2 -0
- package/src/components/Footer/parts.tsx +101 -0
- package/src/components/Footer/types.ts +32 -0
- package/src/components/Footer/variants/Classic.tsx +59 -0
- package/src/components/Footer/variants/Editorial.tsx +47 -0
- package/src/components/Footer/variants/Minimal.tsx +33 -0
- package/src/components/Hero/Hero.test.tsx +71 -0
- package/src/components/Hero/Hero.tsx +12 -0
- package/src/components/Hero/index.ts +2 -0
- package/src/components/Hero/parts.tsx +29 -0
- package/src/components/Hero/types.ts +20 -0
- package/src/components/Hero/variants/Centered.tsx +54 -0
- package/src/components/Hero/variants/Split.tsx +53 -0
- package/src/components/Hero/variants/Statement.tsx +60 -0
- package/src/components/LanguageSwitcher/LanguageSwitcher.test.tsx +64 -0
- package/src/components/LanguageSwitcher/LanguageSwitcher.tsx +37 -0
- package/src/components/LanguageSwitcher/index.ts +6 -0
- package/src/components/LanguageSwitcher/parts.tsx +144 -0
- package/src/components/LanguageSwitcher/types.ts +40 -0
- package/src/components/LanguageSwitcher/useLanguageSwitcher.ts +23 -0
- package/src/components/LanguageSwitcher/variants/Dropdown.tsx +88 -0
- package/src/components/LanguageSwitcher/variants/Modal.tsx +56 -0
- package/src/components/LogoCloud/LogoCloud.test.tsx +35 -0
- package/src/components/LogoCloud/LogoCloud.tsx +14 -0
- package/src/components/LogoCloud/index.ts +2 -0
- package/src/components/LogoCloud/parts.tsx +32 -0
- package/src/components/LogoCloud/types.ts +16 -0
- package/src/components/LogoCloud/variants/Marquee.tsx +30 -0
- package/src/components/LogoCloud/variants/Quiet.tsx +26 -0
- package/src/components/LogoCloud/variants/Strip.tsx +25 -0
- package/src/components/MegaMenu/MegaMenu.test.tsx +89 -0
- package/src/components/{MegaMenu.tsx → MegaMenu/MegaMenu.tsx} +100 -127
- package/src/components/MegaMenu/index.ts +9 -0
- package/src/components/MegaMenu/types.ts +56 -0
- package/src/components/Modal.test.tsx +80 -0
- package/src/components/Modal.tsx +1 -0
- package/src/components/Navbar/Navbar.test.tsx +91 -0
- package/src/components/Navbar/Navbar.tsx +185 -0
- package/src/components/Navbar/index.ts +2 -0
- package/src/components/Navbar/types.ts +30 -0
- package/src/components/Newsletter/Newsletter.test.tsx +45 -0
- package/src/components/Newsletter/Newsletter.tsx +12 -0
- package/src/components/Newsletter/index.ts +2 -0
- package/src/components/Newsletter/parts.tsx +23 -0
- package/src/components/Newsletter/types.ts +12 -0
- package/src/components/Newsletter/useNewsletterForm.ts +27 -0
- package/src/components/Newsletter/variants/Card.tsx +62 -0
- package/src/components/Newsletter/variants/Inline.tsx +54 -0
- package/src/components/Newsletter/variants/Underline.tsx +52 -0
- package/src/components/Pricing/Pricing.test.tsx +67 -0
- package/src/components/Pricing/Pricing.tsx +37 -0
- package/src/components/Pricing/index.ts +2 -0
- package/src/components/Pricing/parts.tsx +132 -0
- package/src/components/Pricing/types.ts +28 -0
- package/src/components/Pricing/variants/Bento.tsx +72 -0
- package/src/components/Pricing/variants/Cards.tsx +79 -0
- package/src/components/Pricing/variants/Compact.tsx +69 -0
- package/src/components/Prose.test.tsx +49 -0
- package/src/components/Section.test.tsx +51 -0
- package/src/components/Slot.test.tsx +45 -0
- package/src/components/Stats/Stats.test.tsx +47 -0
- package/src/components/Stats/Stats.tsx +16 -0
- package/src/components/Stats/index.ts +2 -0
- package/src/components/Stats/parts.tsx +29 -0
- package/src/components/Stats/types.ts +23 -0
- package/src/components/Stats/variants/Cells.tsx +34 -0
- package/src/components/Stats/variants/Editorial.tsx +41 -0
- package/src/components/Stats/variants/Hairline.tsx +31 -0
- package/src/components/Stats/variants/Ticker.tsx +36 -0
- package/src/components/Testimonials/TestimonialCard.tsx +61 -0
- package/src/components/Testimonials/Testimonials.test.tsx +48 -0
- package/src/components/Testimonials/Testimonials.tsx +14 -0
- package/src/components/Testimonials/index.ts +3 -0
- package/src/components/Testimonials/parts.ts +7 -0
- package/src/components/Testimonials/types.ts +24 -0
- package/src/components/Testimonials/variants/Carousel.tsx +94 -0
- package/src/components/Testimonials/variants/Grid.tsx +19 -0
- package/src/components/Testimonials/variants/Marquee.tsx +28 -0
- package/src/test/setup.ts +7 -0
- package/src/utils/cn.test.ts +21 -0
- package/src/utils/contrast.test.ts +71 -0
- package/src/utils/iso2Flag.test.ts +26 -0
- package/src/utils/useTokenForeground.test.ts +50 -0
- package/src/utils/useTokenForeground.ts +1 -0
- package/src/components/Blog.tsx +0 -251
- package/src/components/CTA.tsx +0 -155
- package/src/components/FAQ.tsx +0 -163
- package/src/components/Features.tsx +0 -153
- package/src/components/Footer.tsx +0 -185
- package/src/components/Hero.tsx +0 -169
- package/src/components/LanguageSwitcher.tsx +0 -223
- package/src/components/LogoCloud.tsx +0 -100
- package/src/components/Navbar.tsx +0 -195
- package/src/components/Newsletter.tsx +0 -180
- package/src/components/Pricing.tsx +0 -336
- package/src/components/Stats.tsx +0 -181
- package/src/components/Testimonials.tsx +0 -205
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../../utils/cn";
|
|
3
|
+
|
|
4
|
+
export function CTAActions({
|
|
5
|
+
action,
|
|
6
|
+
secondaryAction,
|
|
7
|
+
align,
|
|
8
|
+
actionClassName = "flex flex-wrap items-center gap-4",
|
|
9
|
+
}: {
|
|
10
|
+
action?: ReactNode;
|
|
11
|
+
secondaryAction?: ReactNode;
|
|
12
|
+
align?: "left" | "center";
|
|
13
|
+
actionClassName?: string;
|
|
14
|
+
}) {
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
className={cn(
|
|
18
|
+
actionClassName,
|
|
19
|
+
align === "center" && "justify-center",
|
|
20
|
+
)}
|
|
21
|
+
>
|
|
22
|
+
{action}
|
|
23
|
+
{secondaryAction}
|
|
24
|
+
</div>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type CTAOption = "panel" | "surface" | "inverse";
|
|
4
|
+
|
|
5
|
+
export interface CTAProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
|
6
|
+
title: ReactNode;
|
|
7
|
+
description?: ReactNode;
|
|
8
|
+
action?: ReactNode;
|
|
9
|
+
secondaryAction?: ReactNode;
|
|
10
|
+
option?: CTAOption;
|
|
11
|
+
align?: "left" | "center";
|
|
12
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { forwardRef } from "react";
|
|
3
|
+
import { cn } from "../../../utils/cn";
|
|
4
|
+
import { Container } from "../../Container";
|
|
5
|
+
import { useTokenForeground } from "../../../utils/useTokenForeground";
|
|
6
|
+
import { CTAActions } from "../parts";
|
|
7
|
+
import type { CTAProps } from "../types";
|
|
8
|
+
|
|
9
|
+
/** inverse — ink panel with a wide left-aligned split. */
|
|
10
|
+
export const CTAInverse = forwardRef<HTMLElement, CTAProps>(
|
|
11
|
+
({ className, title, description, action, secondaryAction, align = "left", id, ...props }, ref) => {
|
|
12
|
+
const fg = useTokenForeground("--color-secondary");
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<section ref={ref} id={id} className={cn("w-full py-6 pb-20", className)} {...props}>
|
|
16
|
+
<Container>
|
|
17
|
+
<div
|
|
18
|
+
className="relative overflow-hidden rounded-[1.75rem] px-7 py-16 sm:px-16 sm:py-20"
|
|
19
|
+
style={{
|
|
20
|
+
backgroundColor: "rgb(var(--color-secondary))",
|
|
21
|
+
color: fg,
|
|
22
|
+
}}
|
|
23
|
+
>
|
|
24
|
+
<div aria-hidden className="pointer-events-none absolute inset-0 grain opacity-[0.09] mix-blend-overlay" />
|
|
25
|
+
<div
|
|
26
|
+
className={cn(
|
|
27
|
+
"relative flex flex-col items-start gap-10",
|
|
28
|
+
align === "center" && "items-center text-center",
|
|
29
|
+
"lg:flex-row lg:items-end lg:justify-between",
|
|
30
|
+
)}
|
|
31
|
+
>
|
|
32
|
+
<div className={cn("max-w-3xl", align === "center" && "mx-auto")}>
|
|
33
|
+
<h2 className="font-display text-balance text-4xl font-semibold leading-[1.02] tracking-[-0.03em] sm:text-6xl">
|
|
34
|
+
{title}
|
|
35
|
+
</h2>
|
|
36
|
+
{description ? (
|
|
37
|
+
<p className={cn("mt-5 max-w-xl text-pretty text-base leading-7 sm:text-lg", align === "center" && "mx-auto")}>
|
|
38
|
+
{description}
|
|
39
|
+
</p>
|
|
40
|
+
) : null}
|
|
41
|
+
</div>
|
|
42
|
+
<CTAActions action={action} secondaryAction={secondaryAction} align={align} />
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
</Container>
|
|
46
|
+
</section>
|
|
47
|
+
);
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
CTAInverse.displayName = "CTAInverse";
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { forwardRef } from "react";
|
|
3
|
+
import { cn } from "../../../utils/cn";
|
|
4
|
+
import { Container } from "../../Container";
|
|
5
|
+
import { useTokenForeground } from "../../../utils/useTokenForeground";
|
|
6
|
+
import { CTAActions } from "../parts";
|
|
7
|
+
import type { CTAProps } from "../types";
|
|
8
|
+
|
|
9
|
+
/** panel — brand gradient panel, rounded corners. */
|
|
10
|
+
export const CTAPanel = forwardRef<HTMLElement, CTAProps>(
|
|
11
|
+
({ className, title, description, action, secondaryAction, align = "left", id, ...props }, ref) => {
|
|
12
|
+
const fg = useTokenForeground("--color-primary");
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<section ref={ref} id={id} className={cn("w-full py-6 pb-20", className)} {...props}>
|
|
16
|
+
<Container>
|
|
17
|
+
<div
|
|
18
|
+
className="relative overflow-hidden rounded-[1.75rem] px-7 py-14 sm:px-14 sm:py-20"
|
|
19
|
+
style={{
|
|
20
|
+
background:
|
|
21
|
+
"radial-gradient(120% 160% at 0% 0%, rgb(var(--color-primary)), rgb(var(--color-primary-hover)) 55%, rgb(var(--color-primary) / 0.85))",
|
|
22
|
+
color: fg,
|
|
23
|
+
}}
|
|
24
|
+
>
|
|
25
|
+
<div aria-hidden className="pointer-events-none absolute inset-0 grain opacity-[0.07] mix-blend-overlay" />
|
|
26
|
+
<div
|
|
27
|
+
aria-hidden
|
|
28
|
+
className="pointer-events-none absolute -right-24 -top-24 h-72 w-72 rounded-full"
|
|
29
|
+
style={{ background: "rgb(var(--color-accent))", opacity: 0.35, filter: "blur(60px)" }}
|
|
30
|
+
/>
|
|
31
|
+
<div
|
|
32
|
+
className={cn(
|
|
33
|
+
"relative flex flex-col gap-9",
|
|
34
|
+
align === "center" ? "items-center text-center" : "items-start",
|
|
35
|
+
)}
|
|
36
|
+
>
|
|
37
|
+
<div className={cn("max-w-2xl", align === "center" && "mx-auto")}>
|
|
38
|
+
<h2 className="font-display text-balance text-3xl font-semibold leading-[1.06] tracking-[-0.02em] sm:text-5xl">
|
|
39
|
+
{title}
|
|
40
|
+
</h2>
|
|
41
|
+
{description ? (
|
|
42
|
+
<p
|
|
43
|
+
className={cn(
|
|
44
|
+
"mt-4 text-pretty text-base leading-7 sm:text-lg",
|
|
45
|
+
align === "center" && "mx-auto max-w-xl",
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
{description}
|
|
49
|
+
</p>
|
|
50
|
+
) : null}
|
|
51
|
+
</div>
|
|
52
|
+
<CTAActions action={action} secondaryAction={secondaryAction} align={align} />
|
|
53
|
+
</div>
|
|
54
|
+
</div>
|
|
55
|
+
</Container>
|
|
56
|
+
</section>
|
|
57
|
+
);
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
CTAPanel.displayName = "CTAPanel";
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import { cn } from "../../../utils/cn";
|
|
3
|
+
import { Container } from "../../Container";
|
|
4
|
+
import { CTAActions } from "../parts";
|
|
5
|
+
import type { CTAProps } from "../types";
|
|
6
|
+
|
|
7
|
+
/** surface — quiet paper band: hairline rule, big display type, actions right. */
|
|
8
|
+
export const CTASurface = forwardRef<HTMLElement, CTAProps>(
|
|
9
|
+
({ className, title, description, action, secondaryAction, align = "left", id, ...props }, ref) => (
|
|
10
|
+
<section ref={ref} id={id} className={cn("w-full", className)} {...props}>
|
|
11
|
+
<Container className="py-20 sm:py-24">
|
|
12
|
+
<div
|
|
13
|
+
className={cn(
|
|
14
|
+
"flex flex-col gap-10 border-t border-border pt-14 sm:flex-row sm:items-end sm:justify-between sm:pt-16",
|
|
15
|
+
align === "center" && "items-center text-center sm:flex-col sm:items-center",
|
|
16
|
+
)}
|
|
17
|
+
>
|
|
18
|
+
<div className={cn("max-w-3xl", align === "center" && "mx-auto")}>
|
|
19
|
+
<h2 className="font-display text-4xl font-semibold leading-[1.03] tracking-[-0.03em] text-balance text-foreground sm:text-6xl">
|
|
20
|
+
{title}
|
|
21
|
+
</h2>
|
|
22
|
+
{description ? (
|
|
23
|
+
<p className={cn("mt-5 max-w-xl text-pretty text-base leading-7 text-muted-foreground sm:text-lg", align === "center" && "mx-auto")}>
|
|
24
|
+
{description}
|
|
25
|
+
</p>
|
|
26
|
+
) : null}
|
|
27
|
+
</div>
|
|
28
|
+
<CTAActions action={action} secondaryAction={secondaryAction} align={align} />
|
|
29
|
+
</div>
|
|
30
|
+
</Container>
|
|
31
|
+
</section>
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
CTASurface.displayName = "CTASurface";
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import {
|
|
4
|
+
Card,
|
|
5
|
+
CardHeader,
|
|
6
|
+
CardTitle,
|
|
7
|
+
CardDescription,
|
|
8
|
+
CardContent,
|
|
9
|
+
CardFooter,
|
|
10
|
+
} from "./Card";
|
|
11
|
+
|
|
12
|
+
describe("Card & subcomponents", () => {
|
|
13
|
+
it("renders an outlined card by default", () => {
|
|
14
|
+
render(<Card>Content</Card>);
|
|
15
|
+
expect(screen.getByText("Content")).toHaveClass("border-border");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("applies a custom variant", () => {
|
|
19
|
+
render(<Card variant="elevated">Elevated</Card>);
|
|
20
|
+
expect(screen.getByText("Elevated")).toHaveClass("shadow-soft");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("adds interactive styling when interactive", () => {
|
|
24
|
+
render(<Card interactive>Interactive</Card>);
|
|
25
|
+
expect(screen.getByText("Interactive")).toHaveClass("hover:-translate-y-0.5");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("renders the full composite header/content/footer", () => {
|
|
29
|
+
render(
|
|
30
|
+
<Card>
|
|
31
|
+
<CardHeader>
|
|
32
|
+
<CardTitle>Title</CardTitle>
|
|
33
|
+
<CardDescription>Description</CardDescription>
|
|
34
|
+
</CardHeader>
|
|
35
|
+
<CardContent>Body</CardContent>
|
|
36
|
+
<CardFooter>Footer</CardFooter>
|
|
37
|
+
</Card>,
|
|
38
|
+
);
|
|
39
|
+
expect(screen.getByText("Title")).toBeInTheDocument();
|
|
40
|
+
expect(screen.getByText("Description")).toBeInTheDocument();
|
|
41
|
+
expect(screen.getByText("Body")).toBeInTheDocument();
|
|
42
|
+
expect(screen.getByText("Footer")).toBeInTheDocument();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("renders an icon in the header when provided", () => {
|
|
46
|
+
render(<CardHeader icon={<span data-testid="hdr-icon" />}>Header</CardHeader>);
|
|
47
|
+
expect(screen.getByTestId("hdr-icon")).toBeInTheDocument();
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { Container, Stack } from "./Container";
|
|
4
|
+
|
|
5
|
+
describe("Container", () => {
|
|
6
|
+
it("renders a div by default", () => {
|
|
7
|
+
render(<Container>Body</Container>);
|
|
8
|
+
const el = screen.getByText("Body");
|
|
9
|
+
expect(el.tagName).toBe("DIV");
|
|
10
|
+
expect(el).toHaveClass("mx-auto");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("applies a size class", () => {
|
|
14
|
+
render(<Container size="sm">Body</Container>);
|
|
15
|
+
expect(screen.getByText("Body")).toHaveClass("max-w-xl");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("does not cap width for the full size", () => {
|
|
19
|
+
render(<Container size="full">Body</Container>);
|
|
20
|
+
expect(screen.getByText("Body")).not.toHaveClass("max-w-xl", "max-w-6xl");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("adds gutters by default and removes them with gutters=false", () => {
|
|
24
|
+
render(<Container>A</Container>);
|
|
25
|
+
expect(screen.getByText("A")).toHaveClass("px-5");
|
|
26
|
+
|
|
27
|
+
render(<Container gutters={false}>B</Container>);
|
|
28
|
+
expect(screen.getByText("B")).not.toHaveClass("px-5");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("renders as the requested element via `as`", () => {
|
|
32
|
+
render(<Container as="section">Body</Container>);
|
|
33
|
+
expect(screen.getByText("Body").tagName).toBe("SECTION");
|
|
34
|
+
});
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
describe("Stack", () => {
|
|
38
|
+
it("renders a vertical flex by default", () => {
|
|
39
|
+
render(<Stack>A</Stack>);
|
|
40
|
+
expect(screen.getByText("A")).toHaveClass("flex", "flex-col");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("renders horizontal when horizontal", () => {
|
|
44
|
+
render(<Stack horizontal>A</Stack>);
|
|
45
|
+
expect(screen.getByText("A")).toHaveClass("flex-row");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("applies a gap", () => {
|
|
49
|
+
render(<Stack gap={6}>A</Stack>);
|
|
50
|
+
expect(screen.getByText("A")).toHaveClass("gap-6");
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
3
|
+
import { FAQ } from "./FAQ";
|
|
4
|
+
|
|
5
|
+
const items = [
|
|
6
|
+
{ question: "How do I install it?", answer: "Run pnpm add." },
|
|
7
|
+
{ question: "How do I theme it?", answer: "Set --color-primary." },
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
describe("FAQ", () => {
|
|
11
|
+
it("renders all questions and the accordion option by default", () => {
|
|
12
|
+
render(<FAQ items={items} />);
|
|
13
|
+
expect(screen.getByText("How do I install it?")).toBeInTheDocument();
|
|
14
|
+
expect(screen.getByText("How do I theme it?")).toBeInTheDocument();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("expands an answer when a question is activated", () => {
|
|
18
|
+
render(<FAQ items={items} />);
|
|
19
|
+
const q = screen.getByRole("button", { name: "How do I install it?" });
|
|
20
|
+
expect(q).toHaveAttribute("aria-expanded", "false");
|
|
21
|
+
|
|
22
|
+
fireEvent.click(q);
|
|
23
|
+
expect(q).toHaveAttribute("aria-expanded", "true");
|
|
24
|
+
expect(screen.getByText("Run pnpm add.")).toBeInTheDocument();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("closes the first item when another is opened (single open by default)", () => {
|
|
28
|
+
render(<FAQ items={items} />);
|
|
29
|
+
fireEvent.click(screen.getByRole("button", { name: "How do I install it?" }));
|
|
30
|
+
fireEvent.click(screen.getByRole("button", { name: "How do I theme it?" }));
|
|
31
|
+
|
|
32
|
+
// Only the second question's answer is visible.
|
|
33
|
+
expect(screen.getByText("Set --color-primary.")).toBeInTheDocument();
|
|
34
|
+
expect(screen.queryByText("Run pnpm add.")).not.toBeInTheDocument();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("keeps multiple items open when allowMultiple is set", () => {
|
|
38
|
+
render(<FAQ items={items} allowMultiple />);
|
|
39
|
+
fireEvent.click(screen.getByRole("button", { name: "How do I install it?" }));
|
|
40
|
+
fireEvent.click(screen.getByRole("button", { name: "How do I theme it?" }));
|
|
41
|
+
|
|
42
|
+
expect(screen.getByText("Run pnpm add.")).toBeInTheDocument();
|
|
43
|
+
expect(screen.getByText("Set --color-primary.")).toBeInTheDocument();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("renders the split and cards options", () => {
|
|
47
|
+
const { rerender } = render(<FAQ items={items} option="split" />);
|
|
48
|
+
expect(screen.getByText("How do I install it?")).toBeInTheDocument();
|
|
49
|
+
|
|
50
|
+
rerender(<FAQ items={items} option="cards" />);
|
|
51
|
+
expect(screen.getByText("How do I install it?")).toBeInTheDocument();
|
|
52
|
+
expect(screen.getByText("Run pnpm add.")).toBeInTheDocument();
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { FAQProps } from "./types";
|
|
2
|
+
import { FaqCards } from "./variants/Cards";
|
|
3
|
+
import { FaqAccordion } from "./variants/Accordion";
|
|
4
|
+
import { FaqSplit } from "./variants/Split";
|
|
5
|
+
|
|
6
|
+
export const FAQ = ({
|
|
7
|
+
className,
|
|
8
|
+
items,
|
|
9
|
+
option = "accordion",
|
|
10
|
+
allowMultiple = false,
|
|
11
|
+
defaultOpen = [],
|
|
12
|
+
eyebrow,
|
|
13
|
+
title,
|
|
14
|
+
description,
|
|
15
|
+
...props
|
|
16
|
+
}: FAQProps) => {
|
|
17
|
+
if (option === "cards") {
|
|
18
|
+
return <FaqCards className={className} items={items} eyebrow={eyebrow} {...props} />;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (option === "split") {
|
|
22
|
+
return (
|
|
23
|
+
<FaqSplit
|
|
24
|
+
className={className}
|
|
25
|
+
items={items}
|
|
26
|
+
allowMultiple={allowMultiple}
|
|
27
|
+
defaultOpen={defaultOpen}
|
|
28
|
+
eyebrow={eyebrow}
|
|
29
|
+
title={title}
|
|
30
|
+
description={description}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// accordion
|
|
37
|
+
return (
|
|
38
|
+
<div className="mx-auto w-full max-w-3xl">
|
|
39
|
+
<FaqAccordion
|
|
40
|
+
className={className}
|
|
41
|
+
items={items}
|
|
42
|
+
allowMultiple={allowMultiple}
|
|
43
|
+
defaultOpen={defaultOpen}
|
|
44
|
+
/>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
};
|
|
48
|
+
FAQ.displayName = "FAQ";
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { cn } from "../../utils/cn";
|
|
4
|
+
import { Plus } from "../../icons";
|
|
5
|
+
import type { FaqItem } from "./types";
|
|
6
|
+
|
|
7
|
+
export function useFaqState(allowMultiple: boolean, defaultOpen: number[]) {
|
|
8
|
+
const [openSet, setOpenSet] = useState<Set<number>>(
|
|
9
|
+
() => new Set(allowMultiple ? defaultOpen : defaultOpen.slice(0, 1)),
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
const toggle = (index: number) => {
|
|
13
|
+
setOpenSet((prev) => {
|
|
14
|
+
const next = new Set(prev);
|
|
15
|
+
if (next.has(index)) {
|
|
16
|
+
next.delete(index);
|
|
17
|
+
} else {
|
|
18
|
+
if (!allowMultiple) next.clear();
|
|
19
|
+
next.add(index);
|
|
20
|
+
}
|
|
21
|
+
return next;
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
return { openSet, toggle, isOpen: (i: number) => openSet.has(i) };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function FaqItemRow({
|
|
29
|
+
item,
|
|
30
|
+
open,
|
|
31
|
+
onToggle,
|
|
32
|
+
style,
|
|
33
|
+
}: {
|
|
34
|
+
item: FaqItem;
|
|
35
|
+
open: boolean;
|
|
36
|
+
onToggle: () => void;
|
|
37
|
+
style: "boxed" | "bare";
|
|
38
|
+
}) {
|
|
39
|
+
return (
|
|
40
|
+
<div>
|
|
41
|
+
<h3>
|
|
42
|
+
<button
|
|
43
|
+
type="button"
|
|
44
|
+
aria-expanded={open}
|
|
45
|
+
onClick={onToggle}
|
|
46
|
+
className={
|
|
47
|
+
style === "boxed"
|
|
48
|
+
? "flex w-full cursor-pointer items-center justify-between gap-4 px-6 py-5 text-left text-[15px] font-medium text-foreground transition-colors duration-150 hover:bg-surface-strong/60 focus-visible:outline-none"
|
|
49
|
+
: "flex w-full cursor-pointer items-center justify-between gap-4 py-5 text-left text-base font-semibold text-foreground transition-colors duration-150 focus-visible:outline-none"
|
|
50
|
+
}
|
|
51
|
+
>
|
|
52
|
+
{item.question}
|
|
53
|
+
<Plus
|
|
54
|
+
className={cn(
|
|
55
|
+
"h-4.5 w-4.5 shrink-0 text-foreground/45 transition-transform duration-200 sm:h-5 sm:w-5",
|
|
56
|
+
open && "rotate-45 text-primary",
|
|
57
|
+
)}
|
|
58
|
+
/>
|
|
59
|
+
</button>
|
|
60
|
+
</h3>
|
|
61
|
+
{open ? (
|
|
62
|
+
<p className={style === "boxed" ? "px-6 pb-6 text-sm leading-7 text-muted-foreground" : "pb-6 text-sm leading-7 text-muted-foreground"}>
|
|
63
|
+
{item.answer}
|
|
64
|
+
</p>
|
|
65
|
+
) : null}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function FaqEyebrow({ label }: { label: string }) {
|
|
71
|
+
return (
|
|
72
|
+
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary">{label}</p>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
export type FAQOption = "accordion" | "split" | "cards";
|
|
4
|
+
|
|
5
|
+
export interface FaqItem {
|
|
6
|
+
question: string;
|
|
7
|
+
answer: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface FAQProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
|
|
11
|
+
items: FaqItem[];
|
|
12
|
+
option?: FAQOption;
|
|
13
|
+
allowMultiple?: boolean;
|
|
14
|
+
defaultOpen?: number[];
|
|
15
|
+
eyebrow?: ReactNode;
|
|
16
|
+
title?: ReactNode;
|
|
17
|
+
description?: ReactNode;
|
|
18
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { cn } from "../../../utils/cn";
|
|
3
|
+
import { useFaqState, FaqItemRow } from "../parts";
|
|
4
|
+
import type { FAQProps } from "../types";
|
|
5
|
+
|
|
6
|
+
interface AccordionProps extends FAQProps {
|
|
7
|
+
allowMultiple?: boolean;
|
|
8
|
+
defaultOpen?: number[];
|
|
9
|
+
bare?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const FaqAccordion = ({
|
|
13
|
+
className,
|
|
14
|
+
items,
|
|
15
|
+
allowMultiple = false,
|
|
16
|
+
defaultOpen = [],
|
|
17
|
+
bare = false,
|
|
18
|
+
option,
|
|
19
|
+
title,
|
|
20
|
+
eyebrow,
|
|
21
|
+
description,
|
|
22
|
+
...props
|
|
23
|
+
}: AccordionProps) => {
|
|
24
|
+
const { isOpen, toggle } = useFaqState(allowMultiple, defaultOpen);
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
className={cn(
|
|
29
|
+
!bare && "divide-y divide-border rounded-2xl border border-border bg-surface",
|
|
30
|
+
className,
|
|
31
|
+
)}
|
|
32
|
+
{...props}
|
|
33
|
+
>
|
|
34
|
+
{items.map((item, index) => (
|
|
35
|
+
<FaqItemRow
|
|
36
|
+
key={item.question}
|
|
37
|
+
item={item}
|
|
38
|
+
open={isOpen(index)}
|
|
39
|
+
onToggle={() => toggle(index)}
|
|
40
|
+
style={bare ? "bare" : "boxed"}
|
|
41
|
+
/>
|
|
42
|
+
))}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
};
|
|
46
|
+
FaqAccordion.displayName = "FaqAccordion";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { cn } from "../../../utils/cn";
|
|
2
|
+
import { FaqEyebrow } from "../parts";
|
|
3
|
+
import type { FAQProps } from "../types";
|
|
4
|
+
|
|
5
|
+
interface CardsProps extends FAQProps {}
|
|
6
|
+
|
|
7
|
+
export const FaqCards = ({
|
|
8
|
+
className,
|
|
9
|
+
items,
|
|
10
|
+
eyebrow,
|
|
11
|
+
title,
|
|
12
|
+
description,
|
|
13
|
+
option,
|
|
14
|
+
...props
|
|
15
|
+
}: CardsProps) => (
|
|
16
|
+
<div className={cn("w-full", className)} {...props}>
|
|
17
|
+
{eyebrow ? <div className="mb-10"><FaqEyebrow label={String(eyebrow)} /></div> : null}
|
|
18
|
+
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
19
|
+
{items.map((item, i) => (
|
|
20
|
+
<div
|
|
21
|
+
key={item.question}
|
|
22
|
+
className="flex flex-col rounded-xl border border-border bg-surface p-7"
|
|
23
|
+
>
|
|
24
|
+
<div className="flex items-baseline gap-4">
|
|
25
|
+
<span className="font-display text-sm font-semibold tabular-nums text-foreground/35">
|
|
26
|
+
{String(i + 1).padStart(2, "0")}
|
|
27
|
+
</span>
|
|
28
|
+
<h3 className="font-display text-lg font-semibold tracking-tight text-foreground">
|
|
29
|
+
{item.question}
|
|
30
|
+
</h3>
|
|
31
|
+
</div>
|
|
32
|
+
<p className="mt-4 border-t border-border pt-4 text-sm leading-6 text-muted-foreground">
|
|
33
|
+
{item.answer}
|
|
34
|
+
</p>
|
|
35
|
+
</div>
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
FaqCards.displayName = "FaqCards";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { cn } from "../../../utils/cn";
|
|
2
|
+
import { FaqEyebrow } from "../parts";
|
|
3
|
+
import { FaqAccordion } from "./Accordion";
|
|
4
|
+
import type { FAQProps } from "../types";
|
|
5
|
+
|
|
6
|
+
interface SplitProps extends FAQProps {
|
|
7
|
+
allowMultiple?: boolean;
|
|
8
|
+
defaultOpen?: number[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const FaqSplit = ({
|
|
12
|
+
className,
|
|
13
|
+
items,
|
|
14
|
+
allowMultiple = false,
|
|
15
|
+
defaultOpen = [],
|
|
16
|
+
eyebrow,
|
|
17
|
+
title,
|
|
18
|
+
description,
|
|
19
|
+
option,
|
|
20
|
+
...props
|
|
21
|
+
}: SplitProps) => (
|
|
22
|
+
<div className={cn("grid w-full gap-10 lg:grid-cols-[0.9fr_1.1fr] lg:gap-20", className)} {...props}>
|
|
23
|
+
<div className="lg:sticky lg:top-24 lg:self-start">
|
|
24
|
+
{eyebrow ? <FaqEyebrow label={String(eyebrow)} /> : null}
|
|
25
|
+
{title ? (
|
|
26
|
+
<h2 className="mt-4 font-display text-3xl font-semibold leading-[1.08] tracking-tight text-foreground sm:text-4xl">
|
|
27
|
+
{title}
|
|
28
|
+
</h2>
|
|
29
|
+
) : null}
|
|
30
|
+
{description ? (
|
|
31
|
+
<p className="mt-4 text-pretty text-base leading-7 text-muted-foreground">{description}</p>
|
|
32
|
+
) : null}
|
|
33
|
+
</div>
|
|
34
|
+
<div className="divide-y divide-border">
|
|
35
|
+
<FaqAccordion
|
|
36
|
+
items={items}
|
|
37
|
+
allowMultiple={allowMultiple}
|
|
38
|
+
defaultOpen={defaultOpen}
|
|
39
|
+
bare
|
|
40
|
+
/>
|
|
41
|
+
</div>
|
|
42
|
+
</div>
|
|
43
|
+
);
|
|
44
|
+
FaqSplit.displayName = "FaqSplit";
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { forwardRef } from "react";
|
|
2
|
+
import { cn } from "../../utils/cn";
|
|
3
|
+
import { getContrastText } from "../../utils/contrast";
|
|
4
|
+
import type { FeatureCardProps } from "./types";
|
|
5
|
+
|
|
6
|
+
export const FeatureCard = forwardRef<HTMLDivElement, FeatureCardProps>(
|
|
7
|
+
({ className, icon, title, description, accent, large = false, ...props }, ref) => {
|
|
8
|
+
const chipStyle = accent
|
|
9
|
+
? { backgroundColor: accent, color: getContrastText(accent, "#111111", "#ffffff") }
|
|
10
|
+
: undefined;
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
ref={ref}
|
|
15
|
+
className={cn(
|
|
16
|
+
"group relative flex h-full flex-col rounded-xl border border-border bg-surface p-7 transition-colors duration-200 hover:border-foreground/15",
|
|
17
|
+
className,
|
|
18
|
+
)}
|
|
19
|
+
{...props}
|
|
20
|
+
>
|
|
21
|
+
{icon ? (
|
|
22
|
+
<div
|
|
23
|
+
className={cn(
|
|
24
|
+
"mb-5 inline-flex h-10 w-10 items-center justify-center rounded-lg text-[1.05rem]",
|
|
25
|
+
!accent && "bg-primary-soft text-primary",
|
|
26
|
+
)}
|
|
27
|
+
style={chipStyle}
|
|
28
|
+
>
|
|
29
|
+
{icon}
|
|
30
|
+
</div>
|
|
31
|
+
) : null}
|
|
32
|
+
<h3 className={cn("font-display font-semibold tracking-tight text-foreground", large ? "text-2xl" : "text-lg")}>
|
|
33
|
+
{title}
|
|
34
|
+
</h3>
|
|
35
|
+
{description ? (
|
|
36
|
+
<p className={cn("mt-2.5 text-pretty leading-relaxed text-muted-foreground", large ? "text-[15px]" : "text-sm")}>
|
|
37
|
+
{description}
|
|
38
|
+
</p>
|
|
39
|
+
) : null}
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
},
|
|
43
|
+
);
|
|
44
|
+
FeatureCard.displayName = "FeatureCard";
|