diff --git a/packages/bruno-api-docs/e2e/tests/scripts/code-editor-copy.spec.ts b/packages/bruno-api-docs/e2e/tests/scripts/code-editor-copy.spec.ts index c3e7b251..40f423b5 100644 --- a/packages/bruno-api-docs/e2e/tests/scripts/code-editor-copy.spec.ts +++ b/packages/bruno-api-docs/e2e/tests/scripts/code-editor-copy.spec.ts @@ -10,7 +10,7 @@ test.describe('Monaco editor copy button', () => { const editor = playground.preRequestScriptEditor; await editor.focus(); - await page.keyboard.type('const answer = 42;'); + await page.keyboard.insertText('const answer = 42;'); await page.keyboard.press('Escape'); const copyBtn = editor.copyButton; diff --git a/packages/bruno-api-docs/e2e/tests/section-nav/section-nav.spec.ts b/packages/bruno-api-docs/e2e/tests/section-nav/section-nav.spec.ts new file mode 100644 index 00000000..72013453 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/section-nav/section-nav.spec.ts @@ -0,0 +1,277 @@ +import { test, expect } from '../../playwright'; +import type { Page } from '@playwright/test'; + +const REQUEST_WITH_CONFIG = ['billing', 'customers', 'Get Customers - Filter by Date Range']; +const REQUEST_WITH_EXEC = ['billing', 'customers', 'Get All Customers']; + +const firstLabel = (page: Page) => + page.getByTestId('section-nav-rail').locator('.section-nav-item-text').first(); + +const openOutline = async (page: Page): Promise => { + await page.getByTestId('section-nav-rail').getByRole('button').first().focus(); + await expect(firstLabel(page)).toBeVisible(); +}; + +const outlineRow = (page: Page, label: string) => + page.getByTestId('section-nav-rail').getByRole('button', { name: label, exact: true }); + +test.describe('Section navigator (the "on this page" outline)', () => { + test('opening it lists the sections on the page', async ({ folderPage, page }) => { + await folderPage.open(['Realtime']); + + const rail = page.getByTestId('section-nav-rail'); + await expect(rail).toBeVisible(); + // Collapsed: the labels are hidden (zero width), only the ticks show. + await expect(firstLabel(page)).toBeHidden(); + + // Focusing a rail marker opens the labelled outline (the keyboard path; deterministic in tests). + await openOutline(page); + + // The page title is always the first entry; the rest are the rendered sections. + await expect(rail.getByRole('button').first()).toHaveText('Realtime'); + await expect(rail).toContainText('Folder Configuration'); + await expect(rail).toContainText('Headers'); + }); + + test('clicking a section scrolls to it and marks it as current', async ({ folderPage, page }) => { + await folderPage.open(['Realtime']); + + await openOutline(page); + await outlineRow(page, 'Headers').click(); + + await expect + .poll(async () => { + const box = await page.getByTestId('folder-config-headers').boundingBox(); + return box ? Math.round(box.y) : Number.MAX_SAFE_INTEGER; + }) + .toBeLessThan(240); + await expect(outlineRow(page, 'Headers')).toHaveAttribute('aria-current', 'location'); + }); + + test('opening expands the rail leftward into a labelled card, keeping its right edge', async ({ + folderPage, + page + }) => { + await folderPage.open(['Realtime']); + + const rail = page.getByTestId('section-nav-rail'); + const collapsed = await rail.boundingBox(); + await openOutline(page); + const opened = await rail.boundingBox(); + if (!collapsed || !opened) throw new Error('expected the rail to be laid out'); + + expect(opened.width).toBeGreaterThan(collapsed.width); + expect(opened.x).toBeLessThan(collapsed.x); + expect(Math.abs(opened.x + opened.width - (collapsed.x + collapsed.width))).toBeLessThan(24); + }); + + test('pressing Escape closes the outline', async ({ folderPage, page }) => { + await folderPage.open(['Realtime']); + + await openOutline(page); + await page.keyboard.press('Escape'); + // The labels collapse back to zero width. + await expect(firstLabel(page)).toBeHidden(); + }); + + test('on a request, groups Params/Auth under a "Configuration" heading and leaves out the code snippet', async ({ + requestPage, + page + }) => { + await requestPage.open(REQUEST_WITH_CONFIG); + // Code Snippet is on the page but opts out of the navigator. + await expect(page.getByTestId('request-section-code-snippet')).toBeVisible(); + + await openOutline(page); + + const rail = page.getByTestId('section-nav-rail'); + await expect(rail).toContainText('Configuration'); + await expect(outlineRow(page, 'Params')).toBeVisible(); + await expect(outlineRow(page, 'Auth')).toBeVisible(); + await expect(rail).not.toContainText('Code Snippet'); + }); + + test('clicking the "Configuration" group jumps to its first section', async ({ requestPage, page }) => { + await requestPage.open(REQUEST_WITH_CONFIG); + + await openOutline(page); + await outlineRow(page, 'Configuration').click(); + + await expect + .poll(async () => { + const box = await page.getByTestId('request-section-params').boundingBox(); + return box ? Math.round(box.y) : Number.MAX_SAFE_INTEGER; + }) + .toBeLessThan(260); + }); + + test('highlighting a section never also highlights its "Configuration" group', async ({ requestPage, page }) => { + await requestPage.open(REQUEST_WITH_CONFIG); + + // Clicking a member marks only that member current — never the group as well. + await openOutline(page); + await outlineRow(page, 'Params').click(); + await expect(outlineRow(page, 'Params')).toHaveAttribute('aria-current', 'location'); + await expect(outlineRow(page, 'Configuration')).not.toHaveAttribute('aria-current'); + }); + + test('shows the Execution Context tabs as entries nested under it', async ({ requestPage, page }) => { + await requestPage.open(REQUEST_WITH_EXEC); + + await openOutline(page); + + const rail = page.getByTestId('section-nav-rail'); + await expect(rail).toContainText('Execution Context'); + for (const tab of ['Variables', 'Scripts', 'Asserts', 'Tests']) { + await expect(outlineRow(page, tab)).toBeVisible(); + } + }); + + test('clicking a tab entry opens a collapsed Execution Context and switches to that tab', async ({ + requestPage, + page + }) => { + await requestPage.open(REQUEST_WITH_EXEC); + + // Collapse the Execution Context first (its toggle is inside the section, not the rail). + const ecSection = page.getByTestId('request-section-execution-context'); + const ecToggle = ecSection.getByRole('button', { name: /Execution Context/ }); + await ecToggle.click(); + await expect(ecToggle).toHaveAttribute('aria-expanded', 'false'); + + // Clicking the Asserts entry should re-open the section and select the Asserts tab. + await openOutline(page); + await outlineRow(page, 'Asserts').click(); + + await expect(ecToggle).toHaveAttribute('aria-expanded', 'true'); + await expect(requestPage.executionContext.tab('asserts')).toHaveAttribute('aria-selected', 'true'); + await expect(requestPage.executionContext.tab('variables')).toHaveAttribute('aria-selected', 'false'); + }); + + test('clicking the Execution Context entry re-opens it when collapsed', async ({ requestPage, page }) => { + await requestPage.open(REQUEST_WITH_EXEC); + + const ecSection = page.getByTestId('request-section-execution-context'); + const ecToggle = ecSection.getByRole('button', { name: /Execution Context/ }); + await ecToggle.click(); + await expect(ecToggle).toHaveAttribute('aria-expanded', 'false'); + + await openOutline(page); + await outlineRow(page, 'Execution Context').click(); + await expect(ecToggle).toHaveAttribute('aria-expanded', 'true'); + }); + + test('is not shown when there is nothing to navigate but the title (a script page)', async ({ + scriptPage, + page + }) => { + await scriptPage.open(['billing', 'Script.js']); + // A script page has no sub-sections or headings, so the rail stays hidden. + await expect(page.getByTestId('section-nav-rail')).toBeHidden(); + }); + + test('appears on an unsupported-request page that has documentation headings', async ({ + unsupportedRequestPage, + page + }) => { + await unsupportedRequestPage.open(['Realtime', 'Live Updates']); + await expect(page.getByTestId('section-nav-rail')).toBeVisible(); + }); + + test('lists the headings written in the overview\'s documentation', async ({ overviewPage, page }) => { + await overviewPage.goto('/'); + + await openOutline(page); + + // The collection docs' `## Getting Started / ## Authentication / ## Rate Limits` become entries. + const rail = page.getByTestId('section-nav-rail'); + await expect(rail).toContainText('Getting Started'); + await expect(rail).toContainText('Authentication'); + await expect(rail).toContainText('Rate Limits'); + }); + + test('clicking a documentation heading opens its "view more" block and scrolls to it', async ({ requestPage, page }) => { + await requestPage.open(REQUEST_WITH_EXEC); + + await openOutline(page); + await outlineRow(page, 'Query Parameters').click(); + + // The description's own heading (revealed if the "view more" block was collapsed) lands near the top. + await expect + .poll(async () => { + const box = await page.getByRole('heading', { name: 'Query Parameters', exact: true }).boundingBox(); + return box ? Math.round(box.y) : Number.MAX_SAFE_INTEGER; + }) + .toBeLessThan(280); + }); + + test('stays visible but clips above a bottom-docked playground (no overlap)', async ({ requestPage, page }) => { + await requestPage.open(REQUEST_WITH_EXEC); + await expect(page.getByTestId('section-nav-rail')).toBeVisible(); + + // The bottom dock splits the screen, so the rail stays but is capped to the docs area's height. + await page.getByTestId('request-try-button').click(); + await expect(page.getByTestId('playground-content')).toBeVisible(); + + const rail = page.getByTestId('section-nav-rail'); + await expect(rail).toBeVisible(); + + // Its bottom edge sits above the playground panel — it never draws over it. + const railBox = await rail.boundingBox(); + const playgroundBox = await page.getByTestId('playground-content').boundingBox(); + expect(railBox).not.toBeNull(); + expect(playgroundBox).not.toBeNull(); + expect(railBox!.y + railBox!.height).toBeLessThanOrEqual(playgroundBox!.y); + }); + + test('hides entirely when a bottom-docked playground leaves too little height', async ({ requestPage, page }) => { + await page.setViewportSize({ width: 1280, height: 250 }); + await requestPage.open(REQUEST_WITH_EXEC); + await expect(page.getByTestId('section-nav-rail')).toBeVisible(); + + // The bottom dock opens to 60% of the viewport, leaving the docs column too short for the rail. + await page.getByTestId('request-try-button').click(); + await expect(page.getByTestId('playground-content')).toBeVisible(); + await expect(page.getByTestId('section-nav-rail')).toBeHidden(); + }); + + test('sits at the docs column edge when the playground is docked to the side, with room', async ({ + requestPage, + page + }) => { + // Wide enough that the docs column keeps its full padding once the playground takes the right. + await page.setViewportSize({ width: 1920, height: 900 }); + await requestPage.open(REQUEST_WITH_EXEC); + await page.getByTestId('request-try-button').click(); + await expect(page.getByTestId('playground-runner')).toBeVisible(); + await page.getByTestId('playground-dock-inline').click(); + + const rail = page.getByTestId('section-nav-rail'); + await expect(rail).toBeVisible(); + // It hangs off the docs column's right edge (the playground fills the right side), so its right + // edge sits well left of the viewport's — not pinned to the viewport edge. + const railBox = await rail.boundingBox(); + expect(railBox).not.toBeNull(); + expect(railBox!.x + railBox!.width).toBeLessThan(1920 - 100); + }); + + test('hides when the inline dock squeezes the docs column too narrow', async ({ requestPage, page }) => { + await page.setViewportSize({ width: 1200, height: 900 }); + await requestPage.open(REQUEST_WITH_EXEC); + await page.getByTestId('request-try-button').click(); + await expect(page.getByTestId('playground-runner')).toBeVisible(); + + // Inline dock at this width leaves the docs column below its padding breakpoint, so rather + // than overlap the content the rail hides. + await page.getByTestId('playground-dock-inline').click(); + await expect(page.getByTestId('section-nav-rail')).toBeHidden(); + }); + + test('is hidden on small (mobile) screens', async ({ requestPage, page }) => { + await requestPage.open(REQUEST_WITH_EXEC); + await expect(page.getByTestId('section-nav-rail')).toBeVisible(); + + await page.setViewportSize({ width: 600, height: 900 }); + await expect(page.getByTestId('section-nav-rail')).toBeHidden(); + }); +}); diff --git a/packages/bruno-api-docs/src/components/ExecutionContext/ExecutionContext.tsx b/packages/bruno-api-docs/src/components/ExecutionContext/ExecutionContext.tsx index c6178577..7d7670a8 100644 --- a/packages/bruno-api-docs/src/components/ExecutionContext/ExecutionContext.tsx +++ b/packages/bruno-api-docs/src/components/ExecutionContext/ExecutionContext.tsx @@ -129,6 +129,7 @@ export const ExecutionContext: React.FC = ({ tabs.push({ id: 'variables', label: 'Variables', + navLabel: 'Variables', count: varCount, rightElement: inheritedVarsBadge, content:
{variables}
@@ -138,6 +139,7 @@ export const ExecutionContext: React.FC = ({ tabs.push({ id: 'scripts', label: 'Scripts', + navLabel: 'Scripts', count: scriptChain.length, rightElement: flowIndicator, content:
{scripts}
@@ -147,6 +149,7 @@ export const ExecutionContext: React.FC = ({ tabs.push({ id: 'asserts', label: 'Asserts', + navLabel: 'Asserts', count: assertions.length, content:
{asserts}
}); @@ -155,6 +158,7 @@ export const ExecutionContext: React.FC = ({ tabs.push({ id: 'tests', label: 'Tests', + navLabel: 'Tests', count: tests.length, rightElement: , content:
{testCases}
diff --git a/packages/bruno-api-docs/src/components/FolderConfiguration/FolderConfiguration.tsx b/packages/bruno-api-docs/src/components/FolderConfiguration/FolderConfiguration.tsx index f707cee7..afe88648 100644 --- a/packages/bruno-api-docs/src/components/FolderConfiguration/FolderConfiguration.tsx +++ b/packages/bruno-api-docs/src/components/FolderConfiguration/FolderConfiguration.tsx @@ -52,7 +52,7 @@ export const FolderConfiguration: React.FC = ({ return ( {hasHeaders && ( -
+
Headers {hasInheritedHeaders && ( @@ -64,7 +64,7 @@ export const FolderConfiguration: React.FC = ({ )} {hasAuth && ( -
+
Auth {authBadge} @@ -74,7 +74,7 @@ export const FolderConfiguration: React.FC = ({ )} {hasVariables && ( -
+
Vars {inheritedVarCount > 0 && } @@ -97,7 +97,7 @@ export const FolderConfiguration: React.FC = ({ )} {hasScripts && ( -
+
Script
@@ -119,7 +119,7 @@ export const FolderConfiguration: React.FC = ({ )} {hasTests && ( -
+
Tests
diff --git a/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx b/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx index de72a524..5c7eb544 100644 --- a/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx +++ b/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx @@ -58,28 +58,28 @@ export const CollectionConfiguration: React.FC = ( return ( {hasHeaders && ( -
+
Headers
)} {hasAuth && ( -
+
Auth
)} {hasVars && ( -
+
Variables
)} {hasScripts && ( -
+
Script {scripts.preRequest && (
@@ -97,7 +97,7 @@ export const CollectionConfiguration: React.FC = ( )} {hasTests && ( -
+
Tests
diff --git a/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx b/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx index 79cab83e..1b47a8af 100644 --- a/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx +++ b/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useRef } from 'react'; import { Navigate } from 'react-router-dom'; import type { HttpRequest } from '@opencollection/types/requests/http'; import type { ScriptFile, Folder as FolderItem } from '@opencollection/types/collection/item'; @@ -10,6 +10,7 @@ import { getAncestorsByUuid } from '../../utils/fileUtils'; import { ItemVariableResolverProvider } from '../../hooks'; import type { Item } from '@opencollection/types/collection/item'; import PrevNext from '../PrevNext/PrevNext'; +import SectionNav from '../SectionNav/SectionNav'; import PoweredByFooter from '../PoweredByFooter/PoweredByFooter'; import { PageWrapper } from '../PageWrapper/PageWrapper'; import { ErrorBoundary } from '../ErrorBoundary/ErrorBoundary'; @@ -19,7 +20,7 @@ import Request from '../../pages/Request/Request'; import Script from '../../pages/Script/Script'; import Folder from '../../pages/Folder/Folder'; import Environments from '../../pages/Environments/Environments'; -import type { PageProps } from '../../routing/types'; +import type { PageProps, PageType } from '../../routing/types'; import { useDocsNavigate } from '../../hooks'; interface PageRouterProps { @@ -27,11 +28,14 @@ interface PageRouterProps { testId?: string; } +const PAGES_WITHOUT_SECTION_NAV = new Set(['environments']); + const PageRouter: React.FC = ({ onOpenPlayground, testId = 'page' }) => { const resolution = useActiveResolution(); const model = useNavModel(); const collection = useAppSelector(selectDocsCollection); const docsNavigate = useDocsNavigate(); + const pageBodyRef = useRef(null); // Map each item's runtime uuid -> its stable slug so breadcrumb clicks // navigate by URL (the same mapping the sidebar uses). @@ -56,6 +60,9 @@ const PageRouter: React.FC = ({ onOpenPlayground, testId = 'pag const { entry, prev, next } = resolution; const pageProps: PageProps = { node: entry, prev, next, collection, onOpenPlayground }; + const showSectionNav = !PAGES_WITHOUT_SECTION_NAV.has(entry.type); + const sectionNavTitle = entry.name; + const goToUuid = (uuid: string) => { const slug = uuidToSlug.get(uuid); // A known item navigates to its slug; the leading collection crumb (and any @@ -101,7 +108,7 @@ const PageRouter: React.FC = ({ onOpenPlayground, testId = 'pag return (
-
+
{renderBody()}
@@ -111,6 +118,10 @@ const PageRouter: React.FC = ({ onOpenPlayground, testId = 'pag
+ {/* The rail manages its own layering and narrow-column visibility — see SectionNav. */} + {showSectionNav && ( + + )} ); }; diff --git a/packages/bruno-api-docs/src/components/Section/Section.tsx b/packages/bruno-api-docs/src/components/Section/Section.tsx index 65d4bdd5..1082a832 100644 --- a/packages/bruno-api-docs/src/components/Section/Section.tsx +++ b/packages/bruno-api-docs/src/components/Section/Section.tsx @@ -3,6 +3,7 @@ import { SectionLabel } from '../SectionLabel/SectionLabel'; import { ChevronArrow } from '../ChevronArrow/ChevronArrow'; import { Collapse } from '../../ui/Collapse/Collapse'; import { useSessionStorage } from '../../hooks'; +import { cx } from '../../utils/cx'; import { StyledWrapper } from './StyledWrapper'; type HeadingLevel = 'h2' | 'h3' | 'h4'; @@ -17,6 +18,9 @@ interface SectionProps { defaultOpen?: boolean; storageKey?: string; as?: HeadingLevel; + hideFromNav?: boolean; + navLevel?: number; + navGroup?: string; } export const Section: React.FC = ({ @@ -28,15 +32,27 @@ export const Section: React.FC = ({ collapsible = false, defaultOpen = true, storageKey, - as = 'h2' + as = 'h2', + hideFromNav = false, + navLevel: navLevelProp, + navGroup }) => { const [open, setOpen] = useSessionStorage(storageKey ? `section-${storageKey}` : '', defaultOpen); const panelId = useId(); const labelId = useId(); + const navLabel = hideFromNav || typeof label !== 'string' ? undefined : label; + const navLevel = navLevelProp ?? (as === 'h4' ? 3 : as === 'h3' ? 2 : 1); + if (collapsible) { return ( - +
+ ))} +
+
+ + ); +}; + +export default SectionNav; diff --git a/packages/bruno-api-docs/src/components/SectionNav/StyledWrapper.ts b/packages/bruno-api-docs/src/components/SectionNav/StyledWrapper.ts new file mode 100644 index 00000000..2c2f503b --- /dev/null +++ b/packages/bruno-api-docs/src/components/SectionNav/StyledWrapper.ts @@ -0,0 +1,125 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.div` + position: fixed; + top: 5rem; + right: 0; + z-index: calc(var(--z-overlay, 50) - 1); + font-family: var(--font-sans); + + .section-nav-map { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.5rem; + margin-right: 0.5rem; + padding: 0.25rem 0.5rem; + overflow: hidden; + border: 1px solid transparent; + border-radius: var(--oc-radius); + } + + .section-nav-item { + display: flex; + align-items: center; + justify-content: flex-end; + padding: 0.1875rem 0.25rem; + background: none; + border: none; + border-radius: 0.3125rem; + font: inherit; + font-size: 0.8125rem; + line-height: 1.15rem; + color: var(--text-secondary); + cursor: pointer; + transition: color 0.12s ease, background 0.12s ease; + } + .section-nav-item:focus-visible { + outline: 2px solid var(--oc-status-info-text); + outline-offset: -2px; + } + + .section-nav-item-text { + max-width: 0; + max-height: 0; + opacity: 0; + overflow: hidden; + text-align: left; + white-space: nowrap; + } + + .section-nav-tick-slot { + flex: none; + display: flex; + justify-content: flex-end; + width: 1.35rem; + } + .section-nav-tick { + height: 0.15625rem; + border-radius: 0.125rem; + background: var(--border-strong); + opacity: 0.6; + transition: opacity 0.15s ease; + } + .section-nav-item:hover .section-nav-tick { + opacity: 0.85; + } + .section-nav-item.is-active .section-nav-tick { + background: var(--oc-colors-accent); + opacity: 1; + } + .section-nav-item.is-active { + color: var(--oc-colors-accent); + } + + &.section-nav--open .section-nav-map { + align-items: stretch; + gap: 0.125rem; + min-width: 11rem; + max-width: 17rem; + padding: 0.5rem; + background: var(--bg-primary); + border-color: var(--border-color); + box-shadow: var(--shadow-md); + overflow-y: auto; + transition: background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; + } + &.section-nav--open .section-nav-item { + justify-content: flex-start; + padding-left: var(--nav-indent, 0.5rem); + } + &.section-nav--open .section-nav-item-text { + max-width: 16rem; + max-height: 6rem; + opacity: 1; + white-space: normal; + overflow-wrap: break-word; + transition: max-width 0.18s ease, max-height 0.18s ease, opacity 0.18s ease; + } + &.section-nav--open .section-nav-tick-slot { + display: none; + } + &.section-nav--open .section-nav-item:hover, + &.section-nav--open .section-nav-item.is-focused { + background: color-mix(in srgb, var(--oc-text) 4%, transparent); + color: var(--text-primary); + } + &.section-nav--open .section-nav-item.is-active { + background: color-mix(in srgb, var(--oc-colors-accent) 8%, transparent); + } + &.section-nav--open .section-nav-item.is-active:hover { + background: color-mix(in srgb, var(--oc-colors-accent) 12%, transparent); + } + + .section-nav-item--title .section-nav-item-text { + color: var(--text-primary); + font-weight: 600; + } + .section-nav-item--title.is-active .section-nav-item-text { + color: var(--oc-colors-accent); + } + + @media (max-width: 48rem) { + display: none; + } +`; diff --git a/packages/bruno-api-docs/src/components/UnsupportedRequest/UnsupportedRequest.tsx b/packages/bruno-api-docs/src/components/UnsupportedRequest/UnsupportedRequest.tsx index dbef85f9..70ff36a9 100644 --- a/packages/bruno-api-docs/src/components/UnsupportedRequest/UnsupportedRequest.tsx +++ b/packages/bruno-api-docs/src/components/UnsupportedRequest/UnsupportedRequest.tsx @@ -94,6 +94,8 @@ export const UnsupportedRequest: React.FC = ({
diff --git a/packages/bruno-api-docs/src/components/ViewMore/ViewMore.tsx b/packages/bruno-api-docs/src/components/ViewMore/ViewMore.tsx index 246f4ba7..0dbf6b3d 100644 --- a/packages/bruno-api-docs/src/components/ViewMore/ViewMore.tsx +++ b/packages/bruno-api-docs/src/components/ViewMore/ViewMore.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useId, useRef, useState } from 'react'; +import { prefersReducedMotion } from '../../utils/motion'; import { StyledWrapper } from './StyledWrapper'; interface ViewMoreProps { @@ -11,11 +12,6 @@ interface ViewMoreProps { const ANIMATION_MS = 260; -const prefersReducedMotion = (): boolean => - typeof window !== 'undefined' - && typeof window.matchMedia === 'function' - && window.matchMedia('(prefers-reduced-motion: reduce)').matches; - export const ViewMore: React.FC = ({ children, collapsedHeight = '30vh', diff --git a/packages/bruno-api-docs/src/hooks/index.ts b/packages/bruno-api-docs/src/hooks/index.ts index b23711cd..982f1163 100644 --- a/packages/bruno-api-docs/src/hooks/index.ts +++ b/packages/bruno-api-docs/src/hooks/index.ts @@ -37,3 +37,4 @@ export { SIDEBAR_MAX_WIDTH, SIDEBAR_DEFAULT_WIDTH } from './useResizableSidebar'; +export { useDocSections, collectSections, SECTION_SCROLL_OFFSET, type DocSection } from './useDocSections'; diff --git a/packages/bruno-api-docs/src/hooks/useDocSections.spec.ts b/packages/bruno-api-docs/src/hooks/useDocSections.spec.ts new file mode 100644 index 00000000..c387958a --- /dev/null +++ b/packages/bruno-api-docs/src/hooks/useDocSections.spec.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { parse } from 'node-html-parser'; +import { collectSections } from './useDocSections'; + +const dom = (html: string) => parse(html) as unknown as HTMLElement; + +describe('collectSections (reads a page\'s navigable sections from the DOM)', () => { + it('lists the marked sections top-to-bottom, keeping each label and depth', () => { + const root = dom(` +
+
+
+
+
+
+
not a section
+
+ `); + + expect(collectSections(root).map((s) => [s.label, s.level])).toEqual([ + ['Params', 1], + ['Folder Configuration', 1], + ['Headers', 2], + ['Auth', 2] + ]); + }); + + it('gives every section a stable, unique id from its label and position', () => { + const root = dom(` +
+
+
+
+ `); + + expect(collectSections(root).map((s) => s.id)).toEqual(['execution-context-0', 'examples-1']); + }); + + it('treats a section with no depth as level 1 and skips ones with a blank label', () => { + const root = dom(` +
+
+
+
+ `); + + expect(collectSections(root)).toMatchObject([{ label: 'Solo', level: 1 }]); + }); + + it('records a section\'s group name and whether it is a switchable tab', () => { + const root = dom(` +
+
+
+ +
+
+ `); + + expect(collectSections(root).map((s) => [s.label, s.group, s.activate])).toEqual([ + ['Params', 'Configuration', false], + ['Auth', 'Configuration', false], + ['Variables', undefined, true], + ['Examples', undefined, false] + ]); + }); + + it('turns a documentation block\'s h1–h6 headings into entries that nest one level deeper per heading level', () => { + const root = dom(` +
+
+
+

Intro

+

Setup

+

Deep dive

+

+

Usage

+
+
+ `); + + expect(collectSections(root).map((s) => [s.label, s.level])).toEqual([ + ['Overview', 1], + ['Intro', 2], + ['Setup', 3], + ['Deep dive', 4], + ['Usage', 2] + ]); + }); + + it('returns an empty list when there is no page content to read', () => { + expect(collectSections(null)).toEqual([]); + }); +}); diff --git a/packages/bruno-api-docs/src/hooks/useDocSections.ts b/packages/bruno-api-docs/src/hooks/useDocSections.ts new file mode 100644 index 00000000..ecaae781 --- /dev/null +++ b/packages/bruno-api-docs/src/hooks/useDocSections.ts @@ -0,0 +1,193 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { RefObject } from 'react'; + +export interface DocSection { + id: string; + label: string; + level: number; + el: HTMLElement; + activate: boolean; + group?: string; +} + +export const SECTION_SCROLL_OFFSET = 88; + +/** The docs scroll container. Fixed and known (the AppShell `
`), so no ancestor walk. */ +export const getScroller = (): HTMLElement | null => document.querySelector('.appshell-content'); + +const slugify = (text: string): string => + text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, ''); + +const HEADING_SELECTOR = 'h1, h2, h3, h4, h5, h6'; + +/** + * Read the sections a page exposes into an ordered list. Two kinds of markers are collected, in + * document (visual) order: + * - `[data-nav-section]` — an explicit Section/config group; `data-nav-level` sets its depth. + * - `[data-nav-headings]` — a rendered-markdown container whose `h1`–`h6` become entries. Each + * heading's depth is the container's `data-nav-level` base plus its heading rank (h1 = +0), + * so a Notion-style outline nests under the doc section that hosts it. + * Pure and DOM-only (no React) so it can be unit-tested against a plain element tree. + */ +export const collectSections = (root: HTMLElement | null): DocSection[] => { + if (!root) return []; + const result: DocSection[] = []; + let index = 0; + for (const el of Array.from(root.querySelectorAll('[data-nav-section], [data-nav-headings]'))) { + if (el.getAttribute('data-nav-headings') != null) { + const base = Number(el.getAttribute('data-nav-level')) || 1; + for (const heading of Array.from(el.querySelectorAll(HEADING_SELECTOR))) { + const label = (heading.textContent || '').trim(); + if (!label) continue; + const rank = Number(heading.tagName.charAt(1)) || 1; + result.push({ id: `${slugify(label) || 'heading'}-${index}`, label, level: base + (rank - 1), el: heading, activate: false }); + index += 1; + } + continue; + } + const label = (el.getAttribute('data-nav-section') || '').trim(); + if (!label) continue; + const level = Number(el.getAttribute('data-nav-level')) || 1; + const activate = el.getAttribute('data-nav-activate') != null; + const group = el.getAttribute('data-nav-group')?.trim() || undefined; + result.push({ id: `${slugify(label) || 'section'}-${index}`, label, level, el, activate, group }); + index += 1; + } + return result; +}; + +/** + * Collect a page's `[data-nav-section]` elements and track which one is currently in + * view. Re-scans when `navKey` changes (a new page mounts) and when the page's content + * mutates (docs render async, sections toggle). `activeId` is the last section whose top + * has scrolled past the offset line, or `null` while the reader is still above the first + * section (i.e. at the page top). SSR-safe: returns empty/null until a browser runs the + * effects. The scroll listener is capturing so it catches whichever ancestor scrolls. + */ +export const useDocSections = ( + rootRef: RefObject, + navKey?: string +): { sections: DocSection[]; activeId: string | null; selectSection: (id: string | null) => void } => { + const [sections, setSections] = useState([]); + const [activeId, setActiveId] = useState(null); + // A click sets the active section directly and locks the scroll-spy until the smooth scroll + // settles (a `scrollend`), so the highlight doesn't flicker through the sections it passes on + // the way. The timestamp is only a safety cap, for when no scroll happens (the target is + // already in view) or the browser doesn't fire `scrollend`. + const spyLockUntil = useRef(0); + + const selectSection = useCallback((id: string | null) => { + setActiveId(id); + spyLockUntil.current = Date.now() + 1500; + }, []); + + useEffect(() => { + const root = rootRef.current; + if (!root) { + setSections([]); + return; + } + // Skip state churn when a mutation leaves the section list unchanged (a collapse + // animating, a code block painting); only the labels and levels matter here. + let signature: string | null = null; + const rescan = () => { + const next = collectSections(root); + const nextSignature = next + .map((section) => `${section.level}:${section.group ?? ''}:${section.label}`) + .join('|'); + if (nextSignature === signature) return; + signature = nextSignature; + setSections(next); + }; + rescan(); + if (typeof MutationObserver === 'undefined') return; + const observer = new MutationObserver(rescan); + observer.observe(root, { childList: true, subtree: true }); + return () => observer.disconnect(); + }, [rootRef, navKey]); + + useEffect(() => { + if (!sections.length) { + setActiveId(null); + return; + } + + // A jumped-to section lands SECTION_SCROLL_OFFSET below the scroll viewport's top edge (its + // scroll-margin-top). Measuring the trigger line from that same edge — not the window's — keeps + // the jumped-to section active regardless of a sticky topbar's height. `active` is the last + // section whose heading has crossed it. + const scroller = getScroller(); + let frame = 0; + const recompute = () => { + frame = 0; + if (Date.now() < spyLockUntil.current) return; + const viewportTop = scroller ? scroller.getBoundingClientRect().top : 0; + const triggerLine = viewportTop + SECTION_SCROLL_OFFSET + 8; + let active: string | null = null; + let activeSection: DocSection | undefined; + for (const section of sections) { + if (section.activate) continue; + if (section.el.getBoundingClientRect().top <= triggerLine) { + active = section.id; + activeSection = section; + } else { + break; + } + } + // When the active section hosts tabs (e.g. Execution Context), highlight the tab that is + // actually open rather than the section itself — but only while that section is expanded, + // so a collapsed section stays highlighted on itself rather than on a hidden tab. + if (activeSection) { + const collapsed = activeSection.el.querySelector( + ':scope > .section-head .section-toggle[aria-expanded="false"]' + ); + if (!collapsed) { + const openTab = sections.find( + (section) => + section.activate + && activeSection!.el.contains(section.el) + && section.el.getAttribute('aria-selected') === 'true' + ); + if (openTab) active = openTab.id; + } + } + setActiveId(active); + }; + const onScroll = () => { + if (!frame) frame = requestAnimationFrame(recompute); + }; + const onScrollEnd = () => { + spyLockUntil.current = 0; + if (frame) { + cancelAnimationFrame(frame); + frame = 0; + } + }; + + recompute(); + // Capture phase so a scroll inside a nested overflow container is still seen. + document.addEventListener('scroll', onScroll, true); + document.addEventListener('scrollend', onScrollEnd, true); + window.addEventListener('resize', onScroll); + // Re-run when a hosted tab is switched via the page's own tab UI (an aria-selected flip), + // so the reflected tab highlight doesn't go stale until the next scroll. + let tabObserver: MutationObserver | undefined; + const attrRoot = rootRef.current; + if (typeof MutationObserver !== 'undefined' && attrRoot) { + tabObserver = new MutationObserver(onScroll); + tabObserver.observe(attrRoot, { attributes: true, subtree: true, attributeFilter: ['aria-selected'] }); + } + return () => { + document.removeEventListener('scroll', onScroll, true); + document.removeEventListener('scrollend', onScrollEnd, true); + window.removeEventListener('resize', onScroll); + tabObserver?.disconnect(); + if (frame) cancelAnimationFrame(frame); + }; + }, [sections, rootRef]); + + return { sections, activeId, selectSection }; +}; diff --git a/packages/bruno-api-docs/src/pages/Folder/Folder.tsx b/packages/bruno-api-docs/src/pages/Folder/Folder.tsx index f506c43f..157eb358 100644 --- a/packages/bruno-api-docs/src/pages/Folder/Folder.tsx +++ b/packages/bruno-api-docs/src/pages/Folder/Folder.tsx @@ -61,7 +61,12 @@ export const Folder: React.FC = ({ item, ancestry = [], collection, {docsHtml && (
-
+
)} diff --git a/packages/bruno-api-docs/src/pages/Overview/Overview.tsx b/packages/bruno-api-docs/src/pages/Overview/Overview.tsx index 4a881b7a..ea9cf8c4 100644 --- a/packages/bruno-api-docs/src/pages/Overview/Overview.tsx +++ b/packages/bruno-api-docs/src/pages/Overview/Overview.tsx @@ -83,6 +83,8 @@ export const Overview: React.FC = ({ collection, testId = 'overvi
diff --git a/packages/bruno-api-docs/src/pages/Request/Request.tsx b/packages/bruno-api-docs/src/pages/Request/Request.tsx index 6adf85df..59cfc2fc 100644 --- a/packages/bruno-api-docs/src/pages/Request/Request.tsx +++ b/packages/bruno-api-docs/src/pages/Request/Request.tsx @@ -72,6 +72,9 @@ type RequestContentProps = Omit & { item: HttpRequest }; const descriptionContent = (item: HttpRequest): string => getItemDocs(item) || getItemDescription(item); +const NAV_GROUP = { configuration: 'Configuration' } as const; +const NAV_LEVEL = { section: 1, configItem: 2 } as const; + const RequestContent: React.FC = ({ item, ancestry = [], @@ -177,7 +180,12 @@ const RequestContent: React.FC = ({ {descHtml && ( -
+
)} @@ -186,7 +194,7 @@ const RequestContent: React.FC = ({ {hasLeftColumn ? ( <> {hasParams && ( -
+
)} @@ -195,6 +203,8 @@ const RequestContent: React.FC = ({
: undefined} > @@ -205,6 +215,8 @@ const RequestContent: React.FC = ({
@@ -216,7 +228,7 @@ const RequestContent: React.FC = ({ )} {showAuth && ( -
+
)} @@ -234,7 +246,7 @@ const RequestContent: React.FC = ({
-
{codeSnippet}
+
{codeSnippet}
diff --git a/packages/bruno-api-docs/src/styles/index.css b/packages/bruno-api-docs/src/styles/index.css index f1d32048..c0a41412 100644 --- a/packages/bruno-api-docs/src/styles/index.css +++ b/packages/bruno-api-docs/src/styles/index.css @@ -1140,3 +1140,8 @@ tr.themed-row:nth-child(even) { border-radius: var(--oc-radius); box-shadow: var(--shadow-md); } + +[data-nav-section], +[data-nav-headings] :is(h1, h2, h3, h4, h5, h6) { + scroll-margin-top: 5.5rem; +} diff --git a/packages/bruno-api-docs/src/ui/Tabs/Tabs.tsx b/packages/bruno-api-docs/src/ui/Tabs/Tabs.tsx index fbb61a3f..80d3d775 100644 --- a/packages/bruno-api-docs/src/ui/Tabs/Tabs.tsx +++ b/packages/bruno-api-docs/src/ui/Tabs/Tabs.tsx @@ -14,6 +14,7 @@ export interface Tab { content: ReactNode; rightElement?: ReactNode; disabled?: boolean; + navLabel?: string; } interface TabsProps { @@ -134,6 +135,9 @@ export const Tabs: React.FC = ({ onClick={() => activate(tab.id)} onKeyDown={(event) => onKeyDown(event, navIndex)} data-testid={tabButtonId(tab.id)} + data-nav-section={tab.navLabel} + data-nav-level={tab.navLabel ? 2 : undefined} + data-nav-activate={tab.navLabel ? '' : undefined} > {tab.label} {renderIndicator(tab)} diff --git a/packages/bruno-api-docs/src/utils/motion.ts b/packages/bruno-api-docs/src/utils/motion.ts new file mode 100644 index 00000000..346c422e --- /dev/null +++ b/packages/bruno-api-docs/src/utils/motion.ts @@ -0,0 +1,8 @@ +/** Whether the user has asked the OS to minimise motion (SSR-safe). */ +export const prefersReducedMotion = (): boolean => + typeof window !== 'undefined' + && typeof window.matchMedia === 'function' + && window.matchMedia('(prefers-reduced-motion: reduce)').matches; + +/** Scroll behaviour that respects the reduced-motion preference. */ +export const scrollBehavior = (): ScrollBehavior => (prefersReducedMotion() ? 'auto' : 'smooth');