@ugurdemirel/landcraft 0.1.5 → 0.1.10
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ugurdemirel/landcraft",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -20,20 +20,20 @@
|
|
|
20
20
|
"require": "./dist/index.cjs"
|
|
21
21
|
},
|
|
22
22
|
"./styles.css": "./dist/styles.css",
|
|
23
|
-
"./theme.css": "./
|
|
23
|
+
"./theme.css": "./dist/theme.css"
|
|
24
24
|
},
|
|
25
25
|
"files": [
|
|
26
26
|
"dist",
|
|
27
|
-
"src",
|
|
28
27
|
"README.md"
|
|
29
28
|
],
|
|
30
29
|
"sideEffects": [
|
|
31
30
|
"*.css"
|
|
32
31
|
],
|
|
33
32
|
"scripts": {
|
|
34
|
-
"build": "pnpm run build:js && pnpm run build:css",
|
|
33
|
+
"build": "pnpm run build:js && pnpm run build:css && pnpm run copy:theme",
|
|
35
34
|
"build:js": "vite build",
|
|
36
35
|
"build:css": "tailwindcss -i ./src/styles/index.css -o ./dist/styles.css",
|
|
36
|
+
"copy:theme": "cp ./src/styles/theme.css ./dist/theme.css",
|
|
37
37
|
"typecheck": "tsc --noEmit",
|
|
38
38
|
"test": "vitest run",
|
|
39
39
|
"test:watch": "vitest",
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { render, screen } from "@testing-library/react";
|
|
3
|
-
import { Badge } from "./Badge";
|
|
4
|
-
import { Zap } from "../icons";
|
|
5
|
-
|
|
6
|
-
describe("Badge", () => {
|
|
7
|
-
it("renders a span with children", () => {
|
|
8
|
-
render(<Badge>New</Badge>);
|
|
9
|
-
expect(screen.getByText("New")).toBeInTheDocument();
|
|
10
|
-
expect(screen.getByText("New").tagName).toBe("SPAN");
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
it("defaults to the soft variant", () => {
|
|
14
|
-
render(<Badge>Soft</Badge>);
|
|
15
|
-
expect(screen.getByText("Soft")).toHaveClass("bg-primary-soft");
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("applies the requested variant", () => {
|
|
19
|
-
render(<Badge variant="solid">Solid</Badge>);
|
|
20
|
-
expect(screen.getByText("Solid")).toHaveClass("bg-primary");
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("renders a dot indicator for the dot variant", () => {
|
|
24
|
-
const { container } = render(<Badge variant="dot">Online</Badge>);
|
|
25
|
-
// The dot is an inner decorative span.
|
|
26
|
-
const dots = container.querySelectorAll("span.h-1\\.5");
|
|
27
|
-
expect(dots.length).toBeGreaterThan(0);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("renders an icon", () => {
|
|
31
|
-
render(
|
|
32
|
-
<Badge icon={<Zap data-testid="icon" />} variant="soft">
|
|
33
|
-
Fast
|
|
34
|
-
</Badge>,
|
|
35
|
-
);
|
|
36
|
-
expect(screen.getByTestId("icon")).toBeInTheDocument();
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("applies sizes", () => {
|
|
40
|
-
render(<Badge size="sm">Small</Badge>);
|
|
41
|
-
expect(screen.getByText("Small")).toHaveClass("px-2.5");
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("sets a computed text color for customColor", () => {
|
|
45
|
-
render(<Badge customColor="#111111">Dark</Badge>);
|
|
46
|
-
const badge = screen.getByText("Dark");
|
|
47
|
-
expect(badge).toHaveStyle({ backgroundColor: "#111111" });
|
|
48
|
-
expect(badge).toHaveStyle({ color: "#ffffff" });
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("forwards arbitrary props like aria-label", () => {
|
|
52
|
-
render(<Badge aria-label="status">v1</Badge>);
|
|
53
|
-
expect(screen.getByLabelText("status")).toBeInTheDocument();
|
|
54
|
-
});
|
|
55
|
-
});
|
package/src/components/Badge.tsx
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
import { forwardRef, type HTMLAttributes, type ReactNode } from "react";
|
|
2
|
-
import { cn } from "../utils/cn";
|
|
3
|
-
import { getContrastText } from "../utils/contrast";
|
|
4
|
-
|
|
5
|
-
export type BadgeVariant = "solid" | "soft" | "outline" | "dot";
|
|
6
|
-
export type BadgeSize = "sm" | "md";
|
|
7
|
-
|
|
8
|
-
export interface BadgeProps extends HTMLAttributes<HTMLSpanElement> {
|
|
9
|
-
variant?: BadgeVariant;
|
|
10
|
-
size?: BadgeSize;
|
|
11
|
-
icon?: ReactNode;
|
|
12
|
-
/** Paints the chip with an arbitrary CSS color; text color is computed for contrast. */
|
|
13
|
-
customColor?: string;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
const base =
|
|
17
|
-
"inline-flex items-center rounded-full font-medium tracking-tight transition-colors duration-200";
|
|
18
|
-
|
|
19
|
-
const sizes: Record<BadgeSize, string> = {
|
|
20
|
-
sm: "px-2.5 py-0.5 text-xs gap-1",
|
|
21
|
-
md: "px-3 py-1 text-[13px] gap-1.5",
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
const variants: Record<BadgeVariant, string> = {
|
|
25
|
-
solid: "bg-primary text-on-primary",
|
|
26
|
-
soft: "bg-primary-soft text-primary",
|
|
27
|
-
outline: "text-muted-foreground ring-1 ring-inset ring-border",
|
|
28
|
-
dot: "text-foreground ring-1 ring-inset ring-border bg-surface",
|
|
29
|
-
};
|
|
30
|
-
|
|
31
|
-
export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
|
|
32
|
-
(
|
|
33
|
-
{ className, variant = "soft", size = "md", icon, customColor, style, children, ...props },
|
|
34
|
-
ref,
|
|
35
|
-
) => {
|
|
36
|
-
const customStyle = customColor
|
|
37
|
-
? { ...style, backgroundColor: customColor, color: getContrastText(customColor, "#111111", "#ffffff") }
|
|
38
|
-
: style;
|
|
39
|
-
|
|
40
|
-
return (
|
|
41
|
-
<span
|
|
42
|
-
ref={ref}
|
|
43
|
-
style={customStyle}
|
|
44
|
-
className={cn(
|
|
45
|
-
base,
|
|
46
|
-
sizes[size],
|
|
47
|
-
customColor ? undefined : variants[variant],
|
|
48
|
-
className,
|
|
49
|
-
)}
|
|
50
|
-
{...props}
|
|
51
|
-
>
|
|
52
|
-
{variant === "dot" ? (
|
|
53
|
-
<span
|
|
54
|
-
aria-hidden
|
|
55
|
-
className={cn(
|
|
56
|
-
"h-1.5 w-1.5 shrink-0 rounded-full",
|
|
57
|
-
customColor ? "currentColor" : "bg-primary",
|
|
58
|
-
)}
|
|
59
|
-
style={customColor ? { backgroundColor: customColor } : undefined}
|
|
60
|
-
/>
|
|
61
|
-
) : null}
|
|
62
|
-
{icon ? <span className="shrink-0 opacity-70">{icon}</span> : null}
|
|
63
|
-
{children}
|
|
64
|
-
</span>
|
|
65
|
-
);
|
|
66
|
-
},
|
|
67
|
-
);
|
|
68
|
-
Badge.displayName = "Badge";
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { render, screen } from "@testing-library/react";
|
|
3
|
-
import { BlogCard } from "./BlogCard";
|
|
4
|
-
import { BlogSection } from "./BlogSection";
|
|
5
|
-
|
|
6
|
-
const posts = [
|
|
7
|
-
{
|
|
8
|
-
title: "Token-based theming",
|
|
9
|
-
excerpt: "A short excerpt.",
|
|
10
|
-
date: "12 Aug 2026",
|
|
11
|
-
readTime: "5 min",
|
|
12
|
-
category: "Strategy",
|
|
13
|
-
href: "/post",
|
|
14
|
-
author: { name: "Aylin Demir" },
|
|
15
|
-
},
|
|
16
|
-
{
|
|
17
|
-
title: "Second post",
|
|
18
|
-
date: "1 Aug 2026",
|
|
19
|
-
},
|
|
20
|
-
];
|
|
21
|
-
|
|
22
|
-
describe("BlogCard", () => {
|
|
23
|
-
it("renders a card with title, category badge and meta (card option)", () => {
|
|
24
|
-
render(<BlogCard post={posts[0]} />);
|
|
25
|
-
expect(screen.getByText("Token-based theming")).toBeInTheDocument();
|
|
26
|
-
expect(screen.getByText("Strategy")).toBeInTheDocument();
|
|
27
|
-
expect(screen.getByText("5 min")).toBeInTheDocument();
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("renders the row option", () => {
|
|
31
|
-
render(<BlogCard post={posts[0]} option="row" />);
|
|
32
|
-
expect(screen.getByText("Token-based theming")).toBeInTheDocument();
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("links to the post href", () => {
|
|
36
|
-
render(<BlogCard post={posts[0]} />);
|
|
37
|
-
expect(
|
|
38
|
-
screen.getByRole("link", { name: /Token-based theming/ }),
|
|
39
|
-
).toHaveAttribute("href", "/post");
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
describe("BlogSection", () => {
|
|
44
|
-
it("renders the heading and posts as a card grid", () => {
|
|
45
|
-
render(
|
|
46
|
-
<BlogSection
|
|
47
|
-
posts={posts}
|
|
48
|
-
eyebrow="Blog"
|
|
49
|
-
title="Latest"
|
|
50
|
-
description="A description."
|
|
51
|
-
/>,
|
|
52
|
-
);
|
|
53
|
-
expect(screen.getByText("Blog")).toBeInTheDocument();
|
|
54
|
-
expect(screen.getByText("Latest")).toBeInTheDocument();
|
|
55
|
-
expect(screen.getByText("Token-based theming")).toBeInTheDocument();
|
|
56
|
-
expect(screen.getByText("Second post")).toBeInTheDocument();
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it("limits the number of posts with the limit prop", () => {
|
|
60
|
-
render(<BlogSection posts={posts} limit={1} />);
|
|
61
|
-
expect(screen.getByText("Token-based theming")).toBeInTheDocument();
|
|
62
|
-
expect(screen.queryByText("Second post")).not.toBeInTheDocument();
|
|
63
|
-
});
|
|
64
|
-
});
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { forwardRef } from "react";
|
|
2
|
-
import type { BlogCardProps } from "./types";
|
|
3
|
-
import { BlogCardCard } from "./variants/Card";
|
|
4
|
-
import { BlogCardRow } from "./variants/Row";
|
|
5
|
-
|
|
6
|
-
export const BlogCard = forwardRef<HTMLAnchorElement, BlogCardProps>(
|
|
7
|
-
({ option = "card", ...props }, ref) => {
|
|
8
|
-
if (option === "row") return <BlogCardRow ref={ref} {...props} />;
|
|
9
|
-
return <BlogCardCard ref={ref} {...props} />;
|
|
10
|
-
},
|
|
11
|
-
);
|
|
12
|
-
BlogCard.displayName = "BlogCard";
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { forwardRef } from "react";
|
|
2
|
-
import { cn } from "../../utils/cn";
|
|
3
|
-
import { ArrowRight } from "../../icons";
|
|
4
|
-
import { Container } from "../Container";
|
|
5
|
-
import { BlogCard } from "./BlogCard";
|
|
6
|
-
import type { BlogSectionProps } from "./types";
|
|
7
|
-
|
|
8
|
-
const gridCols: Record<2 | 3, string> = {
|
|
9
|
-
2: "sm:grid-cols-2",
|
|
10
|
-
3: "sm:grid-cols-2 lg:grid-cols-3",
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
export const BlogSection = forwardRef<HTMLElement, BlogSectionProps>(
|
|
14
|
-
(
|
|
15
|
-
{
|
|
16
|
-
className,
|
|
17
|
-
posts,
|
|
18
|
-
limit,
|
|
19
|
-
columns = 3,
|
|
20
|
-
option = "card",
|
|
21
|
-
eyebrow,
|
|
22
|
-
eyebrowStyle = "caps",
|
|
23
|
-
title,
|
|
24
|
-
description,
|
|
25
|
-
showAllLabel,
|
|
26
|
-
showAllHref,
|
|
27
|
-
LinkComponent,
|
|
28
|
-
...props
|
|
29
|
-
},
|
|
30
|
-
ref,
|
|
31
|
-
) => {
|
|
32
|
-
const visible = limit ? posts.slice(0, limit) : posts;
|
|
33
|
-
const Link = LinkComponent ?? "a";
|
|
34
|
-
|
|
35
|
-
return (
|
|
36
|
-
<section ref={ref} className={cn("w-full py-20 sm:py-24", className)} {...props}>
|
|
37
|
-
<Container>
|
|
38
|
-
{(eyebrow || title || description || showAllHref) && (
|
|
39
|
-
<div className="mb-12 flex flex-col gap-6 sm:flex-row sm:items-end sm:justify-between">
|
|
40
|
-
<div className="max-w-2xl">
|
|
41
|
-
{eyebrow ? (
|
|
42
|
-
<p
|
|
43
|
-
className={cn(
|
|
44
|
-
"text-xs font-semibold uppercase tracking-[0.18em]",
|
|
45
|
-
eyebrowStyle === "caps" && "text-foreground/50",
|
|
46
|
-
eyebrowStyle === "soft" &&
|
|
47
|
-
"inline-flex items-center rounded-full bg-primary-soft px-3 py-1.5 text-primary",
|
|
48
|
-
eyebrowStyle === "plain" && "text-primary",
|
|
49
|
-
)}
|
|
50
|
-
>
|
|
51
|
-
{eyebrow}
|
|
52
|
-
</p>
|
|
53
|
-
) : null}
|
|
54
|
-
{title ? (
|
|
55
|
-
<h2 className="mt-4 font-display text-balance text-3xl font-semibold leading-[1.08] tracking-tight text-foreground sm:text-4xl">
|
|
56
|
-
{title}
|
|
57
|
-
</h2>
|
|
58
|
-
) : null}
|
|
59
|
-
{description ? (
|
|
60
|
-
<p className="mt-4 text-pretty text-base leading-7 text-muted-foreground">
|
|
61
|
-
{description}
|
|
62
|
-
</p>
|
|
63
|
-
) : null}
|
|
64
|
-
</div>
|
|
65
|
-
{showAllHref ? (
|
|
66
|
-
<Link
|
|
67
|
-
href={showAllHref}
|
|
68
|
-
className="group inline-flex shrink-0 cursor-pointer items-center gap-1.5 text-sm font-semibold text-foreground transition-colors duration-150 hover:text-primary"
|
|
69
|
-
>
|
|
70
|
-
{showAllLabel ?? "All posts"}
|
|
71
|
-
<ArrowRight className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-0.5" />
|
|
72
|
-
</Link>
|
|
73
|
-
) : null}
|
|
74
|
-
</div>
|
|
75
|
-
)}
|
|
76
|
-
|
|
77
|
-
{option === "row" ? (
|
|
78
|
-
<div className="divide-y divide-border border-t border-border">
|
|
79
|
-
{visible.map((post) => (
|
|
80
|
-
<BlogCard key={post.title} post={post} option="row" LinkComponent={LinkComponent} />
|
|
81
|
-
))}
|
|
82
|
-
</div>
|
|
83
|
-
) : (
|
|
84
|
-
<div className={cn("grid grid-cols-1 gap-4", gridCols[columns])}>
|
|
85
|
-
{visible.map((post) => (
|
|
86
|
-
<BlogCard key={post.title} post={post} option="card" LinkComponent={LinkComponent} />
|
|
87
|
-
))}
|
|
88
|
-
</div>
|
|
89
|
-
)}
|
|
90
|
-
</Container>
|
|
91
|
-
</section>
|
|
92
|
-
);
|
|
93
|
-
},
|
|
94
|
-
);
|
|
95
|
-
BlogSection.displayName = "BlogSection";
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import type { ReactNode } from "react";
|
|
2
|
-
import type { BlogPost } from "./types";
|
|
3
|
-
|
|
4
|
-
export function AuthorMark({ name, avatar }: { name: string; avatar?: ReactNode }) {
|
|
5
|
-
if (avatar) return avatar;
|
|
6
|
-
const initials = name
|
|
7
|
-
.split(" ")
|
|
8
|
-
.map((p) => p[0])
|
|
9
|
-
.join("")
|
|
10
|
-
.slice(0, 2)
|
|
11
|
-
.toUpperCase();
|
|
12
|
-
return (
|
|
13
|
-
<span className="grid h-8 w-8 shrink-0 place-items-center rounded-full bg-primary-soft text-[11px] font-bold text-primary">
|
|
14
|
-
{initials}
|
|
15
|
-
</span>
|
|
16
|
-
);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export const Cover = ({ post }: { post: BlogPost }) =>
|
|
20
|
-
post.cover ? (
|
|
21
|
-
<img src={post.cover} alt="" loading="lazy" className="aspect-[16/10] w-full object-cover" />
|
|
22
|
-
) : (
|
|
23
|
-
<div
|
|
24
|
-
className="grid aspect-[16/10] w-full place-items-center bg-surface-strong"
|
|
25
|
-
style={
|
|
26
|
-
post.accent
|
|
27
|
-
? { background: `linear-gradient(135deg, ${post.accent}22, ${post.accent}11)` }
|
|
28
|
-
: undefined
|
|
29
|
-
}
|
|
30
|
-
>
|
|
31
|
-
<span
|
|
32
|
-
className="font-display text-4xl font-bold tracking-[-0.04em]"
|
|
33
|
-
style={{ color: post.accent ?? "rgb(var(--color-foreground) / 0.12)" }}
|
|
34
|
-
>
|
|
35
|
-
{post.category?.[0]?.toUpperCase()}
|
|
36
|
-
</span>
|
|
37
|
-
</div>
|
|
38
|
-
);
|
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import type { ElementType, HTMLAttributes, ReactNode } from "react";
|
|
2
|
-
|
|
3
|
-
export interface BlogAuthor {
|
|
4
|
-
name: string;
|
|
5
|
-
avatar?: ReactNode;
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export interface BlogPost {
|
|
9
|
-
title: string;
|
|
10
|
-
excerpt?: string;
|
|
11
|
-
/** Display date, e.g. "12 Aug 2026". */
|
|
12
|
-
date: string;
|
|
13
|
-
readTime?: string;
|
|
14
|
-
category?: string;
|
|
15
|
-
cover?: string;
|
|
16
|
-
/** Arbitrary CSS color for the cover placeholder accent. */
|
|
17
|
-
accent?: string;
|
|
18
|
-
href?: string;
|
|
19
|
-
author?: BlogAuthor;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface BlogCardProps extends HTMLAttributes<HTMLAnchorElement> {
|
|
23
|
-
post: BlogPost;
|
|
24
|
-
option?: "card" | "row";
|
|
25
|
-
/** Component used to render the card link. Defaults to `<a>`. Pass your router's `<Link>` (Next.js, Remix, React Router…) for framework-aware navigation. */
|
|
26
|
-
LinkComponent?: ElementType;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface BlogSectionProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
|
|
30
|
-
posts: BlogPost[];
|
|
31
|
-
/** Show only the first N posts. */
|
|
32
|
-
limit?: number;
|
|
33
|
-
columns?: 2 | 3;
|
|
34
|
-
option?: "card" | "row";
|
|
35
|
-
eyebrow?: ReactNode;
|
|
36
|
-
eyebrowStyle?: "caps" | "soft" | "plain";
|
|
37
|
-
title?: ReactNode;
|
|
38
|
-
description?: ReactNode;
|
|
39
|
-
showAllLabel?: string;
|
|
40
|
-
showAllHref?: string;
|
|
41
|
-
LinkComponent?: ElementType;
|
|
42
|
-
}
|
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import { forwardRef, type ElementType } from "react";
|
|
2
|
-
import { cn } from "../../../utils/cn";
|
|
3
|
-
import { ArrowRight } from "../../../icons";
|
|
4
|
-
import { Badge } from "../../Badge";
|
|
5
|
-
import { AuthorMark, Cover } from "../parts";
|
|
6
|
-
import type { BlogPost } from "../types";
|
|
7
|
-
|
|
8
|
-
interface BlogCardCardProps {
|
|
9
|
-
post: BlogPost;
|
|
10
|
-
LinkComponent?: ElementType;
|
|
11
|
-
className?: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export const BlogCardCard = forwardRef<HTMLAnchorElement, BlogCardCardProps>(
|
|
15
|
-
({ className, post, LinkComponent, ...props }, ref) => {
|
|
16
|
-
const Link = LinkComponent ?? "a";
|
|
17
|
-
const author = post.author;
|
|
18
|
-
return (
|
|
19
|
-
<Link
|
|
20
|
-
ref={ref}
|
|
21
|
-
href={post.href ?? "#"}
|
|
22
|
-
className={cn(
|
|
23
|
-
"group flex h-full cursor-pointer flex-col overflow-hidden rounded-xl border border-border bg-surface transition-colors duration-200 hover:border-foreground/20",
|
|
24
|
-
className,
|
|
25
|
-
)}
|
|
26
|
-
{...props}
|
|
27
|
-
>
|
|
28
|
-
<div className="overflow-hidden">
|
|
29
|
-
<Cover post={post} />
|
|
30
|
-
</div>
|
|
31
|
-
<div className="flex flex-1 flex-col p-6">
|
|
32
|
-
<div className="flex items-center justify-between gap-3">
|
|
33
|
-
{post.category ? <Badge variant="soft" size="sm">{post.category}</Badge> : <span />}
|
|
34
|
-
{post.readTime ? (
|
|
35
|
-
<span className="text-xs text-muted-foreground">{post.readTime}</span>
|
|
36
|
-
) : null}
|
|
37
|
-
</div>
|
|
38
|
-
<h3 className="mt-4 font-display text-lg font-semibold leading-snug tracking-tight text-foreground sm:text-xl">
|
|
39
|
-
{post.title}
|
|
40
|
-
</h3>
|
|
41
|
-
{post.excerpt ? (
|
|
42
|
-
<p className="mt-2.5 line-clamp-2 text-sm leading-6 text-muted-foreground">
|
|
43
|
-
{post.excerpt}
|
|
44
|
-
</p>
|
|
45
|
-
) : null}
|
|
46
|
-
<div className="mt-5 flex items-center justify-between gap-3 border-t border-border pt-4">
|
|
47
|
-
{author ? (
|
|
48
|
-
<span className="flex min-w-0 items-center gap-2.5">
|
|
49
|
-
<AuthorMark name={author.name} avatar={author.avatar} />
|
|
50
|
-
<span className="truncate text-xs font-medium text-foreground">{author.name}</span>
|
|
51
|
-
</span>
|
|
52
|
-
) : (
|
|
53
|
-
<span className="text-xs tabular-nums text-muted-foreground">{post.date}</span>
|
|
54
|
-
)}
|
|
55
|
-
<ArrowRight className="h-4 w-4 text-foreground/35 transition-all duration-200 group-hover:translate-x-0.5 group-hover:text-foreground" />
|
|
56
|
-
</div>
|
|
57
|
-
</div>
|
|
58
|
-
</Link>
|
|
59
|
-
);
|
|
60
|
-
},
|
|
61
|
-
);
|
|
62
|
-
BlogCardCard.displayName = "BlogCardCard";
|
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { forwardRef, type ElementType } from "react";
|
|
2
|
-
import { cn } from "../../../utils/cn";
|
|
3
|
-
import { ArrowUpRight } from "../../../icons";
|
|
4
|
-
import type { BlogPost } from "../types";
|
|
5
|
-
|
|
6
|
-
interface BlogCardRowProps {
|
|
7
|
-
post: BlogPost;
|
|
8
|
-
LinkComponent?: ElementType;
|
|
9
|
-
className?: string;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export const BlogCardRow = forwardRef<HTMLAnchorElement, BlogCardRowProps>(
|
|
13
|
-
({ className, post, LinkComponent, ...props }, ref) => {
|
|
14
|
-
const Link = LinkComponent ?? "a";
|
|
15
|
-
return (
|
|
16
|
-
<Link
|
|
17
|
-
ref={ref}
|
|
18
|
-
href={post.href ?? "#"}
|
|
19
|
-
className={cn("group flex cursor-pointer items-baseline gap-5 py-6", className)}
|
|
20
|
-
{...props}
|
|
21
|
-
>
|
|
22
|
-
<div className="min-w-0 flex-1">
|
|
23
|
-
<div className="flex items-center gap-3">
|
|
24
|
-
{post.category ? <span className="rounded-full bg-primary-soft px-2.5 py-0.5 text-xs font-semibold text-primary">{post.category}</span> : null}
|
|
25
|
-
<span className="text-xs tabular-nums text-muted-foreground">
|
|
26
|
-
{post.date}
|
|
27
|
-
{post.readTime ? ` · ${post.readTime}` : ""}
|
|
28
|
-
</span>
|
|
29
|
-
</div>
|
|
30
|
-
<h3 className="mt-2.5 font-display text-xl font-semibold tracking-tight text-foreground transition-transform duration-200 group-hover:translate-x-1 sm:text-2xl">
|
|
31
|
-
{post.title}
|
|
32
|
-
</h3>
|
|
33
|
-
</div>
|
|
34
|
-
<span className="text-foreground/30 transition-all duration-200 group-hover:translate-x-0.5 group-hover:text-foreground">
|
|
35
|
-
<ArrowUpRight className="h-5 w-5" />
|
|
36
|
-
</span>
|
|
37
|
-
</Link>
|
|
38
|
-
);
|
|
39
|
-
},
|
|
40
|
-
);
|
|
41
|
-
BlogCardRow.displayName = "BlogCardRow";
|
|
@@ -1,91 +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 { Button } from "./Button";
|
|
5
|
-
import { ArrowRight } from "../icons";
|
|
6
|
-
|
|
7
|
-
describe("Button", () => {
|
|
8
|
-
it("renders a button element with children", () => {
|
|
9
|
-
render(<Button>Hello</Button>);
|
|
10
|
-
const btn = screen.getByRole("button", { name: "Hello" });
|
|
11
|
-
expect(btn).toBeInTheDocument();
|
|
12
|
-
expect(btn.tagName).toBe("BUTTON");
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it("defaults to type=button so it never submits forms unintentionally", () => {
|
|
16
|
-
render(<Button>Save</Button>);
|
|
17
|
-
expect(screen.getByRole("button", { name: "Save" })).toHaveAttribute("type", "button");
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it("applies the default primary variant", () => {
|
|
21
|
-
render(<Button>Start</Button>);
|
|
22
|
-
expect(screen.getByRole("button", { name: "Start" })).toHaveClass("bg-primary");
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("applies the requested variant class", () => {
|
|
26
|
-
render(<Button variant="ghost">Ghost</Button>);
|
|
27
|
-
expect(screen.getByRole("button", { name: "Ghost" })).toHaveClass("hover:bg-surface");
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
it("applies the link variant underline behavior", () => {
|
|
31
|
-
render(<Button variant="link">Link</Button>);
|
|
32
|
-
expect(screen.getByRole("button", { name: "Link" })).toHaveClass("hover:underline");
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
it("renders leading and trailing icons", () => {
|
|
36
|
-
render(
|
|
37
|
-
<Button iconLeft={<span data-testid="left" />} iconRight={<ArrowRight data-testid="right" />}>
|
|
38
|
-
Next
|
|
39
|
-
</Button>,
|
|
40
|
-
);
|
|
41
|
-
const btn = screen.getByRole("button", { name: "Next" });
|
|
42
|
-
expect(btn.querySelector('[data-testid="left"]')).toBeInTheDocument();
|
|
43
|
-
expect(btn.querySelector('[data-testid="right"]')).toBeInTheDocument();
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("applies fullWidth", () => {
|
|
47
|
-
render(<Button fullWidth>Wide</Button>);
|
|
48
|
-
expect(screen.getByRole("button", { name: "Wide" })).toHaveClass("w-full");
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("disables and blocks clicks when disabled", async () => {
|
|
52
|
-
const onClick = vi.fn();
|
|
53
|
-
render(
|
|
54
|
-
<Button disabled onClick={onClick}>
|
|
55
|
-
Disabled
|
|
56
|
-
</Button>,
|
|
57
|
-
);
|
|
58
|
-
const btn = screen.getByRole("button", { name: "Disabled" });
|
|
59
|
-
expect(btn).toBeDisabled();
|
|
60
|
-
await userEvent.click(btn);
|
|
61
|
-
expect(onClick).not.toHaveBeenCalled();
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("sets a computed text color and background for customColor", () => {
|
|
65
|
-
render(<Button customColor="#111111">Custom</Button>);
|
|
66
|
-
const btn = screen.getByRole("button", { name: "Custom" });
|
|
67
|
-
expect(btn).toHaveStyle({ backgroundColor: "#111111" });
|
|
68
|
-
// Text is the light candidate for a very dark background.
|
|
69
|
-
expect(btn).toHaveStyle({ color: "#ffffff" });
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("forwards an onClick handler and fires it", async () => {
|
|
73
|
-
const onClick = vi.fn();
|
|
74
|
-
render(<Button onClick={onClick}>Click</Button>);
|
|
75
|
-
await userEvent.click(screen.getByRole("button", { name: "Click" }));
|
|
76
|
-
expect(onClick).toHaveBeenCalledTimes(1);
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("applies button styles onto a single child when asChild is set", () => {
|
|
80
|
-
render(
|
|
81
|
-
<Button asChild>
|
|
82
|
-
<a href="#pricing">Pricing link</a>
|
|
83
|
-
</Button>,
|
|
84
|
-
);
|
|
85
|
-
const link = screen.getByRole("link", { name: "Pricing link" });
|
|
86
|
-
expect(link.tagName).toBe("A");
|
|
87
|
-
expect(link).toHaveClass("bg-primary");
|
|
88
|
-
// No wrapping <button> is rendered.
|
|
89
|
-
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
|
90
|
-
});
|
|
91
|
-
});
|