docusaurus
docusaurus copied to clipboard
Dynamic navbar: navbar item activation strategies
🚀 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
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?
This would address my issue perfectly, I would love it if this were implemented
@slorber Hi, thank you for a great tool ;) do you have this feature on 2.0.0 roadmap ?
@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
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.
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
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
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?
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
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} />;
}
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'
},
This way, we can simply do anything to the dropdown elements by using our custom CSS classes.
@Josh-Cena
@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 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.
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)
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} />;
}
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();
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} />;
}
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 😄
I don't know for you but in 2.2.0
useActiveDocContext(props.docsPluginId)
always returns an Object, I had to check if the propertyactiveDoc
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?
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");
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',
},
]
For me the use case is being able to render some navbar items only when the user selected a specific docs version.
Is this still not finished yet? An active docs-aware version dropdown is all we need.
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.
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.
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 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} />;
}