diff --git a/apps/registry/components/component-preview/component-preview.tsx b/apps/registry/components/component-preview/component-preview.tsx
index d3bf3011..272e463e 100644
--- a/apps/registry/components/component-preview/component-preview.tsx
+++ b/apps/registry/components/component-preview/component-preview.tsx
@@ -2139,7 +2139,7 @@ function SpinnerPreview() {
}
// eslint-disable-next-line max-lines-per-function -- Switch statement mapping all components
-export function ComponentPreview({ componentName }: ComponentPreviewProps) {
+function getComponentPreview(componentName: string): React.ReactNode {
switch (componentName) {
case "accordion":
return ;
@@ -2483,3 +2483,7 @@ export function ComponentPreview({ componentName }: ComponentPreviewProps) {
return
Preview not available
;
}
}
+
+export function ComponentPreview({ componentName }: ComponentPreviewProps) {
+ return getComponentPreview(componentName);
+}
diff --git a/apps/registry/components/header/header.tsx b/apps/registry/components/header/header.tsx
index 54842b4b..7ae762cc 100644
--- a/apps/registry/components/header/header.tsx
+++ b/apps/registry/components/header/header.tsx
@@ -13,7 +13,7 @@ type Registry = {
};
export function Header() {
- const router = useRouter();
+ const { push } = useRouter();
const registry = registryData as Registry;
const navItems = [
@@ -42,7 +42,7 @@ export function Header() {
groupHeading="Components"
items={searchItems}
onSelect={(item) => {
- router.push(`/components/${item.id}`);
+ push(`/components/${item.id}`);
}}
searchPlaceholder="Search components..."
/>
diff --git a/apps/registry/registry.json b/apps/registry/registry.json
index 029360c6..c174df5e 100644
--- a/apps/registry/registry.json
+++ b/apps/registry/registry.json
@@ -4492,5 +4492,5 @@
}
],
"version": "0.2.1",
- "generatedAt": "2026-05-10T19:14:31.709Z"
+ "generatedAt": "2026-05-15T12:39:44.318Z"
}
diff --git a/apps/registry/registry/default/canvas-shell/canvas-shell.tsx b/apps/registry/registry/default/canvas-shell/canvas-shell.tsx
index 85e689ce..812fbfbf 100644
--- a/apps/registry/registry/default/canvas-shell/canvas-shell.tsx
+++ b/apps/registry/registry/default/canvas-shell/canvas-shell.tsx
@@ -111,6 +111,15 @@ function getSafeAreaStyle(
} satisfies CanvasShellSafeAreaStyle;
}
+function getCanvasShellContentStyle(): CSSProperties {
+ return {
+ paddingBottom: "var(--canvas-shell-safe-bottom)",
+ paddingLeft: "var(--canvas-shell-safe-left)",
+ paddingRight: "var(--canvas-shell-safe-right)",
+ paddingTop: "var(--canvas-shell-safe-top)",
+ } satisfies CSSProperties;
+}
+
const hasChromeContent = Boolean;
type CanvasShellChromeAfterProps = Pick<
@@ -235,10 +244,15 @@ function renderLegacyCanvasShell(
);
}
-function renderFloatingContent(
- children: ReactNode,
- contentStyle: CSSProperties,
-) {
+type CanvasShellFloatingContentProps = {
+ children?: ReactNode;
+ contentStyle: CSSProperties;
+};
+
+function CanvasShellFloatingContent({
+ children,
+ contentStyle,
+}: CanvasShellFloatingContentProps) {
return (
- {renderFloatingContent(children, contentStyle)}
+
+ {children}
+
{
- if (!api) {
- return;
- }
-
- setCanScrollPrevious(api.canScrollPrev());
- setCanScrollNext(api.canScrollNext());
- }, []);
-
const scrollPrevious = useCallback(() => {
api?.scrollPrev();
}, [api]);
@@ -117,19 +108,28 @@ function useCarouselLogic({
return;
}
- api.on("reInit", onSelect);
- api.on("select", onSelect);
+ const updateScrollButtons = (currentApi: CarouselApi) => {
+ if (!currentApi) {
+ return;
+ }
+
+ setCanScrollPrevious(currentApi.canScrollPrev());
+ setCanScrollNext(currentApi.canScrollNext());
+ };
+
+ api.on("reInit", updateScrollButtons);
+ api.on("select", updateScrollButtons);
const rafId = requestAnimationFrame(() => {
- onSelect(api);
+ updateScrollButtons(api);
});
return () => {
- api?.off("select", onSelect);
- api?.off("reInit", onSelect);
+ api?.off("select", updateScrollButtons);
+ api?.off("reInit", updateScrollButtons);
cancelAnimationFrame(rafId);
};
- }, [api, onSelect]);
+ }, [api]);
return {
api,
diff --git a/apps/registry/registry/default/code-block/code-block.tsx b/apps/registry/registry/default/code-block/code-block.tsx
index b4e3e961..35c1d40f 100644
--- a/apps/registry/registry/default/code-block/code-block.tsx
+++ b/apps/registry/registry/default/code-block/code-block.tsx
@@ -1,9 +1,10 @@
"use client";
-import { type ReactNode, useEffect, useRef, useState } from "react";
+import { Children, isValidElement, useState } from "react";
import { Check, Copy } from "lucide-react";
import { useTheme } from "next-themes";
+import type { ReactNode, WheelEvent } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import {
oneDark,
@@ -14,33 +15,25 @@ import { cn } from "@vllnt/ui";
import { Button } from "@vllnt/ui";
type CodeBlockProps = {
- children: ReactNode;
+ children?: ReactNode;
className?: string;
+ code?: string;
language?: string;
showLanguage?: boolean;
};
-function extractTextFromChildren(children: ReactNode): string {
- if (typeof children === "string") {
- return children;
- }
- if (typeof children === "number") {
- return String(children);
- }
- if (Array.isArray(children)) {
- return children.map(extractTextFromChildren).join("");
- }
- if (
- children &&
- typeof children === "object" &&
- "props" in children &&
- children.props &&
- typeof children.props === "object" &&
- "children" in children.props
- ) {
- return extractTextFromChildren(children.props.children as ReactNode);
- }
- return String(children ?? "");
+export function extractTextFromChildren(children: ReactNode): string {
+ return Children.toArray(children)
+ .map((child) => {
+ if (typeof child === "string" || typeof child === "number") {
+ return String(child);
+ }
+ if (isValidElement<{ children?: ReactNode }>(child)) {
+ return extractTextFromChildren(child.props.children);
+ }
+ return "";
+ })
+ .join("");
}
function findScrollableParent(
@@ -51,9 +44,19 @@ function findScrollableParent(
return findScrollableParent(element.parentElement);
}
+function redirectVerticalWheel(event: WheelEvent): void {
+ if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
+ const scrollable = findScrollableParent(event.currentTarget);
+ if (scrollable) {
+ scrollable.scrollTop += event.deltaY;
+ event.preventDefault();
+ }
+}
+
export function CodeBlock({
children,
className,
+ code: codeProperty,
language = "typescript",
showLanguage = false,
}: CodeBlockProps) {
@@ -63,28 +66,7 @@ export function CodeBlock({
const resolvedTheme = theme === "system" ? systemTheme : theme;
const isDark = resolvedTheme !== "light";
const codeStyle = isDark ? oneDark : oneLight;
- const code = extractTextFromChildren(children);
-
- const scrollRef = useRef(null);
-
- useEffect(() => {
- const element = scrollRef.current;
- if (!element) return;
-
- const onWheel = (event: WheelEvent) => {
- if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
- const scrollable = findScrollableParent(element);
- if (scrollable) {
- scrollable.scrollTop += event.deltaY;
- event.preventDefault();
- }
- };
-
- element.addEventListener("wheel", onWheel, { passive: false });
- return () => {
- element.removeEventListener("wheel", onWheel);
- };
- }, []);
+ const code = codeProperty ?? extractTextFromChildren(children);
const handleCopy = async () => {
await navigator.clipboard.writeText(code);
@@ -103,7 +85,7 @@ export function CodeBlock({
>
{
+ if (typeof child === "string" || typeof child === "number") {
+ return String(child);
+ }
+ if (isValidElement<{ children?: ReactNode }>(child)) {
+ return extractTextFromChildren(child.props.children);
+ }
+ return "";
+ })
+ .join("");
+}
+
export function CodePlayground({
children,
+ code: codeProperty,
description,
filename,
highlightLines = EMPTY_HIGHLIGHT_LINES,
@@ -48,7 +64,7 @@ export function CodePlayground({
title,
}: CodePlaygroundProps) {
const [copied, setCopied] = useState(false);
- const code = typeof children === "string" ? children : "";
+ const code = codeProperty ?? extractTextFromChildren(children);
const lines = code.split("\n");
const handleCopy = async () => {
diff --git a/apps/registry/registry/default/completion-dialog/completion-dialog.tsx b/apps/registry/registry/default/completion-dialog/completion-dialog.tsx
index f3c4f334..62dc4a38 100644
--- a/apps/registry/registry/default/completion-dialog/completion-dialog.tsx
+++ b/apps/registry/registry/default/completion-dialog/completion-dialog.tsx
@@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useRef } from "react";
import type { ReactNode } from "react";
+import { useDocumentEventListener } from "@vllnt/ui";
import { cn } from "@vllnt/ui";
import { Button } from "@vllnt/ui";
@@ -152,13 +153,7 @@ function CompletionDialogImpl({
[isOpen, onClose, onConfirm, onCancel, confirmShortcut, cancelShortcut],
);
- useEffect(() => {
- if (!isOpen) return;
- document.addEventListener("keydown", handleKeyDown, true);
- return () => {
- document.removeEventListener("keydown", handleKeyDown, true);
- };
- }, [isOpen, handleKeyDown]);
+ useDocumentEventListener("keydown", handleKeyDown, true);
if (!isOpen) return null;
diff --git a/apps/registry/registry/default/content-intro/content-intro.tsx b/apps/registry/registry/default/content-intro/content-intro.tsx
index 0996a51b..acd60e18 100644
--- a/apps/registry/registry/default/content-intro/content-intro.tsx
+++ b/apps/registry/registry/default/content-intro/content-intro.tsx
@@ -1,9 +1,10 @@
"use client";
-import { memo, useCallback, useEffect } from "react";
+import { memo, useCallback } from "react";
import type { ReactNode } from "react";
+import { useDocumentEventListener } from "@vllnt/ui";
import { cn } from "@vllnt/ui";
import { Button } from "@vllnt/ui";
@@ -25,6 +26,8 @@ export type ContentIntroProps = {
completedSections: Set;
/** Estimated time to complete */
estimatedTime: string;
+ /** Rendered introduction content */
+ introContent?: ReactNode;
/** Is loading progress */
isLoading?: boolean;
/** Labels for i18n */
@@ -33,8 +36,8 @@ export type ContentIntroProps = {
onGoToSection: (index: number) => void;
/** Callback when starting */
onStart: () => void;
- /** Render function for intro content */
- renderIntroContent: () => ReactNode;
+ /** Render prop used by existing consumers to provide introduction content */
+ renderIntroContent?: () => ReactNode;
/** Sections for TOC */
sections: ContentIntroSection[];
/** Intro section title */
@@ -54,6 +57,7 @@ function ContentIntroImpl({
additionalContent,
completedSections,
estimatedTime,
+ introContent,
isLoading = false,
labels = EMPTY_CONTENT_INTRO_LABELS,
onGoToSection,
@@ -64,6 +68,7 @@ function ContentIntroImpl({
}: ContentIntroProps): React.ReactNode {
const mergedLabels = { ...DEFAULT_LABELS, ...labels };
const hasProgress = completedSections.size > 0;
+ const renderedIntroContent = introContent ?? renderIntroContent?.();
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
@@ -75,12 +80,7 @@ function ContentIntroImpl({
[onStart],
);
- useEffect(() => {
- document.addEventListener("keydown", handleKeyDown);
- return () => {
- document.removeEventListener("keydown", handleKeyDown);
- };
- }, [handleKeyDown]);
+ useDocumentEventListener("keydown", handleKeyDown);
return (
<>
@@ -89,7 +89,7 @@ function ContentIntroImpl({
{title}
- {renderIntroContent()}
+ {renderedIntroContent}
diff --git a/apps/registry/registry/default/mdx-content/mdx-content.tsx b/apps/registry/registry/default/mdx-content/mdx-content.tsx
index fe098d1a..3034bc0f 100644
--- a/apps/registry/registry/default/mdx-content/mdx-content.tsx
+++ b/apps/registry/registry/default/mdx-content/mdx-content.tsx
@@ -1,3 +1,5 @@
+import { Children } from "react";
+
import { evaluate } from "@mdx-js/mdx";
import type React from "react";
import * as runtime from "react/jsx-runtime";
@@ -33,10 +35,7 @@ const MDXComponents: Components = {
code: ({ children, className, ...props }: React.ComponentProps<"code">) => {
if (typeof className === "string" && className.startsWith("language-")) {
const language = className.replace(/^language-/, "");
- const text =
- typeof children === "string"
- ? children.replace(/\n$/, "")
- : String(children ?? "");
+ const text = Children.toArray(children).join("").replace(/\n$/, "");
return {text};
}
return (
diff --git a/apps/registry/registry/default/search-bar/search-bar.tsx b/apps/registry/registry/default/search-bar/search-bar.tsx
index bfa5b895..f230d50f 100644
--- a/apps/registry/registry/default/search-bar/search-bar.tsx
+++ b/apps/registry/registry/default/search-bar/search-bar.tsx
@@ -51,9 +51,11 @@ function SearchBarInner({
onSearch,
placeholder = "Search posts...",
}: SearchBarProps) {
- const router = useRouter();
+ const { replace } = useRouter();
const searchParameters = useSearchParams();
- const initialQuery = searchParameters.get("search") ?? "";
+ const searchParametersString = searchParameters.toString();
+ const initialQuery =
+ new URLSearchParams(searchParametersString).get("search") ?? "";
const [query, setQuery] = useState(initialQuery);
const debouncedQuery = useDebounce(query, 300);
const isInitialMount = useRef(true);
@@ -66,7 +68,8 @@ function SearchBarInner({
// Sync query with URL search params (e.g., on browser back/forward)
// Sync when user is not actively typing and URL changed externally
useEffect(() => {
- const searchParameter = searchParameters.get("search") ?? "";
+ const searchParameter =
+ new URLSearchParams(searchParametersString).get("search") ?? "";
// Skip if this is the search param we set ourselves
if (searchParameter === lastSetSearchParameterReference.current) {
@@ -80,7 +83,7 @@ function SearchBarInner({
lastDebouncedQueryReference.current = searchParameter;
});
}
- }, [searchParameters, query]); // Include query to properly sync state
+ }, [searchParametersString, query]); // Include query to properly sync state
// Update URL when debounced query changes
useEffect(() => {
@@ -108,7 +111,8 @@ function SearchBarInner({
}
// Check current URL to avoid unnecessary updates
- const currentUrlParameter = searchParameters.get("search") ?? "";
+ const parameters = new URLSearchParams(searchParametersString);
+ const currentUrlParameter = parameters.get("search") ?? "";
// Skip if URL already matches the debounced query
if (trimmedQuery === currentUrlParameter) {
@@ -116,7 +120,6 @@ function SearchBarInner({
return;
}
- const parameters = new URLSearchParams(searchParameters);
if (trimmedQuery) {
parameters.set("search", trimmedQuery);
} else {
@@ -129,8 +132,8 @@ function SearchBarInner({
// nextjs-no-client-side-redirect rule targets window.location
// hard redirects, not Next router.replace — but ESLint doesn't
// know that rule, so we don't add an eslint-disable for it.
- router.replace(`?${newUrl}`);
- }, [debouncedQuery, router, onSearch, searchParameters]);
+ replace(`?${newUrl}`);
+ }, [debouncedQuery, onSearch, replace, searchParametersString]);
// Cleanup timeout on unmount
useEffect(() => {
@@ -169,7 +172,7 @@ function SearchBarInner({
if (onSearch) {
onSearch(trimmedQuery);
} else {
- const parameters = new URLSearchParams(searchParameters);
+ const parameters = new URLSearchParams(searchParametersString);
if (trimmedQuery) {
parameters.set("search", trimmedQuery);
} else {
@@ -177,7 +180,7 @@ function SearchBarInner({
}
const newUrl = parameters.toString();
lastSetSearchParameterReference.current = trimmedQuery;
- router.replace(`?${newUrl}`);
+ replace(`?${newUrl}`);
}
};
diff --git a/apps/registry/registry/default/sidebar/sidebar.tsx b/apps/registry/registry/default/sidebar/sidebar.tsx
index 915e1f65..3a19f139 100644
--- a/apps/registry/registry/default/sidebar/sidebar.tsx
+++ b/apps/registry/registry/default/sidebar/sidebar.tsx
@@ -66,7 +66,7 @@ function useScrollFade(
};
checkScroll();
- container.addEventListener("scroll", checkScroll);
+ container.addEventListener("scroll", checkScroll, { passive: true });
return () => {
container.removeEventListener("scroll", checkScroll);
};
diff --git a/apps/registry/registry/default/slideshow/slideshow.tsx b/apps/registry/registry/default/slideshow/slideshow.tsx
index 7f5914ce..4f79671c 100644
--- a/apps/registry/registry/default/slideshow/slideshow.tsx
+++ b/apps/registry/registry/default/slideshow/slideshow.tsx
@@ -2,9 +2,10 @@
import { memo, useCallback, useEffect, useState } from "react";
-import type { ReactNode } from "react";
+import type { ComponentType, ReactNode } from "react";
import { createPortal } from "react-dom";
+import { useDocumentEventListener } from "@vllnt/ui";
import { useMounted } from "@vllnt/ui";
import { cn } from "@vllnt/ui";
import { CompletionDialog } from "@vllnt/ui";
@@ -14,6 +15,10 @@ export type SlideshowSection = {
title: string;
};
+export type SlideshowSectionContentProps = {
+ section: SlideshowSection;
+};
+
export type SlideshowLabels = {
closeLabel?: string;
closeTocLabel?: string;
@@ -42,8 +47,10 @@ export type SlideshowProps = {
onNavigate: (index: number) => void;
/** Callback to toggle section completion */
onToggleSection: (sectionId: string) => void;
- /** Render function for section content */
- renderContent: (section: SlideshowSection) => ReactNode;
+ /** Render prop used by existing consumers to render the current section content */
+ renderContent?: (section: SlideshowSection) => ReactNode;
+ /** Component used to render the current section content */
+ SectionContent?: ComponentType;
/** Sections to display */
sections: SlideshowSection[];
/** Tutorial title */
@@ -63,6 +70,326 @@ const DEFAULT_LABELS: Required = {
const EMPTY_SLIDESHOW_LABELS: SlideshowLabels = {};
+type MergedSlideshowLabels = Required;
+
+type SlideshowHeaderProps = {
+ currentIndex: number;
+ currentSection: SlideshowSection;
+ isTocOpen: boolean;
+ labels: MergedSlideshowLabels;
+ onExit: () => void;
+ onToggleToc: () => void;
+ sectionCount: number;
+ title: string;
+};
+
+function SlideshowHeader({
+ currentIndex,
+ currentSection,
+ isTocOpen,
+ labels,
+ onExit,
+ onToggleToc,
+ sectionCount,
+ title,
+}: SlideshowHeaderProps): ReactNode {
+ return (
+
+
+
+
+
{title}
+
{currentSection.title}
+
+
+
+
+ {currentIndex + 1}/{sectionCount}
+
+
+
+
+ );
+}
+
+type SlideshowTableOfContentsProps = {
+ completedSections: Set;
+ currentIndex: number;
+ isOpen: boolean;
+ labels: MergedSlideshowLabels;
+ onClose: () => void;
+ onNavigate: (index: number) => void;
+ sections: SlideshowSection[];
+};
+
+function SlideshowTableOfContents({
+ completedSections,
+ currentIndex,
+ isOpen,
+ labels,
+ onClose,
+ onNavigate,
+ sections,
+}: SlideshowTableOfContentsProps): ReactNode {
+ if (!isOpen) return null;
+
+ return (
+ {
+ if (event.key === "Enter" || event.key === " ") onClose();
+ }}
+ role="button"
+ tabIndex={0}
+ >
+
+ {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
+
{
+ event.stopPropagation();
+ }}
+ onKeyDown={(event) => {
+ event.stopPropagation();
+ }}
+ role="dialog"
+ >
+
+
{labels.sectionsLabel}
+
+
+
+ {sections.map((section, index) => {
+ const isCompleted = completedSections.has(section.id);
+ const isCurrent = index === currentIndex;
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
+
+type SlideshowContentProps = {
+ animationDirection: "left" | "right" | null;
+ currentSection: SlideshowSection;
+ renderContent?: (section: SlideshowSection) => ReactNode;
+ SectionContent?: ComponentType;
+};
+
+function SlideshowContent({
+ animationDirection,
+ currentSection,
+ renderContent,
+ SectionContent,
+}: SlideshowContentProps): ReactNode {
+ const content = SectionContent ? (
+
+ ) : (
+ renderContent?.(currentSection)
+ );
+
+ return (
+
+ );
+}
+
+type SlideshowBottomNavProps = {
+ canGoPrevious: boolean;
+ isLastSection: boolean;
+ labels: MergedSlideshowLabels;
+ onNext: () => void;
+ onPrevious: () => void;
+};
+
+function SlideshowBottomNav({
+ canGoPrevious,
+ isLastSection,
+ labels,
+ onNext,
+ onPrevious,
+}: SlideshowBottomNavProps): ReactNode {
+ return (
+
+
+
+
+ );
+}
+
function SlideshowImpl({
completedSections,
completionDialogTitle = "Mark section as complete?",
@@ -73,9 +400,10 @@ function SlideshowImpl({
onNavigate,
onToggleSection,
renderContent,
+ SectionContent,
sections,
title,
-}: SlideshowProps): React.ReactNode {
+}: SlideshowProps): ReactNode {
const mergedLabels = { ...DEFAULT_LABELS, ...labels };
const [animationDirection, setAnimationDirection] = useState<
"left" | "right" | null
@@ -153,8 +481,8 @@ function SlideshowImpl({
[currentIndex, goToSection],
);
- useEffect(() => {
- const handleKeyDown = (event: KeyboardEvent): void => {
+ const handleSlideshowKeyDown = useCallback(
+ (event: KeyboardEvent): void => {
if (isCompletionDialogOpen) return;
if (event.key === "Escape") {
event.preventDefault();
@@ -164,7 +492,7 @@ function SlideshowImpl({
}
if (event.key === "t" || event.key === "T") {
event.preventDefault();
- setIsTocOpen((p) => !p);
+ setIsTocOpen((previous) => !previous);
return;
}
if (event.key === "ArrowRight" || event.key === "j") {
@@ -176,12 +504,11 @@ function SlideshowImpl({
event.preventDefault();
handlePrevious();
}
- };
- document.addEventListener("keydown", handleKeyDown, true);
- return () => {
- document.removeEventListener("keydown", handleKeyDown, true);
- };
- }, [handleNext, handlePrevious, onExit, isTocOpen, isCompletionDialogOpen]);
+ },
+ [handleNext, handlePrevious, isCompletionDialogOpen, isTocOpen, onExit],
+ );
+
+ useDocumentEventListener("keydown", handleSlideshowKeyDown, true);
if (!currentSection || !mounted) return null;
@@ -195,256 +522,49 @@ function SlideshowImpl({
/>
- {/* Header */}
-
-
-
-
-
{title}
-
- {currentSection.title}
-
-
-
-
-
- {currentIndex + 1}/{sections.length}
-
-
-
-
+ {
+ setIsTocOpen((previous) => !previous);
+ }}
+ sectionCount={sections.length}
+ title={title}
+ />
{/* Content */}
- {isTocOpen ? (
-
{
- setIsTocOpen(false);
- }}
- onKeyDown={(event) => {
- if (event.key === "Enter" || event.key === " ")
- setIsTocOpen(false);
- }}
- role="button"
- tabIndex={0}
- >
-
- {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
-
{
- event.stopPropagation();
- }}
- onKeyDown={(event) => {
- event.stopPropagation();
- }}
- role="dialog"
- >
-
-
{mergedLabels.sectionsLabel}
-
-
-
- {sections.map((section, index) => {
- const isCompleted = completedSections.has(section.id);
- const isCurrent = index === currentIndex;
- return (
-
- );
- })}
-
-
-
- ) : null}
-
-
-
-
- {renderContent(currentSection)}
-
-
-
-
+ {
+ setIsTocOpen(false);
+ }}
+ onNavigate={handleTocNavigate}
+ sections={sections}
+ />
- {/* Bottom Nav */}
-
-
-
+
+
+
- {renderFloatingContent(children, contentStyle)}
+
+ {children}
+
{
- if (!api) {
- return;
- }
-
- setCanScrollPrevious(api.canScrollPrev());
- setCanScrollNext(api.canScrollNext());
- }, []);
-
const scrollPrevious = useCallback(() => {
api?.scrollPrev();
}, [api]);
@@ -117,19 +108,28 @@ function useCarouselLogic({
return;
}
- api.on("reInit", onSelect);
- api.on("select", onSelect);
+ const updateScrollButtons = (currentApi: CarouselApi) => {
+ if (!currentApi) {
+ return;
+ }
+
+ setCanScrollPrevious(currentApi.canScrollPrev());
+ setCanScrollNext(currentApi.canScrollNext());
+ };
+
+ api.on("reInit", updateScrollButtons);
+ api.on("select", updateScrollButtons);
const rafId = requestAnimationFrame(() => {
- onSelect(api);
+ updateScrollButtons(api);
});
return () => {
- api?.off("select", onSelect);
- api?.off("reInit", onSelect);
+ api?.off("select", updateScrollButtons);
+ api?.off("reInit", updateScrollButtons);
cancelAnimationFrame(rafId);
};
- }, [api, onSelect]);
+ }, [api]);
return {
api,
diff --git a/packages/ui/src/components/code-block/code-block.mdx b/packages/ui/src/components/code-block/code-block.mdx
index 2f106818..21b84c3f 100644
--- a/packages/ui/src/components/code-block/code-block.mdx
+++ b/packages/ui/src/components/code-block/code-block.mdx
@@ -35,6 +35,8 @@ import { CodeBlock } from '@vllnt/ui'
| Prop | Type | Required | Description |
| ---- | ---- | -------- | ----------- |
+| `children` | `ReactNode` | No | Code content. Strings, numbers, arrays, and nested element text are extracted for rendering and copy. |
+| `code` | `string` | No | Explicit code string. Preferred over `children` when provided. |
| `language` | `string` | No | - |
| `showLanguage` | `boolean` | No | `true` or `false` |
diff --git a/packages/ui/src/components/code-block/code-block.test.tsx b/packages/ui/src/components/code-block/code-block.test.tsx
index c710117e..a6a703ce 100644
--- a/packages/ui/src/components/code-block/code-block.test.tsx
+++ b/packages/ui/src/components/code-block/code-block.test.tsx
@@ -1,7 +1,7 @@
import { render } from "@testing-library/react";
import { describe, expect, it } from "vitest";
-import { CodeBlock } from "./code-block";
+import { CodeBlock, extractTextFromChildren } from "./code-block";
describe("CodeBlock", () => {
describe("rendering", () => {
@@ -16,6 +16,28 @@ describe("CodeBlock", () => {
expect(container.firstChild).toHaveClass("custom-class");
});
+
+ it("extracts strings, numbers, arrays, and nested element text from children", () => {
+ expect(
+ extractTextFromChildren([
+ "const value = ",
+ 1,
+
+ ; return value;
+ ,
+ ]),
+ ).toBe("const value = 1; return value;");
+ });
+
+ it("accepts ReactNode children", () => {
+ const { container } = render(
+
+ const value = 1;
+ ,
+ );
+
+ expect(container.firstChild).toBeInTheDocument();
+ });
});
describe("accessibility", () => {
diff --git a/packages/ui/src/components/code-block/code-block.tsx b/packages/ui/src/components/code-block/code-block.tsx
index e57197a5..9add6def 100644
--- a/packages/ui/src/components/code-block/code-block.tsx
+++ b/packages/ui/src/components/code-block/code-block.tsx
@@ -1,9 +1,10 @@
"use client";
-import { type ReactNode, useEffect, useRef, useState } from "react";
+import { Children, isValidElement, useState } from "react";
import { Check, Copy } from "lucide-react";
import { useTheme } from "next-themes";
+import type { ReactNode, WheelEvent } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import {
oneDark,
@@ -14,33 +15,25 @@ import { cn } from "../../lib/utils";
import { Button } from "../button/button";
type CodeBlockProps = {
- children: ReactNode;
+ children?: ReactNode;
className?: string;
+ code?: string;
language?: string;
showLanguage?: boolean;
};
-function extractTextFromChildren(children: ReactNode): string {
- if (typeof children === "string") {
- return children;
- }
- if (typeof children === "number") {
- return String(children);
- }
- if (Array.isArray(children)) {
- return children.map(extractTextFromChildren).join("");
- }
- if (
- children &&
- typeof children === "object" &&
- "props" in children &&
- children.props &&
- typeof children.props === "object" &&
- "children" in children.props
- ) {
- return extractTextFromChildren(children.props.children as ReactNode);
- }
- return String(children ?? "");
+export function extractTextFromChildren(children: ReactNode): string {
+ return Children.toArray(children)
+ .map((child) => {
+ if (typeof child === "string" || typeof child === "number") {
+ return String(child);
+ }
+ if (isValidElement<{ children?: ReactNode }>(child)) {
+ return extractTextFromChildren(child.props.children);
+ }
+ return "";
+ })
+ .join("");
}
function findScrollableParent(
@@ -51,9 +44,19 @@ function findScrollableParent(
return findScrollableParent(element.parentElement);
}
+function redirectVerticalWheel(event: WheelEvent): void {
+ if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
+ const scrollable = findScrollableParent(event.currentTarget);
+ if (scrollable) {
+ scrollable.scrollTop += event.deltaY;
+ event.preventDefault();
+ }
+}
+
export function CodeBlock({
children,
className,
+ code: codeProperty,
language = "typescript",
showLanguage = false,
}: CodeBlockProps) {
@@ -63,28 +66,7 @@ export function CodeBlock({
const resolvedTheme = theme === "system" ? systemTheme : theme;
const isDark = resolvedTheme !== "light";
const codeStyle = isDark ? oneDark : oneLight;
- const code = extractTextFromChildren(children);
-
- const scrollRef = useRef(null);
-
- useEffect(() => {
- const element = scrollRef.current;
- if (!element) return;
-
- const onWheel = (event: WheelEvent) => {
- if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
- const scrollable = findScrollableParent(element);
- if (scrollable) {
- scrollable.scrollTop += event.deltaY;
- event.preventDefault();
- }
- };
-
- element.addEventListener("wheel", onWheel, { passive: false });
- return () => {
- element.removeEventListener("wheel", onWheel);
- };
- }, []);
+ const code = codeProperty ?? extractTextFromChildren(children);
const handleCopy = async () => {
await navigator.clipboard.writeText(code);
@@ -103,7 +85,7 @@ export function CodeBlock({
>
{
expect(screen.getByText(/greeting/)).toBeInTheDocument();
});
+
+ it("accepts ReactNode children", () => {
+ render(
+
+ const value = 1;
+ ,
+ );
+
+ expect(screen.getByText(/value/)).toBeInTheDocument();
+ });
});
diff --git a/packages/ui/src/components/code-playground/code-playground.tsx b/packages/ui/src/components/code-playground/code-playground.tsx
index 8422845f..06648969 100644
--- a/packages/ui/src/components/code-playground/code-playground.tsx
+++ b/packages/ui/src/components/code-playground/code-playground.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { Children, isValidElement, useState } from "react";
import { Check, Code, Copy, FileCode } from "lucide-react";
import type { ReactNode } from "react";
@@ -27,7 +27,8 @@ function CodeLine({ highlightLines, line, lineNumber }: CodeLineProps) {
}
export type CodePlaygroundProps = {
- children: ReactNode;
+ children?: ReactNode;
+ code?: string;
description?: string;
filename?: string;
highlightLines?: number[];
@@ -38,8 +39,23 @@ export type CodePlaygroundProps = {
const EMPTY_HIGHLIGHT_LINES: number[] = [];
+function extractTextFromChildren(children: ReactNode): string {
+ return Children.toArray(children)
+ .map((child) => {
+ if (typeof child === "string" || typeof child === "number") {
+ return String(child);
+ }
+ if (isValidElement<{ children?: ReactNode }>(child)) {
+ return extractTextFromChildren(child.props.children);
+ }
+ return "";
+ })
+ .join("");
+}
+
export function CodePlayground({
children,
+ code: codeProperty,
description,
filename,
highlightLines = EMPTY_HIGHLIGHT_LINES,
@@ -48,7 +64,7 @@ export function CodePlayground({
title,
}: CodePlaygroundProps) {
const [copied, setCopied] = useState(false);
- const code = typeof children === "string" ? children : "";
+ const code = codeProperty ?? extractTextFromChildren(children);
const lines = code.split("\n");
const handleCopy = async () => {
diff --git a/packages/ui/src/components/completion-dialog/completion-dialog.tsx b/packages/ui/src/components/completion-dialog/completion-dialog.tsx
index 05c1a8c2..647ccd4c 100644
--- a/packages/ui/src/components/completion-dialog/completion-dialog.tsx
+++ b/packages/ui/src/components/completion-dialog/completion-dialog.tsx
@@ -4,6 +4,7 @@ import { memo, useCallback, useEffect, useRef } from "react";
import type { ReactNode } from "react";
+import { useDocumentEventListener } from "../../lib/use-event-callback";
import { cn } from "../../lib/utils";
import { Button } from "../button";
@@ -152,13 +153,7 @@ function CompletionDialogImpl({
[isOpen, onClose, onConfirm, onCancel, confirmShortcut, cancelShortcut],
);
- useEffect(() => {
- if (!isOpen) return;
- document.addEventListener("keydown", handleKeyDown, true);
- return () => {
- document.removeEventListener("keydown", handleKeyDown, true);
- };
- }, [isOpen, handleKeyDown]);
+ useDocumentEventListener("keydown", handleKeyDown, true);
if (!isOpen) return null;
diff --git a/packages/ui/src/components/content-intro/content-intro.mdx b/packages/ui/src/components/content-intro/content-intro.mdx
index e6aaa449..0365e7ca 100644
--- a/packages/ui/src/components/content-intro/content-intro.mdx
+++ b/packages/ui/src/components/content-intro/content-intro.mdx
@@ -33,9 +33,9 @@ const sections = [
Welcome to this tutorial.}
onGoToSection={(index) => console.log(index)}
onStart={() => {}}
- renderIntroContent={() => {}}
sections={sections}
title="My Title"
/>
@@ -52,11 +52,12 @@ const sections = [
| `additionalContent` | `ReactNode` | No | React content (JSX, string, number, etc.) |
| `completedSections` | `Set<string>` | Yes | - |
| `estimatedTime` | `string` | Yes | - |
+| `introContent` | `ReactNode` | No | Rendered introduction content. Preferred over `renderIntroContent` when both are provided. |
| `isLoading` | `boolean` | No | `true` or `false` |
| `labels` | `ContentIntroLabels` | No | `ContentIntroLabels` object |
| `onGoToSection` | `(index: number) => void` | Yes | Callback: `(index: number) => void` |
| `onStart` | `() => void` | Yes | Callback function |
-| `renderIntroContent` | `() => ReactNode` | Yes | Callback function |
+| `renderIntroContent` | `() => ReactNode` | No | Legacy render prop for introduction content |
| `sections` | `ContentIntroSection[]` | Yes | Array of `ContentIntroSection` objects |
| `title` | `string` | Yes | - |
@@ -91,4 +92,3 @@ type ContentIntroLabels = {
| `continueLabel` | `string` | No | - |
| `startLabel` | `string` | No | - |
| `tableOfContentsLabel` | `string` | No | - |
-
diff --git a/packages/ui/src/components/content-intro/content-intro.stories.tsx b/packages/ui/src/components/content-intro/content-intro.stories.tsx
index 898c3333..2a688c84 100644
--- a/packages/ui/src/components/content-intro/content-intro.stories.tsx
+++ b/packages/ui/src/components/content-intro/content-intro.stories.tsx
@@ -6,9 +6,9 @@ const meta = {
args: {
completedSections: new Set(),
estimatedTime: "10 min",
+ introContent: "Welcome to this tutorial.",
onGoToSection: () => {},
onStart: () => {},
- renderIntroContent: () => "Welcome to this tutorial.",
sections: [
{ id: "intro", title: "Introduction" },
{ id: "basics", title: "Basics" },
diff --git a/packages/ui/src/components/content-intro/content-intro.test.tsx b/packages/ui/src/components/content-intro/content-intro.test.tsx
new file mode 100644
index 00000000..13a8efb2
--- /dev/null
+++ b/packages/ui/src/components/content-intro/content-intro.test.tsx
@@ -0,0 +1,39 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { ContentIntro } from "./content-intro";
+
+const defaultProps = {
+ completedSections: new Set(),
+ estimatedTime: "10 min",
+ onGoToSection: vi.fn(),
+ onStart: vi.fn(),
+ sections: [{ id: "intro", title: "Introduction" }],
+ title: "Getting Started",
+};
+
+describe("ContentIntro", () => {
+ it("renders introduction content from the legacy renderIntroContent prop", () => {
+ render(
+ Legacy introduction
}
+ />,
+ );
+
+ expect(screen.getByText("Legacy introduction")).toBeInTheDocument();
+ });
+
+ it("prefers introContent over the legacy renderIntroContent prop", () => {
+ render(
+ New introduction}
+ renderIntroContent={() => Legacy introduction
}
+ />,
+ );
+
+ expect(screen.getByText("New introduction")).toBeInTheDocument();
+ expect(screen.queryByText("Legacy introduction")).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/ui/src/components/content-intro/content-intro.tsx b/packages/ui/src/components/content-intro/content-intro.tsx
index df02931a..95800050 100644
--- a/packages/ui/src/components/content-intro/content-intro.tsx
+++ b/packages/ui/src/components/content-intro/content-intro.tsx
@@ -1,9 +1,10 @@
"use client";
-import { memo, useCallback, useEffect } from "react";
+import { memo, useCallback } from "react";
import type { ReactNode } from "react";
+import { useDocumentEventListener } from "../../lib/use-event-callback";
import { cn } from "../../lib/utils";
import { Button } from "../button";
@@ -25,6 +26,8 @@ export type ContentIntroProps = {
completedSections: Set;
/** Estimated time to complete */
estimatedTime: string;
+ /** Rendered introduction content */
+ introContent?: ReactNode;
/** Is loading progress */
isLoading?: boolean;
/** Labels for i18n */
@@ -33,8 +36,8 @@ export type ContentIntroProps = {
onGoToSection: (index: number) => void;
/** Callback when starting */
onStart: () => void;
- /** Render function for intro content */
- renderIntroContent: () => ReactNode;
+ /** Render prop used by existing consumers to provide introduction content */
+ renderIntroContent?: () => ReactNode;
/** Sections for TOC */
sections: ContentIntroSection[];
/** Intro section title */
@@ -54,6 +57,7 @@ function ContentIntroImpl({
additionalContent,
completedSections,
estimatedTime,
+ introContent,
isLoading = false,
labels = EMPTY_CONTENT_INTRO_LABELS,
onGoToSection,
@@ -64,6 +68,7 @@ function ContentIntroImpl({
}: ContentIntroProps): React.ReactNode {
const mergedLabels = { ...DEFAULT_LABELS, ...labels };
const hasProgress = completedSections.size > 0;
+ const renderedIntroContent = introContent ?? renderIntroContent?.();
const handleKeyDown = useCallback(
(event: KeyboardEvent) => {
@@ -75,12 +80,7 @@ function ContentIntroImpl({
[onStart],
);
- useEffect(() => {
- document.addEventListener("keydown", handleKeyDown);
- return () => {
- document.removeEventListener("keydown", handleKeyDown);
- };
- }, [handleKeyDown]);
+ useDocumentEventListener("keydown", handleKeyDown);
return (
<>
@@ -89,7 +89,7 @@ function ContentIntroImpl({
{title}
- {renderIntroContent()}
+ {renderedIntroContent}
diff --git a/packages/ui/src/components/mdx-content/mdx-content.tsx b/packages/ui/src/components/mdx-content/mdx-content.tsx
index bda9a461..c7b8d372 100644
--- a/packages/ui/src/components/mdx-content/mdx-content.tsx
+++ b/packages/ui/src/components/mdx-content/mdx-content.tsx
@@ -1,3 +1,5 @@
+import { Children } from "react";
+
import { evaluate } from "@mdx-js/mdx";
import type React from "react";
import * as runtime from "react/jsx-runtime";
@@ -33,10 +35,7 @@ const MDXComponents: Components = {
code: ({ children, className, ...props }: React.ComponentProps<"code">) => {
if (typeof className === "string" && className.startsWith("language-")) {
const language = className.replace(/^language-/, "");
- const text =
- typeof children === "string"
- ? children.replace(/\n$/, "")
- : String(children ?? "");
+ const text = Children.toArray(children).join("").replace(/\n$/, "");
return {text};
}
return (
diff --git a/packages/ui/src/components/search-bar/search-bar.tsx b/packages/ui/src/components/search-bar/search-bar.tsx
index 82fdb63a..b5295819 100644
--- a/packages/ui/src/components/search-bar/search-bar.tsx
+++ b/packages/ui/src/components/search-bar/search-bar.tsx
@@ -51,9 +51,11 @@ function SearchBarInner({
onSearch,
placeholder = "Search posts...",
}: SearchBarProps) {
- const router = useRouter();
+ const { replace } = useRouter();
const searchParameters = useSearchParams();
- const initialQuery = searchParameters.get("search") ?? "";
+ const searchParametersString = searchParameters.toString();
+ const initialQuery =
+ new URLSearchParams(searchParametersString).get("search") ?? "";
const [query, setQuery] = useState(initialQuery);
const debouncedQuery = useDebounce(query, 300);
const isInitialMount = useRef(true);
@@ -66,7 +68,8 @@ function SearchBarInner({
// Sync query with URL search params (e.g., on browser back/forward)
// Sync when user is not actively typing and URL changed externally
useEffect(() => {
- const searchParameter = searchParameters.get("search") ?? "";
+ const searchParameter =
+ new URLSearchParams(searchParametersString).get("search") ?? "";
// Skip if this is the search param we set ourselves
if (searchParameter === lastSetSearchParameterReference.current) {
@@ -80,7 +83,7 @@ function SearchBarInner({
lastDebouncedQueryReference.current = searchParameter;
});
}
- }, [searchParameters, query]); // Include query to properly sync state
+ }, [searchParametersString, query]); // Include query to properly sync state
// Update URL when debounced query changes
useEffect(() => {
@@ -108,7 +111,8 @@ function SearchBarInner({
}
// Check current URL to avoid unnecessary updates
- const currentUrlParameter = searchParameters.get("search") ?? "";
+ const parameters = new URLSearchParams(searchParametersString);
+ const currentUrlParameter = parameters.get("search") ?? "";
// Skip if URL already matches the debounced query
if (trimmedQuery === currentUrlParameter) {
@@ -116,7 +120,6 @@ function SearchBarInner({
return;
}
- const parameters = new URLSearchParams(searchParameters);
if (trimmedQuery) {
parameters.set("search", trimmedQuery);
} else {
@@ -129,8 +132,8 @@ function SearchBarInner({
// nextjs-no-client-side-redirect rule targets window.location
// hard redirects, not Next router.replace — but ESLint doesn't
// know that rule, so we don't add an eslint-disable for it.
- router.replace(`?${newUrl}`);
- }, [debouncedQuery, router, onSearch, searchParameters]);
+ replace(`?${newUrl}`);
+ }, [debouncedQuery, onSearch, replace, searchParametersString]);
// Cleanup timeout on unmount
useEffect(() => {
@@ -169,7 +172,7 @@ function SearchBarInner({
if (onSearch) {
onSearch(trimmedQuery);
} else {
- const parameters = new URLSearchParams(searchParameters);
+ const parameters = new URLSearchParams(searchParametersString);
if (trimmedQuery) {
parameters.set("search", trimmedQuery);
} else {
@@ -177,7 +180,7 @@ function SearchBarInner({
}
const newUrl = parameters.toString();
lastSetSearchParameterReference.current = trimmedQuery;
- router.replace(`?${newUrl}`);
+ replace(`?${newUrl}`);
}
};
diff --git a/packages/ui/src/components/sidebar/sidebar.tsx b/packages/ui/src/components/sidebar/sidebar.tsx
index c1114e2b..7b99792a 100644
--- a/packages/ui/src/components/sidebar/sidebar.tsx
+++ b/packages/ui/src/components/sidebar/sidebar.tsx
@@ -66,7 +66,7 @@ function useScrollFade(
};
checkScroll();
- container.addEventListener("scroll", checkScroll);
+ container.addEventListener("scroll", checkScroll, { passive: true });
return () => {
container.removeEventListener("scroll", checkScroll);
};
diff --git a/packages/ui/src/components/slideshow/slideshow.mdx b/packages/ui/src/components/slideshow/slideshow.mdx
index 1b33ca58..097e042f 100644
--- a/packages/ui/src/components/slideshow/slideshow.mdx
+++ b/packages/ui/src/components/slideshow/slideshow.mdx
@@ -31,13 +31,17 @@ const sections = [
},
]
+function SectionContent({ section }) {
+ return {section.title}
+}
+
{}}
onExit={() => {}}
onNavigate={(index) => console.log(index)}
onToggleSection={(sectionId) => console.log(sectionId)}
- renderContent={(section) => console.log(section)}
sections={sections}
title="My Title"
/>
@@ -51,6 +55,7 @@ const sections = [
| Prop | Type | Required | Description |
| ---- | ---- | -------- | ----------- |
+| `SectionContent` | `ComponentType<SlideshowSectionContentProps>` | No | Component used to render the current section content. Preferred over `renderContent` when both are provided. |
| `completedSections` | `Set<string>` | Yes | - |
| `completionDialogTitle` | `string` | No | - |
| `currentIndex` | `number` | Yes | - |
@@ -59,7 +64,7 @@ const sections = [
| `onExit` | `() => void` | Yes | Callback function |
| `onNavigate` | `(index: number) => void` | Yes | Callback: `(index: number) => void` |
| `onToggleSection` | `(sectionId: string) => void` | Yes | Callback: `(sectionId: string) => void` |
-| `renderContent` | `(section: SlideshowSection) => ReactNode` | Yes | Callback: `(section: SlideshowSection) => ReactNode` |
+| `renderContent` | `(section: SlideshowSection) => ReactNode` | No | Legacy render prop used to render the current section content |
| `sections` | `SlideshowSection[]` | Yes | Array of `SlideshowSection` objects |
| `title` | `string` | Yes | - |
@@ -104,4 +109,3 @@ type SlideshowLabels = {
| `openTocLabel` | `string` | No | - |
| `prevLabel` | `string` | No | - |
| `sectionsLabel` | `string` | No | - |
-
diff --git a/packages/ui/src/components/slideshow/slideshow.stories.tsx b/packages/ui/src/components/slideshow/slideshow.stories.tsx
index c321de5a..9341d9c8 100644
--- a/packages/ui/src/components/slideshow/slideshow.stories.tsx
+++ b/packages/ui/src/components/slideshow/slideshow.stories.tsx
@@ -10,7 +10,7 @@ const meta = {
onExit: () => {},
onNavigate: () => {},
onToggleSection: () => {},
- renderContent: () => "Slide content here.",
+ SectionContent: ({ section }) => `Slide content for ${section.title}.`,
sections: [
{ id: "intro", title: "Introduction" },
{ id: "setup", title: "Setup" },
diff --git a/packages/ui/src/components/slideshow/slideshow.test.tsx b/packages/ui/src/components/slideshow/slideshow.test.tsx
new file mode 100644
index 00000000..ca62038c
--- /dev/null
+++ b/packages/ui/src/components/slideshow/slideshow.test.tsx
@@ -0,0 +1,45 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { Slideshow } from "./slideshow";
+
+const defaultProps = {
+ completedSections: new Set(),
+ currentIndex: 0,
+ onComplete: vi.fn(),
+ onExit: vi.fn(),
+ onNavigate: vi.fn(),
+ onToggleSection: vi.fn(),
+ sections: [{ id: "intro", title: "Introduction" }],
+ title: "Tutorial Slideshow",
+};
+
+describe("Slideshow", () => {
+ it("renders section content from the legacy renderContent prop", () => {
+ render(
+ Legacy content for {section.title}
}
+ />,
+ );
+
+ expect(
+ screen.getByText("Legacy content for Introduction"),
+ ).toBeInTheDocument();
+ });
+
+ it("prefers SectionContent over the legacy renderContent prop", () => {
+ render(
+ Legacy content
}
+ SectionContent={({ section }) => New content for {section.title}
}
+ />,
+ );
+
+ expect(
+ screen.getByText("New content for Introduction"),
+ ).toBeInTheDocument();
+ expect(screen.queryByText("Legacy content")).not.toBeInTheDocument();
+ });
+});
diff --git a/packages/ui/src/components/slideshow/slideshow.tsx b/packages/ui/src/components/slideshow/slideshow.tsx
index cdee1450..7cac4410 100644
--- a/packages/ui/src/components/slideshow/slideshow.tsx
+++ b/packages/ui/src/components/slideshow/slideshow.tsx
@@ -2,9 +2,10 @@
import { memo, useCallback, useEffect, useState } from "react";
-import type { ReactNode } from "react";
+import type { ComponentType, ReactNode } from "react";
import { createPortal } from "react-dom";
+import { useDocumentEventListener } from "../../lib/use-event-callback";
import { useMounted } from "../../lib/use-mounted";
import { cn } from "../../lib/utils";
import { CompletionDialog } from "../completion-dialog";
@@ -14,6 +15,10 @@ export type SlideshowSection = {
title: string;
};
+export type SlideshowSectionContentProps = {
+ section: SlideshowSection;
+};
+
export type SlideshowLabels = {
closeLabel?: string;
closeTocLabel?: string;
@@ -42,8 +47,10 @@ export type SlideshowProps = {
onNavigate: (index: number) => void;
/** Callback to toggle section completion */
onToggleSection: (sectionId: string) => void;
- /** Render function for section content */
- renderContent: (section: SlideshowSection) => ReactNode;
+ /** Render prop used by existing consumers to render the current section content */
+ renderContent?: (section: SlideshowSection) => ReactNode;
+ /** Component used to render the current section content */
+ SectionContent?: ComponentType;
/** Sections to display */
sections: SlideshowSection[];
/** Tutorial title */
@@ -63,6 +70,326 @@ const DEFAULT_LABELS: Required = {
const EMPTY_SLIDESHOW_LABELS: SlideshowLabels = {};
+type MergedSlideshowLabels = Required;
+
+type SlideshowHeaderProps = {
+ currentIndex: number;
+ currentSection: SlideshowSection;
+ isTocOpen: boolean;
+ labels: MergedSlideshowLabels;
+ onExit: () => void;
+ onToggleToc: () => void;
+ sectionCount: number;
+ title: string;
+};
+
+function SlideshowHeader({
+ currentIndex,
+ currentSection,
+ isTocOpen,
+ labels,
+ onExit,
+ onToggleToc,
+ sectionCount,
+ title,
+}: SlideshowHeaderProps): ReactNode {
+ return (
+
+
+
+
+
{title}
+
{currentSection.title}
+
+
+
+
+ {currentIndex + 1}/{sectionCount}
+
+
+
+
+ );
+}
+
+type SlideshowTableOfContentsProps = {
+ completedSections: Set;
+ currentIndex: number;
+ isOpen: boolean;
+ labels: MergedSlideshowLabels;
+ onClose: () => void;
+ onNavigate: (index: number) => void;
+ sections: SlideshowSection[];
+};
+
+function SlideshowTableOfContents({
+ completedSections,
+ currentIndex,
+ isOpen,
+ labels,
+ onClose,
+ onNavigate,
+ sections,
+}: SlideshowTableOfContentsProps): ReactNode {
+ if (!isOpen) return null;
+
+ return (
+ {
+ if (event.key === "Enter" || event.key === " ") onClose();
+ }}
+ role="button"
+ tabIndex={0}
+ >
+
+ {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
+
{
+ event.stopPropagation();
+ }}
+ onKeyDown={(event) => {
+ event.stopPropagation();
+ }}
+ role="dialog"
+ >
+
+
{labels.sectionsLabel}
+
+
+
+ {sections.map((section, index) => {
+ const isCompleted = completedSections.has(section.id);
+ const isCurrent = index === currentIndex;
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
+
+type SlideshowContentProps = {
+ animationDirection: "left" | "right" | null;
+ currentSection: SlideshowSection;
+ renderContent?: (section: SlideshowSection) => ReactNode;
+ SectionContent?: ComponentType;
+};
+
+function SlideshowContent({
+ animationDirection,
+ currentSection,
+ renderContent,
+ SectionContent,
+}: SlideshowContentProps): ReactNode {
+ const content = SectionContent ? (
+
+ ) : (
+ renderContent?.(currentSection)
+ );
+
+ return (
+
+ );
+}
+
+type SlideshowBottomNavProps = {
+ canGoPrevious: boolean;
+ isLastSection: boolean;
+ labels: MergedSlideshowLabels;
+ onNext: () => void;
+ onPrevious: () => void;
+};
+
+function SlideshowBottomNav({
+ canGoPrevious,
+ isLastSection,
+ labels,
+ onNext,
+ onPrevious,
+}: SlideshowBottomNavProps): ReactNode {
+ return (
+
+
+
+
+ );
+}
+
function SlideshowImpl({
completedSections,
completionDialogTitle = "Mark section as complete?",
@@ -73,9 +400,10 @@ function SlideshowImpl({
onNavigate,
onToggleSection,
renderContent,
+ SectionContent,
sections,
title,
-}: SlideshowProps): React.ReactNode {
+}: SlideshowProps): ReactNode {
const mergedLabels = { ...DEFAULT_LABELS, ...labels };
const [animationDirection, setAnimationDirection] = useState<
"left" | "right" | null
@@ -153,8 +481,8 @@ function SlideshowImpl({
[currentIndex, goToSection],
);
- useEffect(() => {
- const handleKeyDown = (event: KeyboardEvent): void => {
+ const handleSlideshowKeyDown = useCallback(
+ (event: KeyboardEvent): void => {
if (isCompletionDialogOpen) return;
if (event.key === "Escape") {
event.preventDefault();
@@ -164,7 +492,7 @@ function SlideshowImpl({
}
if (event.key === "t" || event.key === "T") {
event.preventDefault();
- setIsTocOpen((p) => !p);
+ setIsTocOpen((previous) => !previous);
return;
}
if (event.key === "ArrowRight" || event.key === "j") {
@@ -176,12 +504,11 @@ function SlideshowImpl({
event.preventDefault();
handlePrevious();
}
- };
- document.addEventListener("keydown", handleKeyDown, true);
- return () => {
- document.removeEventListener("keydown", handleKeyDown, true);
- };
- }, [handleNext, handlePrevious, onExit, isTocOpen, isCompletionDialogOpen]);
+ },
+ [handleNext, handlePrevious, isCompletionDialogOpen, isTocOpen, onExit],
+ );
+
+ useDocumentEventListener("keydown", handleSlideshowKeyDown, true);
if (!currentSection || !mounted) return null;
@@ -195,256 +522,49 @@ function SlideshowImpl({
/>
- {/* Header */}
-
-
-
-
-
{title}
-
- {currentSection.title}
-
-
-
-
-
- {currentIndex + 1}/{sections.length}
-
-
-
-
+ {
+ setIsTocOpen((previous) => !previous);
+ }}
+ sectionCount={sections.length}
+ title={title}
+ />
{/* Content */}
- {isTocOpen ? (
-
{
- setIsTocOpen(false);
- }}
- onKeyDown={(event) => {
- if (event.key === "Enter" || event.key === " ")
- setIsTocOpen(false);
- }}
- role="button"
- tabIndex={0}
- >
-
- {/* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */}
-
{
- event.stopPropagation();
- }}
- onKeyDown={(event) => {
- event.stopPropagation();
- }}
- role="dialog"
- >
-
-
{mergedLabels.sectionsLabel}
-
-
-
- {sections.map((section, index) => {
- const isCompleted = completedSections.has(section.id);
- const isCurrent = index === currentIndex;
- return (
-
- );
- })}
-
-
-
- ) : null}
-
-
-
-
- {renderContent(currentSection)}
-
-
-
-
+ {
+ setIsTocOpen(false);
+ }}
+ onNavigate={handleTocNavigate}
+ sections={sections}
+ />
- {/* Bottom Nav */}
-
-
-
+
+
+
(
+ callback: (...arguments_: TArguments) => TReturnValue,
+): (...arguments_: TArguments) => TReturnValue {
+ const callbackRef = useRef(callback);
+
+ useIsomorphicLayoutEffect(() => {
+ callbackRef.current = callback;
+ }, [callback]);
+
+ return useCallback((...arguments_: TArguments) => {
+ return callbackRef.current(...arguments_);
+ }, []);
+}
+
+export function useDocumentEventListener(
+ type: TKey,
+ listener: (event: DocumentEventMap[TKey]) => void,
+ options?: EventListenerOptions,
+): void {
+ const listenerRef = useRef(listener);
+
+ useIsomorphicLayoutEffect(() => {
+ listenerRef.current = listener;
+ }, [listener]);
+
+ useEffect(() => {
+ if (typeof document === "undefined") return;
+
+ const handleEvent = (event: DocumentEventMap[TKey]): void => {
+ listenerRef.current(event);
+ };
+
+ document.addEventListener(type, handleEvent, options);
+ return () => {
+ document.removeEventListener(type, handleEvent, options);
+ };
+ }, [options, type]);
+}
+
+type WindowEventListenerOptions = {
+ enabled?: boolean;
+ options?: EventListenerOptions;
+};
+
+export function useWindowEventListener(
+ type: TKey,
+ listener: (event: WindowEventMap[TKey]) => void,
+ { enabled = true, options }: WindowEventListenerOptions = {},
+): void {
+ const listenerRef = useRef(listener);
+
+ useIsomorphicLayoutEffect(() => {
+ listenerRef.current = listener;
+ }, [listener]);
+
+ useEffect(() => {
+ if (!enabled || typeof window === "undefined") return;
+
+ const handleEvent = (event: WindowEventMap[TKey]): void => {
+ listenerRef.current(event);
+ };
+
+ window.addEventListener(type, handleEvent, options);
+ return () => {
+ window.removeEventListener(type, handleEvent, options);
+ };
+ }, [enabled, options, type]);
+}