-
Notifications
You must be signed in to change notification settings - Fork 68.3k
Expand file tree
/
Copy pathCodeTabsGroup.tsx
More file actions
164 lines (146 loc) · 5.43 KB
/
Copy pathCodeTabsGroup.tsx
File metadata and controls
164 lines (146 loc) · 5.43 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
import {
createContext,
useCallback,
useContext,
useEffect,
useId,
useMemo,
useState,
isValidElement,
Children,
cloneElement,
type ReactElement,
type ReactNode,
type MouseEvent as ReactMouseEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from 'react'
import { useRouter } from 'next/router'
import { UnderlineNav } from '@primer/react'
import cx from 'classnames'
import Cookies from '@/frame/components/lib/cookies'
import { CODE_SAMPLE_LANGUAGE_COOKIE_NAME } from '@/frame/lib/constants'
import { sendEvent } from '@/events/components/events'
import { EventType } from '@/events/types'
import { useTranslation } from '@/languages/components/useTranslation'
// React-native replacement for the imperative CodeTabs enhancer (#6619). The old
// component scanned `#article-contents` for `.ghd-codetabs`, inserted a foreign
// `.ghd-codetabs-nav` mountPoint as the container's first child, portaled a nav
// into it, and toggled panel attributes — destructive surgery on React-owned
// nodes that breaks on client-side navigation teardown. Instead, the article body
// hast maps each `.ghd-codetabs` container to <CodeTabsGroup>, which reads its
// `.ghd-codetab` panel children straight from props and renders the nav + panels
// itself. No DOM scanning, no portal, no foreign nodes.
//
// The selected language lives in CodeLanguageContext so multiple code-tab groups
// on one page stay in sync and share the language cookie, matching the previous
// single-component behavior.
type CodeLanguageContextT = {
language: string
setLanguage: (value: string) => void
}
const CodeLanguageContext = createContext<CodeLanguageContextT>({
language: '',
setLanguage: () => {},
})
export function CodeTabsProvider({ children }: { children: ReactNode }) {
// Start empty so server + first client render select each group's first tab
// (deterministic, hydration-safe). The cookie preference is applied after
// hydration, the same moment the old imperative enhancer used to run.
const [language, setLanguageState] = useState('')
useEffect(() => {
const cookieValue = Cookies.get(CODE_SAMPLE_LANGUAGE_COOKIE_NAME)
if (cookieValue) setLanguageState(cookieValue)
}, [])
const setLanguage = useCallback((value: string) => {
setLanguageState(value)
Cookies.set(CODE_SAMPLE_LANGUAGE_COOKIE_NAME, value)
sendEvent({
type: EventType.preference,
preference_name: 'code_language',
preference_value: value,
})
}, [])
const value = useMemo<CodeLanguageContextT>(
() => ({ language, setLanguage }),
[language, setLanguage],
)
return <CodeLanguageContext.Provider value={value}>{children}</CodeLanguageContext.Provider>
}
type PanelTab = {
key: string
label: string
panel: ReactElement<{ className?: string }>
}
function hasClass(className: unknown, target: string): boolean {
return String(className || '')
.split(/\s+/)
.includes(target)
}
function getActiveKey(tabs: PanelTab[], selectedLanguage: string): string {
return tabs.some((tab) => tab.key === selectedLanguage) ? selectedLanguage : (tabs[0]?.key ?? '')
}
type CodeTabsGroupProps = {
className?: string
children?: ReactNode
[key: string]: unknown
}
export function CodeTabsGroup({ className, children, ...rest }: CodeTabsGroupProps) {
const router = useRouter()
const { t } = useTranslation('code_tabs')
const { language, setLanguage } = useContext(CodeLanguageContext)
const baseId = useId()
// Pull the `.ghd-codetab` panel children straight from the converted hast. Fail
// open (render the original markup) if the expected metadata isn't present.
const tabs: PanelTab[] = Children.toArray(children)
.filter((child): child is ReactElement<{ className?: string }> => isValidElement(child))
.filter((child) => hasClass(child.props.className, 'ghd-codetab'))
.map((panel) => {
const props = panel.props as { 'data-lang'?: string; 'data-label'?: string }
const key = props['data-lang']
const label = props['data-label']
if (!key || !label) return null
return { key, label, panel }
})
.filter((tab): tab is PanelTab => tab !== null)
if (!tabs.length) {
return (
<div className={className} {...rest}>
{children}
</div>
)
}
const activeKey = getActiveKey(tabs, language)
return (
<div className={cx(className, 'ghd-codetabs-enhanced')} {...rest}>
<div className="ghd-codetabs-nav">
{/* key on asPath works around a Primer UnderlineNav re-render bug. */}
<UnderlineNav key={router.asPath} aria-label={t('aria_label')} variant="flush">
{tabs.map((tab) => (
<UnderlineNav.Item
key={tab.key}
href={`#${tab.key}`}
aria-current={tab.key === activeKey ? 'page' : undefined}
onSelect={(event: ReactMouseEvent | ReactKeyboardEvent) => {
event.preventDefault()
setLanguage(tab.key)
}}
>
{tab.label}
</UnderlineNav.Item>
))}
</UnderlineNav>
</div>
{tabs.map((tab, index) => {
const isActive = tab.key === activeKey
return cloneElement(tab.panel, {
key: tab.key,
id: `${baseId}-panel-${index}`,
role: 'tabpanel',
tabIndex: 0,
hidden: !isActive,
className: cx(tab.panel.props.className, { 'ghd-codetab-hidden': !isActive }),
} as Record<string, unknown>)
})}
</div>
)
}