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 ( +
+
+
+ {content} +
+
+
+ ); +} + +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 ( +
+
+
+ {content} +
+
+
+ ); +} + +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]); +}