diff --git a/packages/docusaurus-theme-classic/src/theme-classic.d.ts b/packages/docusaurus-theme-classic/src/theme-classic.d.ts index e2256a48824e..8fb54747d09a 100644 --- a/packages/docusaurus-theme-classic/src/theme-classic.d.ts +++ b/packages/docusaurus-theme-classic/src/theme-classic.d.ts @@ -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' { diff --git a/packages/docusaurus-theme-classic/src/theme/DocSidebar/Mobile/index.tsx b/packages/docusaurus-theme-classic/src/theme/DocSidebar/Mobile/index.tsx index 30f23afbf8c6..48314ab6b12e 100644 --- a/packages/docusaurus-theme-classic/src/theme/DocSidebar/Mobile/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/DocSidebar/Mobile/index.tsx @@ -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 = ({ - toggleSidebar, +const DocSidebarMobileSecondaryMenu: NavbarSecondaryMenuComponent = ({ sidebar, path, -}) => ( - -); +}) => { + const mobileSidebar = useNavbarMobileSidebar(); + return ( + + ); +}; function DocSidebarMobile(props: Props) { return ( - diff --git a/packages/docusaurus-theme-classic/src/theme/LayoutProviders/index.tsx b/packages/docusaurus-theme-classic/src/theme/LayoutProviders/index.tsx index 601bea65eaa5..9eba0ff42336 100644 --- a/packages/docusaurus-theme-classic/src/theme/LayoutProviders/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/LayoutProviders/index.tsx @@ -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 { - - - {children} - - + + {children} + diff --git a/packages/docusaurus-theme-classic/src/theme/Navbar/ColorModeToggle/index.tsx b/packages/docusaurus-theme-classic/src/theme/Navbar/ColorModeToggle/index.tsx new file mode 100644 index 000000000000..5c33ac4a3bed --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Navbar/ColorModeToggle/index.tsx @@ -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 ( + + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/Navbar/Content/index.tsx b/packages/docusaurus-theme-classic/src/theme/Navbar/Content/index.tsx new file mode 100644 index 000000000000..a71cd478fa02 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Navbar/Content/index.tsx @@ -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) => ( + + ))} + + ); +} + +function NavbarContentLayout({ + left, + right, +}: { + left: ReactNode; + right: ReactNode; +}) { + return ( +
+
{left}
+
{right}
+
+ ); +} + +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 ( + + {!mobileSidebar.disabled && } + + + + } + right={ + // TODO stop hardcoding items? + // Ask the user to add the respective navbar items => more flexible + <> + + + {autoAddSearchBar && } + + } + /> + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/Navbar/Content/styles.module.css b/packages/docusaurus-theme-classic/src/theme/Navbar/Content/styles.module.css new file mode 100644 index 000000000000..3cbe701f247e --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Navbar/Content/styles.module.css @@ -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; + } +} diff --git a/packages/docusaurus-theme-classic/src/theme/Navbar/Layout/index.tsx b/packages/docusaurus-theme-classic/src/theme/Navbar/Layout/index.tsx new file mode 100644 index 000000000000..00e5736bd8fd --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Navbar/Layout/index.tsx @@ -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 ( +
+ ); +} + +export default function NavbarLayout({children}: Props): JSX.Element { + const { + navbar: {hideOnScroll, style}, + } = useThemeConfig(); + const mobileSidebar = useNavbarMobileSidebar(); + const {navbarRef, isNavbarVisible} = useHideableNavbar(hideOnScroll); + return ( + + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/Navbar/styles.module.css b/packages/docusaurus-theme-classic/src/theme/Navbar/Layout/styles.module.css similarity index 68% rename from packages/docusaurus-theme-classic/src/theme/Navbar/styles.module.css rename to packages/docusaurus-theme-classic/src/theme/Navbar/Layout/styles.module.css index 6dcfe1c7bb7a..8258c17f2cfd 100644 --- a/packages/docusaurus-theme-classic/src/theme/Navbar/styles.module.css +++ b/packages/docusaurus-theme-classic/src/theme/Navbar/Layout/styles.module.css @@ -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; -} diff --git a/packages/docusaurus-theme-classic/src/theme/Navbar/Logo/index.tsx b/packages/docusaurus-theme-classic/src/theme/Navbar/Logo/index.tsx new file mode 100644 index 000000000000..e1967b955f06 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Navbar/Logo/index.tsx @@ -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 ( + + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/Navbar/MobileSidebar/Header/index.tsx b/packages/docusaurus-theme-classic/src/theme/Navbar/MobileSidebar/Header/index.tsx new file mode 100644 index 000000000000..c58b42eb014a --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Navbar/MobileSidebar/Header/index.tsx @@ -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 ( + + ); +} + +export default function NavbarMobileSidebarHeader(): JSX.Element { + return ( +
+ + + +
+ ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/Navbar/MobileSidebar/Layout/index.tsx b/packages/docusaurus-theme-classic/src/theme/Navbar/MobileSidebar/Layout/index.tsx new file mode 100644 index 000000000000..4fe7a4f9984c --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Navbar/MobileSidebar/Layout/index.tsx @@ -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 ( +
+ {header} +
+
{primaryMenu}
+
{secondaryMenu}
+
+
+ ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/Navbar/MobileSidebar/PrimaryMenu/index.tsx b/packages/docusaurus-theme-classic/src/theme/Navbar/MobileSidebar/PrimaryMenu/index.tsx new file mode 100644 index 000000000000..dbde63edb558 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Navbar/MobileSidebar/PrimaryMenu/index.tsx @@ -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 ( +
    + {items.map((item, i) => ( + mobileSidebar.toggle()} + key={i} + /> + ))} +
+ ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/Navbar/MobileSidebar/SecondaryMenu/index.tsx b/packages/docusaurus-theme-classic/src/theme/Navbar/MobileSidebar/SecondaryMenu/index.tsx new file mode 100644 index 000000000000..a15ba55c5684 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Navbar/MobileSidebar/SecondaryMenu/index.tsx @@ -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 ( + + ); +} + +// 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 && ( + secondaryMenu.hide()} /> + )} + {secondaryMenu.content} + + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/Navbar/MobileSidebar/Toggle/index.tsx b/packages/docusaurus-theme-classic/src/theme/Navbar/MobileSidebar/Toggle/index.tsx new file mode 100644 index 000000000000..e4ac62a50584 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Navbar/MobileSidebar/Toggle/index.tsx @@ -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 ( + + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/Navbar/MobileSidebar/index.tsx b/packages/docusaurus-theme-classic/src/theme/Navbar/MobileSidebar/index.tsx new file mode 100644 index 000000000000..4a29c4901525 --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/Navbar/MobileSidebar/index.tsx @@ -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 ( + } + primaryMenu={} + secondaryMenu={} + /> + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/Navbar/index.tsx b/packages/docusaurus-theme-classic/src/theme/Navbar/index.tsx index 1693bb44c7d6..878f2047ad40 100644 --- a/packages/docusaurus-theme-classic/src/theme/Navbar/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/Navbar/index.tsx @@ -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( - () => - // /!\ 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 ( -
-
- - {!colorModeToggle.disabled && ( - - )} - -
- -
-
-
    - {items.map((item, i) => ( - - ))} -
-
- -
- {items.length > 0 && ( - - )} - {secondaryMenu.content} -
-
-
- ); -} +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 ( - + + + ); } diff --git a/packages/docusaurus-theme-common/src/index.ts b/packages/docusaurus-theme-common/src/index.ts index 92c394e4466b..a670df39a2b8 100644 --- a/packages/docusaurus-theme-common/src/index.ts +++ b/packages/docusaurus-theme-common/src/index.ts @@ -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, diff --git a/packages/docusaurus-theme-common/src/utils/mobileSecondaryMenu.tsx b/packages/docusaurus-theme-common/src/utils/mobileSecondaryMenu.tsx deleted file mode 100644 index ed898a903317..000000000000 --- a/packages/docusaurus-theme-common/src/utils/mobileSecondaryMenu.tsx +++ /dev/null @@ -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 = ComponentType< - Props & ExtraProps ->; - -type State = { - component: MobileSecondaryMenuComponent; - props: unknown; -} | null; - -function useContextValue() { - return useState(null); -} - -type ContextValue = ReturnType; - -const Context = React.createContext(null); - -export function MobileSecondaryMenuProvider({ - children, -}: { - children: ReactNode; -}): JSX.Element { - return ( - {children} - ); -} - -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 ; - }; - } - return () => undefined; -} - -function useShallowMemoizedObject>(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, ->({ - component, - props, -}: { - component: MobileSecondaryMenuComponent; - 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; -} diff --git a/packages/docusaurus-theme-common/src/utils/navbarSecondaryMenuUtils.tsx b/packages/docusaurus-theme-common/src/utils/navbarSecondaryMenuUtils.tsx new file mode 100644 index 000000000000..d5bedcb8cef2 --- /dev/null +++ b/packages/docusaurus-theme-common/src/utils/navbarSecondaryMenuUtils.tsx @@ -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 = ComponentType; + +type State = { + shown: boolean; + content: + | { + component: ComponentType; + 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(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; + +const Context = React.createContext(null); + +export function NavbarSecondaryMenuProvider({ + children, +}: { + children: ReactNode; +}): JSX.Element { + return ( + {children} + ); +} + +function useNavbarSecondaryMenuContext(): ContextValue { + const value = useContext(Context); + if (value === null) { + throw new ReactContextError('MobileSecondaryMenuProvider'); + } + return value; +} + +function useShallowMemoizedObject>(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, +>({ + component, + props, +}: { + component: NavbarSecondaryMenuComponent; + 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 ; + } + 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], + ); +} diff --git a/packages/docusaurus-theme-common/src/utils/navbarUtils.tsx b/packages/docusaurus-theme-common/src/utils/navbarUtils.tsx new file mode 100644 index 000000000000..f6a1bc9d7848 --- /dev/null +++ b/packages/docusaurus-theme-common/src/utils/navbarUtils.tsx @@ -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( + 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 ( + + {children} + + ); +} + +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 ( + + {children} + + ); +} diff --git a/project-words.txt b/project-words.txt index 458398a94018..ae29fa0b5180 100644 --- a/project-words.txt +++ b/project-words.txt @@ -98,6 +98,7 @@ globby goss goyal gtag +hardcoding hahaha héctor héllô diff --git a/website/package.json b/website/package.json index 7a9488fcc2c0..71a07a2fb62c 100644 --- a/website/package.json +++ b/website/package.json @@ -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",