@jimmy_harika/websitekit 0.3.1 → 0.5.0

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.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * NavBlock — the site header as a section. Server-safe: desktop links are pure
3
+ * markup with CSS-only hover/focus dropdowns; only the mobile hamburger menu is
4
+ * a client island (./nav-menu). Elegant, letterspaced small-caps via tokens.
5
+ *
6
+ * `transparent` floats the nav over the NEXT section (absolute, light-on-dark
7
+ * text — pair it with a full-bleed hero). `sticky` pins an opaque nav to the
8
+ * top on scroll (ignored when transparent).
9
+ */
10
+ import { renderImage, renderText, type EditPrimitives } from "../editable";
11
+ import { NavMenu } from "./nav-menu";
12
+ import { BG, BODY, HAIRLINE, HEADING, TEXT } from "./primitives";
13
+
14
+ export interface NavLinkItem {
15
+ label: string;
16
+ href: string;
17
+ children?: { label: string; href: string }[];
18
+ }
19
+
20
+ export interface NavBlockProps {
21
+ logoText?: string;
22
+ logoImage?: string;
23
+ links?: NavLinkItem[];
24
+ layout?: "split-center" | "left" | "center";
25
+ /** Float over the next section (light text — pair with a photo hero). */
26
+ transparent?: boolean;
27
+ /** Pin an opaque nav to the top on scroll (ignored when transparent). */
28
+ sticky?: boolean;
29
+ edit?: EditPrimitives;
30
+ }
31
+
32
+ export function NavBlock({
33
+ logoText,
34
+ logoImage,
35
+ links = [],
36
+ layout = "left",
37
+ transparent = false,
38
+ sticky = false,
39
+ edit,
40
+ }: NavBlockProps) {
41
+ const color = transparent ? "#ffffff" : TEXT;
42
+
43
+ const logo = logoImage ? (
44
+ renderImage(edit, {
45
+ field: "logoImage",
46
+ src: logoImage,
47
+ alt: logoText ?? "Logo",
48
+ className: "h-8 w-auto object-contain",
49
+ style: transparent ? { filter: "brightness(0) invert(1)" } : undefined,
50
+ })
51
+ ) : (
52
+ renderText(edit, {
53
+ field: "logoText",
54
+ value: logoText ?? "",
55
+ as: "span",
56
+ placeholder: "Studio Name",
57
+ className: "text-[15px] uppercase tracking-[0.35em]",
58
+ style: { fontFamily: HEADING, color, fontWeight: 400 },
59
+ })
60
+ );
61
+
62
+ const linkList = (items: NavLinkItem[]) => (
63
+ <ul className="flex items-center gap-8">
64
+ {items.map((link, i) => (
65
+ <li key={i} className="group relative">
66
+ <a
67
+ href={link.href || "#"}
68
+ className="text-[11px] uppercase tracking-[0.25em] transition-opacity hover:opacity-70"
69
+ style={{ fontFamily: BODY, color }}
70
+ >
71
+ {link.label}
72
+ </a>
73
+ {link.children && link.children.length > 0 && (
74
+ <div className="invisible absolute left-1/2 top-full z-30 -translate-x-1/2 pt-3 opacity-0 transition-all duration-200 group-hover:visible group-hover:opacity-100 group-focus-within:visible group-focus-within:opacity-100">
75
+ <div
76
+ className="flex min-w-[180px] flex-col gap-2.5 px-5 py-4"
77
+ style={{
78
+ background: BG,
79
+ border: `1px solid ${HAIRLINE}`,
80
+ borderRadius: "var(--pb-radius, 4px)",
81
+ }}
82
+ >
83
+ {link.children.map((child, j) => (
84
+ <a
85
+ key={j}
86
+ href={child.href || "#"}
87
+ className="whitespace-nowrap text-[11px] uppercase tracking-[0.2em] transition-opacity hover:opacity-70"
88
+ style={{ fontFamily: BODY, color: TEXT }}
89
+ >
90
+ {child.label}
91
+ </a>
92
+ ))}
93
+ </div>
94
+ </div>
95
+ )}
96
+ </li>
97
+ ))}
98
+ </ul>
99
+ );
100
+
101
+ // split-center: links split around a centered logo. Odd counts weigh left.
102
+ const splitAt = Math.ceil(links.length / 2);
103
+ const inner =
104
+ layout === "center" ? (
105
+ <div className="flex flex-col items-center gap-5">
106
+ {logo}
107
+ <nav aria-label="Site" className="hidden md:block">
108
+ {linkList(links)}
109
+ </nav>
110
+ </div>
111
+ ) : layout === "split-center" ? (
112
+ <div className="flex items-center justify-between gap-6">
113
+ <nav aria-label="Site" className="hidden flex-1 justify-end md:flex">
114
+ {linkList(links.slice(0, splitAt))}
115
+ </nav>
116
+ <div className="shrink-0 md:px-6">{logo}</div>
117
+ <nav aria-label="Site" className="hidden flex-1 md:flex">
118
+ {linkList(links.slice(splitAt))}
119
+ </nav>
120
+ </div>
121
+ ) : (
122
+ <div className="flex items-center justify-between gap-6">
123
+ {logo}
124
+ <nav aria-label="Site" className="hidden md:block">
125
+ {linkList(links)}
126
+ </nav>
127
+ </div>
128
+ );
129
+
130
+ const position = transparent
131
+ ? "absolute inset-x-0 top-0 z-40"
132
+ : sticky
133
+ ? "sticky top-0 z-40"
134
+ : "relative";
135
+
136
+ return (
137
+ <header
138
+ className={`${position} w-full px-6 py-5 sm:px-10`}
139
+ style={transparent ? undefined : { background: BG, borderBottom: `1px solid ${HAIRLINE}` }}
140
+ >
141
+ <div className="relative mx-auto max-w-6xl">
142
+ {inner}
143
+ <div className="absolute right-0 top-1/2 -translate-y-1/2">
144
+ <NavMenu links={links} logoText={logoText} light={transparent} />
145
+ </div>
146
+ </div>
147
+ </header>
148
+ );
149
+ }
@@ -22,6 +22,16 @@ export const TINT = "color-mix(in srgb, var(--pb-color-text, #1a1a1a) 4%, transp
22
22
  /** A hairline border that adapts to the theme. */
