From 2cfafaac69868d86049bda41111e3fc446908c58 Mon Sep 17 00:00:00 2001 From: Mark Hulbert <39801222+m-hulbert@users.noreply.github.com> Date: Mon, 13 Jul 2026 17:36:36 +0100 Subject: [PATCH] feat(codeblock): collapse long snippets behind a "Show more" toggle Clip code blocks longer than 15 lines (configurable via collapsedLineCount) behind a fade-out gradient and a "Show more"/"Show less" toggle. The toggle is a floating centered pill rather than a full-width bordered bar, so it never reads as a second footer row when the API key selector sits below it. Authors can opt out per block with collapse={false}. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/components/ui/CodeSnippet.tsx | 32 ++++- .../ui/CodeSnippet/CollapsibleCode.test.tsx | 43 ++++++ .../ui/CodeSnippet/CollapsibleCode.tsx | 125 ++++++++++++++++++ 3 files changed, 199 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/CodeSnippet/CollapsibleCode.test.tsx create mode 100644 src/components/ui/CodeSnippet/CollapsibleCode.tsx diff --git a/src/components/ui/CodeSnippet.tsx b/src/components/ui/CodeSnippet.tsx index 9c9564e1d0..95ce3d3a66 100644 --- a/src/components/ui/CodeSnippet.tsx +++ b/src/components/ui/CodeSnippet.tsx @@ -8,6 +8,7 @@ import { getLanguageInfo, stripSdkType, SDK_PREFIXES, SDKType } from './CodeSnip import LanguageSelector from './CodeSnippet/LanguageSelector'; import ApiKeySelector from './CodeSnippet/ApiKeySelector'; import PlainCodeView from './CodeSnippet/PlainCodeView'; +import CollapsibleCode from './CodeSnippet/CollapsibleCode'; import CopyButton from './CodeSnippet/CopyButton'; import SegmentedControl from 'src/components/ui/SegmentedControl'; import { CommandLineIcon, ExclamationTriangleIcon } from '@heroicons/react/24/outline'; @@ -70,6 +71,15 @@ export type CodeSnippetProps = { * Whether to wrap code content instead of scrolling */ wrapCode?: boolean; + /** + * Whether long snippets collapse behind a "Show more" toggle. Defaults to true. + * Set `collapse={false}` in MDX to always show the full snippet. + */ + collapse?: boolean; + /** + * Number of lines shown before a snippet collapses. Defaults to 15. + */ + collapsedLineCount?: number; }; const substituteApiKey = (content: string, apiKey: string, mask = true): string => { @@ -92,6 +102,8 @@ const CodeSnippet: React.FC = ({ showCodeLines = true, languageOrdering, wrapCode = false, + collapse = true, + collapsedLineCount, }) => { const codeRef = useRef(null); @@ -376,6 +388,18 @@ const CodeSnippet: React.FC = ({ selectedApiKey, ]); + const activeLineCount = useMemo(() => { + if (!activeLanguage) { + return 0; + } + const targetLanguage = hasOnlyJsonSnippet ? 'json' : activeLanguage; + const content = codeData.find((code) => code?.language === targetLanguage)?.content; + if (typeof content !== 'string') { + return 0; + } + return content.trimEnd().split(/\r\n|\r|\n/).length; + }, [activeLanguage, hasOnlyJsonSnippet, codeData]); + const hasSnippetForActiveLanguage = useMemo(() => { if (!activeLanguage) { return false; @@ -571,7 +595,13 @@ const CodeSnippet: React.FC = ({ onFocus={() => setIsHovering(true)} onBlur={() => setIsHovering(false)} > - {renderContent} + {collapse && hasSnippetForActiveLanguage ? ( + + {renderContent} + + ) : ( + renderContent + )} {isHovering && activeLanguage && hasSnippetForActiveLanguage && ( { diff --git a/src/components/ui/CodeSnippet/CollapsibleCode.test.tsx b/src/components/ui/CodeSnippet/CollapsibleCode.test.tsx new file mode 100644 index 0000000000..85eb8bcab1 --- /dev/null +++ b/src/components/ui/CodeSnippet/CollapsibleCode.test.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import CollapsibleCode from './CollapsibleCode'; + +describe('CollapsibleCode', () => { + it('renders children without a toggle when at or under the line limit', () => { + render( + +
short snippet
+
, + ); + + expect(screen.getByText('short snippet')).toBeInTheDocument(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('shows a "Show more" toggle when the snippet exceeds the line limit', () => { + render( + +
long snippet
+
, + ); + + const toggle = screen.getByRole('button', { name: /show more/i }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + }); + + it('toggles between "Show more" and "Show less"', () => { + render( + +
long snippet
+
, + ); + + fireEvent.click(screen.getByRole('button', { name: /show more/i })); + + const toggle = screen.getByRole('button', { name: /show less/i }); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + + fireEvent.click(toggle); + expect(screen.getByRole('button', { name: /show more/i })).toBeInTheDocument(); + }); +}); diff --git a/src/components/ui/CodeSnippet/CollapsibleCode.tsx b/src/components/ui/CodeSnippet/CollapsibleCode.tsx new file mode 100644 index 0000000000..d7e676378b --- /dev/null +++ b/src/components/ui/CodeSnippet/CollapsibleCode.tsx @@ -0,0 +1,125 @@ +import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import cn from 'src/utilities/cn'; +import { ChevronDownIcon } from '@heroicons/react/24/outline'; + +/** + * Number of lines shown before a code block collapses behind a "Show more" toggle. + */ +export const DEFAULT_COLLAPSED_LINE_COUNT = 15; + +type CollapsibleCodeProps = { + /** + * The rendered code block to collapse. + */ + children: React.ReactNode; + /** + * Number of lines in the active snippet. Used to decide whether to collapse and to + * compute the collapsed height. + */ + lineCount: number; + /** + * Number of lines to show before collapsing. Defaults to {@link DEFAULT_COLLAPSED_LINE_COUNT}. + */ + maxLines?: number; +}; + +/** + * Clips a rendered code block to `maxLines` with a fade-out and a "Show more" toggle when the + * snippet is longer. Snippets at or under the limit render untouched. + */ +const CollapsibleCode: React.FC = ({ + children, + lineCount, + maxLines = DEFAULT_COLLAPSED_LINE_COUNT, +}) => { + const collapsible = lineCount > maxLines; + const contentRef = useRef(null); + const [expanded, setExpanded] = useState(false); + const [collapsedHeight, setCollapsedHeight] = useState(); + + const measure = useCallback(() => { + const element = contentRef.current; + if (!element || !collapsible || lineCount === 0) { + return; + } + + // scrollHeight reflects the full content height regardless of the maxHeight clip. + const fullHeight = element.scrollHeight; + const inner = element.firstElementChild ?? element; + const styles = window.getComputedStyle(inner); + const paddingTop = parseFloat(styles.paddingTop) || 0; + const paddingBottom = parseFloat(styles.paddingBottom) || 0; + const perLine = (fullHeight - paddingTop - paddingBottom) / lineCount; + + setCollapsedHeight(Math.round(paddingTop + perLine * maxLines)); + }, [collapsible, lineCount, maxLines]); + + useLayoutEffect(() => { + measure(); + }, [measure, children]); + + // Re-measure when the block resizes (responsive wrapping, font loading). + useEffect(() => { + const element = contentRef.current; + if (!element || !collapsible || typeof ResizeObserver === 'undefined') { + return; + } + + const observer = new ResizeObserver(() => measure()); + observer.observe(element); + + return () => observer.disconnect(); + }, [collapsible, measure]); + + if (!collapsible) { + return <>{children}; + } + + // A compact centered pill rather than a full-width bordered bar, so it never reads as a + // second footer row when another band (e.g. the API key selector) sits directly below. + const toggle = ( + + ); + + return ( + <> +
+ {children} + {!expanded && ( + <> +
+ {/* Float the pill over the fade so it doesn't add a bordered band of its own. */} +
{toggle}
+ + )} +
+ {expanded && ( + // Seamless extension of the code block (same background, no border) so the pill sits + // below the content without introducing a divider that competes with rows beneath it. +
{toggle}
+ )} + + ); +}; + +export default CollapsibleCode;