mirror of
https://github.com/facebook/docusaurus.git
synced 2025-06-02 02:42:41 +02:00
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:
parent
f03479f69e
commit
9536ef900d
58 changed files with 1006 additions and 633 deletions
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -103,6 +103,7 @@ export type ThemeConfig = {
|
|||
hideableSidebar: boolean;
|
||||
image: string;
|
||||
metadatas: Array<Record<string, string>>;
|
||||
sidebarCollapsible: boolean;
|
||||
};
|
||||
|
||||
export function useThemeConfig(): ThemeConfig {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue