Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions components/DocContent/DocContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ const DocContent: React.FC<{
// Check if this is the introduction page (exclude copy functionality)
const isIntroductionPage = post.slug === 'introduction'

const hasTabs = !!post?.body?.raw && post.body.raw.includes('<Tabs')
const effectiveHideTOC = hideTableOfContents && !hasTabs

return (
<>
<div className={`doc-content ${source === ONBOARDING_SOURCE ? 'product-onboarding' : ''}`}>
Expand Down Expand Up @@ -74,10 +77,10 @@ const DocContent: React.FC<{
<DocsPrevNext />
</div>

{!hideTableOfContents && (
{!effectiveHideTOC && (
<TableOfContents
toc={toc}
hideTableOfContents={hideTableOfContents}
hideTableOfContents={effectiveHideTOC}
source={source || ''}
/>
)}
Expand Down
171 changes: 158 additions & 13 deletions components/DocsTOC/DocsTOC.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,163 @@
'use client'

import React from 'react'
import React, { useEffect, useRef, useState } from 'react'
import { ONBOARDING_SOURCE } from '../../constants/globals'
import TableOfContents from '@/components/TableOfContents/TableOfContents'

interface TocItemProps {
url: string
depth: number
value: string
}

interface TableOfContentsProps {
interface DocsTOCProps {
toc: TocItemProps[]
hideTableOfContents: boolean
source: string
}

const TableOfContents: React.FC<TableOfContentsProps> = ({ toc, hideTableOfContents, source }) => {
const DocsTOC: React.FC<DocsTOCProps> = ({ toc, hideTableOfContents, source }) => {
const [activeSection, setActiveSection] = useState<string>('')
const [filteredToc, setFilteredToc] = useState<TocItemProps[]>(toc || [])
const tocContainerRef = useRef<HTMLDivElement>(null)
const tocItemsRef = useRef<HTMLDivElement>(null)

// Mirror blog ToC behavior: observe headings and update active section
useEffect(() => {
if (!toc || toc.length === 0) return

const observer = new IntersectionObserver(
(entries) => {
const visibleEntries = entries.filter((entry) => entry.isIntersecting)
if (visibleEntries.length > 0) {
const sortedEntries = visibleEntries.sort(
(a, b) => a.boundingClientRect.top - b.boundingClientRect.top
)
const id = sortedEntries[0].target.getAttribute('id')
if (id) setActiveSection(`#${id}`)
}
},
{
rootMargin: '-10% -20% -80% -20%',
threshold: 0,
}
)

const headings = document.querySelectorAll('h2, h3')
headings.forEach((heading) => observer.observe(heading))

return () => {
headings.forEach((heading) => observer.unobserve(heading))
}
}, [toc])

// Compute TOC entries only for headings that are currently visible (i.e., in active tab panels)
useEffect(() => {
if (!toc || toc.length === 0) return

const computeFiltered = () => {
const next: TocItemProps[] = []
toc.forEach((item) => {
const rawId = item.url.startsWith('#') ? item.url.slice(1) : item.url
const normalizedId = rawId.replace(/-+$/g, '') // trim trailing hyphens
const el = typeof document !== 'undefined'
? (document.getElementById(rawId) || document.getElementById(normalizedId))
: null
if (!el) return
// Only include headings that are currently rendered (not display:none)
// Using getClientRects is robust across nested hidden ancestors
const isRendered = el.getClientRects().length > 0
if (isRendered) next.push(item)
})
setFilteredToc(next)
}

computeFiltered()

// Recompute on tab button clicks
const onTabClick = (e: Event) => {
const target = e.target as HTMLElement
const isTabButton = !!target.closest('button[data-tab-value]')
if (isTabButton) {
// Delay to allow React to update visibility
setTimeout(computeFiltered, 0)
}
}
document.addEventListener('click', onTabClick, { capture: true })

// Also observe attribute changes to panels' hidden attribute
const observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type === 'attributes') {
computeFiltered()
break
}
}
})
observer.observe(document.body, { attributes: true, subtree: true })

// Recompute on resize as layout can change
window.addEventListener('resize', computeFiltered)

return () => {
document.removeEventListener('click', onTabClick, { capture: true } as any)
observer.disconnect()
window.removeEventListener('resize', computeFiltered)
}
}, [toc])

// Intercept TOC link clicks to switch tabs (if needed) before scrolling
useEffect(() => {
const container = tocItemsRef.current
if (!container) return

const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement
const anchor = target.closest('a') as HTMLAnchorElement | null
if (!anchor || !anchor.getAttribute('href')?.startsWith('#')) return

e.preventDefault()
const hash = anchor.getAttribute('href') || ''
const rawId = hash.replace('#', '')
const normalizedId = rawId.replace(/-+$/g, '')
const el = document.getElementById(rawId) || document.getElementById(normalizedId)
if (!el) return

// Activate all ancestor tabs (handles nested Tabs inside Tabs)
const originalEl = el
let searchStart: HTMLElement | null = originalEl
while (searchStart) {
const tabsRoot = searchStart.closest('[data-tabs-root]') as HTMLElement | null
if (!tabsRoot) break
// Find the panel in this tabsRoot that contains the original element
let panel = originalEl.closest('[data-tab-value]') as HTMLElement | null
while (panel && !tabsRoot.contains(panel)) {
panel = panel.parentElement?.closest('[data-tab-value]') as HTMLElement | null
}
const panelTabValue = panel?.getAttribute('data-tab-value')
if (panelTabValue) {
const button = tabsRoot.querySelector(
`button[data-tab-value="${panelTabValue}"]`
) as HTMLButtonElement | null
if (button) button.click()
}
// Move up to find parent tabs group
searchStart = tabsRoot.parentElement
}

// Smooth scroll to the target after switching
setTimeout(() => {
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
// Set active section using the element's id to avoid slug mismatches
const finalId = el.getAttribute('id') || normalizedId || rawId
setActiveSection(`#${finalId}`)
}, 0)
}

container.addEventListener('click', handleClick, { capture: true })
return () => container.removeEventListener('click', handleClick, { capture: true } as any)
}, [])

if (
hideTableOfContents ||
!toc ||
Expand All @@ -26,19 +169,21 @@ const TableOfContents: React.FC<TableOfContentsProps> = ({ toc, hideTableOfConte
}

return (
<div className="doc-toc">
<div className="doc-toc" ref={tocContainerRef}>
<div className="mb-3 text-xs uppercase"> On this page </div>
<div className="doc-toc-items border-l border-signoz_slate-500 pl-3">
{toc.map((tocItem) => (
<div className="doc-toc-item" key={tocItem.url}>
<a data-level={tocItem.depth} href={tocItem.url} className="mb-1 line-clamp-2 text-xs">
{tocItem.value}
</a>
</div>
))}
<div
ref={tocItemsRef}
className="doc-toc-items border-l border-signoz_slate-500 pl-3"
>
<TableOfContents
toc={filteredToc}
activeSection={activeSection}
setActiveSection={setActiveSection}
scrollableContainerRef={tocContainerRef}
/>
</div>
</div>
)
}

export default TableOfContents
export default DocsTOC
8 changes: 6 additions & 2 deletions components/TabItem.jsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import React from 'react';

const TabItem = ({ value, label, children }) => {
return <div value={value} label={label}>{children}</div>;
};
return (
<div value={value} label={label} data-tab-value={value}>
{children}
</div>
)
}

export default TabItem;
17 changes: 13 additions & 4 deletions components/TableOfContents/TableOfContents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,20 @@ const TableOfContents = ({
const pathname = usePathname()
const logEvent = useLogEvent()

const canonicalize = (s: string | null | undefined) => {
if (!s) return ''
const noHash = s.startsWith('#') ? s.slice(1) : s
return noHash.replace(/-+$/g, '')
}

// Effect to handle TOC scrolling
useEffect(() => {
if (!tocRef.current || !activeSection || !scrollableContainerRef.current) return

const activeElement = tocRef.current.querySelector(`a[href="${activeSection}"]`)
const anchors = Array.from(tocRef.current.querySelectorAll('a')) as HTMLAnchorElement[]
const activeElement = anchors.find(
(a) => canonicalize(a.getAttribute('href')) === canonicalize(activeSection)
)
if (!activeElement) return

const scrollableContainer = scrollableContainerRef.current
Expand All @@ -53,7 +62,7 @@ const TableOfContents = ({
return (
<div ref={tocRef} className="flex flex-col gap-1.5">
{toc.map((tocItem: TocItemProps) => {
const isActive = activeSection === tocItem.url
const isActive = canonicalize(activeSection) === canonicalize(tocItem.url)

const handleClick = () => {
// Log the TOC click event
Expand Down Expand Up @@ -82,8 +91,8 @@ const TableOfContents = ({
data-level={tocItem.depth}
href={tocItem.url}
onClick={handleClick}
className={`line-clamp-2 text-[11px] transition-colors hover:text-white ${
isActive ? 'font-medium text-signoz_robin-500' : 'text-gray-500'
className={`line-clamp-2 text-[11px] transition-colors hover:text-signoz_robin-400 focus-visible:text-signoz_robin-400 focus-visible:outline-none ${
isActive ? 'font-medium text-signoz_robin-500' : 'text-signoz_vanilla-300'
}`}
>
{tocItem.value}
Expand Down
16 changes: 12 additions & 4 deletions components/Tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ const Tabs = ({ children, entityName }) => {
const hideSelfHostTab = source === ONBOARDING_SOURCE && entityName === 'plans'

return (
<div className="w-full">
<div className="w-full" data-tabs-root>
<div className="flex border-b border-gray-200 dark:border-gray-700">
{childrenArray.map((child) => {
if (!isValidElement(child)) return null
Expand All @@ -53,6 +53,7 @@ const Tabs = ({ children, entityName }) => {
return (
<button
key={value}
data-tab-value={value}
className={`px-4 py-2 text-sm font-medium focus:outline-none border-b-2 ${
activeTab === value
? 'border-blue-500 text-blue-600 dark:text-blue-400'
Expand All @@ -71,9 +72,16 @@ const Tabs = ({ children, entityName }) => {
return null
}

if (child.props.value === activeTab)
return <div key={child.props.value}>{child.props.children}</div>
return null
const isActive = child.props.value === activeTab
return (
<div
key={child.props.value}
data-tab-value={child.props.value}
hidden={!isActive}
>
{child.props.children}
</div>
)
})}
</div>
</div>
Expand Down