@primer/doctocat-nextjs 0.5.4-rc.bd27497 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,9 +4,20 @@
4
4
 
5
5
  ### Patch Changes
6
6
 
7
+ - [#51](https://github.com/primer/doctocat-nextjs/pull/51) [`4c76c4f`](https://github.com/primer/doctocat-nextjs/commit/4c76c4f5ccd248ec7d1f448c054808287a3ff51d) Thanks [@rezrah](https://github.com/rezrah)! - Add auto-collapsed React code blocks for large code snippets. This feature only applies to code fences with the `jsx live` language identifiers.
8
+
9
+ E.g.
10
+
11
+ ```jsx live
12
+ <>Your code</>
13
+ ```
14
+
7
15
  - [#48](https://github.com/primer/doctocat-nextjs/pull/48) [`ce73c24`](https://github.com/primer/doctocat-nextjs/commit/ce73c24b2e4e924667bf7446a504bd88d8f2ccf0) Thanks [@rezrah](https://github.com/rezrah)! - - Fix inline code font-size in markdown headings. Now inherits size used in the heading.
16
+
8
17
  - Increased spacing below React code blocks, which was previously too small.
9
18
 
19
+ - [#50](https://github.com/primer/doctocat-nextjs/pull/50) [`5d67989`](https://github.com/primer/doctocat-nextjs/commit/5d679895408c1a58342419692db4234dfddefd80) Thanks [@rezrah](https://github.com/rezrah)! - Add `menu-position` frontmatter support for custom sidebar navigation ordering
20
+
10
21
  ## 0.5.3
11
22
 
12
23
  ### Patch Changes
@@ -28,6 +28,11 @@
28
28
  font-family: var(--brand-fontStack-monospace);
29
29
  background-color: var(--brand-color-canvas-subtle);
30
30
  overflow: auto;
31
+ position: relative;
32
+ }
33
+
34
+ .EditorWrapper {
35
+ position: relative;
31
36
  }
32
37
 
33
38
  .Editor pre {
@@ -37,6 +42,11 @@
37
42
  background-color: var(--brand-color-canvas-subtle) !important;
38
43
  }
39
44
 
45
+ .Editor--is-collapsed {
46
+ max-height: 400px;
47
+ overflow: hidden;
48
+ }
49
+
40
50
  .Toolbar {
41
51
  display: flex;
42
52
  gap: var(--base-size-8);
@@ -67,3 +77,44 @@
67
77
  .colorModeButtonActive {
68
78
  background-color: var(--brand-color-canvas-subtle);
69
79
  }
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
+ }
@@ -1,30 +1,66 @@
1
1
  'use client'
2
- import React, {PropsWithChildren, useCallback, useState} from 'react'
2
+ import React, {type PropsWithChildren, useCallback, useState, useRef, useEffect, useId} from 'react'
3
3
  import clsx from 'clsx'
4
4
  import {LiveProvider, LiveEditor, LiveError, LivePreview} from 'react-live'
5
5
  import {useColorMode} from '../../context/color-modes/useColorMode'
6
6
  import {useConfig} from '../../context/useConfig'
7
7
  import styles from './ReactCodeBlock.module.css'
8
8
  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'
10
11
  import {colorModes} from '../../context/color-modes/context'
11
12
 
12
13
  import {lightTheme, darkTheme} from './syntax-highlighting-themes'
13
14
  import {codeTransformer} from './code-transformer'
14
15
 
16
+ const COLLAPSE_HEIGHT = 400 // TODO: Hoist this to config to make user customizable eventually
17
+
15
18
  type ReactCodeBlockProps = {
16
19
  'data-language': string
17
20
  'data-filename'?: string
18
21
  jsxScope: Record<string, unknown>
19
22
  } & PropsWithChildren<HTMLElement>
20
23
 
24
+ const getFocusableElements = () => {
25
+ const focusableElementsQuery = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
26
+
27
+ return Array.from(document.querySelectorAll<HTMLElement>(focusableElementsQuery)).filter(el => {
28
+ const style = window.getComputedStyle(el)
29
+ return style.display !== 'none' && style.visibility !== 'hidden' && !el.hasAttribute('disabled')
30
+ })
31
+ }
32
+
21
33
  export function ReactCodeBlock(props: ReactCodeBlockProps) {
34
+ const uniqueId = useId()
22
35
  const {colorMode, setColorMode} = useColorMode()
23
36
  const {basePath} = useConfig()
24
37
  const initialCode = getCodeFromChildren(props.children)
25
38
  const [code, setCode] = useState(initialCode)
39
+ const rootRef = useRef<HTMLDivElement>(null)
40
+ const [isCodePaneCollapsed, setIsCodePaneCollapsed] = useState<boolean | null>(null)
41
+ const [initialPosition, setInitialPosition] = useState<number | null>(null)
42
+ const editorRef = useRef<HTMLDivElement>(null)
43
+ const resetButtonRef = useRef<HTMLButtonElement>(null)
26
44
  const shouldShowPreview = ['tsx', 'jsx'].includes(props['data-language'])
27
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
63
+
28
64
  /**
29
65
  * Transforms code to prepend basePath to img src attributes
30
66
  */
@@ -41,12 +77,62 @@ export function ReactCodeBlock(props: ReactCodeBlockProps) {
41
77
  navigator.clipboard.writeText(code)
42
78
  }, [code])
43
79
 
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
+
44
95
  const noInline = props['data-filename'] === 'noinline' || false
45
96
 
97
+ useEffect(() => {
98
+ const editor = editorRef.current
99
+
100
+ if (!editor) return
101
+
102
+ const onKeyDown = (e: KeyboardEvent) => {
103
+ if (e.key !== 'Tab') {
104
+ return
105
+ }
106
+
107
+ if (e.shiftKey) {
108
+ e.preventDefault()
109
+ // We know that the previous focusable element is always the reset button
110
+ resetButtonRef.current?.focus()
111
+ return
112
+ }
113
+
114
+ const focusableElements = getFocusableElements()
115
+
116
+ const currentIndex = focusableElements.findIndex(el => el === resetButtonRef.current)
117
+
118
+ if (currentIndex !== -1) {
119
+ e.preventDefault()
120
+ const nextIndex = currentIndex + 1
121
+ focusableElements[nextIndex]?.focus()
122
+ }
123
+ }
124
+
125
+ editor.addEventListener('keydown', onKeyDown)
126
+
127
+ return () => {
128
+ editor.removeEventListener('keydown', onKeyDown)
129
+ }
130
+ }, [])
131
+
46
132
  return (
47
133
  <>
48
134
  <LiveProvider transformCode={transformCodeWithBasePath} code={code} scope={props.jsxScope} noInline={noInline}>
49
- <div className={clsx(styles.CodeBlock, 'custom-component')}>
135
+ <div ref={rootRef} className={clsx(styles.CodeBlock, 'custom-component')}>
50
136
  {shouldShowPreview && (
51
137
  <div>
52
138
  <div className={styles.colorModeMenu}>
@@ -76,12 +162,32 @@ export function ReactCodeBlock(props: ReactCodeBlockProps) {
76
162
  <Button size="small" leadingVisual={CopyIcon} onClick={handleCopy}>
77
163
  Copy
78
164
  </Button>
79
- <Button size="small" leadingVisual={UndoIcon} onClick={handleReset}>
165
+ <Button size="small" leadingVisual={UndoIcon} onClick={handleReset} ref={resetButtonRef}>
80
166
  Reset
81
167
  </Button>
82
168
  </div>
83
- <div className={styles.Editor}>
84
- <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
+ )}
85
191
  </div>
86
192
  {shouldShowPreview && (
87
193
  <div className={styles.Error}>
@@ -82,7 +82,29 @@ export function Sidebar({pageMap}: SidebarProps) {
82
82
  <NextLink href={item.route}>{subNavName}</NextLink>
83
83
  </NavList.GroupHeading>
84
84
  {item.children
85
- .sort((a, b) => ((a as MdxFile).name === 'index' ? -1 : (b as MdxFile).name === 'index' ? 1 : 0)) // puts index page first
85
+ .sort((a, b) => {
86
+ // make sure index page is first
87
+ if ((a as MdxFile).name === 'index') return -1
88
+ if ((b as MdxFile).name === 'index') return 1
89
+
90
+ // Check for menu-position property in frontmatter
91
+ const aPos = (a as MdxFile).frontMatter?.['menu-position']
92
+ const bPos = (b as MdxFile).frontMatter?.['menu-position']
93
+
94
+ // If both have menu-position, sort by menu-position
95
+ if (typeof aPos === 'number' && typeof bPos === 'number') {
96
+ return aPos - bPos
97
+ }
98
+
99
+ // If only one has menu-position, it comes first
100
+ if (typeof aPos === 'number') return -1
101
+ if (typeof bPos === 'number') return 1
102
+
103
+ // Neither has menu-position, sort alphabetically by title or name
104
+ const aTitle = (a as MdxFile).frontMatter?.title || (a as MdxFile).name
105
+ const bTitle = (b as MdxFile).frontMatter?.title || (b as MdxFile).name
106
+ return aTitle.localeCompare(bTitle)
107
+ })
86
108
  // only show index page if it has show-tabs
87
109
  .filter(child => (child as MdxFile).name !== 'index' || hasShowTabs(child as ExtendedPageItem))
88
110
  .map(child => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primer/doctocat-nextjs",
3
- "version": "0.5.4-rc.bd27497",
3
+ "version": "0.5.4",
4
4
  "description": "A Next.js theme for building Primer documentation sites",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/types.ts CHANGED
@@ -31,6 +31,7 @@ export type FrontMatter = {
31
31
  description?: string
32
32
  filePath?: string
33
33
  keywords?: string[]
34
+ menu_position?: number
34
35
  related?: {
35
36
  title: string
36
37
  href: string