Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(playground): persist framework and mode selection #3169

Merged
merged 9 commits into from
Oct 9, 2023
233 changes: 191 additions & 42 deletions src/components/global/Playground/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, { RefObject, forwardRef, useEffect, useMemo, useRef, useState } from 'react';

import useBaseUrl from '@docusaurus/useBaseUrl';
import './playground.css';
Expand All @@ -13,52 +13,64 @@ import TabItem from '@theme/TabItem';

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

const ControlButton = ({
isSelected,
handleClick,
title,
label,
disabled,
}: {
isSelected: boolean;
handleClick: () => void;
title: string;
label: string;
disabled?: boolean;
}) => {
const controlButton = (
<button
title={disabled ? undefined : title}
disabled={disabled}
className={`playground__control-button ${isSelected ? 'playground__control-button--selected' : ''}`}
onClick={handleClick}
data-text={label}
>
{label}
</button>
);
if (disabled) {
return (
<Tippy theme="playground" arrow={false} placement="bottom" content={`Unavailable for ${label}`}>
{/* Tippy requires a wrapper element for disabled elements: https://atomiks.github.io/tippyjs/v5/creating-tooltips/#disabled-elements */}
<div>{controlButton}</div>
</Tippy>
import { useScrollPositionBlocker } from '@docusaurus/theme-common';
import useIsBrowser from '@docusaurus/useIsBrowser';

const ControlButton = forwardRef(
(
Copy link
Contributor Author

@averyjohnston averyjohnston Oct 5, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The only changes to ControlButton here are wrapping it in forwardRef() (along with its ref param) and adding ref={ref} to the <button>. We need to forward the ref from the CodeBlockButton component below so we can use it in the handleClick function there.

{
isSelected,
handleClick,
title,
label,
disabled,
}: {
isSelected: boolean;
handleClick: () => void;
title: string;
label: string;
disabled?: boolean;
},
ref: RefObject<HTMLButtonElement>
) => {
const controlButton = (
<button
title={disabled ? undefined : title}
disabled={disabled}
className={`playground__control-button ${isSelected ? 'playground__control-button--selected' : ''}`}
onClick={handleClick}
data-text={label}
ref={ref}
>
{label}
</button>
);
if (disabled) {
return (
<Tippy theme="playground" arrow={false} placement="bottom" content={`Unavailable for ${label}`}>
{/* Tippy requires a wrapper element for disabled elements: https://atomiks.github.io/tippyjs/v5/creating-tooltips/#disabled-elements */}
<div>{controlButton}</div>
</Tippy>
);
}
return controlButton;
}
return controlButton;
};
);

const CodeBlockButton = ({ language, usageTarget, setUsageTarget, disabled }) => {
const CodeBlockButton = ({ language, usageTarget, setAndSaveUsageTarget, disabled }) => {
const buttonRef = useRef<HTMLButtonElement>(null);
const langValue = UsageTarget[language];

return (
<ControlButton
isSelected={usageTarget === langValue}
handleClick={() => {
setUsageTarget(langValue);
setAndSaveUsageTarget(langValue, buttonRef.current);
}}
title={`Show ${language} code`}
label={language}
disabled={disabled}
ref={buttonRef}
/>
);
};
Expand Down Expand Up @@ -158,21 +170,66 @@ export default function Playground({

const { isDarkTheme } = useThemeContext();

/**
* When deploying, Docusaurus builds the app in an SSR environment.
* We need to check whether we're in a browser so we know if we can
* use the window or localStorage objects.
*/
const isBrowser = useIsBrowser();

const hostRef = useRef<HTMLDivElement | null>(null);
const codeRef = useRef(null);
const frameiOS = useRef<HTMLIFrameElement | null>(null);
const frameMD = useRef<HTMLIFrameElement | null>(null);
const consoleBodyRef = useRef<HTMLDivElement | null>(null);

const defaultMode = typeof mode !== 'undefined' ? mode : Mode.iOS;
const { blockElementScrollPositionUntilNextRender } = useScrollPositionBlocker();

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PlaygroundTabs already uses this function. Does it need to be included in this file as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you have the call the function for every component that needs to block scroll on click.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, I see where the confusion might lie -- PlaygroundTabs is for the tabs that switch which file is being shown within the code. The new addition is for the framework switcher for the whole playground.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to not use useScrollPositionBlocker? I'm asking in preparation of the Docusaurus update. It was changed for internal use only. If it's needed, then I'll make a note of it for the update.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm 🤔 We definitely need it or something like it; otherwise the scroll can jump around when toggling the frameworks which leads to an unpleasant UX. If we got rid of the import, we'd either need to copy the code directly into our repo, or roll our own solution.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just adding the decision: useScrollPositionBlocker will be used as normal even when upgrading Docusaurus.

const getDefaultMode = () => {
/**
* If a custom mode was specified, use that.
*/
if (mode) return mode;

/**
* Otherwise, if there is a saved mode from previously clicking
* the mode button, use that.
*/
if (isBrowser) {
const storedMode = localStorage.getItem(MODE_STORAGE_KEY);
if (storedMode) return storedMode;
}

/**
* Default to iOS mode as a fallback.
*/
return Mode.iOS;
};

const getDefaultUsageTarget = () => {
// If defined, Angular target should be the default
/**
* If there is a saved target from previously clicking the
* framework buttons, and there is code for it, use that.
*/
if (isBrowser) {
const storedTarget = localStorage.getItem(USAGE_TARGET_STORAGE_KEY);
if (storedTarget && code[storedTarget] !== undefined) {
return storedTarget;
}
}

/**
* If there is no saved target, and Angular code is available,
* default to that.
*/
if (code[UsageTarget.Angular] !== undefined) {
return UsageTarget.Angular;
}

// Otherwise, default to the first target passed.
/**
* If there is no Angular code available, fall back to the
* first available framework.
*/
return Object.keys(code)[0];
};

Expand All @@ -182,7 +239,7 @@ export default function Playground({
*/
const frameSize = FRAME_SIZES[size] || size;
const [usageTarget, setUsageTarget] = useState(getDefaultUsageTarget());
const [ionicMode, setIonicMode] = useState(defaultMode);
const [ionicMode, setIonicMode] = useState(getDefaultMode());
const [codeSnippets, setCodeSnippets] = useState({});
const [renderIframes, setRenderIframes] = useState(false);
const [iframesLoaded, setIframesLoaded] = useState(false);
Expand All @@ -196,6 +253,52 @@ export default function Playground({
*/
const [resetCount, setResetCount] = useState(0);

const setAndSaveMode = (mode: Mode) => {
setIonicMode(mode);

if (isBrowser) {
localStorage.setItem(MODE_STORAGE_KEY, mode);

/**
* Tell other playgrounds on the page that the mode has
* updated, so they can sync up.
*/
window.dispatchEvent(
new CustomEvent(MODE_UPDATED_EVENT, {
detail: mode,
})
);
}
};

const setAndSaveUsageTarget = (target: UsageTarget, tab: HTMLElement) => {
setUsageTarget(target);

if (isBrowser) {
localStorage.setItem(USAGE_TARGET_STORAGE_KEY, target);

/**
* This prevents the scroll position from jumping around if
* there is a playground above this one with code that changes
* in length between frameworks.
*
* Note that we don't need this when changing the mode because
* the two mode iframes are always the same height.
*/
blockElementScrollPositionUntilNextRender(tab);

/**
* Tell other playgrounds on the page that the framework
* has updated, so they can sync up.
*/
window.dispatchEvent(
new CustomEvent(USAGE_TARGET_UPDATED_EVENT, {
detail: target,
})
);
}
};

/**
* Rather than encode isDarkTheme into the frame source
* url, we post a message to each frame so that
Expand Down Expand Up @@ -324,6 +427,47 @@ export default function Playground({
io.observe(hostRef.current!);
});

/**
* Sometimes, the app isn't fully hydrated on the first render,
* causing isBrowser to be set to false even if running the app
* in a browser (vs. SSR). isBrowser is then updated on the next
* render cycle.
*
* This useEffect contains code that can only run in the browser,
* and also needs to run on that first go-around. Note that
* isBrowser will never be set from true back to false, so the
* code within the if(isBrowser) check will only run once.
*/
useEffect(() => {
if (isBrowser) {
/**
* Load the stored mode and/or usage target, if present
* from previously being toggled.
*/
const storedMode = localStorage.getItem(MODE_STORAGE_KEY);
if (storedMode) setIonicMode(storedMode);
const storedUsageTarget = localStorage.getItem(USAGE_TARGET_STORAGE_KEY);
if (storedUsageTarget) setUsageTarget(storedUsageTarget);

/**
* Listen for any playground on the page to have its mode or framework
* updated so this playground can switch to the same setting.
*/
window.addEventListener(MODE_UPDATED_EVENT, (e: CustomEvent) => {
const mode = e.detail;
if (Object.values(Mode).includes(mode)) {
setIonicMode(mode); // don't use setAndSave to avoid infinite loop
}
});
window.addEventListener(USAGE_TARGET_UPDATED_EVENT, (e: CustomEvent) => {
const usageTarget = e.detail;
if (Object.values(UsageTarget).includes(usageTarget)) {
setUsageTarget(usageTarget); // don't use setAndSave to avoid infinite loop
}
});
}
}, [isBrowser]);

const isIOS = ionicMode === Mode.iOS;
const isMD = ionicMode === Mode.MD;

Expand Down Expand Up @@ -526,7 +670,7 @@ export default function Playground({
key={`code-block-${lang}`}
language={lang}
usageTarget={usageTarget}
setUsageTarget={setUsageTarget}
setAndSaveUsageTarget={setAndSaveUsageTarget}
disabled={!hasCode}
/>
);
Expand All @@ -536,14 +680,14 @@ export default function Playground({
<ControlButton
disabled={mode && mode === 'md'}
isSelected={isIOS}
handleClick={() => setIonicMode(Mode.iOS)}
handleClick={() => setAndSaveMode(Mode.iOS)}
title="iOS mode"
label="iOS"
/>
<ControlButton
disabled={mode && mode === 'ios'}
isSelected={isMD}
handleClick={() => setIonicMode(Mode.MD)}
handleClick={() => setAndSaveMode(Mode.MD)}
title="MD mode"
label="MD"
/>
Expand Down Expand Up @@ -750,3 +894,8 @@ const isFrameReady = (frame: HTMLIFrameElement) => {
}
return (frame.contentWindow as any).demoReady === true;
};

const USAGE_TARGET_STORAGE_KEY = 'playground_usage_target';
const MODE_STORAGE_KEY = 'playground_mode';
const USAGE_TARGET_UPDATED_EVENT = 'playground-usage-target-updated';
const MODE_UPDATED_EVENT = 'playground-event-updated';
Loading