23
23
  export const HAIRLINE = "color-mix(in srgb, var(--pb-color-text, #1a1a1a) 12%, transparent)";
24
24
 
25
+ /* ── v0.5 theme treatments (computed by themeVars from buttonStyle/headingCase) ── */
26
+ /** Button surface — primary when the theme is "filled", transparent when "outline". */
27
+ export const BTN_BG = "var(--pb-btn-bg, var(--pb-color-primary, #1a1a1a))";
28
+ /** Button label color matching BTN_BG. */
29
+ export const BTN_FG = "var(--pb-btn-fg, var(--pb-color-bg, #ffffff))";
30
+ /** Button border matching the theme's button treatment. */
31
+ export const BTN_BORDER = "var(--pb-btn-border, var(--pb-color-primary, #1a1a1a))";
32
+ /** Heading text-transform driven by the theme ("none" | "uppercase"). */
33
+ export const HEADING_CASE = "var(--pb-heading-case, none)" as CSSProperties["textTransform"];
34
+
25
35
  /** One book, as a block sees it — purely presentational. The app resolves live
26
36
  * data (e.g. an author's real books) into this shape before render. */
27
37
  export interface BookItem {
@@ -74,6 +84,50 @@ export function Eyebrow({ children }: { children?: ReactNode }) {
74
84
  );
75
85
  }
76
86
 
