Skip to content

Commit c9117aa

Browse files
feat: fixedHeight prop
1 parent 6b43827 commit c9117aa

File tree

1 file changed

+66
-46
lines changed

1 file changed

+66
-46
lines changed

src/react-components/code-sample.tsx

Lines changed: 66 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,19 @@
1-
import { domMax, LazyMotion } from "motion/react";
2-
import React, { useEffect, useMemo } from "react";
3-
import { SpeakeasyCodeSamplesCore } from "../core.js";
4-
import {
5-
GetCodeSamplesRequest,
6-
MethodPaths,
7-
} from "../models/operations/getcodesamples.js";
8-
import { OperationId } from "../types/custom.js";
9-
import { getMethodPath, useCodeSampleState } from "./code-sample.state.js";
1+
import {domMax, LazyMotion} from "motion/react";
2+
import React, {useEffect, useMemo} from "react";
3+
import {SpeakeasyCodeSamplesCore} from "../core.js";
4+
import {GetCodeSamplesRequest, MethodPaths,} from "../models/operations/getcodesamples.js";
5+
import {OperationId} from "../types/custom.js";
6+
import {getMethodPath, useCodeSampleState} from "./code-sample.state.js";
107
import classes from "./code-sample.styles.js";
11-
import { CodeViewer, ErrorDisplay } from "./code-viewer.js";
8+
import {CodeViewer, ErrorDisplay} from "./code-viewer.js";
129
import codehikeTheme from "./codehike/theme.js";
13-
import { CopyButton } from "./copy-button.js";
14-
import { LanguageSelectorSkeleton, LoadingSkeleton } from "./skeleton.js";
15-
import { getCssVars, useSystemColorMode } from "./styles.js";
16-
import {
17-
CodeSampleFilenameTitle,
18-
CodeSampleTitle,
19-
type CodeSampleTitleComponent,
20-
} from "./titles.js";
21-
import { prettyLanguageName } from "./utils.js";
22-
import { Selector } from "./selector";
23-
import { UsageSnippet } from "../models/components";
10+
import {CopyButton} from "./copy-button.js";
11+
import {LanguageSelectorSkeleton, LoadingSkeleton} from "./skeleton.js";
12+
import {getCssVars, useSystemColorMode} from "./styles.js";
13+
import {CodeSampleFilenameTitle, CodeSampleTitle, type CodeSampleTitleComponent,} from "./titles.js";
14+
import {prettyLanguageName} from "./utils.js";
15+
import {Selector} from "./selector";
16+
import {UsageSnippet} from "../models/components";
2417

