Skip to content

Commit 4c76c4f

Browse files
authored
Add auto-collapsed React code blocks for large code snippets. (#51)
* Add auto-collapsed React code blocks for large code snippets. * fix linter * update alt texts * fix mona avatar width
1 parent c7bf4a2 commit 4c76c4f

4 files changed

Lines changed: 160 additions & 10 deletions

File tree

.changeset/bright-parrots-smash.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@primer/doctocat-nextjs': patch
3+
---
4+
5+
Add auto-collapsed React code blocks for large code snippets. This feature only applies to code fences with the `jsx live` language identifiers.
6+
7+
E.g.
8+
9+
```jsx live
10+
<>Your code</>
11+
```

packages/site/content/content-example/tabbed-component/react.mdx

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ You can place an icon inside the Button in either the leading or the trailing po
8282
</Stack>
8383
```
8484

85-
### Animation
85+
### Collapsed long code blocks
8686

8787
You can place an icon inside the Button in either the leading or the trailing position to enhance the visual context. It is recommended to use an [Octicon](https://primer.style/octicons) here.
8888

@@ -95,15 +95,43 @@ You can place an icon inside the Button in either the leading or the trailing po
9595
Filter
9696
</Button>
9797
</Animate>
98+
<Animate animate="fade-in">
99+
<Button leadingVisual={<SearchIcon />}>Search</Button>
100+
<Button trailingVisual={<ChevronDownIcon />}>Select</Button>
101+
<Button leadingVisual={<FilterIcon />} trailingVisual={<ChevronDownIcon />}>
102+
Filter
103+
</Button>
104+
</Animate>
105+
<Animate animate="fade-in">
106+
<Button leadingVisual={<SearchIcon />}>Search</Button>
107+
<Button trailingVisual={<ChevronDownIcon />}>Select</Button>
108+
<Button leadingVisual={<FilterIcon />} trailingVisual={<ChevronDownIcon />}>
109+
Filter
110+
</Button>
111+
</Animate>
112+
<Animate animate="fade-in">
113+
<Button leadingVisual={<SearchIcon />}>Search</Button>
114+
<Button trailingVisual={<ChevronDownIcon />}>Select</Button>
115+
<Button leadingVisual={<FilterIcon />} trailingVisual={<ChevronDownIcon />}>
116+
Filter
117+
</Button>
118+
</Animate>
119+
<Animate animate="fade-in">
120+
<Button leadingVisual={<SearchIcon />}>Search</Button>
121+
<Button trailingVisual={<ChevronDownIcon />}>Select</Button>
122+
<Button leadingVisual={<FilterIcon />} trailingVisual={<ChevronDownIcon />}>
123+
Filter
124+
</Button>
125+
</Animate>
98126
</AnimationProvider>
99127
```
100128

101129
### Images
102130

103-
```
131+
```jsx live
104132
<>
105-
<img src="/images/avatar-mona.png" />
106-
<Avatar src="/images/avatar-mona.png" />
133+
<img src="/images/avatar-mona.png" alt="Mona profile" width="36" />
134+
<Avatar src="/images/avatar-mona.png" alt="Mona profile" />
107135
</>
108136
```
109137

packages/theme/components/layout/code-block/ReactCodeBlock.module.css

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@
2828
font-family: var(--brand-fontStack-monospace);
2929
background-color: var(--brand-color-canvas-subtle);
3030
overflow: auto;
31+
position: relative;
32+
}
33+
34+
.EditorWrapper {
35+
position: relative;
3136
}
3237

3338
.Editor pre {
@@ -37,6 +42,11 @@
3742
background-color: var(--brand-color-canvas-subtle) !important;
3843
}
3944

45+
.Editor--is-collapsed {
46+
max-height: 400px;
47+
overflow: hidden;
48+
}
49+
4050
.Toolbar {
4151
display: flex;
4252
gap: var(--base-size-8);
@@ -67,3 +77,44 @@
6777
.colorModeButtonActive {
6878
background-color: var(--brand-color-canvas-subtle);
6979
}
80+
81+
.collapseButton {
82+
position: relative;
83+
display: flex;
84+
justify-content: center;
85+
width: 100%;
86+
background-color: var(--brand-color-canvas-subtle);
87+
border: none;
88+
border-top: var(--brand-borderWidth-thin) solid var(--brand-color-border-default);
89+
border-bottom-left-radius: var(--brand-borderRadius-medium);
90+
border-bottom-right-radius: var(--brand-borderRadius-medium);
91+
padding: var(--base-size-8) var(--base-size-12);
92+
font-size: var(--brand-text-size-100);
93+
font-family: var(--brand-fontStack-system);
94+
cursor: pointer;
95+
display: flex;
96+
align-items: center;
97+
gap: var(--base-size-4);
98+
z-index: 10;
99+
transition: background-color var(--brand-animation-duration-default) var(--brand-animation-easing-glide);
100+
}
101+
102+
.collapseButton--collapsed::before {
103+
content: '';
104+
position: absolute;
105+
top: -41px;
106+
left: 0;
107+
width: 100%;
108+
height: 40px;
109+
background: linear-gradient(to bottom, transparent, var(--brand-color-canvas-subtle));
110+
z-index: -1;
111+
}
112+
113+
.collapseButton:hover,
114+
.collapseButton:focus {
115+
background-color: var(--brand-color-canvas-default);
116+
}
117+
118+
.collapseLabel {
119+
font-size: var(--brand-text-size-100) !important;
120+
}

packages/theme/components/layout/code-block/ReactCodeBlock.tsx

Lines changed: 66 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
'use client'
2-
import React, {type PropsWithChildren, useCallback, useState, useRef, useEffect} from 'react'
2+
import React, {type PropsWithChildren, useCallback, useState, useRef, useEffect, useId} from 'react'
33
import clsx from 'clsx'
44
import {LiveProvider, LiveEditor, LiveError, LivePreview} from 'react-live'
55
import {useColorMode} from '../../context/color-modes/useColorMode'
66
import {useConfig} from '../../context/useConfig'
77
import styles from './ReactCodeBlock.module.css'
88
import {ActionBar, Button, ThemeProvider} from '@primer/react'
9-
import {CopyIcon, MoonIcon, SunIcon, UndoIcon} from '@primer/octicons-react'
9+
import {Text} from '@primer/react-brand'
10+
import {CopyIcon, MoonIcon, SunIcon, UndoIcon, UnfoldIcon, FoldIcon} from '@primer/octicons-react'
1011
import {colorModes} from '../../context/color-modes/context'
1112

1213
import {lightTheme, darkTheme} from './syntax-highlighting-themes'
1314
import {codeTransformer} from './code-transformer'
1415

16+
const COLLAPSE_HEIGHT = 400 // TODO: Hoist this to config to make user customizable eventually
17+
1518
type ReactCodeBlockProps = {
1619
'data-language': string
1720
'data-filename'?: string
@@ -28,13 +31,35 @@ const getFocusableElements = () => {
2831
}
2932

3033
export function ReactCodeBlock(props: ReactCodeBlockProps) {
34+
const uniqueId = useId()
3135
const {colorMode, setColorMode} = useColorMode()
3236
const {basePath} = useConfig()
3337
const initialCode = getCodeFromChildren(props.children)
3438
const [code, setCode] = useState(initialCode)
35-
const shouldShowPreview = ['tsx', 'jsx'].includes(props['data-language'])
39+
const rootRef = useRef<HTMLDivElement>(null)
40+
const [isCodePaneCollapsed, setIsCodePaneCollapsed] = useState<boolean | null>(null)
41+
const [initialPosition, setInitialPosition] = useState<number | null>(null)
3642
const editorRef = useRef<HTMLDivElement>(null)
3743
const resetButtonRef = useRef<HTMLButtonElement>(null)
44+
const shouldShowPreview = ['tsx', 'jsx'].includes(props['data-language'])
45+
46+
// scroll back to the initial y pos on collapse state change
47+
useEffect(() => {
48+
if (rootRef.current && initialPosition === null) {
49+
const rect = rootRef.current.getBoundingClientRect()
50+
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
51+
setInitialPosition(rect.top + scrollTop)
52+
}
53+
}, [initialPosition])
54+
55+
useEffect(() => {
56+
if (editorRef.current && isCodePaneCollapsed === null) {
57+
const editorHeight = editorRef.current.scrollHeight
58+
setIsCodePaneCollapsed(editorHeight > COLLAPSE_HEIGHT)
59+
}
60+
}, [code, isCodePaneCollapsed])
61+
62+
const shouldShowCollapse = isCodePaneCollapsed !== null && (editorRef.current?.scrollHeight || 0) > COLLAPSE_HEIGHT
3863

3964
/**
4065
* Transforms code to prepend basePath to img src attributes
@@ -52,6 +77,21 @@ export function ReactCodeBlock(props: ReactCodeBlockProps) {
5277
navigator.clipboard.writeText(code)
5378
}, [code])
5479

80+
const handleCollapsibleCodePane = useCallback(() => {
81+
const newCollapsedState = !isCodePaneCollapsed
82+
83+
if (!isCodePaneCollapsed && newCollapsedState && initialPosition !== null) {
84+
requestAnimationFrame(() => {
85+
window.scrollTo({
86+
top: initialPosition,
87+
behavior: 'smooth',
88+
})
89+
})
90+
}
91+
92+
setIsCodePaneCollapsed(newCollapsedState)
93+
}, [isCodePaneCollapsed, initialPosition])
94+
5595
const noInline = props['data-filename'] === 'noinline' || false
5696

5797
useEffect(() => {
@@ -92,7 +132,7 @@ export function ReactCodeBlock(props: ReactCodeBlockProps) {
92132
return (
93133
<>
94134
<LiveProvider transformCode={transformCodeWithBasePath} code={code} scope={props.jsxScope} noInline={noInline}>
95-
<div className={clsx(styles.CodeBlock, 'custom-component')}>
135+
<div ref={rootRef} className={clsx(styles.CodeBlock, 'custom-component')}>
96136
{shouldShowPreview && (
97137
<div>
98138
<div className={styles.colorModeMenu}>
@@ -126,8 +166,28 @@ export function ReactCodeBlock(props: ReactCodeBlockProps) {
126166
Reset
127167
</Button>
128168
</div>
129-
<div className={styles.Editor} ref={editorRef}>
130-
<LiveEditor theme={colorMode === 'light' ? lightTheme : darkTheme} onChange={setCode} />
169+
<div className={styles.EditorWrapper}>
170+
<div
171+
className={clsx(styles.Editor, isCodePaneCollapsed && styles['Editor--is-collapsed'])}
172+
ref={editorRef}
173+
id={`${uniqueId}-code-editor-content`}
174+
>
175+
<LiveEditor theme={colorMode === 'light' ? lightTheme : darkTheme} onChange={setCode} />
176+
</div>
177+
{shouldShowCollapse && (
178+
<button
179+
className={clsx(styles.collapseButton, isCodePaneCollapsed && styles['collapseButton--collapsed'])}
180+
onClick={handleCollapsibleCodePane}
181+
aria-expanded={!isCodePaneCollapsed}
182+
aria-controls={`${uniqueId}-code-editor-content`}
183+
aria-label={isCodePaneCollapsed ? 'Show full code block' : 'Collapse code block'}
184+
>
185+
<Text size="100" className={styles.collapseLabel}>
186+
{isCodePaneCollapsed ? 'Show full code' : 'Collapse code'}
187+
</Text>
188+
<Text variant="muted">{isCodePaneCollapsed ? <UnfoldIcon /> : <FoldIcon />}</Text>
189+
</button>
190+
)}
131191
</div>
132192
{shouldShowPreview && (
133193
<div className={styles.Error}>

0 commit comments

Comments
 (0)