-
-
Notifications
You must be signed in to change notification settings - Fork 1.5k
/
Copy pathcodeTabs.tsx
183 lines (155 loc) · 5.35 KB
/
codeTabs.tsx
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
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
'use client';
import {useContext, useEffect, useRef, useState} from 'react';
import styled from '@emotion/styled';
import {CodeBlockProps} from './codeBlock';
import {CodeContext} from './codeContext';
// human readable versions of names
const HUMAN_LANGUAGE_NAMES = {
coffee: 'CoffeeScript',
cpp: 'C++',
csharp: 'C#',
es6: 'JavaScript (ES6)',
fsharp: 'F#',
html: 'HTML',
javascript: 'JavaScript',
json: 'JSON',
jsx: 'JSX',
php: 'PHP',
powershell: 'PowerShell',
typescript: 'TypeScript',
yaml: 'YAML',
yml: 'YAML',
};
interface CodeTabProps {
children: React.ReactElement<CodeBlockProps> | React.ReactElement<CodeBlockProps>[];
}
export function CodeTabs({children}: CodeTabProps) {
const codeBlocks = Array.isArray(children) ? [...children] : [children];
// the idea here is that we have two selection states. The shared selection
// always wins unless what is in the shared selection does not exist on the
// individual code block. In that case the local selection overrides. The
// final selection is what is then rendered.
const codeContext = useContext(CodeContext);
const [localSelection, setLocalSelection] = useState<string | null>(null);
const [lastScrollOffset, setLastScrollOffset] = useState<number | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
// When the selection switches we scroll so that the box that was toggled
// stays scrolled like it was before. This is because boxes above the changed
// box might also toggle and change height.
useEffect(() => {
if (containerRef.current === null) {
return;
}
if (lastScrollOffset !== null) {
const diff = containerRef.current.getBoundingClientRect().y - lastScrollOffset;
window.scroll(window.scrollX, window.scrollY + diff);
setLastScrollOffset(null);
}
}, [lastScrollOffset]);
// The title is what we use for sorting and also for remembering the
// selection. If there is no title fall back to the title cased language name
// (or override from `LANGUAGES`).
const possibleChoices = codeBlocks.map<string>(({props: {title, language}}) => {
if (title) {
return title;
}
if (!language) {
return 'Text';
}
if (language in HUMAN_LANGUAGE_NAMES) {
return HUMAN_LANGUAGE_NAMES[language];
}
return language[0].toUpperCase() + language.substring(1);
});
// disambiguate duplicates by enumerating them.
const tabTitleSeen: Record<string, number> = {};
possibleChoices.forEach((tabTitle, index) => {
const hasMultiple = possibleChoices.filter(x => x === tabTitle).length > 1;
if (hasMultiple) {
tabTitleSeen[tabTitle] ??= 0;
tabTitleSeen[tabTitle] += 1;
possibleChoices[index] = `${tabTitle} ${tabTitleSeen[tabTitle]}`;
}
});
// The groupId is used to store the selection in localStorage.
// It is a unique identifier based on the tab titles.
const groupId = 'Tabgroup:' + possibleChoices.sort().join('|');
const [sharedSelections, setSharedSelections] = codeContext?.sharedCodeSelection ?? [];
const sharedSelectionChoice = sharedSelections
? possibleChoices.find(x => x === sharedSelections[groupId])
: null;
const localSelectionChoice = localSelection
? possibleChoices.find(x => x === localSelection)
: null;
// Prioritize sharedSelectionChoice over the local selection
const finalSelection =
sharedSelectionChoice ?? localSelectionChoice ?? possibleChoices[0];
// Save the selected tab for Tabgroup to localStorage whenever it changes
useEffect(() => {
localStorage.setItem(groupId, finalSelection);
}, [finalSelection]);
// Whenever local selection and the final selection are not in sync, the local
// selection is updated from the final one. This means that when the shared
// selection moves to something that is unsupported by the block it stays on
// its last selection.
useEffect(() => setLocalSelection(finalSelection), [finalSelection]);
const selectedIndex = possibleChoices.indexOf(finalSelection);
const code = codeBlocks[selectedIndex];
const buttons = possibleChoices.map((choice, idx) => (
<TabButton
key={idx}
data-active={choice === finalSelection || possibleChoices.length === 1}
onClick={() => {
if (containerRef.current) {
// see useEffect above.
setLastScrollOffset(containerRef.current.getBoundingClientRect().y);
}
setSharedSelections?.([groupId, choice]);
setLocalSelection(choice);
}}
>
{choice}
</TabButton>
));
return (
<Container ref={containerRef}>
<TabBar>{buttons}</TabBar>
<div className="tab-content" data-sentry-mask>
{code}
</div>
</Container>
);
}
const Container = styled('div')`
margin-bottom: 1.5rem;
pre[class*='language-'] {
padding: 10px 12px;
border-radius: 0 0 3px 3px;
}
`;
const TabBar = styled('div')`
background: #251f3d;
border-bottom: 1px solid #40364a;
height: 36px;
display: flex;
align-items: center;
padding: 0 0.5rem;
border-radius: 3px 3px 0 0;
`;
const TabButton = styled('button')`
color: #9481a4;
padding: 7px 6px 4px;
display: inline-block;
cursor: pointer;
border: none;
font-size: 0.75rem;
background: none;
outline: none;
border-bottom: 3px solid transparent;
&:focus,
&[data-active='true'] {
color: #fff;
font-weight: 500;
border-bottom-color: #6c5fc7;
}
`;