Skip to content

Commit

Permalink
feat: add synchronized, persistent tab groups (#2172)
Browse files Browse the repository at this point in the history
I was working on the quickstart stuff for w3up and found myself really
wishing that I had synchronized tab groups, so I can split the
quickstart into broad categories (install, create space, upload, view,
etc) and have the details in tabs for the various runtime envs (CLI,
node, browser).

That would feel super awkward with our current non-syncing tabs, but I
think it could be pretty cool if we get the tabs in sync.

Since I was having a bit of the old docs writer's block, I decided to
just do it and ported over the feature from docusaurus, which is where I
originally cribbed the tabs component in the first place.

There are a few minor changes from their implementation, mostly just
using `@next/router` instead of their thing and using jsdoc for types
instead of proper typescript.

There was one thing I copy/pasted that I didn't fully understand, namely
the `useEvent` utility, which it seems is needed to make a dynamic
callback usable as a dependency in another `useCallback`. It's used to
refresh the tab state from local storage after the initial render. It
seems to work, but I'm just calling it out since it was new to me.

No tests yet, but here's a gif of it doing the thing:


![tab-groups](https://user-images.githubusercontent.com/678715/208783777-63ba932b-3ac3-4ddd-8fb3-19acef819502.gif)
  • Loading branch information
yusefnapora committed Jan 2, 2023
1 parent 3ed273d commit 3be2028
Show file tree
Hide file tree
Showing 5 changed files with 217 additions and 3 deletions.
105 changes: 105 additions & 0 deletions packages/website/components/contexts/tabGroupChoiceContext.js
@@ -0,0 +1,105 @@
import React, { createContext, useState, useCallback, useEffect, useMemo, useContext } from 'react';

const TAB_CHOICE_PREFIX = 'web3storage.tab.';

/**
* @typedef {object} ContextValue
* @property {boolean} ready - True if tab choices have been restored from storage
* @property {Record<string, string>} tabGroupChoices - A map of `groupId` to the chosen tab `value`
* @property {(groupId: string, value: string) => void} setTabGroupChoice - Set the tab `value` for a given `groupId`
*/

/** @type {ContextValue} */
const initialValue = {
ready: false,
tabGroupChoices: {},
setTabGroupChoice: () => {},
};

const TabGroupContext = createContext(initialValue);

/**
* Stores the chosen `value` for a given tab `groupId` in local storage. Browser only.
*/
function storeTabGroupChoice(groupId, value) {
if (typeof window === 'undefined') {
console.error('localStorage is not available during server rendering');
return;
}
const key = `${TAB_CHOICE_PREFIX}${groupId}`;
window.localStorage.setItem(key, value);
}

/**
* Loads all tab group choices from local storage. Browser only.
* @returns {Record<string, string>}
*/
function getAllTabGroupChoices() {
if (typeof window === 'undefined') {
console.error('localStorage is not available during server rendering');
return {};
}
const storage = window.localStorage;

/** @type {Record<string, string>} */
const choices = {};
for (let i = 0; i < storage.length; i++) {
const key = storage.key(i);
if (!key?.startsWith(TAB_CHOICE_PREFIX)) {
continue;
}
const groupId = key.substring(TAB_CHOICE_PREFIX.length);
choices[groupId] = storage.getItem(key) || '';
}

return choices;
}

/**
* @returns {ContextValue}
*/
function useContextValue() {
const [ready, setReady] = useState(false);
const [tabGroupChoices, setChoices] = useState(/** @type {Record<string, string>} */ {});
const setChoiceSyncWithLocalStorage = useCallback((groupId, value) => {
storeTabGroupChoice(groupId, value);
}, []);

useEffect(() => {
try {
const choices = getAllTabGroupChoices();
setChoices(choices);
console.log('tab choices ready:', choices);
} catch (err) {
console.error('error loading tab choices from local storage:', err);
}

setReady(true);
}, []);

const setTabGroupChoice = useCallback(
(groupId, value) => {
setChoices(oldChoices => ({ ...oldChoices, [groupId]: value }));
setChoiceSyncWithLocalStorage(groupId, value);
},
[setChoiceSyncWithLocalStorage]
);

return useMemo(() => ({ ready, tabGroupChoices, setTabGroupChoice }), [ready, tabGroupChoices, setTabGroupChoice]);
}

export function TabGroupChoiceProvider({ children }) {
const value = useContextValue();
return <TabGroupContext.Provider value={value}>{children}</TabGroupContext.Provider>;
}

/**
* @returns {ContextValue}
*/
export function useTabGroupChoices() {
const context = useContext(TabGroupContext);
if (context == null) {
throw new Error(`Hook useTabGroupChoice was called outside the provider <TabGroupChoiceProvider />`);
}
return context;
}
5 changes: 4 additions & 1 deletion packages/website/components/general/appProviders.js
Expand Up @@ -6,6 +6,7 @@ import { UserProvider } from 'components/contexts/userContext';
import { UploadsProvider } from 'components/contexts/uploadsContext';
import { PinRequestsProvider } from 'components/contexts/pinRequestsContext';
import { TokensProvider } from 'components/contexts/tokensContext';
import { TabGroupChoiceProvider } from 'components/contexts/tabGroupChoiceContext';

const queryClient = new QueryClient({
defaultOptions: {
Expand All @@ -29,7 +30,9 @@ const AppProviders = ({ authorizationProps, children }) => {
<UserProvider loadStorage={pathname.indexOf('/account') !== -1}>
<UploadsProvider>
<PinRequestsProvider>
<TokensProvider>{children}</TokensProvider>
<TokensProvider>
<TabGroupChoiceProvider>{children}</TabGroupChoiceProvider>
</TokensProvider>
</PinRequestsProvider>
</UploadsProvider>
</UserProvider>
Expand Down
65 changes: 64 additions & 1 deletion packages/website/components/tabs/tabs.js
@@ -1,5 +1,9 @@
import React, { useState, cloneElement, Children, isValidElement } from 'react';
import React, { useState, cloneElement, Children, isValidElement, useEffect, useCallback } from 'react';
import clsx from 'clsx';
import { useRouter } from 'next/router';

import { useTabGroupChoices } from 'components/contexts/tabGroupChoiceContext';
import { useEvent } from 'lib/utils';

/**
* @param {React.ReactElement} comp
Expand All @@ -9,6 +13,35 @@ function isTabItem(comp) {
return typeof comp.props.value !== 'undefined';
}

/**
* @param {string|undefined} groupId
*/
function useTabQueryString(groupId) {
const router = useRouter();
const { query, pathname } = router;
const get = useCallback(() => (groupId ? query[groupId] : undefined), [groupId, query]);

const set = useCallback(
value => {
if (!groupId) {
return;
}
const newQuery = { ...query, [groupId]: encodeURIComponent(value) };
router.replace(
{
pathname,
query: newQuery,
},
undefined,
{ shallow: true }
);
},
[groupId, router, pathname, query]
);

return { get, set };
}

/**
* @typedef {object} TabItemProps
* @property {React.ReactNode} children
Expand Down Expand Up @@ -37,13 +70,15 @@ export function TabItem(props) {
* @typedef {object} TabsProps
* @property {React.ReactNode} children
* @property {string} [className]
* @property {string} [groupId] if present, `<Tabs>` with the same groupId will synchronize their active tab.
*/

/**
*
* @param {TabsProps} props
*/
export function Tabs(props) {
const { groupId } = props;
const children =
Children.map(props.children, child => {
if (isValidElement(child) && isTabItem(child)) {
Expand All @@ -64,6 +99,9 @@ export function Tabs(props) {
throw new Error(`All <TabItem> children of a <Tabs> component must have a unique 'value' prop`);
}

const { ready: tabChoicesReady, tabGroupChoices, setTabGroupChoice } = useTabGroupChoices();
const tabQueryString = useTabQueryString(groupId);

const defaultValue = values.length > 0 ? values[0].value : null;
const [selectedValue, setSelectedValue] = useState(defaultValue);

Expand All @@ -72,6 +110,27 @@ export function Tabs(props) {
*/
const tabRefs = [];

// Lazily restore the saved tab choices (if they exist)
// We can't use localStorage on first render, since it's not availble server side
// and would cause a hydration mismatch.
const restoreTabChoice = useEvent(() => {
if (!tabChoicesReady) {
return;
}
const toRestore = tabQueryString.get() ?? (groupId && tabGroupChoices[groupId]);
const isValid = toRestore && values.some(v => v.value === toRestore);
if (isValid) {
setSelectedValue(toRestore);
}
});

useEffect(() => {
// wait for localStorage values to be set
if (tabChoicesReady) {
restoreTabChoice();
}
}, [tabChoicesReady, restoreTabChoice]);

/**
* @param {React.FocusEvent<HTMLLIElement> | React.MouseEvent<HTMLLIElement>} e
*/
Expand All @@ -83,6 +142,10 @@ export function Tabs(props) {
const newValue = values[newTabIndex].value;
if (newValue !== selectedValue) {
setSelectedValue(newValue);
tabQueryString.set(newValue);
if (groupId) {
setTabGroupChoice(groupId, newValue);
}
}
};

Expand Down
43 changes: 43 additions & 0 deletions packages/website/lib/utils.js
@@ -1,3 +1,5 @@
import { useEffect, useLayoutEffect, useRef, useCallback } from 'react'

/**
* If it's a different day, it returns the day, otherwise it returns the hour
* @param {*} timestamp
Expand Down Expand Up @@ -104,3 +106,44 @@ export const elementIsInViewport = element => {
}
return false;
};


/**
* This hook is like `useLayoutEffect`, but without the SSR warning.
* It seems hacky but it's used in many React libs (Redux, Formik...).
* Also mentioned here: https://github.com/facebook/react/issues/16956
*
* It is useful when you need to update a ref as soon as possible after a React
* render (before `useEffect`).
*/
export const useIsomorphicLayoutEffect = (typeof window !== 'undefined')
? useLayoutEffect
: useEffect;

/**
* Temporary userland implementation until an official hook is implemented
* See RFC: https://github.com/reactjs/rfcs/pull/220
*
* Permits to transform an unstable callback (like an arrow function provided as
* props) to a "stable" callback that is safe to use in a `useEffect` dependency
* array. Useful to avoid React stale closure problems + avoid useless effect
* re-executions.
*
* This generally works but has some potential drawbacks, such as
* https://github.com/facebook/react/issues/16956#issuecomment-536636418
*
* @template {(...args: never[]) => unknown} T
* @param {T} callback
* @returns {T}
*/
export function useEvent(callback) {
const ref = useRef(callback);

useIsomorphicLayoutEffect(() => {
ref.current = callback;
}, [callback]);

// @ts-expect-error: TS is right that this callback may be a supertype of T,
// but good enough for our use
return useCallback((...args) => ref.current(...args), []);
}
2 changes: 1 addition & 1 deletion packages/website/pages/docs/intro.md
Expand Up @@ -52,7 +52,7 @@ node --version && npm --version

You need a web3.storage account to get your API token and manage your stored data. You can sign up **for free** using your email address or GitHub.

<Tabs>
<Tabs groupId='account-type'>
<TabItem value="Email" label="Email">
##### Sign up using email
1. Go to [web3.storage/login](https://web3.storage/login) to get started.
Expand Down

0 comments on commit 3be2028

Please sign in to comment.