Skip to content

Commit 6fba80d

Browse files
feat(playground): persist framework and mode selection (#3169)
1 parent f3fae69 commit 6fba80d

File tree

1 file changed

+191
-42
lines changed

1 file changed

+191
-42
lines changed

src/components/global/Playground/index.tsx

Lines changed: 191 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useMemo, useRef, useState } from 'react';
1+
import React, { RefObject, forwardRef, useEffect, useMemo, useRef, useState } from 'react';
22

33
import useBaseUrl from '@docusaurus/useBaseUrl';
44
import './playground.css';
@@ -13,52 +13,64 @@ import TabItem from '@theme/TabItem';
1313

1414
import { IconHtml, IconTs, IconVue, IconDefault, IconCss, IconDots } from './icons';
1515

16-
const ControlButton = ({
17-
isSelected,
18-
handleClick,
19-
title,
20-
label,
21-
disabled,
22-
}: {
23-
isSelected: boolean;
24-
handleClick: () => void;
25-
title: string;
26-
label: string;
27-
disabled?: boolean;
28-
}) => {
29-
const controlButton = (
30-
<button
31-
title={disabled ? undefined : title}
32-
disabled={disabled}
33-
className={`playground__control-button ${isSelected ? 'playground__control-button--selected' : ''}`}
34-
onClick={handleClick}
35-
data-text={label}
36-
>
37-
{label}
38-
</button>
39-
);
40-
if (disabled) {
41-
return (
42-
<Tippy theme="playground" arrow={false} placement="bottom" content={`Unavailable for ${label}`}>
43-
{/* Tippy requires a wrapper element for disabled elements: https://atomiks.github.io/tippyjs/v5/creating-tooltips/#disabled-elements */}
44-
<div>{controlButton}</div>
45-
</Tippy>
16+
import { useScrollPositionBlocker } from '@docusaurus/theme-common';
17+
import useIsBrowser from '@docusaurus/useIsBrowser';
18+
19+
const ControlButton = forwardRef(
20+
(
21+
{
22+
isSelected,
23+
handleClick,
24+
title,
25+
label,
26+
disabled,
27+
}: {
28+
isSelected: boolean;
29+
handleClick: () => void;
30+
title: string;
31+
label: string;
32+
disabled?: boolean;
33+
},
34+
ref: RefObject<HTMLButtonElement>
35+
) => {
36+
const controlButton = (
37+
<button
38+
title={disabled ? undefined : title}
39+
disabled={disabled}
40+
className={`playground__control-button ${isSelected ? 'playground__control-button--selected' : ''}`}
41+
onClick={handleClick}
42+
data-text={label}
43+
ref={ref}
44+
>
45+
{label}
46+
</button>
4647
);
48+
if (disabled) {
49+
return (
50+
<Tippy theme="playground" arrow={false} placement="bottom" content={`Unavailable for ${label}`}>
51+
{/* Tippy requires a wrapper element for disabled elements: https://atomiks.github.io/tippyjs/v5/creating-tooltips/#disabled-elements */}
52+
<div>{controlButton}</div>
53+
</Tippy>
54+
);
55+
}
56+
return controlButton;
4757
}
48-
return controlButton;
49-
};
58+
);
5059

51-
const CodeBlockButton = ({ language, usageTarget, setUsageTarget, disabled }) => {
60+
const CodeBlockButton = ({ language, usageTarget, setAndSaveUsageTarget, disabled }) => {
61+
const buttonRef = useRef<HTMLButtonElement>(null);
5262
const langValue = UsageTarget[language];
63+
5364
return (
5465
<ControlButton
5566
isSelected={usageTarget === langValue}
5667
handleClick={() => {
57-
setUsageTarget(langValue);
68+
setAndSaveUsageTarget(langValue, buttonRef.current);
5869
}}
5970
title={`Show ${language} code`}
6071
label={language}
6172
disabled={disabled}
73+
ref={buttonRef}
6274
/>
6375
);
6476
};
@@ -158,21 +170,66 @@ export default function Playground({
158170

159171
const { isDarkTheme } = useThemeContext();
160172

173+
/**
174+
* When deploying, Docusaurus builds the app in an SSR environment.
175+
* We need to check whether we're in a browser so we know if we can
176+
* use the window or localStorage objects.
177+
*/
178+
const isBrowser = useIsBrowser();
179+
161180
const hostRef = useRef<HTMLDivElement | null>(null);
162181
const codeRef = useRef(null);
163182
const frameiOS = useRef<HTMLIFrameElement | null>(null);
164183
const frameMD = useRef<HTMLIFrameElement | null>(null);
165184
const consoleBodyRef = useRef<HTMLDivElement | null>(null);
166185

167-
const defaultMode = typeof mode !== 'undefined' ? mode : Mode.iOS;
186+
const { blockElementScrollPositionUntilNextRender } = useScrollPositionBlocker();
187+
188+
const getDefaultMode = () => {
189+
/**
190+
* If a custom mode was specified, use that.
191+
*/
192+
if (mode) return mode;
193+
194+
/**
195+
* Otherwise, if there is a saved mode from previously clicking
196+
* the mode button, use that.
197+
*/
198+
if (isBrowser) {
199+
const storedMode = localStorage.getItem(MODE_STORAGE_KEY);
200+
if (storedMode) return storedMode;
201+
}
202+
203+
/**
204+
* Default to iOS mode as a fallback.
205+
*/
206+
return Mode.iOS;
207+
};
168208

169209
const getDefaultUsageTarget = () => {
170-
// If defined, Angular target should be the default
210+
/**
211+
* If there is a saved target from previously clicking the
212+
* framework buttons, and there is code for it, use that.
213+
*/
214+
if (isBrowser) {
215+
const storedTarget = localStorage.getItem(USAGE_TARGET_STORAGE_KEY);
216+
if (storedTarget && code[storedTarget] !== undefined) {
217+
return storedTarget;
218+
}
219+
}
220+
221+
/**
222+
* If there is no saved target, and Angular code is available,
223+
* default to that.
224+
*/
171225
if (code[UsageTarget.Angular] !== undefined) {
172226
return UsageTarget.Angular;
173227
}
174228

175-
// Otherwise, default to the first target passed.
229+
/**
230+
* If there is no Angular code available, fall back to the
231+
* first available framework.
232+
*/
176233
return Object.keys(code)[0];
177234
};
178235

@@ -182,7 +239,7 @@ export default function Playground({
182239
*/
183240
const frameSize = FRAME_SIZES[size] || size;
184241
const [usageTarget, setUsageTarget] = useState(getDefaultUsageTarget());
185-
const [ionicMode, setIonicMode] = useState(defaultMode);
242+
const [ionicMode, setIonicMode] = useState(getDefaultMode());
186243
const [codeSnippets, setCodeSnippets] = useState({});
187244
const [renderIframes, setRenderIframes] = useState(false);
188245
const [iframesLoaded, setIframesLoaded] = useState(false);
@@ -196,6 +253,52 @@ export default function Playground({
196253
*/
197254
const [resetCount, setResetCount] = useState(0);
198255

256+
const setAndSaveMode = (mode: Mode) => {
257+
setIonicMode(mode);
258+
259+
if (isBrowser) {
260+
localStorage.setItem(MODE_STORAGE_KEY, mode);
261+
262+
/**
263+
* Tell other playgrounds on the page that the mode has
264+
* updated, so they can sync up.
265+
*/
266+
window.dispatchEvent(
267+
new CustomEvent(MODE_UPDATED_EVENT, {
268+
detail: mode,
269+
})
270+
);
271+
}
272+
};
273+
274+
const setAndSaveUsageTarget = (target: UsageTarget, tab: HTMLElement) => {
275+
setUsageTarget(target);
276+
277+
if (isBrowser) {
278+
localStorage.setItem(USAGE_TARGET_STORAGE_KEY, target);
279+
280+
/**
281+
* This prevents the scroll position from jumping around if
282+
* there is a playground above this one with code that changes
283+
* in length between frameworks.
284+
*
285+
* Note that we don't need this when changing the mode because
286+
* the two mode iframes are always the same height.
287+
*/
288+
blockElementScrollPositionUntilNextRender(tab);
289+
290+
/**
291+
* Tell other playgrounds on the page that the framework
292+
* has updated, so they can sync up.
293+
*/
294+
window.dispatchEvent(
295+
new CustomEvent(USAGE_TARGET_UPDATED_EVENT, {
296+
detail: target,
297+
})
298+
);
299+
}
300+
};
301+
199302
/**
200303
* Rather than encode isDarkTheme into the frame source
201304
* url, we post a message to each frame so that
@@ -324,6 +427,47 @@ export default function Playground({
324427
io.observe(hostRef.current!);
325428
});
326429

430+
/**
431+
* Sometimes, the app isn't fully hydrated on the first render,
432+
* causing isBrowser to be set to false even if running the app
433+
* in a browser (vs. SSR). isBrowser is then updated on the next
434+
* render cycle.
435+
*
436+
* This useEffect contains code that can only run in the browser,
437+
* and also needs to run on that first go-around. Note that
438+
* isBrowser will never be set from true back to false, so the
439+
* code within the if(isBrowser) check will only run once.
440+
*/
441+
useEffect(() => {
442+
if (isBrowser) {
443+
/**
444+
* Load the stored mode and/or usage target, if present
445+
* from previously being toggled.
446+
*/
447+
const storedMode = localStorage.getItem(MODE_STORAGE_KEY);
448+
if (storedMode) setIonicMode(storedMode);
449+
const storedUsageTarget = localStorage.getItem(USAGE_TARGET_STORAGE_KEY);
450+
if (storedUsageTarget) setUsageTarget(storedUsageTarget);
451+
452+
/**
453+
* Listen for any playground on the page to have its mode or framework
454+
* updated so this playground can switch to the same setting.
455+
*/
456+
window.addEventListener(MODE_UPDATED_EVENT, (e: CustomEvent) => {
457+
const mode = e.detail;
458+
if (Object.values(Mode).includes(mode)) {
459+
setIonicMode(mode); // don't use setAndSave to avoid infinite loop
460+
}
461+
});
462+
window.addEventListener(USAGE_TARGET_UPDATED_EVENT, (e: CustomEvent) => {
463+
const usageTarget = e.detail;
464+
if (Object.values(UsageTarget).includes(usageTarget)) {
465+
setUsageTarget(usageTarget); // don't use setAndSave to avoid infinite loop
466+
}
467+
});
468+
}
469+
}, [isBrowser]);
470+
327471
const isIOS = ionicMode === Mode.iOS;
328472
const isMD = ionicMode === Mode.MD;
329473

@@ -526,7 +670,7 @@ export default function Playground({
526670
key={`code-block-${lang}`}
527671
language={lang}
528672
usageTarget={usageTarget}
529-
setUsageTarget={setUsageTarget}
673+
setAndSaveUsageTarget={setAndSaveUsageTarget}
530674
disabled={!hasCode}
531675
/>
532676
);
@@ -536,14 +680,14 @@ export default function Playground({
536680
<ControlButton
537681
disabled={mode && mode === 'md'}
538682
isSelected={isIOS}
539-
handleClick={() => setIonicMode(Mode.iOS)}
683+
handleClick={() => setAndSaveMode(Mode.iOS)}
540684
title="iOS mode"
541685
label="iOS"
542686
/>
543687
<ControlButton
544688
disabled={mode && mode === 'ios'}
545689
isSelected={isMD}
546-
handleClick={() => setIonicMode(Mode.MD)}
690+
handleClick={() => setAndSaveMode(Mode.MD)}
547691
title="MD mode"
548692
label="MD"
549693
/>
@@ -750,3 +894,8 @@ const isFrameReady = (frame: HTMLIFrameElement) => {
750894
}
751895
return (frame.contentWindow as any).demoReady === true;
752896
};
897+
898+
const USAGE_TARGET_STORAGE_KEY = 'playground_usage_target';
899+
const MODE_STORAGE_KEY = 'playground_mode';
900+
const USAGE_TARGET_UPDATED_EVENT = 'playground-usage-target-updated';
901+
const MODE_UPDATED_EVENT = 'playground-event-updated';

0 commit comments

Comments
 (0)