87
+ /**
88
+ * Theme-driven button link (v0.5). Reads the `--pb-btn-*` vars so one component
89
+ * follows the theme's `buttonStyle` (filled/outline). `outline` forces the
90
+ * outline treatment regardless of theme (e.g. teaser cards).
91
+ */
92
+ export function TokenButton({
93
+ href,
94
+ children,
95
+ outline = false,
96
+ }: {
97
+ href: string;
98
+ children: ReactNode;
99
+ outline?: boolean;
100
+ }) {
101
+ const external = href?.startsWith("http");
102
+ return (
103
+ <a
104
+ href={href || "#"}
105
+ target={external ? "_blank" : undefined}
106
+ rel={external ? "noopener noreferrer" : undefined}
107
+ className="inline-flex items-center justify-center px-6 py-3 text-[12px] font-medium uppercase tracking-[0.2em] transition-opacity hover:opacity-80"
108
+ style={
109
+ outline
110
+ ? {
111
+ background: "transparent",
112
+ color: TEXT,
113
+ border: `1px solid ${ACCENT}`,
114
+ borderRadius: RADIUS,
115
+ fontFamily: BODY,
116
+ }
117
+ : {
118
+ background: BTN_BG,
119
+ color: BTN_FG,
120
+ border: `1px solid ${BTN_BORDER}`,
121
+ borderRadius: RADIUS,
122
+ fontFamily: BODY,
123
+ }
124
+ }
125
+ >
126
+ {children}
127
+ </a>
128
+ );
129
+ }
130
+
77
131
  /** Pill / link button in the theme accent — used for book buy links + CTAs. */
