From dbe1107ca98b825f449dab0178b425b0f3dc0909 Mon Sep 17 00:00:00 2001 From: Gracjan Sadowicz Date: Wed, 21 Oct 2026 11:25:23 +0200 Subject: [PATCH] RDoc-4147 Allow linking content from switchable sections --- sidebarsTemplates.js | 5 ++ src/theme/Tabs/index.tsx | 68 +++++++++++++++++++++-- templates/tabs.mdx | 116 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 templates/tabs.mdx diff --git a/sidebarsTemplates.js b/sidebarsTemplates.js index a08178543f..6187581986 100644 --- a/sidebarsTemplates.js +++ b/sidebarsTemplates.js @@ -87,6 +87,11 @@ export default { id: "frames", label: "Frames", }, + { + type: "doc", + id: "tabs", + label: "Tabs", + }, { type: "doc", id: "gallery-example", diff --git a/src/theme/Tabs/index.tsx b/src/theme/Tabs/index.tsx index a8b90975c2..be8c56a6f5 100644 --- a/src/theme/Tabs/index.tsx +++ b/src/theme/Tabs/index.tsx @@ -1,5 +1,15 @@ -import React, { cloneElement, type ReactElement, type ReactNode } from "react"; +import React, { + cloneElement, + useEffect, + useRef, + useState, + type ReactElement, + type ReactNode, + type RefObject, +} from "react"; import clsx from "clsx"; +import { useLocation } from "@docusaurus/router"; +import { usePrevious } from "@docusaurus/theme-common"; import { useScrollPositionBlocker, useTabsContextValue, @@ -25,9 +35,57 @@ interface TabListProps { interface TabContentProps { lazy: boolean; selectedValue: string; + panelsRef: RefObject; children: ReactNode; } +function getHashTarget(hash: string): HTMLElement | null { + try { + return hash ? document.getElementById(decodeURIComponent(hash.slice(1))) : null; + } catch { + // Malformed percent-encoding + return null; + } +} + +// Hidden panels have no layout, so open the tab holding the hash target before scrolling to it +function useOpenTabOfHashTarget( + panelsRef: RefObject, + tabValues: readonly TabValue[], + selectValue: (value: string) => void +) { + const location = useLocation(); + const previousLocation = usePrevious(location); + // New object per request, so the same id scrolls again + const [scrollRequest, setScrollRequest] = useState<{ id: string } | null>(null); + + // Every navigation, so a repeated link reopens the tab; search-only changes are `queryString` tab clicks + useEffect(() => { + if (previousLocation?.hash === location.hash && previousLocation.search !== location.search) { + return; + } + const target = getHashTarget(location.hash); + if (!target) { + return; + } + const panels = Array.from(panelsRef.current?.children ?? []); + const tab = tabValues[panels.findIndex((panel) => panel.contains(target))]; + if (tab) { + selectValue(tab.value); + setScrollRequest({ id: target.id }); + } + }, [location]); + + // After the panel is unhidden and grouped Tabs have switched + useEffect(() => { + if (!scrollRequest) { + return; + } + const timeout = setTimeout(() => document.getElementById(scrollRequest.id)?.scrollIntoView()); + return () => clearTimeout(timeout); + }, [scrollRequest]); +} + function TabList({ className, block, selectedValue, selectValue, tabValues }: TabListProps) { const tabRefs: (HTMLLIElement | null)[] = []; const { blockElementScrollPositionUntilNextRender } = useScrollPositionBlocker(); @@ -115,7 +173,7 @@ function TabList({ className, block, selectedValue, selectValue, tabValues }: Ta ); } -function TabContent({ lazy, children, selectedValue }: TabContentProps) { +function TabContent({ lazy, children, selectedValue, panelsRef }: TabContentProps) { const childTabs = (Array.isArray(children) ? children : [children]).filter(Boolean) as TabItemElement[]; if (lazy) { const selectedTabItem = childTabs.find((tabItem) => tabItem.props.value === selectedValue); @@ -128,7 +186,7 @@ function TabContent({ lazy, children, selectedValue }: TabContentProps) { }); } return ( -
+
{childTabs.map((tabItem) => cloneElement(tabItem, { key: tabItem.props.value, @@ -141,6 +199,8 @@ function TabContent({ lazy, children, selectedValue }: TabContentProps) { function TabsComponent(props: Props): ReactNode { const tabs = useTabsContextValue(props); + const panelsRef = useRef(null); + useOpenTabOfHashTarget(panelsRef, tabs.tabValues, tabs.selectValue); return (
- + {props.children}
diff --git a/templates/tabs.mdx b/templates/tabs.mdx new file mode 100644 index 0000000000..773d335827 --- /dev/null +++ b/templates/tabs.mdx @@ -0,0 +1,116 @@ +--- +title: "Tabs" +unlisted: true +hide_table_of_contents: false +sidebar_label: Tabs +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +# Tabs + +`` shows alternatives in one place: the same operation as C# code and as an RQL query, the sync and async +variant of a method, or the syntax of related classes. Only the selected tab is visible. The others stay in the page, +hidden. + +`Tabs` and `TabItem` are not registered globally, so import them in every file that uses them, language partials +included: + +```mdx +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; +``` + +## Basic usage + +````mdx + + +```csharp +var employees = session.Query() + .Where(x => x.FirstName == "Robert") + .ToList(); +``` + + +```sql +from "Employees" where FirstName == "Robert" +``` + + +```` + +#### Live example + + + +```csharp +var employees = session.Query() + .Where(x => x.FirstName == "Robert") + .ToList(); +``` + + +```sql +from "Employees" where FirstName == "Robert" +``` + + + +## Linking to content inside a tab + +Put an anchor inside the `TabItem`: an `` or a Markdown heading. A link to it opens the tab and scrolls to +it, whether it comes from another page, from the same page or from the "In this article" TOC. Link to it the same way +as to any other anchor. + +````mdx + + +... + + + + +```sql +from "Employees" where FirstName == "Robert" +``` + + + +[See this query in RQL](#robert-rql) +```` + +#### Live example + + + +```csharp +var employees = session.Query() + .Where(x => x.FirstName == "Robert") + .ToList(); +``` + + + + +```sql +from "Employees" where FirstName == "Robert" +``` + + + +[See this query in RQL](#robert-rql) + +- Hidden tabs are part of the page, so every anchor needs an id that is unique on the whole page, not just in its tab. +- Opening a tab from a link works like clicking it: with a `groupId`, the other `Tabs` in the group switch too and the + choice is remembered. + +## Props + +| Prop | On | Description | +| --------- | --------- | ------------------------------------------------------------------------------------------------------------------- | +| `groupId` | `Tabs` | `Tabs` sharing a `groupId` switch together, and the choice is remembered across pages. API pages use `languageSyntax`. | +| `value` | `TabItem` | Required. Identifies the tab within its `Tabs`. In a group, tabs with the same `value` switch together. | +| `label` | `TabItem` | Text on the tab. Defaults to `value`. | +| `default` | `TabItem` | Selects this tab when the page opens. Without it the first tab is selected. A choice remembered through `groupId` wins. |