feat(v2): redesign mobile UX: inline TOC + doc sidebar in main menu (#4273)

* feat(v2): mobile TOC

* Bug fixes and various improvements

* Redesign

* extract TOCCollapsible component

* TS improvements

* Assign sidebar name directly to the doc route => no need for either permalinkToSidebar or GlobalData

* revert changes to useWindowSize, fix FOUC issues

* extract DocSidebarDesktop component

* remove now useless menu infima classes

* TOCHeadings => rename + remove unused onClick prop

* Extract DocSidebarItem

* minor renaming

* replace GlobalData usage by a React teleport system to render in the navbar mobile sidebar menu directly from the DocPage component

* useWindowSize => simulate SSR size in dev to make FOUC issues more obvious

* fix remaining sidebar layout shift

* update docs snapshots

* remove unused code translations

* remove unused code translations

* fix minor update-code-translations bug

* Add more build-size paths to watch

* Restyle back button

* Add  missing`menu` class

* extract useShallowMemoizedObject

* fix routes tests + better routes formatting

* use Translate api for labels

* use Translate api for labels

* Update translations

* Improve dark mode support for back button

* Merge branch 'master' into lex111/inline-color-code

# Conflicts:
#	packages/core/dist/css/default-dark/default-dark-rtl.min.css
#	packages/core/dist/css/default-dark/default-dark.min.css
#	packages/core/dist/css/default/default-rtl.min.css
#	packages/core/dist/css/default/default.min.css

* replace useCollapse by new useCollapsible

* Cleanup and use clean-btn for TOCCollapsible button

* Make TOC links clickable over full width

* Cleanup

* fix uncollapsible sidebar that can be collapsed + create <Collapsible> component

* dependency array typo

* rollback sidebars community commit typo

Co-authored-by: slorber <lorber.sebastien@gmail.com>
This commit is contained in:
Alexey Pyltsyn 2021-07-09 17:50:38 +03:00 committed by GitHub
parent f03479f69e
commit 9536ef900d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 1006 additions and 633 deletions

View file

@ -0,0 +1,111 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import React, {
useState,
ReactNode,
useContext,
createContext,
useEffect,
ComponentType,
useMemo,
} from 'react';
/*
The idea behind all this is that a specific component must be able to fill a placeholder in the generic layout
The doc page should be able to fill the secondary menu of the main mobile navbar.
This permits to reduce coupling between the main layout and the specific page.
This kind of feature is often called portal/teleport/gateway... various unmaintained React libs exist
Most up-to-date one: https://github.com/gregberge/react-teleporter
Not sure any of those is safe regarding concurrent mode.
*/
type ExtraProps = {
toggleSidebar: () => void;
};
export type MobileSecondaryMenuComponent<Props extends unknown> = ComponentType<
Props & ExtraProps
>;
type State = {
component: MobileSecondaryMenuComponent<unknown>;
props: unknown;
} | null;
function useContextValue() {
return useState<State>(null);
}
type ContextValue = ReturnType<typeof useContextValue>;
const Context = createContext<ContextValue | null>(null);
export function MobileSecondaryMenuProvider({children}: {children: ReactNode}) {
return (
<Context.Provider value={useContextValue()}>{children}</Context.Provider>
);
}
function useMobileSecondaryMenuContext(): ContextValue {
const value = useContext(Context);
if (value === null) {
throw new Error(
'MobileSecondaryMenuProvider was not used correctly, context value is null',
);
}
return value;
}
export function useMobileSecondaryMenuRenderer(): (
extraProps: ExtraProps,
) => ReactNode | undefined {
const [state] = useMobileSecondaryMenuContext();
if (state) {
const Comp = state.component;
return function render(extraProps) {
return <Comp {...state.props} {...extraProps} />;
};
}
return () => undefined;
}
function useShallowMemoizedObject<O extends Record<string, unknown>>(obj: O) {
return useMemo(
() => obj,
// Is this safe?
[...Object.keys(obj), ...Object.values(obj)],
);
}
// Fill the secondary menu placeholder with some real content
export function MobileSecondaryMenuFiller<
Props extends Record<string, unknown>
>({
component,
props,
}: {
component: MobileSecondaryMenuComponent<Props & ExtraProps>;
props: Props;
}): JSX.Element | null {
const [, setState] = useMobileSecondaryMenuContext();
// To avoid useless context re-renders, props are memoized shallowly
const memoizedProps = useShallowMemoizedObject(props);
useEffect(() => {
// @ts-expect-error: context is not 100% typesafe but it's ok
setState({component, props: memoizedProps});
}, [setState, component, memoizedProps]);
useEffect(() => {
return () => setState(null);
}, [setState]);
return null;
}

View file

@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import {
import React, {
useState,
useEffect,
useRef,
@ -13,46 +13,38 @@ import {
RefObject,
Dispatch,
SetStateAction,
TransitionEvent,
ReactNode,
} from 'react';
/*
Lex111: Dynamic transition duration is used in Material deisign, this technique is good for a large number of items.
https://material.io/archive/guidelines/motion/duration-easing.html#duration-easing-dynamic-durations
https://github.com/mui-org/material-ui/blob/e724d98eba018e55e1a684236a2037e24bcf050c/packages/material-ui/src/styles/createTransitions.js#L40-L43
*/
function getAutoHeightDuration(height: number) {
const constant = height / 36;
return Math.round((4 + 15 * constant ** 0.25 + constant / 5) * 10);
}
type CollapsibleAnimationConfig = {
duration?: number;
easing?: string;
};
const DefaultAnimationEasing = 'ease-in-out';
export type UseCollapsibleConfig = {
initialState: boolean | (() => boolean);
animation?: CollapsibleAnimationConfig;
};
export type UseCollapsibleReturns = {
collapsed: boolean;
setCollapsed: Dispatch<SetStateAction<boolean>>;
toggleCollapsed: () => void;
getToggleProps(): {
onClick?: () => void;
};
getCollapsibleProps(): {
ref: RefObject<any>; // any because TS is a pain for HTML element refs, see https://twitter.com/sebastienlorber/status/1412784677795110914
onTransitionEnd: (e: TransitionEvent) => void;
};
};
// This hook just define the state
export function useCollapsible({
initialState,
}: UseCollapsibleConfig): UseCollapsibleReturns {
const [collapsed, setCollapsed] = useState(initialState ?? false);
const toggleCollapsed = useCallback(() => {
setCollapsed((expanded) => !expanded);
}, []);
return {
collapsed,
setCollapsed,
toggleCollapsed,
};
}
const CollapsedStyles = {
display: 'none',
overflow: 'hidden',
@ -72,6 +64,21 @@ function applyCollapsedStyle(el: HTMLElement, collapsed: boolean) {
el.style.height = collapsedStyles.height;
}
/*
Lex111: Dynamic transition duration is used in Material design, this technique is good for a large number of items.
https://material.io/archive/guidelines/motion/duration-easing.html#duration-easing-dynamic-durations
https://github.com/mui-org/material-ui/blob/e724d98eba018e55e1a684236a2037e24bcf050c/packages/material-ui/src/styles/createTransitions.js#L40-L43
*/
function getAutoHeightDuration(height: number) {
const constant = height / 36;
return Math.round((4 + 15 * constant ** 0.25 + constant / 5) * 10);
}
type CollapsibleAnimationConfig = {
duration?: number;
easing?: string;
};
function useCollapseAnimation({
collapsibleRef,
collapsed,
@ -135,41 +142,39 @@ function useCollapseAnimation({
}, [collapsibleRef, collapsed, animation]);
}
/*
This hook encapsulate the animated collapsible behavior
You have to apply the getToggleProps + getCollapsibleProps wire everything
Similar to other solutions in the React ecosystem, like Downshift for Selects
*/
export function useCollapsible({
initialState,
type CollapsibleElementType = React.ElementType<
Pick<React.HTMLAttributes<unknown>, 'className' | 'onTransitionEnd'>
>;
export function Collapsible({
as: As = 'div',
collapsed,
children,
animation,
}: UseCollapsibleConfig): UseCollapsibleReturns {
const collapsibleRef = useRef<HTMLElement>(null);
const [collapsed, setCollapsed] = useState(initialState ?? false);
const toggleCollapsed = useCallback(() => {
setCollapsed((expanded) => !expanded);
}, []);
className,
}: {
as?: CollapsibleElementType; // TODO better typing, allow any html element (keyof JSX.IntrinsicElement => not working)
collapsed: boolean;
children: ReactNode;
animation?: CollapsibleAnimationConfig;
className?: string;
}) {
// any because TS is a pain for HTML element refs, see https://twitter.com/sebastienlorber/status/1412784677795110914
const collapsibleRef = useRef<any>(null);
useCollapseAnimation({collapsibleRef, collapsed, animation});
return {
collapsed,
setCollapsed,
toggleCollapsed,
getToggleProps: () => ({
onClick: toggleCollapsed,
}),
getCollapsibleProps: () => ({
ref: collapsibleRef,
onTransitionEnd: (e) => {
return (
<As
// @ts-expect-error: see https://twitter.com/sebastienlorber/status/1412784677795110914
ref={collapsibleRef}
onTransitionEnd={(e) => {
if (e.propertyName === 'height') {
applyCollapsedStyle(collapsibleRef.current!, collapsed);
}
},
}),
};
}}
className={className}>
{children}
</As>
);
}

View file

@ -103,6 +103,7 @@ export type ThemeConfig = {
hideableSidebar: boolean;
image: string;
metadatas: Array<Record<string, string>>;
sidebarCollapsible: boolean;
};
export function useThemeConfig(): ThemeConfig {