docusaurus icon indicating copy to clipboard operation
docusaurus copied to clipboard

Dynamic navbar: navbar item activation strategies

Open Yorkemartin opened this issue 3 years ago • 26 comments

🚀 Feature

An example:

I have three instances of docs, each segmented into three categories with their respective sidebars: "concepts" "guides" and "reference". These docs can be called "V1" "V2" and "V3", but are fixed docs once released so versioning isn't helpful in the canonical sense. When on "V2"-"Concepts" - clicking the Guides button on the navbar will take me to "V2"-"Guides", along with the appropriate sidebar.

Have you read the Contributing Guidelines on issues?

yes

Motivation

I'm trying to make the above work and have been unsuccessful after many days of wrangling

Pitch

It seems useful to have navbar routing that can accommodate multiple doc instances

Yorkemartin avatar Mar 11 '21 01:03 Yorkemartin

Hi,

I'm not totally sure to understand your usecase, a better description would be appreciated, and also if you can put a deploy preview of your doc site with 3 docs instance online on Netlify/Vercel/whatever, that would help to figure out the UX you try to achieve.

This looks kind-of related to https://github.com/facebook/docusaurus/issues/3930

My idea was we could build a generic system where each navbar item has an "activation strategy".

This means the navbar items could be different on a per-route basis, for both mobile and desktop.

This "strategy config" must be:

  • serializable (provided in Docusaurus config, but must be usable on client-side code)
  • composable, so that user has flexibility to create advanced rules

Here's a badly designed example:

const navbarItems = [
  {
    to: "/guides/",
    label: "Guides",
    when: { routeMatch: "/guides/.*" }
  },
  {
    to: "/api/ios",
    label: "iOS API",
    when: { plugin: { name: "@docusaurus/plugin-content-docs", id: "ios" } }
  },
  {
    to: "/api/android",
    label: "Android API",
    when: {
      type: "and",
      items: [
        { plugin: { name: "@docusaurus/plugin-content-docs", id: "ios" } },
        { routeMatch: "/guides/.*" }
      ]
    }
  }
];

We could work on defining a few useful activation conditions and the API surface, and also enable composition with AND/OR logic.

Is this what you are looking for?

slorber avatar Mar 12 '21 14:03 slorber

This would address my issue perfectly, I would love it if this were implemented

Yorkemartin avatar Mar 16 '21 18:03 Yorkemartin

@slorber Hi, thank you for a great tool ;) do you have this feature on 2.0.0 roadmap ?

dswiecki avatar Jun 25 '21 13:06 dswiecki

@dswiecki this has been requested quite often so we'll build it, however this is not the simplest one to design, and we have more important features to work on in the short term, cf the beta blog post https://docusaurus.io/blog/2021/05/12/announcing-docusaurus-two-beta#whats-next

slorber avatar Jun 25 '21 14:06 slorber

Hey, multiple people have asked me whether it's possible to only display certain navbar items when user is logged in (by sending HTTP requests).

I like the activeWhen API, and I think we can make the type be like:

activeWhen: {strategy: string, params: any}

And then have a custom @theme/useNavbarItemIsActive hook, meant to be swizzled if the user wants additional logic so that this is accessible on the client side.

Josh-Cena avatar Aug 13 '21 15:08 Josh-Cena

We are a static site generator and not a hybrid framework like Next.js. This means that we can only check for user authentication status once in the browser.

Having navbar items that require user authentication means that there will be navbar layout shifts happening after React hydration, and I'm not sure it's a good pattern to encourage: the strategies should rather work on the server to avoid layout shifts. But if we allow custom user-provided strategies we can't really prevent the user to do that anyway.

This post section explains the problem with gifs: https://www.joshwcomeau.com/react/the-perils-of-rehydration/#schrodingers-user:


And then have a custom @theme/useNavbarItemIsActive hook, meant to be swizzled

My plan was more to have a map of strategies in @theme/NavbarStrategies/index.tsx, similar to what we have for NavbarItem. I think having an empty map of custom strategies could also be useful so that user can swizzle a smaller part: @theme/NavbarStrategies/CustomStrategies.tsx.