78
132
  export function AccentLink({
79
133
  href,
@@ -0,0 +1,74 @@
1
+ "use client";
2
+
3
+ /**
4
+ * <Reveal> — scroll-reveal wrapper (v0.5). Fades + slides a section in the
5
+ * first time ~15% of it enters the viewport. Children render immediately (the
6
+ * wrapper is a plain block div — no layout shift, only opacity/transform).
7
+ * Respects `prefers-reduced-motion: reduce` and degrades to a static render
8
+ * when IntersectionObserver is unavailable. The production PageRenderer wraps
9
+ * sections in this when `doc.theme.reveal === true`; the editor canvas never
10
+ * does (reveals would fight inline editing).
11
+ */
12
+ import { useEffect, useRef, useState, type ReactNode } from "react";
13
+
14
+ export interface RevealProps {
15
+ children: ReactNode;
16
+ }
17
+
18
+ export function Reveal({ children }: RevealProps) {
19
+ const ref = useRef<HTMLDivElement>(null);
20
+ const [shown, setShown] = useState(false);
21
+ // Becomes true only when we know we can (and should) animate — keeps the
22
+ // server render static so no-JS/reduced-motion visitors see content.
23
+ const [armed, setArmed] = useState(false);
24
+
25
+ useEffect(() => {
26
+ const el = ref.current;
27
+ if (!el) return;
28
+ const reduced =
29
+ typeof window.matchMedia === "function" &&
30
+ window.matchMedia("(prefers-reduced-motion: reduce)").matches;
31
+ if (reduced || typeof IntersectionObserver === "undefined") {
32
+ setShown(true);
33
+ return;
34
+ }
35
+ // Already in view at hydration (above the fold): stay static instead of
36
+ // hiding then re-animating content the visitor is already reading.
37
+ const rect = el.getBoundingClientRect();
38
+ if (rect.top < window.innerHeight && rect.bottom > 0) {
39
+ setShown(true);
40
+ return;
41
+ }
42
+ // Sections taller than the viewport can never reach a 0.15 ratio — scale
43
+ // the threshold down so they still reveal.
44
+ const threshold = Math.min(0.15, (window.innerHeight / Math.max(el.offsetHeight, 1)) * 0.15);
45
+ setArmed(true);
46
+ const io = new IntersectionObserver(
47
+ (entries) => {
48
+ for (const entry of entries) {
49
+ if (entry.isIntersecting) {
50
+ setShown(true);
51
+ io.disconnect();
52
+ }
53
+ }
54
+ },
55
+ { threshold },
56
+ );
57
+ io.observe(el);
58
+ return () => io.disconnect();
59
+ }, []);
60
+
61
+ const hidden = armed && !shown;
62
+ return (
63
+ <div
64
+ ref={ref}
65
+ style={{
66
+ opacity: hidden ? 0 : 1,
67
+ transform: hidden ? "translateY(24px)" : "none",
68
+ transition: "opacity 500ms ease-out, transform 500ms ease-out",
69
+ }}
70
+ >
71
+ {children}
72
+ </div>
73
+ );
74
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * SiteFooterBlock — site footer as a section. Variants:
3
+ * - "mega": Instagram thumb strip + handle, nav, socials, bottom bar
4
+ * - "minimal": one-line © + socials
5
+ * - "centered": nav + socials + © stacked and centered
6
+ * Server-safe; the optional back-to-top control is a micro client island.
7
+ * Social links render as letterspaced small-caps text (icon sets don't cover
8
+ * every network consistently — type does, and matches the aesthetic).
9
+ */
10
+ import { BackToTop } from "./back-to-top";
11
+ import { BODY, HAIRLINE, MUTED, TEXT } from "./primitives";
12
+
13
+ export type SiteFooterSocialKind =
14
+ | "instagram"
15
+ | "facebook"
16
+ | "pinterest"
17
+ | "tiktok"
18
+ | "x"
19
+ | "email";
20
+
21
+ export interface SiteFooterSocial {
22
+ kind: SiteFooterSocialKind;
23
+ href: string;
24
+ }
25
+
26
+ export interface SiteFooterNavLink {
27
+ label: string;
28
+ href: string;
29
+ }
30
+
31
+ export interface SiteFooterBlockProps {
32
+ variant?: "mega" | "minimal" | "centered";
33
+ instagramHandle?: string;
34
+ /** Thumbnail strip (mega variant) — recent-work square crops. */
35
+ instagramImages?: string[];
36
+ social?: SiteFooterSocial[];
37
+ nav?: SiteFooterNavLink[];
38
+ copyright?: string;
39
+ backToTop?: boolean;
40
+ }
41
+
42
+ const SOCIAL_LABEL: Record<SiteFooterSocialKind, string> = {
43
+ instagram: "Instagram",
44
+ facebook: "Facebook",
45
+ pinterest: "Pinterest",
46
+ tiktok: "TikTok",
47
+ x: "X",
48
+ email: "Email",
49
+ };
50
+
51
+ function SocialLinks({ social }: { social: SiteFooterSocial[] }) {
52
+ if (social.length === 0) return null;
53
+ return (
54
+ <ul className="flex flex-wrap items-center justify-center gap-x-6 gap-y-2">
55
+ {social.map((s, i) => (
56
+ <li key={i}>
57
+ <a
58
+ href={s.href || "#"}
59
+ target={s.kind === "email" ? undefined : "_blank"}
60
+ rel={s.kind === "email" ? undefined : "noopener noreferrer"}
61
+ className="text-[11px] uppercase tracking-[0.25em] transition-opacity hover:opacity-70"
62
+ style={{ fontFamily: BODY, color: TEXT }}
63
+ >
64
+ {SOCIAL_LABEL[s.kind]}
65
+ </a>
66
+ </li>
67
+ ))}
68
+ </ul>
69
+ );
70
+ }
71
+
72
+ function NavLinks({ nav }: { nav: SiteFooterNavLink[] }) {
73
+ if (nav.length === 0) return null;
74
+ return (
75
+ <ul className="flex flex-wrap items-center justify-center gap-x-8 gap-y-2">
76
+ {nav.map((link, i) => (
77
+ <li key={i}>
78
+ <a
79
+ href={link.href || "#"}
80
+ className="text-[11px] uppercase tracking-[0.25em] transition-opacity hover:opacity-70"
81
+ style={{ fontFamily: BODY, color: TEXT }}
82
+ >
83
+ {link.label}
84
+ </a>
85
+ </li>
86
+ ))}
87
+ </ul>
88
+ );
89
+ }
90
+
91
+ function Copyright({ text }: { text?: string }) {
92
+ return (
93
+ <p className="text-[11px] tracking-[0.1em]" style={{ fontFamily: BODY, color: MUTED }}>
94
+ {text || `© ${new Date().getFullYear()}`}
95
+ </p>
96
+ );
97
+ }
98
+
99
+ export function SiteFooterBlock({
100
+ variant = "centered",
101
+ instagramHandle,
102
+ instagramImages = [],
103
+ social = [],
104
+ nav = [],
105
+ copyright,
106
+ backToTop = false,
107
+ }: SiteFooterBlockProps) {
108
+ if (variant === "minimal") {
109
+ return (
110
+ <footer
111
+ className="w-full px-6 py-8 sm:px-10"
112
+ style={{ borderTop: `1px solid ${HAIRLINE}` }}
113
+ >
114
+ <div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-4 sm:flex-row">
115
+ <Copyright text={copyright} />
116
+ <div className="flex items-center gap-6">
117
+ <SocialLinks social={social} />
118
+ {backToTop && <BackToTop />}
119
+ </div>
120
+ </div>
121
+ </footer>
122
+ );
123
+ }
124
+
125
+ if (variant === "mega") {
126
+ const igHref =
127
+ social.find((s) => s.kind === "instagram")?.href ||
128
+ (instagramHandle ? `https://instagram.com/${instagramHandle.replace(/^@/, "")}` : "#");
129
+ return (
130
+ <footer className="w-full" style={{ borderTop: `1px solid ${HAIRLINE}` }}>
131
+ {instagramImages.length > 0 && (
132
+ <a
133
+ href={igHref}
134
+ target="_blank"
135
+ rel="noopener noreferrer"
136
+ aria-label={instagramHandle ? `Instagram ${instagramHandle}` : "Instagram"}
137
+ className="grid grid-cols-3 sm:grid-cols-6"
138
+ >
139
+ {instagramImages.slice(0, 6).map((src, i) => (
140
+ // eslint-disable-next-line @next/next/no-img-element
141
+ <img
142
+ key={i}
143
+ src={src}
144
+ alt=""
145
+ loading="lazy"
146
+ className={`aspect-square w-full object-cover transition-opacity hover:opacity-90 ${i >= 3 ? "hidden sm:block" : ""}`}
147
+ />
148
+ ))}
149
+ </a>
150
+ )}
151
+ <div className="mx-auto flex max-w-6xl flex-col items-center gap-8 px-6 py-14 sm:px-10">
152
+ {instagramHandle && (
153
+ <a
154
+ href={igHref}
155
+ target="_blank"
156
+ rel="noopener noreferrer"
157
+ className="text-[13px] uppercase tracking-[0.35em] transition-opacity hover:opacity-70"
158
+ style={{ fontFamily: BODY, color: TEXT }}
159
+ >
160
+ @{instagramHandle.replace(/^@/, "")}
161
+ </a>
162
+ )}
163
+ <NavLinks nav={nav} />
164
+ <SocialLinks social={social} />
165
+ </div>
166
+ <div className="px-6 py-5 sm:px-10" style={{ borderTop: `1px solid ${HAIRLINE}` }}>
167
+ <div className="mx-auto flex max-w-6xl flex-col items-center justify-between gap-3 sm:flex-row">
168
+ <Copyright text={copyright} />
169
+ {backToTop && <BackToTop />}
170
+ </div>
171
+ </div>
172
+ </footer>
173
+ );
174
+ }
175
+
176
+ // centered
177
+ return (
178
+ <footer
179
+ className="w-full px-6 py-14 sm:px-10"
180
+ style={{ borderTop: `1px solid ${HAIRLINE}` }}
181
+ >
182
+ <div className="mx-auto flex max-w-4xl flex-col items-center gap-7 text-center">
183
+ <NavLinks nav={nav} />
184
+ <SocialLinks social={social} />
185
+ <Copyright text={copyright} />
186
+ {backToTop && <BackToTop />}
187
+ </div>
188
+ </footer>
189
+ );
190
+ }
@@ -0,0 +1,200 @@
1
+ "use client";
2
+
3
+ /**
4
+ * <Slider> — the shared client island behind every sliding block (v0.5:
5
+ * hero-slideshow, testimonial-slider). Owns index state + next/prev + optional
6
+ * autoplay; slides are pre-rendered ReactNodes (server components pass them
7
+ * in), so blocks stay server-safe and only this primitive ships JS.
8
+ *
9
+ * Variants:
10
+ * - "fade": slides stacked, crossfade. With `overlay` set the overlay defines
11
+ * the container height (slides all absolute behind it — hero-style);
12
+ * without it the ACTIVE slide is in-flow and defines the height.
13
+ * - "track": center-dominant filmstrip — fixed-width slides on a translating
14
+ * flex track, neighbors peeking on both sides.
15
+ *
16
+ * Accessibility: arrows are real <button>s (keyboard-operable), slides are
17
+ * labelled groups, inactive fade slides are aria-hidden. Autoplay is disabled
18
+ * under `prefers-reduced-motion` and stops on manual navigation.
19
+ */
20
+ import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
21
+
22
+ export interface SliderProps {
23
+ slides: ReactNode[];
24
+ variant?: "fade" | "track";
25
+ /** Milliseconds between auto-advances. 0/undefined = no autoplay. */
26
+ autoplayMs?: number;
27
+ arrows?: "overlay" | "below" | "none";
28
+ /** Extra classes on the root (size fade sliders here, e.g. `min-h-[70vh]`). */
29
+ className?: string;
30
+ /** Static content rendered above fade slides (defines height — hero copy). */
31
+ overlay?: ReactNode;
32
+ /** CSS length of each track-mode slide, e.g. "min(72vw, 880px)". */
33
+ slideWidth?: string;
34
+ /** Accessible name for the carousel region. */
35
+ label?: string;
36
+ }
37
+
38
+ const TRACK_GAP_PX = 24;
39
+
40
+ function usePrefersReducedMotion(): boolean {
41
+ const [reduced, setReduced] = useState(false);
42
+ useEffect(() => {
43
+ if (typeof window.matchMedia !== "function") return;
44
+ const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
45
+ setReduced(mq.matches);
46
+ const onChange = (e: MediaQueryListEvent) => setReduced(e.matches);
47
+ mq.addEventListener("change", onChange);
48
+ return () => mq.removeEventListener("change", onChange);
49
+ }, []);
50
+ return reduced;
51
+ }
52
+
53
+ export function Slider({
54
+ slides,
55
+ variant = "fade",
56
+ autoplayMs,
57
+ arrows = "overlay",
58
+ className = "",
59
+ overlay,
60
+ slideWidth = "min(72vw, 880px)",
61
+ label = "Slideshow",
62
+ }: SliderProps) {
63
+ const count = slides.length;
64
+ const [index, setIndex] = useState(0);
65
+ const interactedRef = useRef(false);
66
+ const reduced = usePrefersReducedMotion();
67
+
68
+ const go = (delta: number, manual = false) => {
69
+ if (manual) interactedRef.current = true;
70
+ setIndex((i) => (i + delta + count) % count);
71
+ };
72
+
73
+ useEffect(() => {
74
+ if (!autoplayMs || autoplayMs <= 0 || count < 2 || reduced) return;
75
+ const t = window.setInterval(() => {
76
+ if (!interactedRef.current) setIndex((i) => (i + 1) % count);
77
+ }, autoplayMs);
78
+ return () => window.clearInterval(t);
79
+ }, [autoplayMs, count, reduced]);
80
+
81
+ if (count === 0) return null;
82
+ const duration = reduced ? "0ms" : undefined;
83
+ const showArrows = count > 1 && arrows !== "none";
84
+
85
+ const arrowButtons = (overlayStyle: boolean) => (
86
+ <>
87
+ <ArrowButton dir="prev" overlay={overlayStyle} onClick={() => go(-1, true)} />
88
+ <ArrowButton dir="next" overlay={overlayStyle} onClick={() => go(1, true)} />
89
+ </>
90
+ );
91
+
92
+ if (variant === "track") {
93
+ const w = `(${slideWidth})`;
94
+ return (
95
+ <section aria-roledescription="carousel" aria-label={label} className={className}>
96
+ <div className="overflow-hidden">
97
+ <div
98
+ className="flex transition-transform duration-500 ease-out"
99
+ style={{
100
+ gap: `${TRACK_GAP_PX}px`,
101
+ transform: `translateX(calc(50% - ${w} / 2 - ${index} * (${w} + ${TRACK_GAP_PX}px)))`,
102
+ transitionDuration: duration,
103
+ }}
104
+ >
105
+ {slides.map((slide, i) => (
106
+ <div
107
+ key={i}
108
+ role="group"
109
+ aria-roledescription="slide"
110
+ aria-label={`${i + 1} of ${count}`}
111
+ className="shrink-0 transition-opacity duration-500"
112
+ style={{ width: slideWidth, opacity: i === index ? 1 : 0.45, transitionDuration: duration }}
113
+ >
114
+ {slide}
115
+ </div>
116
+ ))}
117
+ </div>
118
+ </div>
119
+ {showArrows && (
120
+ <div className="mt-6 flex justify-center gap-3">{arrowButtons(false)}</div>
121
+ )}
122
+ </section>
123
+ );
124
+ }
125
+
126
+ // fade
127
+ const sizedByOverlay = overlay != null;
128
+ return (
129
+ <section
130
+ aria-roledescription="carousel"
131
+ aria-label={label}
132
+ className={`relative overflow-hidden ${className}`}
133
+ >
134
+ {slides.map((slide, i) => {
135
+ const active = i === index;
136
+ const style: CSSProperties = {
137
+ opacity: active ? 1 : 0,
138
+ transition: "opacity 700ms ease-out",
139
+ transitionDuration: duration,
140
+ pointerEvents: active ? undefined : "none",
141
+ };
142
+ return (
143
+ <div
144
+ key={i}
145
+ role="group"
146
+ aria-roledescription="slide"
147
+ aria-label={`${i + 1} of ${count}`}
148
+ aria-hidden={!active}
149
+ className={sizedByOverlay || !active ? "absolute inset-0" : "relative"}
150
+ style={style}
151
+ >
152
+ {slide}
153
+ </div>
154
+ );
155
+ })}
156
+ {overlay != null && <div className="relative z-10">{overlay}</div>}
157
+ {showArrows && arrows === "overlay" && (
158
+ <div className="pointer-events-none absolute inset-x-0 top-1/2 z-20 flex -translate-y-1/2 justify-between px-4">
159
+ {arrowButtons(true)}
160
+ </div>
161
+ )}
162
+ {showArrows && arrows === "below" && (
163
+ <div className="relative z-10 mt-6 flex justify-center gap-3">{arrowButtons(false)}</div>
164
+ )}
165
+ </section>
166
+ );
167
+ }
168
+
169
+ function ArrowButton({
170
+ dir,
171
+ overlay,
172
+ onClick,
173
+ }: {
174
+ dir: "prev" | "next";
175
+ overlay: boolean;
176
+ onClick: () => void;
177
+ }) {
178
+ return (
179
+ <button
180
+ type="button"
181
+ onClick={onClick}
182
+ aria-label={dir === "prev" ? "Previous slide" : "Next slide"}
183
+ className={
184
+ overlay
185
+ ? "pointer-events-auto flex size-10 items-center justify-center rounded-full bg-black/30 text-lg text-white transition hover:bg-black/50"
186
+ : "flex size-10 items-center justify-center rounded-full border text-lg transition hover:opacity-70"
187
+ }
188
+ style={
189
+ overlay
190
+ ? undefined
191
+ : {
192
+ borderColor: "color-mix(in srgb, var(--pb-color-text, #1a1a1a) 25%, transparent)",
193
+ color: "var(--pb-color-text, #1a1a1a)",
194
+ }
195
+ }
196
+ >
197
+ <span aria-hidden>{dir === "prev" ? "‹" : "›"}</span>
198
+ </button>
199
+ );
200
+ }