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

@ -460,6 +460,64 @@ declare module '@theme/Navbar' {
export default function Navbar(): JSX.Element;
}
declare module '@theme/Navbar/ColorModeToggle' {
export interface Props {
readonly className?: string;
}
export default function NavbarColorModeToggle(
props: Props,
): JSX.Element | null;
}
declare module '@theme/Navbar/Logo' {
export default function NavbarLogo(): JSX.Element;
}
declare module '@theme/Navbar/Content' {
export default function NavbarContent(): JSX.Element;
}
declare module '@theme/Navbar/Layout' {
export interface Props {
children: React.ReactNode;
}
export default function NavbarLayout(props: Props): JSX.Element;
}
declare module '@theme/Navbar/MobileSidebar' {
export default function NavbarMobileSidebar(): JSX.Element;
}
declare module '@theme/Navbar/MobileSidebar/Layout' {
import type {ReactNode} from 'react';
interface Props {
header: ReactNode;
primaryMenu: ReactNode;
secondaryMenu: ReactNode;
}
export default function NavbarMobileSidebarLayout(props: Props): JSX.Element;
}
declare module '@theme/Navbar/MobileSidebar/Toggle' {
export default function NavbarMobileSidebarToggle(): JSX.Element;
}
declare module '@theme/Navbar/MobileSidebar/PrimaryMenu' {
export default function NavbarMobileSidebarPrimaryMenu(): JSX.Element;
}
declare module '@theme/Navbar/MobileSidebar/SecondaryMenu' {
export default function NavbarMobileSidebarSecondaryMenu(): JSX.Element;
}
declare module '@theme/Navbar/MobileSidebar/Header' {
export default function NavbarMobileSidebarHeader(): JSX.Element;
}
declare module '@theme/NavbarItem/DefaultNavbarItem' {
import type {Props as NavbarNavLinkProps} from '@theme/NavbarItem/NavbarNavLink';
@ -758,7 +816,7 @@ declare module '@theme/ColorModeToggle' {
readonly onChange: (colorMode: ColorMode) => void;
}
export default function Toggle(props: Props): JSX.Element;
export default function ColorModeToggle(props: Props): JSX.Element;
}
declare module '@theme/Logo' {

View file

@ -8,40 +8,43 @@
import React from 'react';
import clsx from 'clsx';
import {
MobileSecondaryMenuFiller,
type MobileSecondaryMenuComponent,
NavbarSecondaryMenuFiller,
type NavbarSecondaryMenuComponent,
ThemeClassNames,
useNavbarMobileSidebar,
} from '@docusaurus/theme-common';
import DocSidebarItems from '@theme/DocSidebarItems';
import type {Props} from '@theme/DocSidebar/Mobile';
// eslint-disable-next-line react/function-component-definition
const DocSidebarMobileSecondaryMenu: MobileSecondaryMenuComponent<Props> = ({
toggleSidebar,
const DocSidebarMobileSecondaryMenu: NavbarSecondaryMenuComponent<Props> = ({
sidebar,
path,
}) => (
<ul className={clsx(ThemeClassNames.docs.docSidebarMenu, 'menu__list')}>
<DocSidebarItems
items={sidebar}
activePath={path}
onItemClick={(item) => {
// Mobile sidebar should only be closed if the category has a link
if (item.type === 'category' && item.href) {
toggleSidebar();
}
if (item.type === 'link') {
toggleSidebar();
}
}}
level={1}
/>
</ul>
);
}) => {
const mobileSidebar = useNavbarMobileSidebar();
return (
<ul className={clsx(ThemeClassNames.docs.docSidebarMenu, 'menu__list')}>
<DocSidebarItems
items={sidebar}
activePath={path}
onItemClick={(item) => {
// Mobile sidebar should only be closed if the category has a link
if (item.type === 'category' && item.href) {
mobileSidebar.toggle();
}
if (item.type === 'link') {
mobileSidebar.toggle();
}
}}
level={1}
/>
</ul>
);
};
function DocSidebarMobile(props: Props) {
return (
<MobileSecondaryMenuFiller
<NavbarSecondaryMenuFiller
component={DocSidebarMobileSecondaryMenu}
props={props}
/>

View file

@ -11,8 +11,8 @@ import {
TabGroupChoiceProvider,
AnnouncementBarProvider,
DocsPreferredVersionContextProvider,
MobileSecondaryMenuProvider,
ScrollControllerProvider,
NavbarProvider,
PluginHtmlClassNameProvider,
} from '@docusaurus/theme-common';
import type {Props} from '@theme/LayoutProviders';
@ -24,11 +24,9 @@ export default function LayoutProviders({children}: Props): JSX.Element {
<TabGroupChoiceProvider>
<ScrollControllerProvider>
<DocsPreferredVersionContextProvider>
<MobileSecondaryMenuProvider>
<PluginHtmlClassNameProvider>
{children}
</PluginHtmlClassNameProvider>
</MobileSecondaryMenuProvider>
<PluginHtmlClassNameProvider>
<NavbarProvider>{children}</NavbarProvider>
</PluginHtmlClassNameProvider>
</DocsPreferredVersionContextProvider>
</ScrollControllerProvider>
</TabGroupChoiceProvider>

View file

@ -0,0 +1,30 @@
/**
* 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 {useColorMode, useThemeConfig} from '@docusaurus/theme-common';
import ColorModeToggle from '@theme/ColorModeToggle';
import type {Props} from '@theme/Navbar/ColorModeToggle';
import React from 'react';
export default function NavbarColorModeToggle({
className,
}: Props): JSX.Element | null {
const disabled = useThemeConfig().colorMode.disableSwitch;
const {colorMode, setColorMode} = useColorMode();
if (disabled) {
return null;
}
return (
<ColorModeToggle
className={className}
value={colorMode}
onChange={setColorMode}
/>
);
}

View file

@ -0,0 +1,81 @@
/**
* 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} from 'react';
import type {Props as NavbarItemConfig} from '@theme/NavbarItem';
import NavbarItem from '@theme/NavbarItem';
import NavbarColorModeToggle from '@theme/Navbar/ColorModeToggle';
import SearchBar from '@theme/SearchBar';
import {
splitNavbarItems,
useNavbarMobileSidebar,
useThemeConfig,
} from '@docusaurus/theme-common';
import NavbarMobileSidebarToggle from '@theme/Navbar/MobileSidebar/Toggle';
import NavbarLogo from '@theme/Navbar/Logo';
import styles from './styles.module.css';
function useNavbarItems() {
// TODO temporary casting until ThemeConfig type is improved
return useThemeConfig().navbar.items as NavbarItemConfig[];
}
function NavbarItems({items}: {items: NavbarItemConfig[]}): JSX.Element {
return (
<>
{items.map((item, i) => (
<NavbarItem {...item} key={i} />
))}
</>
);
}
function NavbarContentLayout({
left,
right,
}: {
left: ReactNode;
right: ReactNode;
}) {
return (
<div className="navbar__inner">
<div className="navbar__items">{left}</div>
<div className="navbar__items navbar__items--right">{right}</div>
</div>
);
}
export default function NavbarContent(): JSX.Element {
const mobileSidebar = useNavbarMobileSidebar();
const items = useNavbarItems();
const [leftItems, rightItems] = splitNavbarItems(items);
const autoAddSearchBar = !items.some((item) => item.type === 'search');
return (
<NavbarContentLayout
left={
// TODO stop hardcoding items?
<>
{!mobileSidebar.disabled && <NavbarMobileSidebarToggle />}
<NavbarLogo />
<NavbarItems items={leftItems} />
</>
}
right={
// TODO stop hardcoding items?
// Ask the user to add the respective navbar items => more flexible
<>
<NavbarItems items={rightItems} />
<NavbarColorModeToggle className={styles.colorModeToggle} />
{autoAddSearchBar && <SearchBar />}
</>
}
/>
);
}

View file

@ -0,0 +1,15 @@
/**
* 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.
*/
/*
Hide color mode toggle in small viewports
*/
@media (max-width: 996px) {
.colorModeToggle {
display: none;
}
}

View file

@ -0,0 +1,57 @@
/**
* 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 ComponentProps} from 'react';
import clsx from 'clsx';
import NavbarMobileSidebar from '@theme/Navbar/MobileSidebar';
import type {Props} from '@theme/Navbar/Layout';
import {
useThemeConfig,
useHideableNavbar,
useNavbarMobileSidebar,
} from '@docusaurus/theme-common';
import styles from './styles.module.css';
function NavbarBackdrop(props: ComponentProps<'div'>) {
return (
<div
role="presentation"
{...props}
className={clsx('navbar-sidebar__backdrop', props.className)}
/>
);
}
export default function NavbarLayout({children}: Props): JSX.Element {
const {
navbar: {hideOnScroll, style},
} = useThemeConfig();
const mobileSidebar = useNavbarMobileSidebar();
const {navbarRef, isNavbarVisible} = useHideableNavbar(hideOnScroll);
return (
<nav
ref={navbarRef}
className={clsx(
'navbar',
'navbar--fixed-top',
hideOnScroll && [
styles.navbarHideable,
!isNavbarVisible && styles.navbarHidden,
],
{
'navbar--dark': style === 'dark',
'navbar--primary': style === 'primary',
'navbar-sidebar--show': mobileSidebar.shown,
},
)}>
{children}
<NavbarBackdrop onClick={mobileSidebar.toggle} />
<NavbarMobileSidebar />
</nav>
);
}

View file

@ -5,15 +5,6 @@
* LICENSE file in the root directory of this source tree.
*/
/*
Hide toggle in small viewports
*/
@media (max-width: 996px) {
.toggle {
display: none;
}
}
.navbarHideable {
transition: transform var(--ifm-transition-fast) ease;
}
@ -21,7 +12,3 @@ Hide toggle in small viewports
.navbarHidden {
transform: translate3d(0, calc(-100% - 2px), 0);
}
.navbarSidebarToggle {
margin-right: 1rem;
}

View file

@ -0,0 +1,19 @@
/**
* 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 from 'react';
import Logo from '@theme/Logo';
export default function NavbarLogo(): JSX.Element {
return (
<Logo
className="navbar__brand"
imageClassName="navbar__logo"
titleClassName="navbar__title"
/>
);
}

View file

@ -0,0 +1,34 @@
/**
* 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 from 'react';
import NavbarColorModeToggle from '@theme/Navbar/ColorModeToggle';
import IconClose from '@theme/IconClose';
import NavbarLogo from '@theme/Navbar/Logo';
import {useNavbarMobileSidebar} from '@docusaurus/theme-common';
function CloseButton() {
const mobileSidebar = useNavbarMobileSidebar();
return (
<button
type="button"
className="clean-btn navbar-sidebar__close"
onClick={() => mobileSidebar.toggle()}>
<IconClose color="var(--ifm-color-emphasis-600)" />
</button>
);
}
export default function NavbarMobileSidebarHeader(): JSX.Element {
return (
<div className="navbar-sidebar__brand">
<NavbarLogo />
<NavbarColorModeToggle className="margin-right--md" />
<CloseButton />
</div>
);
}

View file

@ -0,0 +1,31 @@
/**
* 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 from 'react';
import clsx from 'clsx';
import type {Props} from '@theme/Navbar/MobileSidebar/Layout';
import {useNavbarSecondaryMenu} from '@docusaurus/theme-common';
export default function NavbarMobileSidebarLayout({
header,
primaryMenu,
secondaryMenu,
}: Props): JSX.Element {
const {shown: secondaryMenuShown} = useNavbarSecondaryMenu();
return (
<div className="navbar-sidebar">
{header}
<div
className={clsx('navbar-sidebar__items', {
'navbar-sidebar__items--show-secondary': secondaryMenuShown,
})}>
<div className="navbar-sidebar__item menu">{primaryMenu}</div>
<div className="navbar-sidebar__item menu">{secondaryMenu}</div>
</div>
</div>
);
}

View file

@ -0,0 +1,38 @@
/**
* 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 from 'react';
import {useNavbarMobileSidebar, useThemeConfig} from '@docusaurus/theme-common';
import type {Props as NavbarItemConfig} from '@theme/NavbarItem';
import NavbarItem from '../../../NavbarItem';
function useNavbarItems() {
// TODO temporary casting until ThemeConfig type is improved
return useThemeConfig().navbar.items as NavbarItemConfig[];
}
// The primary menu displays the navbar items
export default function NavbarMobilePrimaryMenu(): JSX.Element {
const mobileSidebar = useNavbarMobileSidebar();
// TODO how can the order be defined for mobile?
// Should we allow providing a different list of items?
const items = useNavbarItems();
return (
<ul className="menu__list">
{items.map((item, i) => (
<NavbarItem
mobile
{...item}
onClick={() => mobileSidebar.toggle()}
key={i}
/>
))}
</ul>
);
}

View file

@ -0,0 +1,38 @@
/**
* 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 ComponentProps} from 'react';
import {useNavbarSecondaryMenu, useThemeConfig} from '@docusaurus/theme-common';
import Translate from '@docusaurus/Translate';
function SecondaryMenuBackButton(props: ComponentProps<'button'>) {
return (
<button {...props} type="button" className="clean-btn navbar-sidebar__back">
<Translate
id="theme.navbar.mobileSidebarSecondaryMenu.backButtonLabel"
description="The label of the back button to return to main menu, inside the mobile navbar sidebar secondary menu (notably used to display the docs sidebar)">
Back to main menu
</Translate>
</button>
);
}
// The secondary menu slides from the right and shows contextual information
// such as the docs sidebar
export default function NavbarMobileSidebarSecondaryMenu(): JSX.Element | null {
const isPrimaryMenuEmpty = useThemeConfig().navbar.items.length === 0;
const secondaryMenu = useNavbarSecondaryMenu();
return (
<>
{/* edge-case: prevent returning to the primaryMenu when it's empty */}
{!isPrimaryMenuEmpty && (
<SecondaryMenuBackButton onClick={() => secondaryMenu.hide()} />
)}
{secondaryMenu.content}
</>
);
}

View file

@ -0,0 +1,25 @@
/**
* 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 from 'react';
import IconMenu from '@theme/IconMenu';
import {useNavbarMobileSidebar} from '@docusaurus/theme-common';
export default function MobileSidebarToggle(): JSX.Element {
const mobileSidebar = useNavbarMobileSidebar();
return (
<button
onClick={mobileSidebar.toggle}
onKeyDown={mobileSidebar.toggle}
aria-label="Navigation bar toggle"
className="navbar__toggle clean-btn"
type="button"
tabIndex={0}>
<IconMenu />
</button>
);
}

View file

@ -0,0 +1,33 @@
/**
* 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 from 'react';
import NavbarMobileSidebarLayout from '@theme/Navbar/MobileSidebar/Layout';
import NavbarMobileSidebarHeader from '@theme/Navbar/MobileSidebar/Header';
import {
useLockBodyScroll,
useNavbarMobileSidebar,
} from '@docusaurus/theme-common';
import NavbarMobileSidebarPrimaryMenu from '@theme/Navbar/MobileSidebar/PrimaryMenu';
import NavbarMobileSidebarSecondaryMenu from '@theme/Navbar/MobileSidebar/SecondaryMenu';
export default function NavbarMobileSidebar(): JSX.Element | null {
const mobileSidebar = useNavbarMobileSidebar();
useLockBodyScroll(mobileSidebar.shown);
if (!mobileSidebar.shouldRender) {
return null;
}
return (
<NavbarMobileSidebarLayout
header={<NavbarMobileSidebarHeader />}
primaryMenu={<NavbarMobileSidebarPrimaryMenu />}
secondaryMenu={<NavbarMobileSidebarSecondaryMenu />}
/>
);
}

View file

@ -5,300 +5,14 @@
* LICENSE file in the root directory of this source tree.
*/
import React, {useCallback, useState, useEffect} from 'react';
import clsx from 'clsx';
import Translate from '@docusaurus/Translate';
import SearchBar from '@theme/SearchBar';
import ColorModeToggle from '@theme/ColorModeToggle';
import {
useThemeConfig,
useMobileSecondaryMenuRenderer,
usePrevious,
useHistoryPopHandler,
useHideableNavbar,
useLockBodyScroll,
useWindowSize,
useColorMode,
} from '@docusaurus/theme-common';
import {useActivePlugin} from '@docusaurus/plugin-content-docs/client';
import NavbarItem, {type Props as NavbarItemConfig} from '@theme/NavbarItem';
import Logo from '@theme/Logo';
import IconMenu from '@theme/IconMenu';
import IconClose from '@theme/IconClose';
import styles from './styles.module.css';
// retrocompatible with v1
const DefaultNavItemPosition = 'right';
function useNavbarItems() {
// TODO temporary casting until ThemeConfig type is improved
return useThemeConfig().navbar.items as NavbarItemConfig[];
}
// If split links by left/right
// if position is unspecified, fallback to right (as v1)
function splitNavItemsByPosition(items: NavbarItemConfig[]) {
const leftItems = items.filter(
(item) => (item.position ?? DefaultNavItemPosition) === 'left',
);
const rightItems = items.filter(
(item) => (item.position ?? DefaultNavItemPosition) === 'right',
);
return {
leftItems,
rightItems,
};
}
function useMobileSidebar() {
const windowSize = useWindowSize();
// Mobile sidebar not visible on hydration: can avoid SSR rendering
const shouldRender = 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 {shouldRender, toggle, shown};
}
function useColorModeToggle() {
const {
colorMode: {disableSwitch},
} = useThemeConfig();
const {colorMode, setColorMode} = useColorMode();
return {
value: colorMode,
onChange: setColorMode,
disabled: disableSwitch,
};
}
function useSecondaryMenu({
sidebarShown,
toggleSidebar,
}: NavbarMobileSidebarProps) {
const content = useMobileSecondaryMenuRenderer()?.({
toggleSidebar,
});
const previousContent = usePrevious(content);
const [shown, setShown] = useState<boolean>(
() =>
// /!\ content is set with useEffect,
// so it's not available on mount anyway
// "return !!content" => always returns false
false,
);
// When content is become available for the first time (set in useEffect)
// we set this content to be shown!
useEffect(() => {
const contentBecameAvailable = content && !previousContent;
if (contentBecameAvailable) {
setShown(true);
}
}, [content, previousContent]);
const hasContent = !!content;
// 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 (!sidebarShown) {
setShown(true);
}
}, [sidebarShown, hasContent]);
const hide = useCallback(() => {
setShown(false);
}, []);
return {shown, hide, content};
}
type NavbarMobileSidebarProps = {
sidebarShown: boolean;
toggleSidebar: () => void;
};
function NavbarMobileSidebar({
sidebarShown,
toggleSidebar,
}: NavbarMobileSidebarProps) {
useLockBodyScroll(sidebarShown);
const items = useNavbarItems();
const colorModeToggle = useColorModeToggle();
const secondaryMenu = useSecondaryMenu({
sidebarShown,
toggleSidebar,
});
return (
<div className="navbar-sidebar">
<div className="navbar-sidebar__brand">
<Logo
className="navbar__brand"
imageClassName="navbar__logo"
titleClassName="navbar__title"
/>
{!colorModeToggle.disabled && (
<ColorModeToggle
className={styles.navbarSidebarToggle}
value={colorModeToggle.value}
onChange={colorModeToggle.onChange}
/>
)}
<button
type="button"
className="clean-btn navbar-sidebar__close"
onClick={toggleSidebar}>
<IconClose
color="var(--ifm-color-emphasis-600)"
className={styles.navbarSidebarCloseSvg}
/>
</button>
</div>
<div
className={clsx('navbar-sidebar__items', {
'navbar-sidebar__items--show-secondary': secondaryMenu.shown,
})}>
<div className="navbar-sidebar__item menu">
<ul className="menu__list">
{items.map((item, i) => (
<NavbarItem mobile {...item} onClick={toggleSidebar} key={i} />
))}
</ul>
</div>
<div className="navbar-sidebar__item menu">
{items.length > 0 && (
<button
type="button"
className="clean-btn navbar-sidebar__back"
onClick={secondaryMenu.hide}>
<Translate
id="theme.navbar.mobileSidebarSecondaryMenu.backButtonLabel"
description="The label of the back button to return to main menu, inside the mobile navbar sidebar secondary menu (notably used to display the docs sidebar)">
Back to main menu
</Translate>
</button>
)}
{secondaryMenu.content}
</div>
</div>
</div>
);
}
import React from 'react';
import NavbarLayout from '@theme/Navbar/Layout';
import NavbarContent from '@theme/Navbar/Content';
export default function Navbar(): JSX.Element {
const {
navbar: {hideOnScroll, style},
} = useThemeConfig();
const mobileSidebar = useMobileSidebar();
const colorModeToggle = useColorModeToggle();
const activeDocPlugin = useActivePlugin();
const {navbarRef, isNavbarVisible} = useHideableNavbar(hideOnScroll);
const items = useNavbarItems();
const hasSearchNavbarItem = items.some((item) => item.type === 'search');
const {leftItems, rightItems} = splitNavItemsByPosition(items);
return (
<nav
ref={navbarRef}
className={clsx(
'navbar',
'navbar--fixed-top',
hideOnScroll && [
styles.navbarHideable,
!isNavbarVisible && styles.navbarHidden,
],
{
'navbar--dark': style === 'dark',
'navbar--primary': style === 'primary',
'navbar-sidebar--show': mobileSidebar.shown,
},
)}>
<div className="navbar__inner">
<div className="navbar__items">
{(items?.length > 0 || activeDocPlugin) && (
<button
aria-label="Navigation bar toggle"
className="navbar__toggle clean-btn"
type="button"
tabIndex={0}
onClick={mobileSidebar.toggle}
onKeyDown={mobileSidebar.toggle}>
<IconMenu />
</button>
)}
<Logo
className="navbar__brand"
imageClassName="navbar__logo"
titleClassName="navbar__title"
/>
{leftItems.map((item, i) => (
<NavbarItem {...item} key={i} />
))}
</div>
<div className="navbar__items navbar__items--right">
{rightItems.map((item, i) => (
<NavbarItem {...item} key={i} />
))}
{!colorModeToggle.disabled && (
<ColorModeToggle
className={styles.toggle}
value={colorModeToggle.value}
onChange={colorModeToggle.onChange}
/>
)}
{!hasSearchNavbarItem && <SearchBar />}
</div>
</div>
<div
role="presentation"
className="navbar-sidebar__backdrop"
onClick={mobileSidebar.toggle}
/>
{mobileSidebar.shouldRender && (
<NavbarMobileSidebar
sidebarShown={mobileSidebar.shown}
toggleSidebar={mobileSidebar.toggle}
/>
)}
</nav>
<NavbarLayout>
<NavbarContent />
</NavbarLayout>
);
}

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>
);
}

View file

@ -98,6 +98,7 @@ globby
goss
goyal
gtag
hardcoding
hahaha
héctor
héllô

View file

@ -8,7 +8,7 @@
"build": "docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear && rimraf changelog",
"clear": "docusaurus clear && rimraf changelog && rimraf _dogfooding/_swizzle_theme_tests",
"serve": "docusaurus serve",
"test:css-order": "node testCSSOrder.mjs",
"test:swizzle:eject:js": "cross-env SWIZZLE_ACTION='eject' SWIZZLE_TYPESCRIPT='false' node _dogfooding/testSwizzleThemeClassic.mjs",