Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion src/components/ui/CodeSnippet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 => {
Expand All @@ -92,6 +102,8 @@ const CodeSnippet: React.FC<CodeSnippetProps> = ({
showCodeLines = true,
languageOrdering,
wrapCode = false,
collapse = true,
collapsedLineCount,
}) => {
const codeRef = useRef<HTMLDivElement>(null);

Expand Down Expand Up @@ -376,6 +388,18 @@ const CodeSnippet: React.FC<CodeSnippetProps> = ({
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;
Expand Down Expand Up @@ -571,7 +595,13 @@ const CodeSnippet: React.FC<CodeSnippetProps> = ({
onFocus={() => setIsHovering(true)}
onBlur={() => setIsHovering(false)}
>
{renderContent}
{collapse && hasSnippetForActiveLanguage ? (
<CollapsibleCode key={activeLanguage} lineCount={activeLineCount} maxLines={collapsedLineCount}>
{renderContent}
</CollapsibleCode>
) : (
renderContent
)}
{isHovering && activeLanguage && hasSnippetForActiveLanguage && (
<CopyButton
onCopy={() => {
Expand Down
43 changes: 43 additions & 0 deletions src/components/ui/CodeSnippet/CollapsibleCode.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<CollapsibleCode lineCount={5} maxLines={15}>
<pre>short snippet</pre>
</CollapsibleCode>,
);

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(
<CollapsibleCode lineCount={40} maxLines={15}>
<pre>long snippet</pre>
</CollapsibleCode>,
);

const toggle = screen.getByRole('button', { name: /show more/i });
expect(toggle).toHaveAttribute('aria-expanded', 'false');
});

it('toggles between "Show more" and "Show less"', () => {
render(
<CollapsibleCode lineCount={40} maxLines={15}>
<pre>long snippet</pre>
</CollapsibleCode>,
);

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();
});
});
125 changes: 125 additions & 0 deletions src/components/ui/CodeSnippet/CollapsibleCode.tsx
Original file line number Diff line number Diff line change
@@ -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<CollapsibleCodeProps> = ({
children,
lineCount,
maxLines = DEFAULT_COLLAPSED_LINE_COUNT,
}) => {
const collapsible = lineCount > maxLines;
const contentRef = useRef<HTMLDivElement>(null);
const [expanded, setExpanded] = useState(false);
const [collapsedHeight, setCollapsedHeight] = useState<number>();

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 = (
<button
type="button"
onClick={() => setExpanded((value) => !value)}
aria-expanded={expanded}
className={cn(
'pointer-events-auto inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-medium shadow-sm transition-colors',
'border border-neutral-300 dark:border-neutral-1000',
'bg-neutral-000 dark:bg-neutral-1300 text-neutral-800 dark:text-neutral-400',
'hover:bg-neutral-100 dark:hover:bg-neutral-1100 focus-base',
)}
>
<span>{expanded ? 'Show less' : 'Show more'}</span>
<ChevronDownIcon className={cn('size-[16px] transition-transform', expanded && 'rotate-180')} aria-hidden />
</button>
);

return (
<>
<div
ref={contentRef}
className={cn('relative', !expanded && 'overflow-hidden')}
style={!expanded && collapsedHeight ? { maxHeight: collapsedHeight } : undefined}
>
{children}
{!expanded && (
<>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-neutral-000 dark:from-neutral-1300 to-transparent"
/>
{/* Float the pill over the fade so it doesn't add a bordered band of its own. */}
<div className="pointer-events-none absolute inset-x-0 bottom-3 flex justify-center">{toggle}</div>
</>
)}
</div>
{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.
<div className="-mt-2 flex justify-center bg-neutral-000 pb-2 dark:bg-neutral-1300">{toggle}</div>
)}
</>
);
};

export default CollapsibleCode;
Loading