We should probably add something similar to allow providing custom Navbar Items. . I'd like the infrastructure to look similar in both cases and make it easy to extend/maintain (small swizzle API surface).

I can support you if you want to work on this

slorber avatar Aug 13 '21 15:08 slorber

My plan was more to have a map of strategies in @theme/NavbarStrategies/index.tsx,

I can't think of many use cases besides loggedIn and routeMatch 🤦‍♂️ But having a CustomStrategies.tsx is similar to what I have in mind

Agree that there will be layout shifts, but that doesn't sound like what we can help anyways (I've seen this behavior on a lot of sites using REST API for login). We can allow the user to set a default render state to minimize the impact, maybe?

the strategies should rather work on the server to avoid layout shifts

Makes sense. Strategies that we offer should conform to this, but we probably can't limit the users too much either

In any case, I might set my hands on this if no-one will be working on this soon


I imagine the API surface to be like:

export default function useNavbarItemStatus({type, params}): 'active' | 'disabled' | 'hidden' {
  return NavbarStrategies[type](params);
}

And then called in NavbarItem:

export default function NavbarItem({type, activeWhen, ...props}) {
  const status = useNavbarItemStatus(activeWhen);
  switch (status) {
    case 'hidden': return null;
    case 'disabled': return <NavbarItemComponent disabled {...props} />;
    case 'active': return <NavbarItemComponent {...props} />;
  }
}

So that you can do something like:

const navbarItems = [
  {
    to: "/guides/",
    label: "Guides",
    activeWhen: { type: 'routeMatch', params: "/guides/.*" }
  },
  {
    to: "/concepts/",
    label: "Concepts",
    activeWhen: { type: 'routeMatch', params: "/concepts/.*" }
  },
  {
    to: "/secret/",
    label: "Secrets",
    activeWhen: { type: 'loggedIn' }
  },
];

We can even have the syntactic sugar

type Strategy = { type: string, params: any} | {[type: string]: any}

to make the API close to what you have

Josh-Cena avatar Aug 14 '21 01:08 Josh-Cena

when: { plugin: {name: "@docusaurus/plugin-content-docs", id: "ios"} }

This doesn't look like an explainable / intuitive API to me... Is this an attempt to improve SSR?

Josh-Cena avatar Oct 21 '21 13:10 Josh-Cena

Just updated this to showcase that it can cover the need of https://github.com/facebook/docusaurus/pull/5756, it is not at all a final/definitive API

slorber avatar Oct 21 '21 13:10 slorber

React-Native is using a conditional version dropdown, that is only displayed on docs pages. https://github.com/facebook/react-native-website/blob/main/website/src/theme/NavbarItem/DocsVersionDropdownNavbarItem.js

The implementation can likely be greatly simplified, just using swizzle --wrap should allow you to conditionally render it.

Until we have proper support for this, I suggest the following workaround:

import React from "react";
import {useActiveDocContext} from '@docusaurus/plugin-content-docs/client';
import DocsVersionDropdownNavbarItem from '@theme-original/NavbarItem/DocsVersionDropdownNavbarItem';

export default function DocsVersionDropdownNavbarItemWrapper(props) {

  // do not display this navbar item if current page is not a doc
  const activeDocContext = useActiveDocContext(props.docsPluginId);
  if (!activeDocContext) {
    return null;
  }

  return <DocsVersionDropdownNavbarItem {...props} />;
}

slorber avatar Apr 28 '22 17:04 slorber

I don't know if it helps, but I added some CSS in custom.css to hide irrelevant version dropdown from navbar. The only problem is the dropdown elements in navbar have no CSS class included and I had to use the sequences of the dropdown in my CSS like this:

.navbar.navbar--fixed-top .navbar__items.navbar__items--right > .navbar__item:nth-child(3),
.navbar.navbar--fixed-top .navbar__items.navbar__items--right > .navbar__item:nth-child(4) {
  display: none;
}

html.docs-wrapper.plugin-id-default .navbar.navbar--fixed-top .navbar__items.navbar__items--right > .navbar__item:nth-child(3) {
  display: block;
}

html.docs-wrapper.plugin-id-docsFormio .navbar.navbar--fixed-top .navbar__items.navbar__items--right > .navbar__item:nth-child(4) {
  display: block;
}