2518
export type CodeSamplesViewerProps = {
2619
/** Whether the code snippet should be copyable. */
@@ -47,7 +40,8 @@ export type CodeSamplesViewerProps = {
4740
* @default CodeSampleMethodTitle
4841
*/
4942
title?: CodeSampleTitleComponent | React.ReactNode | string | false;
50-
/** The operations to get code samples for. If only one is provided, no selector will be shown.
43+
/**
44+
* The operations to get code samples for. If only one is provided, no selector will be shown.
5145
* Can be queried by either operationId or method+path.
5246
*/
5347
operations?: MethodPaths[] | OperationId[];
@@ -60,40 +54,47 @@ export type CodeSamplesViewerProps = {
6054
* Sets the style of the code window.
6155
*/
6256
codeWindowStyle?: React.CSSProperties;
57+
/**
58+
* If true, the code window will be fixed to the height of the longest code snippet.
59+
* This can be useful for preventing layout shifts when switching between code snippets.
60+
* Overrides any height set in codeWindowStyle.
61+
*/
62+
fixedHeight?: boolean;
6363

6464
className?: string | undefined;
6565
style?: React.CSSProperties;
6666
};
6767

6868
export function CodeSamplesViewer({
69-
theme = "system",
70-
title = CodeSampleFilenameTitle,
71-
defaultLanguage,
72-
operations,
73-
copyable,
74-
client: clientProp,
75-
style,
76-
codeWindowStyle,
77-
className,
78-
}: CodeSamplesViewerProps) {
69+
theme = "system",
70+
title = CodeSampleFilenameTitle,
71+
defaultLanguage,
72+
operations,
73+
copyable,
74+
client: clientProp,
75+
style,
76+
codeWindowStyle,
77+
fixedHeight,
78+
className,
79+
}: CodeSamplesViewerProps) {
7980
const requestParams: GetCodeSamplesRequest = React.useMemo(() => {
8081
if (typeof operations?.[0] === "string")
81-
return { operationIds: operations as OperationId[] };
82+
return {operationIds: operations as OperationId[]};
8283
else if (operations?.[0]?.method && operations[0].path)
83-
return { methodPaths: operations as MethodPaths[] };
84+
return {methodPaths: operations as MethodPaths[]};
8485

8586
return {};
8687
}, [operations]);
8788

88-
const { state, selectSnippet } = useCodeSampleState({
89+
const {state, selectSnippet} = useCodeSampleState({
8990
client: clientProp,
9091
requestParams,
9192
});
9293

9394
// On mount, select the defaults
9495
useEffect(() => {
9596
if (!state.snippets || state.status !== "success") return;
96-
selectSnippet({ language: defaultLanguage });
97+
selectSnippet({language: defaultLanguage});
9798
}, [state.status]);
9899

99100
const systemColorMode = useSystemColorMode();
@@ -105,13 +106,13 @@ export function CodeSamplesViewer({
105106
const languages: string[] = useMemo(() => {
106107
return [
107108
...new Set(
108-
state.snippets?.map(({ raw }) => prettyLanguageName(raw.language)),
109+
state.snippets?.map(({raw}) => prettyLanguageName(raw.language)),
109110
),
110111
];
111112
}, [state.snippets]);
112113

113114
const getOperationKey = (snippet: UsageSnippet | undefined): string => {
114-
let { operationId } = snippet;
115+
let {operationId} = snippet;
115116
const methodPathDisplay = getMethodPath(snippet);
116117
if (!operationId) {
117118
operationId = methodPathDisplay;
@@ -123,7 +124,7 @@ export function CodeSamplesViewer({
123124
// For the selector, we try to show operation ID but fall back on method+path if it's missing
124125
const operationIdToMethodAndPath: Record<string, string> = useMemo(() => {
125126
return Object.fromEntries(
126-
state.snippets?.map(({ raw }) => [
127+
state.snippets?.map(({raw}) => [
127128
getOperationKey(raw),
128129
getMethodPath(raw),
129130
]) ?? [],
@@ -132,6 +133,25 @@ export function CodeSamplesViewer({
132133

133134
const operationIds = Object.keys(operationIdToMethodAndPath);
134135

136+
const longestCodeHeight = React.useMemo(() => {
137+
const largestLines = Math.max(
138+
...Object.values(state.snippets ?? [])
139+
.filter((snippet) => snippet.code !== undefined)
140+
.map((code) => code.code!.split("\n").length),
141+
);
142+
143+
const lineHeight = 23;
144+
const padding = 12;
145+
return largestLines * lineHeight + padding * 2;
146+
}, [state.snippets]);
147+
148+
if (fixedHeight) {
149+
codeWindowStyle = {
150+
...codeWindowStyle,
151+
height: longestCodeHeight,
152+
};
153+
}
154+
135155
return (
136156
<LazyMotion strict features={domMax}>
137157
<div
@@ -151,10 +171,10 @@ export function CodeSamplesViewer({
151171
status={state.status}
152172
data={state.selectedSnippet?.raw}
153173
/>
154-
<div style={{ display: "flex", gap: "0.75rem" }}>
174+
<div style={{display: "flex", gap: "0.75rem"}}>
155175
{state.status === "loading" && (
156-
<div style={{ width: "180px" }}>
157-
<LanguageSelectorSkeleton />
176+
<div style={{width: "180px"}}>
177+
<LanguageSelectorSkeleton/>
158178
</div>
159179
)}
160180
{state.status === "success" && operationIds.length > 1 && (
@@ -175,7 +195,7 @@ export function CodeSamplesViewer({
175195
state.selectedSnippet?.raw.language,
176196
)}
177197
values={languages}
178-
onChange={(language: string) => selectSnippet({ language })}
198+
onChange={(language: string) => selectSnippet({language})}
179199
className={classes.selector}
180200
/>
181201
)}
@@ -184,10 +204,10 @@ export function CodeSamplesViewer({
184204
)}
185205
<div className={classes.codeContainer}>
186206
{state.status === "success" && copyable && (
187-
<CopyButton code={state.selectedSnippet.code} />
207+
<CopyButton code={state.selectedSnippet.code}/>
188208
)}
189-
{state.status === "loading" && <LoadingSkeleton />}
190-
{state.status === "error" && <ErrorDisplay error={state.error} />}
209+
{state.status === "loading" && <LoadingSkeleton/>}
210+
{state.status === "error" && <ErrorDisplay error={state.error}/>}
191211
{state.status === "success" && (
192212
<CodeViewer
193213
status={state.status}

0 commit comments

Comments
 (0)