From 4d8c0471dcc2809c96bc9dad6a5b676c030a3e02 Mon Sep 17 00:00:00 2001 From: Sachin Venugopalan Date: Fri, 31 Jul 2026 13:44:49 +0530 Subject: [PATCH 1/7] feat:Table of content changes for easy navigation --- .../e2e/tests/section-nav/section-nav.spec.ts | 251 +++++++++++++++ .../ExecutionContext/ExecutionContext.tsx | 4 + .../FolderConfiguration.tsx | 10 +- .../CollectionConfiguration.tsx | 10 +- .../src/components/PageRouter/PageRouter.tsx | 23 +- .../src/components/Section/Section.tsx | 27 +- .../components/SectionNav/SectionNav.spec.tsx | 15 + .../src/components/SectionNav/SectionNav.tsx | 291 ++++++++++++++++++ .../components/SectionNav/StyledWrapper.ts | 127 ++++++++ .../UnsupportedRequest/UnsupportedRequest.tsx | 2 + .../src/components/ViewMore/ViewMore.tsx | 6 +- packages/bruno-api-docs/src/hooks/index.ts | 1 + .../src/hooks/useDocSections.spec.ts | 94 ++++++ .../src/hooks/useDocSections.ts | 192 ++++++++++++ .../src/pages/Folder/Folder.tsx | 7 +- .../src/pages/Overview/Overview.tsx | 2 + .../src/pages/Request/Request.tsx | 17 +- packages/bruno-api-docs/src/styles/index.css | 5 + packages/bruno-api-docs/src/ui/Tabs/Tabs.tsx | 4 + packages/bruno-api-docs/src/utils/motion.ts | 8 + 20 files changed, 1071 insertions(+), 25 deletions(-) create mode 100644 packages/bruno-api-docs/e2e/tests/section-nav/section-nav.spec.ts create mode 100644 packages/bruno-api-docs/src/components/SectionNav/SectionNav.spec.tsx create mode 100644 packages/bruno-api-docs/src/components/SectionNav/SectionNav.tsx create mode 100644 packages/bruno-api-docs/src/components/SectionNav/StyledWrapper.ts create mode 100644 packages/bruno-api-docs/src/hooks/useDocSections.spec.ts create mode 100644 packages/bruno-api-docs/src/hooks/useDocSections.ts create mode 100644 packages/bruno-api-docs/src/utils/motion.ts 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..fe853b53 --- /dev/null +++ b/packages/bruno-api-docs/e2e/tests/section-nav/section-nav.spec.ts @@ -0,0 +1,251 @@ +import { test, expect } from '../../playwright'; + +const REQUEST_WITH_CONFIG = ['billing', 'customers', 'Get Customers - Filter by Date Range']; +const REQUEST_WITH_EXEC = ['billing', 'customers', 'Get All Customers']; + +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'); + const panel = page.getByTestId('section-nav-panel'); + await expect(rail).toBeVisible(); + await expect(panel).toBeHidden(); + + // Focusing a rail marker reveals the labelled popup (the keyboard path; deterministic in tests). + await rail.getByRole('button').first().focus(); + await expect(panel).toBeVisible(); + + // The page title is always the first entry; the rest are the rendered sections. + await expect(page.getByTestId('section-nav-item').first()).toHaveText('Realtime'); + await expect(panel).toContainText('Folder Configuration'); + await expect(panel).toContainText('Headers'); + }); + + test('clicking a section scrolls to it and marks it as current', async ({ folderPage, page }) => { + await folderPage.open(['Realtime']); + + const headersTick = page.getByTestId('section-nav-rail').getByRole('button', { name: 'Headers', exact: true }); + await headersTick.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(headersTick).toHaveAttribute('aria-current', 'location'); + }); + + test('pressing Escape closes the popup', async ({ folderPage, page }) => { + await folderPage.open(['Realtime']); + const rail = page.getByTestId('section-nav-rail'); + const panel = page.getByTestId('section-nav-panel'); + + await rail.getByRole('button').first().focus(); + await expect(panel).toBeVisible(); + + await page.keyboard.press('Escape'); + await expect(panel).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(); + + const rail = page.getByTestId('section-nav-rail'); + const panel = page.getByTestId('section-nav-panel'); + await rail.getByRole('button').first().focus(); + await expect(panel).toBeVisible(); + + await expect(panel).toContainText('Configuration'); + await expect(panel.getByTestId('section-nav-item').filter({ hasText: /^Params$/ })).toBeVisible(); + await expect(panel.getByTestId('section-nav-item').filter({ hasText: /^Auth$/ })).toBeVisible(); + await expect(panel).not.toContainText('Code Snippet'); + }); + + test('clicking the "Configuration" group jumps to its first section', async ({ requestPage, page }) => { + await requestPage.open(REQUEST_WITH_CONFIG); + + const configTick = page.getByTestId('section-nav-rail').getByRole('button', { name: 'Configuration', exact: true }); + await configTick.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); + const rail = page.getByTestId('section-nav-rail'); + const paramsTick = rail.getByRole('button', { name: 'Params', exact: true }); + const configTick = rail.getByRole('button', { name: 'Configuration', exact: true }); + + // Clicking a member marks only that member current — never the group as well. + await paramsTick.click(); + await expect(paramsTick).toHaveAttribute('aria-current', 'location'); + await expect(configTick).not.toHaveAttribute('aria-current'); + }); + + test('shows the Execution Context tabs as entries nested under it', async ({ requestPage, page }) => { + await requestPage.open(REQUEST_WITH_EXEC); + + const rail = page.getByTestId('section-nav-rail'); + const panel = page.getByTestId('section-nav-panel'); + await rail.getByRole('button').first().focus(); + await expect(panel).toBeVisible(); + + await expect(panel).toContainText('Execution Context'); + for (const tab of ['Variables', 'Scripts', 'Asserts', 'Tests']) { + await expect(panel.getByTestId('section-nav-item').filter({ hasText: new RegExp(`^${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 page.getByTestId('section-nav-rail').getByRole('button', { name: 'Asserts', exact: true }).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 page.getByTestId('section-nav-rail').getByRole('button', { name: 'Execution Context', exact: true }).click(); + await expect(ecToggle).toHaveAttribute('aria-expanded', 'true'); + }); + + test('still appears on a script page (with just the page title)', async ({ scriptPage, page }) => { + await scriptPage.open(['billing', 'Script.js']); + + const rail = page.getByTestId('section-nav-rail'); + await expect(rail).toBeVisible(); + // A script page has no sub-sections, so the rail carries just the page-title entry. + await expect(page.getByTestId('section-nav-tick')).toHaveCount(1); + + await rail.getByRole('button').first().focus(); + await expect(page.getByTestId('section-nav-panel')).toBeVisible(); + }); + + test('still appears on an unsupported-request page', 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('/'); + + const rail = page.getByTestId('section-nav-rail'); + const panel = page.getByTestId('section-nav-panel'); + await rail.getByRole('button').first().focus(); + await expect(panel).toBeVisible(); + + // The collection docs' `## Getting Started / ## Authentication / ## Rate Limits` become entries. + await expect(panel).toContainText('Getting Started'); + await expect(panel).toContainText('Authentication'); + await expect(panel).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); + + const headingTick = page + .getByTestId('section-nav-rail') + .getByRole('button', { name: 'Query Parameters', exact: true }); + await expect(headingTick).toBeVisible(); + + await headingTick.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('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 7a3e5429..19ca750e 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 fde30252..8d927b30 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..6c86f97c 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'; @@ -6,10 +6,12 @@ import { useActiveResolution, useNavModel } from '../../routing/hooks'; import { useAppSelector } from '../../store/hooks'; import { selectDocsCollection } from '../../store/slices/docs'; import { getItemUuid } from '../../utils/itemUtils'; +import { getItemName } from '../../utils/schemaHelpers'; 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'; @@ -32,6 +34,7 @@ const PageRouter: React.FC = ({ onOpenPlayground, testId = 'pag 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 +59,18 @@ const PageRouter: React.FC = ({ onOpenPlayground, testId = 'pag const { entry, prev, next } = resolution; const pageProps: PageProps = { node: entry, prev, next, collection, onOpenPlayground }; + const showSectionNav + = entry.type === 'overview' + || entry.type === 'folder' + || entry.type === 'request' + || entry.type === 'script'; + const sectionNavTitle + = entry.type === 'overview' + ? collection.info?.name || 'Overview' + : item + ? getItemName(item) || 'Untitled' + : 'Overview'; + const goToUuid = (uuid: string) => { const slug = uuidToSlug.get(uuid); // A known item navigates to its slug; the leading collection crumb (and any @@ -101,7 +116,7 @@ const PageRouter: React.FC = ({ onOpenPlayground, testId = 'pag return (
-
+
{renderBody()}
@@ -111,6 +126,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..6b00f2be 100644 --- a/packages/bruno-api-docs/src/components/Section/Section.tsx +++ b/packages/bruno-api-docs/src/components/Section/Section.tsx @@ -17,6 +17,9 @@ interface SectionProps { defaultOpen?: boolean; storageKey?: string; as?: HeadingLevel; + hideFromNav?: boolean; + navLevel?: number; + navGroup?: string; } export const Section: React.FC = ({ @@ -28,15 +31,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 ( - +
+ + ))} + +
+ + {/* Rail: the always-visible mini-map, and the accessible navigation for keyboard / AT users. + Roving tab index keeps it a single tab stop; arrows move between ticks. */} +
+ {entries.map((entry, index) => ( + + ))} +
+
+ + ); +}; + +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..ae64ef5e --- /dev/null +++ b/packages/bruno-api-docs/src/components/SectionNav/StyledWrapper.ts @@ -0,0 +1,127 @@ +import styled from '@emotion/styled'; + +export const StyledWrapper = styled.div` + position: fixed; + top: 5rem; + right: 0; + /* Floats above the page content, but deliberately below overlays, popovers, tooltips, the + variable hover card (all --z-popover) and the playground backdrop (z 9998) so it never + stands out over them. */ + 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; + padding: 0.25rem 0.5rem; + /* Height is capped (inline) to the docs area; clip so ticks never spill over the playground. */ + overflow: hidden; + } + .section-nav-tick-btn { + display: flex; + align-items: center; + justify-content: flex-end; + width: 1.35rem; + padding: 0.125rem 0; + background: none; + border: none; + cursor: pointer; + } + .section-nav-tick-btn:focus-visible { + outline: 2px solid var(--oc-status-info-text); + outline-offset: 2px; + border-radius: 0.25rem; + } + .section-nav-tick { + height: 0.15625rem; + border-radius: 0.125rem; + background: var(--border-strong); + opacity: 0.6; + transition: 150ms; + } + .section-nav-tick-btn:hover .section-nav-tick { + opacity: 0.85; + } + .section-nav-tick.is-active { + background: var(--oc-colors-text-yellow); + opacity: 1; + } + + .section-nav-panel { + position: absolute; + top: 0; + right: calc(100% + 0.35rem); + transform: translateX(0.5rem); + min-width: 11rem; + max-width: 17rem; + max-height: calc(100vh - 7rem); + overflow-y: auto; + padding: 0.375rem; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: var(--oc-radius); + opacity: 0; + visibility: hidden; + transition: opacity 0.16s ease, transform 0.16s ease, visibility 0.16s ease; + } + &.section-nav--open .section-nav-panel { + opacity: 1; + visibility: visible; + transform: translateX(0); + } + + .section-nav-list { + list-style: none; + margin: 0; + padding: 0; + } + + .section-nav-item { + display: block; + width: 100%; + padding: 0.1875rem 0.5rem; + background: none; + border: none; + border-radius: 0.3125rem; + text-align: left; + 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-text { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .section-nav-item:hover, + .section-nav-item.is-focused { + background: color-mix(in srgb, var(--oc-colors-text-yellow) 15%, transparent); + color: var(--text-primary); + } + .section-nav-item:focus-visible { + outline: 2px solid var(--oc-status-info-text); + outline-offset: -2px; + } + .section-nav-item.is-active, + .section-nav-item.is-active:hover { + color: var(--oc-colors-text-yellow); + } + + /* The page-title row uses the primary golden accent and stays bold in every state (so activating + it never changes its width). Other rows use the secondary text colour (see .section-nav-item), + lifting to golden only when active. */ + .section-nav-item--title { + color: var(--oc-colors-text-yellow); + font-weight: 600; + } + + @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 a21d85c5..4044ffbd 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 adaa5d1e..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 d7bf5e4f..683a779f 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..409f29b6 --- /dev/null +++ b/packages/bruno-api-docs/src/hooks/useDocSections.ts @@ -0,0 +1,192 @@ +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 nearest scrollable ancestor, or null when the page scrolls on the window itself. */ +export const getScrollParent = (el: HTMLElement | null): HTMLElement | null => { + let node = el?.parentElement ?? null; + while (node) { + const overflowY = getComputedStyle(node).overflowY; + if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) { + return node; + } + node = node.parentElement; + } + return null; +}; + +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 briefly locks the scroll-spy, so the smooth + // scroll to it doesn't flicker the highlight through the sections it passes on the way. + const spyLockUntil = useRef(0); + + const selectSection = useCallback((id: string | null) => { + setActiveId(id); + spyLockUntil.current = Date.now() + 600; + }, []); + + 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 = getScrollParent(sections[0].el); + 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); + }; + + recompute(); + // Capture phase so a scroll inside a nested overflow container is still seen. + document.addEventListener('scroll', onScroll, 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); + 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 8908aecb..96c53caf 100644 --- a/packages/bruno-api-docs/src/pages/Folder/Folder.tsx +++ b/packages/bruno-api-docs/src/pages/Folder/Folder.tsx @@ -63,7 +63,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 ef282178..509a2131 100644 --- a/packages/bruno-api-docs/src/pages/Request/Request.tsx +++ b/packages/bruno-api-docs/src/pages/Request/Request.tsx @@ -177,7 +177,12 @@ const RequestContent: React.FC = ({ {descHtml && ( -
+
)} @@ -186,7 +191,7 @@ const RequestContent: React.FC = ({ {hasLeftColumn ? ( <> {hasParams && ( -
+
)} @@ -195,6 +200,8 @@ const RequestContent: React.FC = ({
: undefined} > @@ -205,6 +212,8 @@ const RequestContent: React.FC = ({
@@ -216,7 +225,7 @@ const RequestContent: React.FC = ({ )} {showAuth && ( -
+
)} @@ -234,7 +243,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 421278f6..68de1cbb 100644 --- a/packages/bruno-api-docs/src/ui/Tabs/Tabs.tsx +++ b/packages/bruno-api-docs/src/ui/Tabs/Tabs.tsx @@ -13,6 +13,7 @@ export interface Tab { content: ReactNode; rightElement?: ReactNode; disabled?: boolean; + navLabel?: string; } interface TabsProps { @@ -130,6 +131,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'); From 1ba89f8f36c184d3d745bb26ef556b89a6b73a61 Mon Sep 17 00:00:00 2001 From: Sachin Venugopalan Date: Fri, 31 Jul 2026 16:17:37 +0530 Subject: [PATCH 2/7] Code Refactoring --- .../e2e/tests/section-nav/section-nav.spec.ts | 89 +++++++++++++------ .../src/components/SectionNav/SectionNav.tsx | 23 +++-- .../components/SectionNav/StyledWrapper.ts | 41 +++++---- 3 files changed, 98 insertions(+), 55 deletions(-) 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 index fe853b53..e78bab39 100644 --- 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 @@ -1,8 +1,19 @@ 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 openPopup = async (page: Page): Promise => { + await page.getByTestId('section-nav-rail').getByRole('button').first().focus(); + await expect(page.getByTestId('section-nav-panel')).toBeVisible(); +}; +const popupRow = (page: Page, label: string) => + page.getByTestId('section-nav-panel').getByTestId('section-nav-item').filter({ hasText: new RegExp(`^${label}$`) }); + +const railTick = (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']); @@ -25,8 +36,8 @@ test.describe('Section navigator (the "on this page" outline)', () => { test('clicking a section scrolls to it and marks it as current', async ({ folderPage, page }) => { await folderPage.open(['Realtime']); - const headersTick = page.getByTestId('section-nav-rail').getByRole('button', { name: 'Headers', exact: true }); - await headersTick.click(); + await openPopup(page); + await popupRow(page, 'Headers').click(); await expect .poll(async () => { @@ -34,7 +45,20 @@ test.describe('Section navigator (the "on this page" outline)', () => { return box ? Math.round(box.y) : Number.MAX_SAFE_INTEGER; }) .toBeLessThan(240); - await expect(headersTick).toHaveAttribute('aria-current', 'location'); + await expect(railTick(page, 'Headers')).toHaveAttribute('aria-current', 'location'); + }); + + test('the popup opens over the rail, hiding the ticks behind it', async ({ folderPage, page }) => { + await folderPage.open(['Realtime']); + + const railBox = await page.getByTestId('section-nav-rail').boundingBox(); + await openPopup(page); + const panelBox = await page.getByTestId('section-nav-panel').boundingBox(); + if (!railBox || !panelBox) throw new Error('expected the rail and panel to be laid out'); + // The panel sits over the rail (overlapping it) and extends far left to show the labels. + expect(panelBox.x).toBeLessThan(railBox.x); + expect(panelBox.x + panelBox.width).toBeGreaterThan(railBox.x); + await expect(page.getByTestId('section-nav-rail')).toHaveCSS('opacity', '0'); }); test('pressing Escape closes the popup', async ({ folderPage, page }) => { @@ -71,8 +95,8 @@ test.describe('Section navigator (the "on this page" outline)', () => { test('clicking the "Configuration" group jumps to its first section', async ({ requestPage, page }) => { await requestPage.open(REQUEST_WITH_CONFIG); - const configTick = page.getByTestId('section-nav-rail').getByRole('button', { name: 'Configuration', exact: true }); - await configTick.click(); + await openPopup(page); + await popupRow(page, 'Configuration').click(); await expect .poll(async () => { @@ -84,14 +108,12 @@ test.describe('Section navigator (the "on this page" outline)', () => { test('highlighting a section never also highlights its "Configuration" group', async ({ requestPage, page }) => { await requestPage.open(REQUEST_WITH_CONFIG); - const rail = page.getByTestId('section-nav-rail'); - const paramsTick = rail.getByRole('button', { name: 'Params', exact: true }); - const configTick = rail.getByRole('button', { name: 'Configuration', exact: true }); // Clicking a member marks only that member current — never the group as well. - await paramsTick.click(); - await expect(paramsTick).toHaveAttribute('aria-current', 'location'); - await expect(configTick).not.toHaveAttribute('aria-current'); + await openPopup(page); + await popupRow(page, 'Params').click(); + await expect(railTick(page, 'Params')).toHaveAttribute('aria-current', 'location'); + await expect(railTick(page, 'Configuration')).not.toHaveAttribute('aria-current'); }); test('shows the Execution Context tabs as entries nested under it', async ({ requestPage, page }) => { @@ -121,7 +143,8 @@ test.describe('Section navigator (the "on this page" outline)', () => { await expect(ecToggle).toHaveAttribute('aria-expanded', 'false'); // Clicking the Asserts entry should re-open the section and select the Asserts tab. - await page.getByTestId('section-nav-rail').getByRole('button', { name: 'Asserts', exact: true }).click(); + await openPopup(page); + await popupRow(page, 'Asserts').click(); await expect(ecToggle).toHaveAttribute('aria-expanded', 'true'); await expect(requestPage.executionContext.tab('asserts')).toHaveAttribute('aria-selected', 'true'); @@ -136,23 +159,24 @@ test.describe('Section navigator (the "on this page" outline)', () => { await ecToggle.click(); await expect(ecToggle).toHaveAttribute('aria-expanded', 'false'); - await page.getByTestId('section-nav-rail').getByRole('button', { name: 'Execution Context', exact: true }).click(); + await openPopup(page); + await popupRow(page, 'Execution Context').click(); await expect(ecToggle).toHaveAttribute('aria-expanded', 'true'); }); - test('still appears on a script page (with just the page title)', async ({ scriptPage, page }) => { + 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']); - - const rail = page.getByTestId('section-nav-rail'); - await expect(rail).toBeVisible(); - // A script page has no sub-sections, so the rail carries just the page-title entry. - await expect(page.getByTestId('section-nav-tick')).toHaveCount(1); - - await rail.getByRole('button').first().focus(); - await expect(page.getByTestId('section-nav-panel')).toBeVisible(); + // A script page has no sub-sections or headings, so the rail stays hidden. + await expect(page.getByTestId('section-nav-rail')).toBeHidden(); }); - test('still appears on an unsupported-request page', async ({ unsupportedRequestPage, page }) => { + 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(); }); @@ -174,12 +198,8 @@ test.describe('Section navigator (the "on this page" outline)', () => { test('clicking a documentation heading opens its "view more" block and scrolls to it', async ({ requestPage, page }) => { await requestPage.open(REQUEST_WITH_EXEC); - const headingTick = page - .getByTestId('section-nav-rail') - .getByRole('button', { name: 'Query Parameters', exact: true }); - await expect(headingTick).toBeVisible(); - - await headingTick.click(); + await openPopup(page); + await popupRow(page, 'Query Parameters').click(); // The description's own heading (revealed if the "view more" block was collapsed) lands near the top. await expect @@ -209,6 +229,17 @@ test.describe('Section navigator (the "on this page" outline)', () => { 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 diff --git a/packages/bruno-api-docs/src/components/SectionNav/SectionNav.tsx b/packages/bruno-api-docs/src/components/SectionNav/SectionNav.tsx index b3132011..94db5037 100644 --- a/packages/bruno-api-docs/src/components/SectionNav/SectionNav.tsx +++ b/packages/bruno-api-docs/src/components/SectionNav/SectionNav.tsx @@ -38,6 +38,8 @@ const DOCS_MIN_WIDTH = 768; // The rail's fixed top (`top: 5rem`) plus a small gap; its height is capped at the docs area's // bottom minus this so it clips at, rather than draws over, a bottom-docked playground. const RAIL_TOP_PX = 88; +// Below this remaining height the rail hides entirely rather than show a cramped/overlapping sliver. +const MIN_RAIL_HEIGHT = 24; // Keep a mouse press from focusing a button (so clicking a tick jumps without leaving the popup open). const preventFocusSteal = (event: React.MouseEvent): void => event.preventDefault(); @@ -76,22 +78,20 @@ export const SectionNav: React.FC = ({ rootRef, title, navKey, const [tooNarrow, setTooNarrow] = useState(false); const [mapMaxHeight, setMapMaxHeight] = useState(undefined); useEffect(() => { - const scroller = getScrollParent(rootRef.current); const docsColumn = rootRef.current?.closest('.appshell-body'); const measure = () => { const { innerWidth, innerHeight } = window; - const rect = scroller?.getBoundingClientRect(); + const rect = docsColumn?.getBoundingClientRect(); setRightOffset(Math.max(0, Math.round(innerWidth - (rect?.right ?? innerWidth)))); - const docsWidth = docsColumn?.getBoundingClientRect().width ?? rect?.width ?? innerWidth; - setTooNarrow(docsWidth <= DOCS_MIN_WIDTH); + setTooNarrow((rect?.width ?? innerWidth) <= DOCS_MIN_WIDTH); setMapMaxHeight(Math.max(0, Math.round((rect?.bottom ?? innerHeight) - RAIL_TOP_PX))); }; measure(); window.addEventListener('resize', measure); let observer: ResizeObserver | undefined; - if (scroller && typeof ResizeObserver !== 'undefined') { + if (docsColumn && typeof ResizeObserver !== 'undefined') { observer = new ResizeObserver(measure); - observer.observe(scroller); + observer.observe(docsColumn); } return () => { window.removeEventListener('resize', measure); @@ -195,8 +195,15 @@ export const SectionNav: React.FC = ({ rootRef, title, navKey, event.preventDefault(); }; - // No gutter left in the docs column — hide rather than overlap the content. - if (tooNarrow) return null; + // Nothing to navigate but the page title, no side gutter in the docs column, or too little height + // above a bottom-docked playground — don't show the rail at all. + if ( + sections.length === 0 + || tooNarrow + || (mapMaxHeight !== undefined && mapMaxHeight < MIN_RAIL_HEIGHT) + ) { + return null; + } const tabStop = Math.min(rovingIndex, Math.max(0, entries.length - 1)); diff --git a/packages/bruno-api-docs/src/components/SectionNav/StyledWrapper.ts b/packages/bruno-api-docs/src/components/SectionNav/StyledWrapper.ts index ae64ef5e..c186e6bf 100644 --- a/packages/bruno-api-docs/src/components/SectionNav/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/SectionNav/StyledWrapper.ts @@ -4,9 +4,6 @@ export const StyledWrapper = styled.div` position: fixed; top: 5rem; right: 0; - /* Floats above the page content, but deliberately below overlays, popovers, tooltips, the - variable hover card (all --z-popover) and the playground backdrop (z 9998) so it never - stands out over them. */ z-index: calc(var(--z-overlay, 50) - 1); font-family: var(--font-sans); @@ -15,9 +12,9 @@ export const StyledWrapper = styled.div` flex-direction: column; align-items: flex-end; gap: 0.5rem; - padding: 0.25rem 0.5rem; - /* Height is capped (inline) to the docs area; clip so ticks never spill over the playground. */ + padding: 0.25rem 0.75rem 0.5rem 0.5rem; overflow: hidden; + transition: opacity 0.15s ease; } .section-nav-tick-btn { display: flex; @@ -45,20 +42,20 @@ export const StyledWrapper = styled.div` opacity: 0.85; } .section-nav-tick.is-active { - background: var(--oc-colors-text-yellow); + background: var(--oc-colors-accent); opacity: 1; } .section-nav-panel { position: absolute; top: 0; - right: calc(100% + 0.35rem); + right: 0.5rem; transform: translateX(0.5rem); min-width: 11rem; max-width: 17rem; max-height: calc(100vh - 7rem); overflow-y: auto; - padding: 0.375rem; + padding: 0.5rem; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: var(--oc-radius); @@ -71,11 +68,17 @@ export const StyledWrapper = styled.div` visibility: visible; transform: translateX(0); } + &.section-nav--open .section-nav-map { + opacity: 0; + } .section-nav-list { list-style: none; margin: 0; padding: 0; + display: flex; + flex-direction: column; + gap: 0.125rem; } .section-nav-item { @@ -95,31 +98,33 @@ export const StyledWrapper = styled.div` } .section-nav-item-text { display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: break-word; } .section-nav-item:hover, .section-nav-item.is-focused { - background: color-mix(in srgb, var(--oc-colors-text-yellow) 15%, transparent); + background: color-mix(in srgb, var(--oc-text) 4%, transparent); color: var(--text-primary); } .section-nav-item:focus-visible { outline: 2px solid var(--oc-status-info-text); outline-offset: -2px; } - .section-nav-item.is-active, + .section-nav-item.is-active { + background: color-mix(in srgb, var(--oc-colors-accent) 8%, transparent); + color: var(--oc-colors-accent); + } .section-nav-item.is-active:hover { - color: var(--oc-colors-text-yellow); + background: color-mix(in srgb, var(--oc-colors-accent) 12%, transparent); + color: var(--oc-colors-accent); } - /* The page-title row uses the primary golden accent and stays bold in every state (so activating - it never changes its width). Other rows use the secondary text colour (see .section-nav-item), - lifting to golden only when active. */ .section-nav-item--title { - color: var(--oc-colors-text-yellow); + color: var(--text-primary); font-weight: 600; } + .section-nav-item--title.is-active { + color: var(--oc-colors-accent); + } @media (max-width: 48rem) { display: none; From 6019da38098aa559ecacb89be73190e142343dec Mon Sep 17 00:00:00 2001 From: Sachin Venugopalan Date: Fri, 31 Jul 2026 17:24:53 +0530 Subject: [PATCH 3/7] Comments addressed --- .../src/components/PageRouter/PageRouter.tsx | 18 +++++------------- .../src/components/SectionNav/SectionNav.tsx | 3 --- 2 files changed, 5 insertions(+), 16 deletions(-) diff --git a/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx b/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx index 6c86f97c..1b47a8af 100644 --- a/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx +++ b/packages/bruno-api-docs/src/components/PageRouter/PageRouter.tsx @@ -6,7 +6,6 @@ import { useActiveResolution, useNavModel } from '../../routing/hooks'; import { useAppSelector } from '../../store/hooks'; import { selectDocsCollection } from '../../store/slices/docs'; import { getItemUuid } from '../../utils/itemUtils'; -import { getItemName } from '../../utils/schemaHelpers'; import { getAncestorsByUuid } from '../../utils/fileUtils'; import { ItemVariableResolverProvider } from '../../hooks'; import type { Item } from '@opencollection/types/collection/item'; @@ -21,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 { @@ -29,6 +28,8 @@ 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(); @@ -59,17 +60,8 @@ const PageRouter: React.FC = ({ onOpenPlayground, testId = 'pag const { entry, prev, next } = resolution; const pageProps: PageProps = { node: entry, prev, next, collection, onOpenPlayground }; - const showSectionNav - = entry.type === 'overview' - || entry.type === 'folder' - || entry.type === 'request' - || entry.type === 'script'; - const sectionNavTitle - = entry.type === 'overview' - ? collection.info?.name || 'Overview' - : item - ? getItemName(item) || 'Untitled' - : 'Overview'; + const showSectionNav = !PAGES_WITHOUT_SECTION_NAV.has(entry.type); + const sectionNavTitle = entry.name; const goToUuid = (uuid: string) => { const slug = uuidToSlug.get(uuid); diff --git a/packages/bruno-api-docs/src/components/SectionNav/SectionNav.tsx b/packages/bruno-api-docs/src/components/SectionNav/SectionNav.tsx index 94db5037..9969f886 100644 --- a/packages/bruno-api-docs/src/components/SectionNav/SectionNav.tsx +++ b/packages/bruno-api-docs/src/components/SectionNav/SectionNav.tsx @@ -7,11 +7,8 @@ import { scrollBehavior } from '../../utils/motion'; import { StyledWrapper } from './StyledWrapper'; interface SectionNavProps { - // The page content root whose `[data-nav-section]` elements are the jump targets. rootRef: RefObject; - // Shown as the top "back to the top" entry and used as the accessible name. title: string; - // Changes per page so the section list is re-scanned on navigation. navKey?: string; testId?: string; } From a17e0c3012fbc00b7e821672b5c478d9d58f6e9d Mon Sep 17 00:00:00 2001 From: Sachin Venugopalan Date: Fri, 31 Jul 2026 18:50:39 +0530 Subject: [PATCH 4/7] Comments resolved --- .../e2e/tests/section-nav/section-nav.spec.ts | 125 ++++++++--------- .../CollectionConfiguration.tsx | 10 +- .../src/components/SectionNav/SectionNav.tsx | 68 +++------ .../components/SectionNav/StyledWrapper.ts | 129 ++++++++---------- .../src/hooks/useDocSections.ts | 30 ++-- 5 files changed, 160 insertions(+), 202 deletions(-) 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 index e78bab39..72013453 100644 --- 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 @@ -4,14 +4,15 @@ 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 openPopup = async (page: Page): Promise => { +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(page.getByTestId('section-nav-panel')).toBeVisible(); + await expect(firstLabel(page)).toBeVisible(); }; -const popupRow = (page: Page, label: string) => - page.getByTestId('section-nav-panel').getByTestId('section-nav-item').filter({ hasText: new RegExp(`^${label}$`) }); -const railTick = (page: Page, label: string) => +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)', () => { @@ -19,25 +20,24 @@ test.describe('Section navigator (the "on this page" outline)', () => { await folderPage.open(['Realtime']); const rail = page.getByTestId('section-nav-rail'); - const panel = page.getByTestId('section-nav-panel'); await expect(rail).toBeVisible(); - await expect(panel).toBeHidden(); + // Collapsed: the labels are hidden (zero width), only the ticks show. + await expect(firstLabel(page)).toBeHidden(); - // Focusing a rail marker reveals the labelled popup (the keyboard path; deterministic in tests). - await rail.getByRole('button').first().focus(); - await expect(panel).toBeVisible(); + // 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(page.getByTestId('section-nav-item').first()).toHaveText('Realtime'); - await expect(panel).toContainText('Folder Configuration'); - await expect(panel).toContainText('Headers'); + 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 openPopup(page); - await popupRow(page, 'Headers').click(); + await openOutline(page); + await outlineRow(page, 'Headers').click(); await expect .poll(async () => { @@ -45,32 +45,33 @@ test.describe('Section navigator (the "on this page" outline)', () => { return box ? Math.round(box.y) : Number.MAX_SAFE_INTEGER; }) .toBeLessThan(240); - await expect(railTick(page, 'Headers')).toHaveAttribute('aria-current', 'location'); + await expect(outlineRow(page, 'Headers')).toHaveAttribute('aria-current', 'location'); }); - test('the popup opens over the rail, hiding the ticks behind it', async ({ folderPage, page }) => { + test('opening expands the rail leftward into a labelled card, keeping its right edge', async ({ + folderPage, + page + }) => { await folderPage.open(['Realtime']); - const railBox = await page.getByTestId('section-nav-rail').boundingBox(); - await openPopup(page); - const panelBox = await page.getByTestId('section-nav-panel').boundingBox(); - if (!railBox || !panelBox) throw new Error('expected the rail and panel to be laid out'); - // The panel sits over the rail (overlapping it) and extends far left to show the labels. - expect(panelBox.x).toBeLessThan(railBox.x); - expect(panelBox.x + panelBox.width).toBeGreaterThan(railBox.x); - await expect(page.getByTestId('section-nav-rail')).toHaveCSS('opacity', '0'); + 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 popup', async ({ folderPage, page }) => { + test('pressing Escape closes the outline', async ({ folderPage, page }) => { await folderPage.open(['Realtime']); - const rail = page.getByTestId('section-nav-rail'); - const panel = page.getByTestId('section-nav-panel'); - - await rail.getByRole('button').first().focus(); - await expect(panel).toBeVisible(); + await openOutline(page); await page.keyboard.press('Escape'); - await expect(panel).toBeHidden(); + // 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 ({ @@ -81,22 +82,20 @@ test.describe('Section navigator (the "on this page" outline)', () => { // 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'); - const panel = page.getByTestId('section-nav-panel'); - await rail.getByRole('button').first().focus(); - await expect(panel).toBeVisible(); - - await expect(panel).toContainText('Configuration'); - await expect(panel.getByTestId('section-nav-item').filter({ hasText: /^Params$/ })).toBeVisible(); - await expect(panel.getByTestId('section-nav-item').filter({ hasText: /^Auth$/ })).toBeVisible(); - await expect(panel).not.toContainText('Code Snippet'); + 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 openPopup(page); - await popupRow(page, 'Configuration').click(); + await openOutline(page); + await outlineRow(page, 'Configuration').click(); await expect .poll(async () => { @@ -110,23 +109,21 @@ test.describe('Section navigator (the "on this page" outline)', () => { await requestPage.open(REQUEST_WITH_CONFIG); // Clicking a member marks only that member current — never the group as well. - await openPopup(page); - await popupRow(page, 'Params').click(); - await expect(railTick(page, 'Params')).toHaveAttribute('aria-current', 'location'); - await expect(railTick(page, 'Configuration')).not.toHaveAttribute('aria-current'); + 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); - const rail = page.getByTestId('section-nav-rail'); - const panel = page.getByTestId('section-nav-panel'); - await rail.getByRole('button').first().focus(); - await expect(panel).toBeVisible(); + await openOutline(page); - await expect(panel).toContainText('Execution Context'); + const rail = page.getByTestId('section-nav-rail'); + await expect(rail).toContainText('Execution Context'); for (const tab of ['Variables', 'Scripts', 'Asserts', 'Tests']) { - await expect(panel.getByTestId('section-nav-item').filter({ hasText: new RegExp(`^${tab}$`) })).toBeVisible(); + await expect(outlineRow(page, tab)).toBeVisible(); } }); @@ -143,8 +140,8 @@ test.describe('Section navigator (the "on this page" outline)', () => { await expect(ecToggle).toHaveAttribute('aria-expanded', 'false'); // Clicking the Asserts entry should re-open the section and select the Asserts tab. - await openPopup(page); - await popupRow(page, 'Asserts').click(); + 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'); @@ -159,8 +156,8 @@ test.describe('Section navigator (the "on this page" outline)', () => { await ecToggle.click(); await expect(ecToggle).toHaveAttribute('aria-expanded', 'false'); - await openPopup(page); - await popupRow(page, 'Execution Context').click(); + await openOutline(page); + await outlineRow(page, 'Execution Context').click(); await expect(ecToggle).toHaveAttribute('aria-expanded', 'true'); }); @@ -184,22 +181,20 @@ test.describe('Section navigator (the "on this page" outline)', () => { test('lists the headings written in the overview\'s documentation', async ({ overviewPage, page }) => { await overviewPage.goto('/'); - const rail = page.getByTestId('section-nav-rail'); - const panel = page.getByTestId('section-nav-panel'); - await rail.getByRole('button').first().focus(); - await expect(panel).toBeVisible(); + await openOutline(page); // The collection docs' `## Getting Started / ## Authentication / ## Rate Limits` become entries. - await expect(panel).toContainText('Getting Started'); - await expect(panel).toContainText('Authentication'); - await expect(panel).toContainText('Rate Limits'); + 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 openPopup(page); - await popupRow(page, 'Query Parameters').click(); + 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 diff --git a/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx b/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx index 51529273..5c7eb544 100644 --- a/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx +++ b/packages/bruno-api-docs/src/components/OverviewCollectionConfiguration/CollectionConfiguration.tsx @@ -59,28 +59,28 @@ export const CollectionConfiguration: React.FC = ( {hasHeaders && (
- Headers + Headers
)} {hasAuth && (
- Auth + Auth
)} {hasVars && (
- Variables + Variables
)} {hasScripts && (
- Script + Script {scripts.preRequest && (

Pre-Request

@@ -98,7 +98,7 @@ export const CollectionConfiguration: React.FC = ( {hasTests && (
- Tests + Tests
)} diff --git a/packages/bruno-api-docs/src/components/SectionNav/SectionNav.tsx b/packages/bruno-api-docs/src/components/SectionNav/SectionNav.tsx index 9969f886..818119b1 100644 --- a/packages/bruno-api-docs/src/components/SectionNav/SectionNav.tsx +++ b/packages/bruno-api-docs/src/components/SectionNav/SectionNav.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { RefObject } from 'react'; import { Portal } from '../../ui/Portal/Portal'; import { useEscapeKey } from '../../hooks'; -import { useDocSections, getScrollParent } from '../../hooks/useDocSections'; +import { useDocSections, getScroller } from '../../hooks/useDocSections'; import { scrollBehavior } from '../../utils/motion'; import { StyledWrapper } from './StyledWrapper'; @@ -113,9 +113,8 @@ export const SectionNav: React.FC = ({ rootRef, title, navKey, }, []); const scrollToTop = useCallback(() => { - const scroller = getScrollParent(rootRef.current); - (scroller ?? window).scrollTo({ top: 0, behavior: scrollBehavior() }); - }, [rootRef]); + (getScroller() ?? window).scrollTo({ top: 0, behavior: scrollBehavior() }); + }, []); const scrollTo = useCallback((el: HTMLElement) => { el.scrollIntoView({ behavior: scrollBehavior(), block: 'start' }); @@ -175,7 +174,7 @@ export const SectionNav: React.FC = ({ rootRef, title, navKey, ); const focusTick = useCallback((index: number) => { - const ticks = wrapperRef.current?.querySelectorAll('.section-nav-tick-btn'); + const ticks = wrapperRef.current?.querySelectorAll('.section-nav-item'); const count = ticks?.length ?? 0; if (!count) return; const clamped = Math.max(0, Math.min(index, count - 1)); @@ -222,40 +221,6 @@ export const SectionNav: React.FC = ({ rootRef, title, navKey, } }} > - {/* Labelled popup: a visual mirror of the rail. Hidden from assistive tech, whose users get - the rail's labelled buttons instead, so the two don't duplicate the navigation. */} - - - {/* Rail: the always-visible mini-map, and the accessible navigation for keyboard / AT users. - Roving tab index keeps it a single tab stop; arrows move between ticks. */}
= ({ rootRef, title, navKey, ))}
diff --git a/packages/bruno-api-docs/src/components/SectionNav/StyledWrapper.ts b/packages/bruno-api-docs/src/components/SectionNav/StyledWrapper.ts index c186e6bf..d62d44df 100644 --- a/packages/bruno-api-docs/src/components/SectionNav/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/SectionNav/StyledWrapper.ts @@ -11,118 +11,107 @@ export const StyledWrapper = styled.div` display: flex; flex-direction: column; align-items: flex-end; - gap: 0.5rem; - padding: 0.25rem 0.75rem 0.5rem 0.5rem; + gap: 0.125rem; + padding: 0.25rem 0.5rem; overflow: hidden; - transition: opacity 0.15s ease; + border: 1px solid transparent; + border-radius: var(--oc-radius); + transition: background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; } - .section-nav-tick-btn { + + .section-nav-item { display: flex; align-items: center; justify-content: flex-end; - width: 1.35rem; - padding: 0.125rem 0; + 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-tick-btn:focus-visible { + .section-nav-item:focus-visible { outline: 2px solid var(--oc-status-info-text); - outline-offset: 2px; - border-radius: 0.25rem; + outline-offset: -2px; + } + + .section-nav-item-text { + max-width: 0; + opacity: 0; + overflow: hidden; + text-align: left; + white-space: nowrap; + transition: max-width 0.18s ease, opacity 0.18s ease; + } + + .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: 150ms; + transition: opacity 0.15s ease; } - .section-nav-tick-btn:hover .section-nav-tick { + .section-nav-item:hover .section-nav-tick { opacity: 0.85; } - .section-nav-tick.is-active { + .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-panel { - position: absolute; - top: 0; - right: 0.5rem; - transform: translateX(0.5rem); + &.section-nav--open .section-nav-map { + align-items: stretch; min-width: 11rem; max-width: 17rem; - max-height: calc(100vh - 7rem); - overflow-y: auto; padding: 0.5rem; background: var(--bg-primary); - border: 1px solid var(--border-color); - border-radius: var(--oc-radius); - opacity: 0; - visibility: hidden; - transition: opacity 0.16s ease, transform 0.16s ease, visibility 0.16s ease; - } - &.section-nav--open .section-nav-panel { - opacity: 1; - visibility: visible; - transform: translateX(0); - } - &.section-nav--open .section-nav-map { - opacity: 0; + border-color: var(--border-color); + box-shadow: var(--shadow-md); + overflow-y: auto; } - - .section-nav-list { - list-style: none; - margin: 0; - padding: 0; - display: flex; - flex-direction: column; - gap: 0.125rem; + &.section-nav--open .section-nav-item { + justify-content: flex-start; + padding-left: var(--nav-indent, 0.5rem); } - - .section-nav-item { - display: block; - width: 100%; - padding: 0.1875rem 0.5rem; - background: none; - border: none; - border-radius: 0.3125rem; - text-align: left; - 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-text { - display: block; + &.section-nav--open .section-nav-item-text { + max-width: 16rem; + opacity: 1; + white-space: normal; overflow-wrap: break-word; } - .section-nav-item:hover, - .section-nav-item.is-focused { + &.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-item:focus-visible { - outline: 2px solid var(--oc-status-info-text); - outline-offset: -2px; - } - .section-nav-item.is-active { + &.section-nav--open .section-nav-item.is-active { background: color-mix(in srgb, var(--oc-colors-accent) 8%, transparent); - color: var(--oc-colors-accent); } - .section-nav-item.is-active:hover { + &.section-nav--open .section-nav-item.is-active:hover { background: color-mix(in srgb, var(--oc-colors-accent) 12%, transparent); - color: var(--oc-colors-accent); } - .section-nav-item--title { + .section-nav-item--title .section-nav-item-text { color: var(--text-primary); font-weight: 600; } - .section-nav-item--title.is-active { + .section-nav-item--title.is-active .section-nav-item-text { color: var(--oc-colors-accent); } diff --git a/packages/bruno-api-docs/src/hooks/useDocSections.ts b/packages/bruno-api-docs/src/hooks/useDocSections.ts index 409f29b6..60e02b4a 100644 --- a/packages/bruno-api-docs/src/hooks/useDocSections.ts +++ b/packages/bruno-api-docs/src/hooks/useDocSections.ts @@ -12,18 +12,8 @@ export interface DocSection { export const SECTION_SCROLL_OFFSET = 88; -/** The nearest scrollable ancestor, or null when the page scrolls on the window itself. */ -export const getScrollParent = (el: HTMLElement | null): HTMLElement | null => { - let node = el?.parentElement ?? null; - while (node) { - const overflowY = getComputedStyle(node).overflowY; - if ((overflowY === 'auto' || overflowY === 'scroll') && node.scrollHeight > node.clientHeight) { - return node; - } - node = node.parentElement; - } - return null; -}; +/** 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 @@ -83,13 +73,15 @@ export const useDocSections = ( ): { 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 briefly locks the scroll-spy, so the smooth - // scroll to it doesn't flicker the highlight through the sections it passes on the way. + // 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() + 600; + spyLockUntil.current = Date.now() + 1500; }, []); useEffect(() => { @@ -127,7 +119,7 @@ export const useDocSections = ( // 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 = getScrollParent(sections[0].el); + const scroller = getScroller(); let frame = 0; const recompute = () => { frame = 0; @@ -167,10 +159,15 @@ export const useDocSections = ( const onScroll = () => { if (!frame) frame = requestAnimationFrame(recompute); }; + const onScrollEnd = () => { + spyLockUntil.current = 0; + onScroll(); + }; 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. @@ -182,6 +179,7 @@ export const useDocSections = ( } return () => { document.removeEventListener('scroll', onScroll, true); + document.removeEventListener('scrollend', onScrollEnd, true); window.removeEventListener('resize', onScroll); tabObserver?.disconnect(); if (frame) cancelAnimationFrame(frame); From 834a565c480395920d6d42261481e16fa391d8d3 Mon Sep 17 00:00:00 2001 From: Sachin Venugopalan Date: Fri, 31 Jul 2026 18:57:42 +0530 Subject: [PATCH 5/7] Style fixes --- .../src/components/SectionNav/StyledWrapper.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/bruno-api-docs/src/components/SectionNav/StyledWrapper.ts b/packages/bruno-api-docs/src/components/SectionNav/StyledWrapper.ts index d62d44df..e7c4d2d0 100644 --- a/packages/bruno-api-docs/src/components/SectionNav/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/SectionNav/StyledWrapper.ts @@ -11,7 +11,8 @@ export const StyledWrapper = styled.div` display: flex; flex-direction: column; align-items: flex-end; - gap: 0.125rem; + gap: 0.5rem; + margin-right: 0.5rem; padding: 0.25rem 0.5rem; overflow: hidden; border: 1px solid transparent; @@ -41,11 +42,12 @@ export const StyledWrapper = styled.div` .section-nav-item-text { max-width: 0; + max-height: 0; opacity: 0; overflow: hidden; text-align: left; white-space: nowrap; - transition: max-width 0.18s ease, opacity 0.18s ease; + transition: max-width 0.18s ease, max-height 0.18s ease, opacity 0.18s ease; } .section-nav-tick-slot { @@ -74,6 +76,7 @@ export const StyledWrapper = styled.div` &.section-nav--open .section-nav-map { align-items: stretch; + gap: 0.125rem; min-width: 11rem; max-width: 17rem; padding: 0.5rem; @@ -88,6 +91,7 @@ export const StyledWrapper = styled.div` } &.section-nav--open .section-nav-item-text { max-width: 16rem; + max-height: 6rem; opacity: 1; white-space: normal; overflow-wrap: break-word; From 18370363079cd5a4097b9651f7c58aba7d1298f9 Mon Sep 17 00:00:00 2001 From: Sachin Venugopalan Date: Fri, 31 Jul 2026 19:27:16 +0530 Subject: [PATCH 6/7] Comments resolved --- .../e2e/tests/scripts/code-editor-copy.spec.ts | 2 +- .../src/components/SectionNav/StyledWrapper.ts | 4 ++-- packages/bruno-api-docs/src/hooks/useDocSections.ts | 5 ++++- 3 files changed, 7 insertions(+), 4 deletions(-) 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/src/components/SectionNav/StyledWrapper.ts b/packages/bruno-api-docs/src/components/SectionNav/StyledWrapper.ts index e7c4d2d0..2c2f503b 100644 --- a/packages/bruno-api-docs/src/components/SectionNav/StyledWrapper.ts +++ b/packages/bruno-api-docs/src/components/SectionNav/StyledWrapper.ts @@ -17,7 +17,6 @@ export const StyledWrapper = styled.div` overflow: hidden; border: 1px solid transparent; border-radius: var(--oc-radius); - transition: background 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; } .section-nav-item { @@ -47,7 +46,6 @@ export const StyledWrapper = styled.div` overflow: hidden; text-align: left; white-space: nowrap; - transition: max-width 0.18s ease, max-height 0.18s ease, opacity 0.18s ease; } .section-nav-tick-slot { @@ -84,6 +82,7 @@ export const StyledWrapper = styled.div` 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; @@ -95,6 +94,7 @@ export const StyledWrapper = styled.div` 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; diff --git a/packages/bruno-api-docs/src/hooks/useDocSections.ts b/packages/bruno-api-docs/src/hooks/useDocSections.ts index 60e02b4a..ecaae781 100644 --- a/packages/bruno-api-docs/src/hooks/useDocSections.ts +++ b/packages/bruno-api-docs/src/hooks/useDocSections.ts @@ -161,7 +161,10 @@ export const useDocSections = ( }; const onScrollEnd = () => { spyLockUntil.current = 0; - onScroll(); + if (frame) { + cancelAnimationFrame(frame); + frame = 0; + } }; recompute(); From e74f6d5c12b3e2109c75c417932b419eebe40cc1 Mon Sep 17 00:00:00 2001 From: Sachin Venugopalan Date: Fri, 31 Jul 2026 19:31:37 +0530 Subject: [PATCH 7/7] Comments resolved --- .../src/components/Section/Section.tsx | 3 ++- .../src/pages/Request/Request.tsx | 17 ++++++++++------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/bruno-api-docs/src/components/Section/Section.tsx b/packages/bruno-api-docs/src/components/Section/Section.tsx index 6b00f2be..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'; @@ -46,7 +47,7 @@ export const Section: React.FC = ({ if (collapsible) { return ( & { 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 = [], @@ -180,7 +183,7 @@ const RequestContent: React.FC = ({
@@ -191,7 +194,7 @@ const RequestContent: React.FC = ({ {hasLeftColumn ? ( <> {hasParams && ( -
+
)} @@ -200,8 +203,8 @@ const RequestContent: React.FC = ({
: undefined} > @@ -212,8 +215,8 @@ const RequestContent: React.FC = ({
@@ -225,7 +228,7 @@ const RequestContent: React.FC = ({ )} {showAuth && ( -
+
)}