If only we could add some custom CSS into the dropdown elements, the problem would be solved peacefully. As a suggestion, It can be done by implementing a parameter called customCss in Navbar docs version dropdown, like this:

            type: 'docsVersionDropdown',
            position: 'right',
            docsPluginId: 'default',
            customCss: 'menu-2'
          },
          {
            type: 'docsVersionDropdown',
            position: 'right',
            docsPluginId: 'docsFormio',
            customCss: 'menu-3'
          },

image

This way, we can simply do anything to the dropdown elements by using our custom CSS classes.

@Josh-Cena

al1re2a avatar Nov 20 '22 13:11 al1re2a

@al1re2a we support passing a className: "my-css-class", which is the default class prop for React components CSS.

Maybe it's not documented well enough?

However I think it's applied to the link instead of the parent dropdown container so we should probably apply this class to the parent instead, or give a way to pass a custom class to both elements independently.

Note with the new :has() (support is improving fast!) it may be less necessary:

I'm thinking of this:

html.docs-wrapper.plugin-id-default .navbar__item:has(> a.my-custom-class) {
  display: block;
}

slorber avatar Nov 23 '22 17:11 slorber

@slorber Thanks for the tips. It worked for me perfectly.

Maybe it's not documented well enough?

Unfortunately yes. Although the className is supported in almost all Navbar elements, it is not mentioned in docsVersionDropdown at all. I would appreciate if it is documented as well.

al1re2a avatar Nov 27 '22 05:11 al1re2a

React-Native is using a conditional version dropdown, that is only displayed on docs pages. https://github.com/facebook/react-native-website/blob/main/website/src/theme/NavbarItem/DocsVersionDropdownNavbarItem.js

The implementation can likely be greatly simplified, just using swizzle --wrap should allow you to conditionally render it.

Until we have proper support for this, I suggest the following workaround:

import React from "react";
import {useActiveDocContext} from '@docusaurus/plugin-content-docs/client';
import DocsVersionDropdownNavbarItem from '@theme-original/NavbarItem/DocsVersionDropdownNavbarItem';

export default function DocsVersionDropdownNavbarItemWrapper(props) {

  // do not display this navbar item if current page is not a doc
  const activeDocContext = useActiveDocContext(props.docsPluginId);
  if (!activeDocContext) {
    return null;
  }

  return <DocsVersionDropdownNavbarItem {...props} />;
}

@slorber

I tried this out but nothing changes really. What's the expected result of this exactly?

I'm not sure if this could work but I was hoping I could do something like:

get the current route -> show the navbar item if the route is part of the plugin

Example:

docusaurus.config.js

      [
        '@docusaurus/plugin-content-docs',
        {
          id: 'charge-controller',
          path: 'docs/ChargeController',
          routeBasePath: 'charge-controller',
          sidebarPath: require.resolve('./docs/ChargeController/sidebars.js'),
        },
      ],

navItems:

  {
    type: 'docsVersionDropdown',
    docsPluginId: 'charge-controller',
    position: 'right',
    className: "charge-controller-version-dropdown"
  },

current URI = "http://localhost:3000/charge-controller" --> URI contains the docsPluginId for charge-controller docs --> show the component

otherwise, return null.

Is this possible? Or perhaps I'm understanding your code wrong. From what I can see, it doesn't seem to do what I'd expect (hide dropdowns except for the one that corresponds to the active plugin id)

Zenahr avatar Jan 10 '23 08:01 Zenahr

Nevermind my previous comment. I got something working with rather simple code. What this does: Context-sensitive hiding/showing of version dropdowns. For anyone interested in this:

import React from "react";
import DocsVersionDropdownNavbarItem from '@theme-original/NavbarItem/DocsVersionDropdownNavbarItem';
import { useLocation }  from '@docusaurus/router';

