refactor(theme-{classic,common}): split navbar into smaller components + cleanup + swizzle config (#6895)

This commit is contained in:
Sébastien Lorber 2022-03-18 16:21:53 +01:00 committed by GitHub
parent ecbe0b26c5
commit a1d333e96b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 808 additions and 456 deletions

View file

@ -71,13 +71,6 @@ export {
export {default as Details, type DetailsProps} from './components/Details';
export {
MobileSecondaryMenuProvider,
MobileSecondaryMenuFiller,
useMobileSecondaryMenuRenderer,
} from './utils/mobileSecondaryMenu';
export type {MobileSecondaryMenuComponent} from './utils/mobileSecondaryMenu';
export {
useDocsPreferredVersion,
useDocsPreferredVersionByPluginId,
@ -151,6 +144,17 @@ export {
TabGroupChoiceProvider,
} from './utils/tabGroupChoiceUtils';
export {
splitNavbarItems,
NavbarProvider,
useNavbarMobileSidebar,
} from './utils/navbarUtils';
export {
useNavbarSecondaryMenu,
NavbarSecondaryMenuFiller,
} from './utils/navbarSecondaryMenuUtils';
export type {NavbarSecondaryMenuComponent} from './utils/navbarSecondaryMenuUtils';
export {default as useHideableNavbar} from './hooks/useHideableNavbar';
export {
default as useKeyboardNavigation,

View file

@ -1,113 +0,0 @@
/**
* 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,
useContext,
useEffect,
useMemo,
type ReactNode,
type ComponentType,
} from 'react';
import {ReactContextError} from './reactUtils';
/*
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> = ComponentType<
Props & ExtraProps
>;
type State = {
component: MobileSecondaryMenuComponent<unknown>;
props: unknown;
} | null;
function useContextValue() {
return useState<State>(null);
}
type ContextValue = ReturnType<typeof useContextValue>;
const Context = React.createContext<ContextValue | null>(null);
export function MobileSecondaryMenuProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
return (
<Context.Provider value={useContextValue()}>{children}</Context.Provider>
);
}
function useMobileSecondaryMenuContext(): ContextValue {
const value = useContext(Context);
if (value === null) {
throw new ReactContextError('MobileSecondaryMenuProvider');
}
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?
// eslint-disable-next-line react-hooks/exhaustive-deps
[...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% type-safe but it's ok
setState({component, props: memoizedProps});
}, [setState, component, memoizedProps]);
useEffect(() => () => setState(null), [setState]);
return null;
}

View file

@ -0,0 +1,170 @@
/**
* 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,
useContext,
useEffect,
useMemo,
useCallback,
type ReactNode,
type ComponentType,
} from 'react';
import {ReactContextError} from './reactUtils';
import {usePrevious} from './usePrevious';
import {useNavbarMobileSidebar} from './navbarUtils';
/*
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.
*/
export type NavbarSecondaryMenuComponent<Props> = ComponentType<Props>;
type State = {
shown: boolean;
content:
| {
component: ComponentType<object>;
props: object;
}
| {component: null; props: null};
};
const InitialState: State = {
shown: false,
content: {component: null, props: null},
};
function useContextValue() {
const mobileSidebar = useNavbarMobileSidebar();
const [state, setState] = useState<State>(InitialState);
const setShown = (shown: boolean) => setState((s) => ({...s, shown}));
const hasContent = state.content?.component !== null;
const previousHasContent = usePrevious(state.content?.component !== null);
// When content is become available for the first time (set in useEffect)
// we set this content to be shown!
useEffect(() => {
const contentBecameAvailable = hasContent && !previousHasContent;
if (contentBecameAvailable) {
setShown(true);
}
}, [hasContent, previousHasContent]);
// On sidebar close, secondary menu is set to be shown on next re-opening
// (if any secondary menu content available)
useEffect(() => {
if (!hasContent) {
setShown(false);
return;
}
if (!mobileSidebar.shown) {
setShown(true);
}
}, [mobileSidebar.shown, hasContent]);
return [state, setState] as const;
}
type ContextValue = ReturnType<typeof useContextValue>;
const Context = React.createContext<ContextValue | null>(null);
export function NavbarSecondaryMenuProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
return (
<Context.Provider value={useContextValue()}>{children}</Context.Provider>
);
}
function useNavbarSecondaryMenuContext(): ContextValue {
const value = useContext(Context);
if (value === null) {
throw new ReactContextError('MobileSecondaryMenuProvider');
}
return value;
}
function useShallowMemoizedObject<O extends Record<string, unknown>>(obj: O) {
return useMemo(
() => obj,
// Is this safe?
// eslint-disable-next-line react-hooks/exhaustive-deps
[...Object.keys(obj), ...Object.values(obj)],
);
}
// Fill the secondary menu placeholder with some real content
export function NavbarSecondaryMenuFiller<
Props extends Record<string, unknown>,
>({
component,
props,
}: {
component: NavbarSecondaryMenuComponent<Props>;
props: Props;
}): JSX.Element | null {
const [, setState] = useNavbarSecondaryMenuContext();
// To avoid useless context re-renders, props are memoized shallowly
const memoizedProps = useShallowMemoizedObject(props);
useEffect(() => {
// @ts-expect-error: context is not 100% type-safe but it's ok
setState((s) => ({...s, content: {component, props: memoizedProps}}));
}, [setState, component, memoizedProps]);
useEffect(
() => () => setState((s) => ({...s, component: null, props: null})),
[setState],
);
return null;
}
function renderElement(state: State): JSX.Element | undefined {
if (state.content?.component) {
const Comp = state.content.component;
return <Comp {...state.content.props} />;
}
return undefined;
}
export function useNavbarSecondaryMenu(): {
shown: boolean;
hide: () => void;
content: JSX.Element | undefined;
} {
const [state, setState] = useNavbarSecondaryMenuContext();
const hide = useCallback(
() => setState((s) => ({...s, shown: false})),
[setState],
);
return useMemo(
() => ({
shown: state.shown,
hide,
content: renderElement(state),
}),
[hide, state],
);
}

View file

@ -0,0 +1,129 @@
/**
* 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, {
type ReactNode,
useCallback,
useEffect,
useState,
useMemo,
} from 'react';
import useWindowSize from '../hooks/useWindowSize';
import {useHistoryPopHandler} from './historyUtils';
import {NavbarSecondaryMenuProvider} from './navbarSecondaryMenuUtils';
import {useActivePlugin} from '@docusaurus/plugin-content-docs/client';
import {useThemeConfig} from './useThemeConfig';
import {ReactContextError} from './reactUtils';
const DefaultNavItemPosition = 'right';
// If split links by left/right
// if position is unspecified, fallback to right
export function splitNavbarItems<T extends {position?: 'left' | 'right'}>(
items: T[],
): [leftItems: T[], rightItems: T[]] {
function isLeft(item: T): boolean {
return (item.position ?? DefaultNavItemPosition) === 'left';
}
const leftItems = items.filter(isLeft);
const rightItems = items.filter((item) => !isLeft(item));
return [leftItems, rightItems];
}
type NavbarMobileSidebarContextValue = {
disabled: boolean;
shouldRender: boolean;
toggle: () => void;
shown: boolean;
};
const NavbarMobileSidebarContext = React.createContext<
NavbarMobileSidebarContextValue | undefined
>(undefined);
// Mobile sidebar can be disabled in case it would lead to an empty sidebar
// In this case it's not useful to display a navbar sidebar toggle button
function useNavbarMobileSidebarDisabled() {
const activeDocPlugin = useActivePlugin();
const {items} = useThemeConfig().navbar;
return items.length === 0 && !activeDocPlugin;
}
function useNavbarMobileSidebarContextValue(): NavbarMobileSidebarContextValue {
const disabled = useNavbarMobileSidebarDisabled();
const windowSize = useWindowSize();
// Mobile sidebar not visible until user interaction: can avoid SSR rendering
const shouldRender = !disabled && windowSize === 'mobile'; // || windowSize === 'ssr';
const [shown, setShown] = useState(false);
// Close mobile sidebar on navigation pop
// Most likely firing when using the Android back button (but not only)
useHistoryPopHandler(() => {
if (shown) {
setShown(false);
// Should we prevent the navigation here?
// See https://github.com/facebook/docusaurus/pull/5462#issuecomment-911699846
return false; // prevent pop navigation
}
return undefined;
});
const toggle = useCallback(() => {
setShown((s) => !s);
}, []);
useEffect(() => {
if (windowSize === 'desktop') {
setShown(false);
}
}, [windowSize]);
// Return stable context value
return useMemo(
() => ({
disabled,
shouldRender,
toggle,
shown,
}),
[disabled, shouldRender, toggle, shown],
);
}
function NavbarMobileSidebarProvider({
children,
}: {
children: ReactNode;
}): JSX.Element {
const value = useNavbarMobileSidebarContextValue();
return (
<NavbarMobileSidebarContext.Provider value={value}>
{children}
</NavbarMobileSidebarContext.Provider>
);
}
export function useNavbarMobileSidebar(): NavbarMobileSidebarContextValue {
const context = React.useContext(NavbarMobileSidebarContext);
if (context == null) {
throw new ReactContextError('NavbarMobileSidebarProvider');
}
return context;
}
// Add all Navbar providers at once
export function NavbarProvider({children}: {children: ReactNode}): JSX.Element {
return (
<NavbarMobileSidebarProvider>
<NavbarSecondaryMenuProvider>{children}</NavbarSecondaryMenuProvider>
</NavbarMobileSidebarProvider>
);
}