export default function DocsVersionDropdownNavbarItemWrapper(props) {
  const { docsPluginId, className, type } = props
  const { pathname } = useLocation()
  
  /* (Custom) check if docsPluginId contains pathname
  Given that the docsPluginId is 'charge-controller' and the routeBasePath is 'charge-controller', we can check against the current URI (pathname).
  If the pathname contains the docsPluginId, we want to show the version dropdown. Otherwise, we don't want to show it.
  This gives us one, global, context-aware version dropdown that works with multi-instance setups.

  (Example for a possible configuration)
  docusaurus.config.js:
  ****************************************************************************************************
  [
    '@docusaurus/plugin-content-docs',
    {
      id: 'charge-controller',
      path: 'docs/ChargeController',
      routeBasePath: 'charge-controller',
      sidebarPath: require.resolve('./docs/ChargeController/sidebars.js'),
    },
  ],
  ****************************************************************************************************

  navbarItems.js (or as an attribute in docusaurus.config.js):
  ****************************************************************************************************
  {
    type: 'docsVersionDropdown',
    docsPluginId: 'charge-controller',
    position: 'right',
  },
  {
    type: 'docsVersionDropdown',
    docsPluginId: 'charge-point',
    position: 'right',
  },
  ****************************************************************************************************
  */
  const doesPathnameContainDocsPluginId = pathname.includes(docsPluginId)
  if (!doesPathnameContainDocsPluginId) {
    return null
  }
  return <DocsVersionDropdownNavbarItem {...props} />;
}

Zenahr avatar Jan 10 '23 08:01 Zenahr

The solution I suggested is quite similar but more robust. The docs plugin id is not always contained in the URL.

 const activeDocContext = useActiveDocContext(props.docsPluginId);

This returns something if the current docs plugin is active.

You can also try using this undocumented hook (more low-level but core, less likely to be refactored)

import useRouteContext from '@docusaurus/useRouteContext';

const {plugin: {id, name}} = useRouteContext();

slorber avatar Jan 18 '23 18:01 slorber

I don't know for you but in 2.2.0 useActiveDocContext(props.docsPluginId) always returns an Object, I had to check if the property activeDoc is actually defined inside this object :

import React from "react";
import {useActiveDocContext} from '@docusaurus/plugin-content-docs/client';
import DocsVersionDropdownNavbarItem from '@theme-original/NavbarItem/DocsVersionDropdownNavbarItem';

export default function DocsVersionDropdownNavbarItemWrapper(props) {

  // do not display this navbar item if current page is not a doc
  const { activeDoc } = useActiveDocContext(props.docsPluginId);
  if (!activeDoc) {
    return null;
  }

  return <DocsVersionDropdownNavbarItem {...props} />;
}

jbltx avatar Jan 20 '23 00:01 jbltx

Ah yes, I don't always run the pseudo-code I suggest to use so take this with a grain of salt and adapt it a bit if needed 😄

slorber avatar Jan 20 '23 11:01 slorber

I don't know for you but in 2.2.0 useActiveDocContext(props.docsPluginId) always returns an Object, I had to check if the property activeDoc is actually defined inside this object :

import React from "react";
import {useActiveDocContext} from '@docusaurus/plugin-content-docs/client';
import DocsVersionDropdownNavbarItem from '@theme-original/NavbarItem/DocsVersionDropdownNavbarItem';

export default function DocsVersionDropdownNavbarItemWrapper(props) {

  // do not display this navbar item if current page is not a doc
  const { activeDoc } = useActiveDocContext(props.docsPluginId);
  if (!activeDoc) {
    return null;
  }

  return <DocsVersionDropdownNavbarItem {...props} />;
}

Can this be applied not to the /docs folder, but on the contrary, to the versioned multi-instance docs that reside next to /docs?

aleksimo avatar Mar 24 '23 20:03 aleksimo

Can this be applied not to the /docs folder, but on the contrary, to the versioned multi-instance docs that reside next to /docs?

I don't know what you mean here, a repro would help.

You can pass a pluginId as hook arg so if you have a 2nd docs plugin instance you can use its id here: useActiveDocContext("my-android-sdk-plugin-id");

slorber avatar Apr 07 '23 10:04 slorber

So I have several instances of docs, each with its own sidebar and versioning. The instances are located in separate folders in the root:

  • /docs (not versioned)
  • /instance-1 (3 versions)
  • /instance-2 (2 versions)
  • etc.

To make the versions work, I added the version dropdowns to each versioned item in the navbar, thus making several version dropdowns on the UI.

With the code above, I wanted to make a single navbar dropdown that will appear only for versioned instances and contain versions depending on the instance you are currently reading.

I was able to achieve that with the following found code:

import React from "react";
import DocsVersionDropdownNavbarItem from '@theme-original/NavbarItem/DocsVersionDropdownNavbarItem';
import { useLocation }  from '@docusaurus/router';

export default function DocsVersionDropdownNavbarItemWrapper(props) {
  const { docsPluginId, className, type } = props
  const { pathname } = useLocation()
 
  const doesPathnameContainDocsPluginId = pathname.includes(docsPluginId)
  if (!doesPathnameContainDocsPluginId) {
    return null
  }
  return <DocsVersionDropdownNavbarItem {...props} />;
}

The only thing, in docusaurus.config.js, I had to create 3 separate dropdown items, but this doesn't affect the result.

items: [
          {
            label: 'Instance-0',
            to:'docs/...',
          },
          { 
            to: 'path-to-instance-1-id',
            label: 'Instance-1',
          },
          {
            to: 'path-to-instance-2-id',
            label: 'Instance-2',
          },
          {
            type: 'docsVersionDropdown',
            docsPluginId: 'instance-1',
            position: 'right',
          },
          {
            type: 'docsVersionDropdown',
            docsPluginId: 'instance-2',
            position: 'right',
          },
]

aleksimo avatar Apr 07 '23 14:04 aleksimo

For me the use case is being able to render some navbar items only when the user selected a specific docs version.

fharper avatar Jul 13 '23 20:07 fharper

Is this still not finished yet? An active docs-aware version dropdown is all we need.

image

Docs Multi-Instance can result into multiple docs with their own versions. I don't know how the Docs Multi-instance was released without taking into consideration the versions per instance.

luchtech avatar Dec 30 '23 11:12 luchtech

Docs Multi-Instance can result into multiple docs with their own versions. I don't know how the Docs Multi-instance was released without taking into consideration the versions per instance.

Because there are workarounds possible now to achieve this and this is an open-source project with limited volunteer maintainer time to implement things. It's generally considered that if there's a way to achieve something, even if that way is swizzling or custom components that it's not a high priority and that providing a usable base set of features for 75+% of use cases/sites is the goal not making APIs for every single advanced use case someone wants.

homotechsual avatar Dec 30 '23 12:12 homotechsual

For my site, I wanted to avoid displaying the version dropdown when the active route is a blog post. Here is how I achieved it.

I created src/theme/NavbarItem/DocsVersionDropdownNavbarItem.js and added the following code, and that alone solved the problem for me.

import React from 'react';
import DocsVersionDropdownNavbarItem from '@theme-original/NavbarItem/DocsVersionDropdownNavbarItem';
import {useLocation} from '@docusaurus/router';

export default function DocsVersionDropdownNavbarItemWrapper(props) {
  const location = useLocation();

  const unversionedRoutes = [
    // any route that starts with `/blog`
    /^\/blog(?:\/[\w-]+)?(?:\/#\w+)?$/g
  ]

  function checkPathname(pathname) {
    // Check if the provided pathname matches any of the regexes in the list
    return unversionedRoutes.some(regex => regex.test(pathname))
  }

  if (checkPathname(location.pathname)) {
    return null;
  }

  return <DocsVersionDropdownNavbarItem {...props} />;
}

Hope this would help someone else!

pavinduLakshan avatar Feb 25 '24 14:02 pavinduLakshan

@pavinduLakshan using useRouteContext is likely a better, more robust solution.

I didn't try but this should work:

import React from 'react';
import useRouteContext from '@docusaurus/useRouteContext';
import DocsVersionDropdownNavbarItem from '@theme-original/NavbarItem/DocsVersionDropdownNavbarItem';

export default function DocsVersionDropdownNavbarItemWrapper(props) {
  const {plugin} = useRouteContext();

  if (plugin.name === "docusaurus-plugin-content-blog") {
    return null;
  }

  return <DocsVersionDropdownNavbarItem {...props} />;
}

slorber avatar Feb 25 '24 16:02 slorber